데이터 접근 기술은 작업마다 필요한 추상화 수준이 달라서, 통제권과 생산성 사이에서 고르는 선택입니다.
JDBC가 남기는 반복
자바 데이터베이스 연결(JDBC)은 데이터 접근의 가장 바닥 계층입니다. 커넥션 획득, SQL 실행, 결과 매핑, 자원 반환, 예외 처리가 코드에 그대로 드러납니다. 그래서 저장소 클래스마다 비슷한 try-finally 구조가 반복됩니다.
이 반복은 누락의 위험을 키웁니다. 자원 반환을 한 번이라도 빠뜨리면 커넥션 풀이 천천히 고갈됩니다. 이 JDBC 작업을 이해하면 상위 데이터 접근 기술이 무엇을 대신 처리하는지 분명해집니다.
// JDBC 직접 사용 - 매 메서드마다 반복되는 구조
Connection con = dataSource.getConnection();
PreparedStatement pstmt = con.prepareStatement(sql);
try {
pstmt.setLong(1, id);
ResultSet rs = pstmt.executeQuery();
// 결과 매핑 ...
} catch (SQLException e) {
throw new MyDbException(e);
} finally {
pstmt.close(); // 여기서 또 SQLException 가능
con.close();
}위 코드에서 실제 로직은 SQL과 매핑뿐인데, 자원 반환과 예외 변환이 더 많은 줄을 차지합니다. 저장소가 늘어날수록 이 구조가 그대로 복제됩니다.
JdbcTemplate가 줄이는 것
JdbcTemplate은 spring-jdbc 라이브러리에 포함된 도구로, 별도 설정 없이 바로 씁니다. 템플릿 콜백 패턴으로 커넥션 획득과 종료, statement와 결과 집합 종료, 트랜잭션 동기화, 예외 번역을 대신 처리합니다. 개발자는 SQL을 쓰고, 파라미터를 정의하고, 결과를 매핑하면 됩니다.
다만 SQL 통제는 여전히 개발자 책임입니다. SQL 문자열과 RowMapper를 직접 설계해야 하므로, 복잡한 조회가 많아지면 SQL과 매핑 코드가 장황해집니다.
private final JdbcTemplate template;
public JdbcItemRepository(DataSource dataSource) {
this.template = new JdbcTemplate(dataSource);
}
public Optional<Item> findById(Long id) {
String sql = "select id, item_name, price, quantity from item where id = ?";
try {
Item item = template.queryForObject(sql, itemRowMapper(), id);
return Optional.of(item);
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}
private RowMapper<Item> itemRowMapper() {
return (rs, rowNum) -> {
Item item = new Item();
item.setId(rs.getLong("id"));
item.setItemName(rs.getString("item_name"));
item.setPrice(rs.getInt("price"));
item.setQuantity(rs.getInt("quantity"));
return item;
};
}순서 기반 ? 바인딩은 컬럼 순서가 바뀌면 조용히 어긋납니다. 그래서 스프링은 이름 기반 바인딩인 NamedParameterJdbcTemplate을 권장합니다.
private final NamedParameterJdbcTemplate template;
public Item save(Item item) {
String sql = "insert into item (item_name, price, quantity) " +
"values (:itemName, :price, :quantity)";
SqlParameterSource param = new BeanPropertySqlParameterSource(item);
KeyHolder keyHolder = new GeneratedKeyHolder();
template.update(sql, param, keyHolder);
item.setId(keyHolder.getKey().longValue());
return item;
}BeanPropertySqlParameterSource는 객체의 프로퍼티명을 그대로 바인딩 이름에 맞춥니다. 단순 INSERT만 필요하면 SimpleJdbcInsert로 SQL 문자열 자체를 생략할 수도 있습니다.
예외 추상화와 예외 번역
SQLException은 JDBC 기술에 종속된 체크 예외입니다. 이 예외를 서비스 계층까지 그대로 전달하면 상위 코드가 저장소 기술 세부사항에 종속됩니다. 나중에 JDBC에서 JPA로 바꾸면 예외 타입 때문에 상위 코드까지 영향을 받습니다.
스프링은 이 문제를 예외 번역으로 풉니다. SQLExceptionTranslator가 기술 종속 예외를 DataAccessException 계층으로 바꿉니다. JdbcTemplate은 이 변환을 내부에서 자동으로 수행합니다.
flowchart TD
SQL["SQLException<br/>JDBC 기술 종속 체크 예외"] --> TR["SQLExceptionTranslator<br/>예외 번역기"]
TR --> DAE["DataAccessException<br/>스프링 데이터 접근 예외 최상위, 언체크"]
DAE --> BAD["BadSqlGrammarException<br/>SQL 문법 오류"]
DAE --> DUP["DuplicateKeyException<br/>키 중복 위반"]
DAE --> EMPTY["EmptyResultDataAccessException<br/>결과가 없음"]
DataAccessException은 런타임 예외라서 모든 메서드에 throws를 달지 않아도 됩니다. JPA로 구현을 바꿔도 스프링은 JPA 예외를 같은 계층으로 번역해 변경 폭을 줄입니다. 대신 스프링이라는 기술에는 종속됩니다.
추상화가 예외 원인까지 지우지는 않습니다. 모든 오류를 공통 예외 하나로 뭉뚱그리면 원인 추적이 어려워지므로, 제약 위반과 문법 오류는 구분해 받는 편이 낫습니다.
데이터소스와 트랜잭션 경계
커넥션을 매번 새로 만들면 TCP 연결과 DB 인증까지 거쳐야 해서 비쌉니다. 그래서 미리 만든 커넥션을 재사용하는 커넥션 풀을 씁니다. 스프링 부트는 spring.datasource.url 같은 속성을 읽어 HikariDataSource를 기본 풀로 등록합니다.
spring:
datasource:
url: jdbc:h2:tcp://localhost/~/test
username: sa
password:풀 크기는 운영 부하에 맞춰 조정합니다. HikariCP의 기본 최대 풀 크기는 10입니다(HikariCP 공식 문서 maximumPoolSize 기준).
커넥션 위에는 트랜잭션 경계가 있습니다. 스프링 부트는 등록된 기술을 보고 트랜잭션 매니저를 자동 등록합니다. JDBC 계열이면 DataSourceTransactionManager, JPA면 JpaTransactionManager를 고릅니다. 등록되는 빈 이름은 transactionManager입니다.
flowchart LR
SVC["서비스 계층<br/>@Transactional 경계"] --> REPO["리포지토리<br/>JdbcTemplate 또는 JPA"]
REPO --> DS["DataSource<br/>HikariDataSource 커넥션 풀"]
DS --> DB[("데이터베이스")]
TM["트랜잭션 매니저<br/>커넥션 동기화 관리"] -. 같은 커넥션 공유 .-> REPO
트랜잭션 경계는 보통 서비스 계층에 둡니다. 한 업무가 여러 저장소 작업을 하나로 묶기 때문입니다. @Transactional은 프록시 기반 선언형 도구라, 메서드 호출을 트랜잭션으로 감싸 줍니다.
전파(propagation)는 기존 트랜잭션에 참여할지 새로 만들지를 정하는 규칙입니다. 기본값 REQUIRED는 기존 트랜잭션이 있으면 참여하고 없으면 새로 시작합니다. 감사 로그처럼 본 작업과 독립된 커밋이 꼭 필요할 때만 REQUIRES_NEW를 제한적으로 씁니다.
프록시 기반이라 두 가지를 조심해야 합니다. 같은 객체 안에서 자기 메서드를 직접 부르면 프록시를 우회해 트랜잭션이 안 걸립니다(self-invocation). 내부 참여 호출에서 난 예외를 바깥에서 잡더라도, 커밋 시점에는 UnexpectedRollbackException이 날 수 있습니다.
기술 선택 기준
실무 선택지는 JDBC와 그 위의 추상화에 MyBatis, JPA, Spring Data JPA, Querydsl까지 더해집니다. 아래 표는 각 기술이 잘 맞는 상황과 주의점을 비교합니다.
| 기술 | 잘 맞는 상황 | 강점 | 주의점 |
|---|---|---|---|
| JDBC | 원리 이해, 저수준 제어 | 가장 직접적 | 반복 코드와 예외 처리가 많음 |
| JdbcTemplate | 단순 CRUD, 명확한 SQL | JDBC 반복을 크게 줄임 | 복잡 조회는 금방 장황해짐 |
| MyBatis | SQL 제어권이 중요한 조직 | SQL이 명확, DB 특화 활용 쉬움 | 매퍼 관리 비용이 커질 수 있음 |
| JPA | 도메인 모델 중심 CRUD | 변경 감지, 연관관계, 생산성 | fetch 전략과 성능 해석 필수 |
| Spring Data JPA | 반복 리포지토리 감소 | 기본 CRUD와 페이징이 빠름 | JPA 원리 이해를 대신 못 함 |
| Querydsl | 동적 조건 조회, 관리자 검색 | 타입 안전한 동적 쿼리 | 성능을 자동 개선하지 않음 |
기준은 통제권과 생산성 사이의 트레이드오프로 좁혀집니다. SQL을 세밀하게 통제해야 하면 MyBatis나 JdbcTemplate이 유리합니다. 도메인 모델과 변경 감지 이점을 크게 가져가려면 JPA가 유리합니다.
흔한 조합은 단순 CRUD를 Spring Data JPA로, 복잡한 동적 조회를 Querydsl로 나누는 방식입니다. 조회 모델과 쓰기 모델의 성격이 다르면 기술을 섞는 것도 정상적인 선택입니다.
선택의 마지막 기준은 성능입니다. 병목 구간은 기술 교체보다 SQL과 인덱스 점검이 먼저입니다. ORM을 써도 결국 커넥션, 트랜잭션, 실행 계획을 이해해야 장애를 정확히 해석할 수 있습니다.
정리
스프링 데이터 접근 기술은 JDBC의 반복 작업과 기술별 예외를 단계별로 감춥니다. JdbcTemplate은 자원 반환과 예외 번역을 맡기면서 SQL 통제권을 남깁니다. DataAccessException 계층은 상위 계층이 특정 데이터 접근 기술에 결합되는 정도를 줄입니다.
데이터소스와 트랜잭션 경계를 이해해야 커넥션 고갈과 롤백 문제를 해석할 수 있습니다. 작업의 통제권과 생산성 요구에 맞춰 접근 기술을 조합합니다.