개발 공부/백엔드

스프링 DB 접근 기술 : 1. H2 데이터베이스 연동과 순수 Jdbc

baby-t 2025. 9. 27. 14:16

 

https://www.inflearn.com/

 

인프런 - 라이프타임 커리어 플랫폼

프로그래밍, 인공지능, 데이터, 마케팅, 디자인등 입문부터 실전까지 업계 최고 선배들에게 배울 수 있는 곳.

www.inflearn.com

인프런 사이트의 김영한님의 강의를 보면서 작성한 글입니다.

 

 

 

이번 포스팅에서는 데이터를 영속적으로 보관하기 위해, H2 데이터베이스에 연결하고 순수 JDBC 기술을 사용하여 리포지토리를 구현하는 과정을 알아보겠습니다.

## 목차 (Table of Contents)

이번 포스팅은 스프링의 데이터베이스 접근 기술 시리즈의 첫 번째 파트입니다.

  1. H2 데이터베이스 설정 (현재글)
  2. 순수 JDBC (현재글)
  3. 스프링 JdbcTemplate
  4. JPA
  5. 스프링 데이터 JPA

## 1. H2 데이터베이스 설치 및 테이블 생성

실무에서는 MySQL, Oracle 등 고성능 데이터베이스를 사용하지만, 개발 및 학습용으로는 가볍고 빠른 H2 인메모리/파일 DB가 매우 유용합니다.

  1. H2 데이터베이스 설치: 공식 홈페이지에서 다운로드 후 설치합니다.
  2. 연결 및 DB 파일 생성: H2 콘솔(웹)을 실행하면, JDBC URL의 IP 주소 부분이 192.168.x.x 등으로 되어 있을 수 있습니다. 이 부분을 **localhost**로 변경하고 연결합니다. 최초 연결 시 사용자 홈 디렉토리에 **test.mv.db**라는 데이터베이스 파일이 생성되는 것을 확인할 수 있습니다.
  3. URL 설정: 이후부터는 애플리케이션에서 데이터베이스에 접근할 때 사용할 JDBC URL을 **jdbc:h2:tcp://localhost/~/test**로 설정하고 사용합니다.
  4. 테이블 생성 (DDL): DB에 접속하여 아래의 SQL을 실행해 member 테이블을 생성합니다.
    • generated by default as identity: id 컬럼에 값을 직접 넣지 않고 INSERT 할 경우, 데이터베이스가 자동으로 고유한 ID 값을 생성해준다는 의미입니다.
  5. SQL
    drop table if exists member CASCADE;
    create table member
    (
        id   bigint generated by default as identity,
        name varchar(255),
        primary key (id)
    );
    

생성된 멤버 테이블
실제 값을 넣은 후의 모습

실무 팁: 위와 같은 DDL(Data Definition Language)은 프로젝트 내에 sql/ddl.sql과 같은 파일로 만들어 보관하면, 나중에 테이블 구조를 파악하거나 다른 환경에 배포할 때 매우 유용합니다.


## 2. Spring Boot에 JDBC 연동하기

JDBC(Java Database Connectivity)는 자바 애플리케이션이 데이터베이스와 통신할 수 있도록 해주는 표준 API입니다. Java에서 데이터베이스를 연동하는 가장 오래되고 근본적인 기술이지만, 개발자가 직접 Connection을 열고 닫는 등 많은 부분을 수동으로 처리해야 하는 특징이 있습니다.

### 1. 의존성 추가

build.gradle 파일에 JDBC와 H2 데이터베이스 드라이버 의존성을 추가합니다.

Groovy
 
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    runtimeOnly 'com.h2database:h2'
}

### 2. DB 접속 정보 설정

src/main/resources/application.properties 파일에 스프링 부트가 데이터베이스에 접속할 때 필요한 정보를 추가합니다.

Properties
 
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa

이제 애플리케이션을 실행하면, 스프링 부트가 이 정보를 바탕으로 데이터베이스 커넥션 풀(Connection Pool)을 생성하여 DB와 연동할 준비를 마칩니다.


