인프런 - 라이프타임 커리어 플랫폼
프로그래밍, 인공지능, 데이터, 마케팅, 디자인등 입문부터 실전까지 업계 최고 선배들에게 배울 수 있는 곳.
www.inflearn.com
인프런 사이트의 김영한님의 강의를 보면서 작성한 글입니다.
이번 포스팅에서는 데이터를 영속적으로 보관하기 위해, H2 데이터베이스에 연결하고 순수 JDBC 기술을 사용하여 리포지토리를 구현하는 과정을 알아보겠습니다.
## 목차 (Table of Contents)
이번 포스팅은 스프링의 데이터베이스 접근 기술 시리즈의 첫 번째 파트입니다.
- H2 데이터베이스 설정 (현재글)
- 순수 JDBC (현재글)
- 스프링 JdbcTemplate
- JPA
- 스프링 데이터 JPA
## 1. H2 데이터베이스 설치 및 테이블 생성
실무에서는 MySQL, Oracle 등 고성능 데이터베이스를 사용하지만, 개발 및 학습용으로는 가볍고 빠른 H2 인메모리/파일 DB가 매우 유용합니다.
- H2 데이터베이스 설치: 공식 홈페이지에서 다운로드 후 설치합니다.
- 연결 및 DB 파일 생성: H2 콘솔(웹)을 실행하면, JDBC URL의 IP 주소 부분이 192.168.x.x 등으로 되어 있을 수 있습니다. 이 부분을 **localhost**로 변경하고 연결합니다. 최초 연결 시 사용자 홈 디렉토리에 **test.mv.db**라는 데이터베이스 파일이 생성되는 것을 확인할 수 있습니다.
- URL 설정: 이후부터는 애플리케이션에서 데이터베이스에 접근할 때 사용할 JDBC URL을 **jdbc:h2:tcp://localhost/~/test**로 설정하고 사용합니다.
- 테이블 생성 (DDL): DB에 접속하여 아래의 SQL을 실행해 member 테이블을 생성합니다.
- generated by default as identity: id 컬럼에 값을 직접 넣지 않고 INSERT 할 경우, 데이터베이스가 자동으로 고유한 ID 값을 생성해준다는 의미입니다.
-
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 데이터베이스 드라이버 의존성을 추가합니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'
}
### 2. DB 접속 정보 설정
src/main/resources/application.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() 해주어야 합니다.
/**
* 순수 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 파일을 열어 코드 단 한 줄만 수정하면 됩니다.
@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), 즉 "확장에는 열려 있고, 수정에는 닫혀 있다"를 완벽하게 지킨 사례입니다.
'개발 공부 > 백엔드' 카테고리의 다른 글
| 스프링 DB 접근 기술 : 2. JdbcTemplate (0) | 2025.09.27 |
|---|---|
| DB까지 연결하는 통합 테스트 (0) | 2025.09.27 |
| 스프링 부트 - 스프링 MVC로 회원 관리 웹 기능 만들기 (0) | 2025.09.25 |
| 스프링 부트 - 스프링 빈과 의존관계 (0) | 2025.09.17 |
| 스프링 부트 회원 관리 예제 만들기: 설계부터 테스트, DI까지 (2) | 2025.09.16 |