본문 바로가기

Spring/스프링 입문

05. 스프링 DB 접근 기술

💡 본 게시글은 김영한님의 인프런(Inflearn) 강의 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술에 대해 공부하고, 정리한 내용입니다.

1) H2 데이터베이스

(1) 설치하기

H2 데이터베이스는 개발이나 테스트 용도로 사용하기 적합한 가벼운 DB입니다. 설치 방법은 다음과 같습니다.

 

(1) 먼저 H2 데이터베이스 공식 홈페이지에 접속합니다.

(2) 홈페이지에서 1.4.200 버전의 H2 데이터베이스를 다운로드 받습니다.

(3) 윈도우 사용자는 다운로드 받은 폴더의 bin/h2.bat 파일을 실행하면, H2 콘솔이 실행됩니다.

 

이제 데이터베이스 파일을 생성해야 합니다.

 

JDBC URL은 데이터베이스 파일이 위치하는 경로를 나타냅니다. 기본 설정된 JDBC URL을 사용하면 파일 접근 오류가 발생할 수 있으므로, JDBC URL을 jdbc:h2:tcp://localhost/~/test로 수정합니다.

 

수정 후 콘솔 창에서 "연결" 버튼을 클릭하면 H2 데이터베이스에 접속할 수 있습니다. 이렇게 하면 H2 데이터베이스 설치와 설정이 완료됩니다.


(2) 테이블 생성하기

 H2 데이터베이스가 설치 완료되었으면, 이제 테이블을 생성하겠습니다.

 

SQL문 작성: 테이블을 생성하기 위해 아래의 SQL문을 작성합니다.

drop table if exists member CASCADE;
create member
(
 id bigint generated by default as identity,
 name varchar(255),
 primary key (id)
);

 

SQL문 실행: 위 SQL문을 H2 콘솔에 입력하고 '실행' 버튼을 눌러 테이블을 생성합니다.

 

테이블 확인: 'MEMBER' 테이블이 정상적으로 생성되었는지 확인합니다.

 

만약 테이블 생성 중 오류가 발생하여 H2 데이터베이스가 정상적으로 생성되지 않았다면, 아래의 방법을 시도해 보세요.

  • H2 데이터베이스를 종료한 후 다시 시작합니다.
  • JDBC URL에서 ':8082' 앞의 부분을 'localhost'로 변경한 후 다시 접속합니다.

이번 과정을 통해 H2 데이터베이스를 설정했습니다. 다음에는 H2 데이터베이스와 순수 JDBC를 이용해 데이터베이스 연결을 진행하겠습니다.


2) 순수 JDBC

 이제 어플리케이션과 데이터베이스를 연동하고, JDBC를 이용해 DB에 쿼리를 전송하는 과정을 진행해보겠습니다.

 

(1) 순수 JDBC 세팅하기

ⓛ 의존성 추가

  •  build.gradle 파일에 아래의 코드를 추가해서 JDBC와 H2 데이터베이스에 필요한 의존성을 추가합니다.
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'

 

② 데이터베이스 접속 정보 설정

  •  src - resources - application.properties 파일에 아래의 코드를 추가해서 데이터베이스 접속 정보를 설정합니다.
spring.datasource.url = jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name = org.h2.Driver
spring.datasource.username=sa

 위의 설정을 하면 순수 JDBC를 사용할 수 있는 환경이 구성되었습니다. 이렇게 하면 어플리케이션에서 DB에 직접 쿼리를 전송하고, DB에 정보를 추가하거나 가져올 수 있습니다.


(2) JDBC API를 이용하여 DB 연결하기 

이번에는 JDBC API를 활용하여 데이터베이스에 연결하는 과정을 간단하게 정리하겠습니다.

 

 

DB에 연결해서 사용하려면 data source라는 것이 필요합니다. 그러므로 DataSource Type의 변수를 final로 선언하고, 생성자를 통해 주입받습니다.

 

spring boot가 Data Source를 만들어놓기 때문에, spring을 통해 주입받을 수 있습니다.

public class JdbcMemberRepository implements MemberRepository {

    private final DataSource dataSource;

    public JdbcMemberRepository(DataSource dataSource) {
        this.dataSource = dataSource;
        //dataSource.getConnection();
    }
    ...

 

 이후 dataSource.getConnection()을 통해 connection을 받을 수 있고, 여기에 sql문을 전달하여 DB에 정보를 넣고 뺄 수 있습니다.


(3) save, findById, findByName, findAll 구현하기 