## 3. 순수 JDBC 리포지토리 구현

이제 MemoryMemberRepository를 대체할 JdbcMemberRepository를 순수 JDBC 코드로 구현해 보겠습니다. 순수 JDBC는 많은 반복적인 코드가 필요합니다.

JdbcMemberRepository는 Connection, PreparedStatement, ResultSet 등의 JDBC API를 사용하여 DB와 통신합니다. 이 과정에서 리소스 누수를 막기 위해 사용한 자원을 반드시 close() 해주어야 합니다.

 

Java
/**
 * 순수 JDBC를 사용한 MemberRepository 구현체
 * - JDBC API를 직접 사용하여 데이터베이스와 통신합니다.
 * - 많은 반복적인 코드(Connection 관리, 예외 처리 등)가 필요합니다.
 */
public class JdbcMemberRepository implements MemberRepository {

    // 데이터베이스 커넥션을 얻기 위해 스프링으로부터 주입받는 DataSource 입니다.
    private final DataSource dataSource;

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

    @Override
    public Member save(Member member) {
        // 회원을 저장하기 위한 SQL 쿼리입니다. 이름은 파라미터로 바인딩합니다.
        String sql = "insert into member(name) values(?)";

        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null; // INSERT 결과로 생성된 id 값을 받기 위한 변수입니다.

        try {
            // 데이터베이스 커넥션을 가져옵니다.
            conn = getConnection();

            // Statement.RETURN_GENERATED_KEYS 옵션은 INSERT 쿼리 실행 후,
            // 데이터베이스가 자동으로 생성해준 id 값을 반환받기 위해 필요합니다.
            pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);

            // SQL 쿼리의 첫 번째 물음표(?)에 회원의 이름을 바인딩합니다.
            pstmt.setString(1, member.getName());

            // 실제 데이터베이스로 쿼리를 실행합니다. (INSERT, UPDATE, DELETE는 executeUpdate 사용)
            pstmt.executeUpdate();

            // 데이터베이스가 생성해준 생성된 키(id)를 ResultSet으로 반환받습니다.
            rs = pstmt.getGeneratedKeys();

            // ResultSet에 값이 있는지 확인합니다.
            if (rs.next()) {
                // 값을 long 타입으로 꺼내서 Member 객체의 id에 설정해줍니다.
                member.setId(rs.getLong(1));
            } else {
                // 키 값을 가져오는 데 실패한 경우 예외를 발생시킵니다.
                throw new SQLException("ID 조회에 실패했습니다.");
            }
            return member;
        } catch (Exception e) {
            // 예외가 발생하면 RuntimeException으로 전환하여 던집니다.
            throw new IllegalStateException(e);
        } finally {
            // 사용이 끝난 리소스는 반드시 해제하여 커넥션 누수를 방지해야 합니다.
            close(conn, pstmt, rs);
        }
    }

    @Override
    public Optional<Member> findById(Long id) {
        String sql = "select * from member where id = ?";
        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null; // SELECT 쿼리의 결과를 담을 객체입니다.

        try {
            conn = getConnection();
            pstmt = conn.prepareStatement(sql);
            pstmt.setLong(1, id);

            // SELECT 쿼리는 executeQuery를 사용하여 실행하고, 그 결과를 ResultSet으로 받습니다.
            rs = pstmt.executeQuery();

            // 결과가 있는지 확인합니다. id는 PK이므로 결과는 최대 1개입니다.
            if (rs.next()) {
                Member member = new Member();
                member.setId(rs.getLong("id"));
                member.setName(rs.getString("name"));
                // 결과가 있으면 Optional.of()로 감싸서 반환합니다.
                return Optional.of(member);
            } else {
                // 결과가 없으면 Optional.empty()를 반환합니다.
                return Optional.empty();
            }
        } catch (Exception e) {
            throw new IllegalStateException(e);
        } finally {
            close(conn, pstmt, rs);
        }
    }

    @Override
    public Optional<Member> findByName(String name) {
        String sql = "select * from member where name = ?";
        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        try {
            conn = getConnection();
            pstmt = conn.prepareStatement(sql);
            pstmt.setString(1, name);
            rs = pstmt.executeQuery();
            if (rs.next()) {
                Member member = new Member();
                member.setId(rs.getLong("id"));
                member.setName(rs.getString("name"));
                return Optional.of(member);
            }
            return Optional.empty();
        } catch (Exception e) {
            throw new IllegalStateException(e);
        } finally {
            close(conn, pstmt, rs);
        }
    }

    @Override
    public List<Member> findAll() {
        String sql = "select * from member";
        Connection conn = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;
        try {
            conn = getConnection();
            pstmt = conn.prepareStatement(sql);
            rs = pstmt.executeQuery();
            List<Member> members = new ArrayList<>();
            // 결과가 여러 개일 수 있으므로, while 문을 사용하여 모든 결과를 순회합니다.
            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);
        }
    }

    private Connection getConnection() {
        // 스프링 프레임워크를 통해 데이터베이스 커넥션을 획득할 때는
        // DataSourceUtils를 사용하는 것이 정석입니다.
        // 트랜잭션이 적용된 커넥션을 일관성 있게 유지해줍니다.
        return DataSourceUtils.getConnection(dataSource);
    }

    private void close(Connection conn, PreparedStatement pstmt, ResultSet rs) {
        try {
            // [질문] 왜 null이 아닐 때만 닫는지?
            // [답변] try 블록에서 객체 생성 중 예외가 발생하면(예: conn.prepareStatement() 실패),
            // 해당 객체(pstmt)는 null 상태로 남게 됩니다. 이 상태에서 finally 블록의
            // pstmt.close()를 호출하면 NullPointerException이 발생하여,
            // 원래 발생했던 더 중요한 예외를 덮어버리게 됩니다.
            // 이를 방지하기 위해 자원이 null이 아닐 때만 close()를 호출하는 것입니다.
            if (rs != null) {
                rs.close();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
        try {
            if (pstmt != null) {
                pstmt.close();
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
        try {
            if (conn != null) {
                close(conn);
            }
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    private void close(Connection conn) throws SQLException {
        // 커넥션을 닫을 때도 DataSourceUtils를 사용해야 합니다.
        // 트랜잭션이 적용된 커넥션은 바로 닫지 않고, 트랜잭션이 끝날 때 닫습니다.
        DataSourceUtils.releaseConnection(conn, dataSource);
    }
}

 


## 4. 리포지토리 교체하기 (스프링의 DI 활용)

이제 마지막 단계입니다. 기존 MemoryMemberRepository를 새로 만든 JdbcMemberRepository로 교체해 보겠습니다.

이전 포스팅에서 작성했던 SpringConfig 파일을 열어 코드 단 한 줄만 수정하면 됩니다.

Java
 
@Configuration
public class SpringConfig {

    private final 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(); // 이 부분을 주석 처리하고
        return new JdbcMemberRepository(dataSource); // 이 코드로 교체!
    }
}

MemberService는 MemberRepository라는 인터페이스에만 의존하고 있기 때문에, 그 구현체가 MemoryMemberRepository에서 JdbcMemberRepository로 바뀌었다는 사실조차 알지 못합니다. MemberService의 코드는 단 한 줄도 수정할 필요가 없습니다.

이것이 바로 스프링의 DI(의존성 주입)와 인터페이스 기반 설계의 가장 큰 장점입니다. 우리는 설정 파일(SpringConfig)의 코드 한 줄만으로 애플리케이션의 핵심 기능을 손쉽게 교체할 수 있었습니다. 이는 객체 지향 설계 원칙 중 개방-폐쇄 원칙(OCP, Open-Closed Principle), 즉 "확장에는 열려 있고, 수정에는 닫혀 있다"를 완벽하게 지킨 사례입니다.