 이 내용은 20년 전에 많이 사용되던 JDBC API를 직접 이용한 코딩 방식에 관한 것입니다. 저도 이런 방식으로 학교에서 수업을 듣고 프로젝트를 개발했었는데, 지금 와서 생각하니 옛날 방식으로 개발을 공부하고 진행했다는 것에 아쉬운 감정이 듭니다. 강의에서는 이런 방식을 이해하는 것은 중요하지만, 이런 내용은 참고만 하고 넘어가는 것을 추천하였습니다.

① save 

@Override
public Member save(Member member) {
    // 새 회원을 저장하기 위한 SQL 쿼리
    String sql = "insert into member(name) values(?)";
    Connection conn = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
        // 데이터베이스 연결
        conn = getConnection();
        
        // PreparedStatement 생성, 자동 생성된 키(Primary Key)를 반환하도록 설정
        pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
        
        // 첫 번째 파라미터(1)에 회원 이름 설정
        pstmt.setString(1, member.getName());
        
        // SQL 실행
        pstmt.executeUpdate();
        
        // 생성된 키(Primary Key)를 가져옴
        rs = pstmt.getGeneratedKeys();
        
        // 생성된 키가 있으면 회원 객체에 ID 설정
        if (rs.next()) {
            member.setId(rs.getLong(1));
        } else {
            // 키 생성 실패 시 예외 발생
            throw new SQLException("id 조회 실패");
        }
        
        // 생성된 회원 객체 반환
        return member;
        
    } catch (Exception e) {
        // 예외 발생 시 상태 불안정 예외 반환
        throw new IllegalStateException(e);
        
    } finally {
        // 리소스 정리
        close(conn, pstmt, rs);
    }
}

 

 

② findById

@Override
public Optional<Member> findById(Long id) {
    // ID를 기반으로 회원을 조회하기 위한 SQL 쿼리
    String sql = "select * from member where id = ?";
    Connection conn = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
        // 데이터베이스 연결
        conn = getConnection();
        
        // PreparedStatement 생성
        pstmt = conn.prepareStatement(sql);
        
        // 첫 번째 파라미터(1)에 조회할 회원 ID 설정
        pstmt.setLong(1, id);
        
        // SQL 실행 및 결과 집합(rs) 반환
        rs = pstmt.executeQuery();
        
        // 결과가 있으면 Member 객체로 매핑
        if (rs.next()) {
            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
           
            // 찾은 회원을 Optional로 감싸 반환
            return Optional.of(member);
        } else {
            // 회원을 찾지 못한 경우, 빈 Optional 반환
            return Optional.empty();
        }
    } catch (Exception e) {
        // 예외 발생 시 상태 불안정 예외 반환
        throw new IllegalStateException(e);
        
    } finally {
        // 리소스 정리
        close(conn, pstmt, rs);
    }
}

 

③ findByName

@Override
public Optional<Member> findByName(String name) {
    // 이름을 기반으로 회원을 조회하기 위한 SQL 쿼리
    String sql = "select * from member where name = ?";
    Connection conn = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
        // 데이터베이스 연결
        conn = getConnection();
        
        // PreparedStatement 생성
        pstmt = conn.prepareStatement(sql);
        
        // 첫 번째 파라미터(1)에 조회할 회원 이름 설정
        pstmt.setString(1, name);
        
        // SQL 실행 및 결과 집합(rs) 반환
        rs = pstmt.executeQuery();
        
        // 결과가 있으면 Member 객체로 매핑
        if (rs.next()) {
            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            
            // 찾은 회원을 Optional로 감싸 반환
            return Optional.of(member);
        }
        
        // 회원을 찾지 못한 경우, 빈 Optional 반환
        return Optional.empty();
        
    } catch (Exception e) {
        // 예외 발생 시 상태 불안정 예외 반환
        throw new IllegalStateException(e);
        
    } finally {
        // 리소스 정리
        close(conn, pstmt, rs);
    }
}

 

④ findAll

@Override
public List<Member> findAll() {
    // 모든 회원을 조회하기 위한 SQL 쿼리
    String sql = "select * from member";
    Connection conn = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
        // 데이터베이스 연결
        conn = getConnection();
        
        // PreparedStatement 생성 및 쿼리 실행
        pstmt = conn.prepareStatement(sql);
        
        // 결과 집합(rs) 반환
        rs = pstmt.executeQuery();
        
        // 회원 정보를 저장할 리스트 생성
        List<Member> members = new ArrayList<>();
        
        // 모든 회원 정보를 순회하면서 리스트에 추가
        while(rs.next()) {
            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            members.add(member);
        }
        
        // 회원 리스트 반환
        return members;
        
    } catch (Exception e) {
        // 예외 발생 시 상태 불안정 예외 반환
        throw new IllegalStateException(e);
    } finally {
        // 리소스 정리
        close(conn, pstmt, rs);
    }
}

 

getConnection()과 close()

// 데이터베이스 연결을 가져오는 메서드
private Connection getConnection() {

    // DataSourceUtils를 사용하여 dataSource에서 커넥션을 얻음
    return DataSourceUtils.getConnection(dataSource);
}

// 데이터베이스 리소스를 닫는 메서드
private void close(Connection conn, PreparedStatement pstmt, ResultSet rs){
    try {
        // ResultSet이 null이 아니면 닫음
        if (rs != null) {
            rs.close();
        }
    } catch (SQLException e) {
        e.printStackTrace();
    }
    
    try {
        // PreparedStatement가 null이 아니면 닫음
        if (pstmt != null) {
            pstmt.close();
        }
    } catch (SQLException e) {
        e.printStackTrace();
    }
    
    try {
        // Connection이 null이 아니면 닫음
        if (conn != null) {
            close(conn);
        }
    } catch (SQLException e) {
        e.printStackTrace();
    }
}

// Connection 객체를 닫는 메서드
private void close(Connection conn) throws SQLException {

    // DataSourceUtils를 사용하여 dataSource에 커넥션을 반환
    DataSourceUtils.releaseConnection(conn, dataSource);
}

(4) MemoryMemverRepository에서 JdbcMemberRepository로 변경하기

이렇게 각 기능(메서드)들을 JDBC를 이용해 구현한 후, MemoryMemberRepositoryJdbcMemberRepository로 변경하면 됩니다.

 

아래는 MemoryMemberRepository를 Spring 컨테이너에 등록하는 과정이었던 예전 코드입니다:

@Configuration
public class SpringConfig {

    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository());
    }

    @Bean
    public MemberRepository memberRepository(){
        return new MemoryMemberRepository();
    }
}

 

이제 이 중 memberRepository() 메서드에서 return new MemoryMemberRepository()return new JdbcMemberRepository()로 변경해야 합니다:

@Configuration
public class SpringConfig {

    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository());
    }

    @Bean
    public MemberRepository memberRepository(){
    	// Jdbc Repository로 변경
        return new JdbcMemberRepository();
    }
}

 

JDBC에서 필요한 DataSource는 Spring에서 제공하므로, 이를 주입받아 사용하면 됩니다. @Configuration도 Spring Bean으로 관리되므로, Spring Boot가 DataSource를 Bean으로 생성하고 관리합니다:

@Configuration
public class SpringConfig {
    private DataSource dataSource;

    @Autowired
    public SpringConfig(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository());
    }

    @Bean
    public MemberRepository memberRepository(){
        return new MemoryMemberRepository();
    }
}

 

변경 후 H2 데이터베이스를 실행하고 테스트를 진행하면 모든 테스트를 통과하는 것을 확인할 수 있습니다.

 

 그러나, 위의 코드들은 SQL문을 직접 작성하고, Connection, PreparedStatement, ResultSet 변수를 선언하는 등 많은 코드가 필요하며, 다수의 try-catch문을 사용하여 코드의 가독성이 떨어진다는 단점이 있습니다.


 

 

(5) Spring을 사용하는 이유 

이번 시간에 공부한 내용을 통해서 Spring을 사용하는 이유를 알게되습니다.

 

 MemoryMemberRepository에서 JdbcMemberRepository로 전환하는 과정에서 단지 SpringConfig만 수정했습니다. MemberService나 MemberController는 전혀 수정하지 않았습니다. 이처럼, SpringConfig만 수정하여 Repository를 변경하였습니다.

 

 Spring은 다형성을 활용할 수 있도록 Spring 컨테이너에서 Dependency Injection(DI)와 같은 기능을 제공합니다. 이로 인해 객체지향적인 설계가 가능하며, 다형성의 활용이 가능해집니다.

 

이런 점은 DB의 구현체를 Memory에서 Jdbc로 변경할 때 코드를 크게 수정하지 않고, 단지 SpringConfig에서만 수정하여 구현체를 변경할 수 있다는 이점을 가지게 됩니다.

 

 

위 이미지는 구현 클래스를 추가하는 이미지 입니다. MemberService는 MemberRepository에 의존하고 있습니다. 즉, MemberRepository의 기능을 사용하고 있습니다.

 

 그리고 MemberRepository 인터페이스는 이제 MemoryMemberRepository와 JdbcMemberRepository 구현체를 가지게 됩니다. 기존에는 MemoryMemberRepository를 Spring Bean으로 등록하고 사용하고 있었습니다.

 

 이번에는 MemoryMemberRepository를 제거하고, Jdbc 버전의 MemberRepository를 연결했습니다. 그리고 다른 코드는 변경하지 않았습니다. 다형성의 성질을 이용하여 JdbcMemberRepository를 생성하고 이를 사용하게 되었습니다.

 

 이런 원칙을 개방-폐쇄 원칙(OCP, Open-Closed Principle)이라고 합니다. 이는 "확장에는 열려 있고, 수정 & 변경에는 닫혀 있다"는 뜻입니다.

 

인터페이스 기반의 다형성, 즉 객체 지향에서의 다형성의 개념을 활용하면 기능을 완전히 변경하더라도 애플리케이션 전체의 코드를 수정하지 않아도 된다는 것입니다. 이번 시간에는 Spring DI를 활용하면 "기존 코드에는 손대지 않고, 설정만으로도 구현 클래스를 변경할 수 있다"는 사실을 배웠습니다.