Spring 데이터 접근 예외
Spring이 JDBC 오류를 데이터 접근 예외로 변환하고 JdbcTemplate이 자원을 관리하는 범위와 업무 예외 번역 경계를 구분합니다.
JDBC 반복을 직접 줄이려다 보면 연결 종료, 구문 파라미터, 결과 커서, 예외 변환 가운데 하나를 빠뜨리기 쉽습니다.
Spring은 DataAccessException 계층으로 기술별 실패를 정규화하고 JdbcTemplate의 템플릿-콜백 구조로 자원 수명을 고정합니다.
이 추상화가 업무 실패까지 자동으로 알아내는 것은 아니므로 프레임워크 번역과 애플리케이션 번역을 두 단계로 나눠야 합니다.
SQLExceptionTranslator
SQLException에는 SQLState, 공급자 코드, 원인 연쇄가 들어 있지만 데이터베이스마다 분류 체계가 다릅니다.
Spring의 변환기는 DataSource 메타데이터에서 데이터베이스 이름을 확인하고 공급자별 코드 표를 적용한 뒤, 필요하면 SQLState 기반으로 대체합니다.
결과는 일시적·비일시적 오류, 무결성·리소스 실패 같은 공통 계층입니다.
package board.jdbc;
import java.sql.SQLException;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator;
public final class TranslatorProbe {
public static void main(String[] args) {
var translator = new SQLErrorCodeSQLExceptionTranslator("H2");
var source = new SQLException(
"Unique index or primary key violation",
"23505",
23505);
DataAccessException translated = translator.translate(
"insert post",
"insert into posts(request_key) values (?)",
source);
if (translated == null) {
throw new IllegalStateException("exception was not translated");
}
System.out.printf("springType=%s state=%s causeKept=%s%n",
translated.getClass().getSimpleName(),
((SQLException) translated.getCause()).getSQLState(),
translated.getCause() == source);
}
}springType=DuplicateKeyException state=23505 causeKept=true작업과 SQL 문자열은 진단을 돕지만 파라미터 원문을 무조건 메시지에 넣지 않습니다.
제목, 토큰, 개인 정보가 노출될 수 있습니다.
변환기가 null을 반환할 가능성도 API 계약에 있으므로 직접 사용할 때는 UncategorizedSQLException 대체를 준비합니다.
보통은 JdbcTemplate가 이 흐름을 책임집니다.
DataAccessException 한계
TransientDataAccessException은 같은 작업이 나중에 성공할 가능성을 나타냅니다.
그렇다고 모든 하위 타입을 즉시 재시도하면 안 됩니다.
트랜잭션이 이미 커밋되었는지 모르는 쓰기, 잠금 경쟁을 악화시키는 빠른 반복, 요청 기한을 넘는 대기는 별도 위험입니다.
DataIntegrityViolationException도 업무 중복과 동일하지 않습니다.
외래 키, NOT NULL, 확인 제약 조건이 모두 들어올 수 있습니다.
어댑터는 자신이 소유한 제약 조건을 식별해 DuplicateRequest나 ConcurrentChange로 좁히고, 나머지는 원인을 보존한 인프라 실패로 전달합니다.
예외 변환의 이점은 서비스가 JDBC와 JPA의 서로 다른 검사 타입을 몰라도 된다는 점입니다.
Spring 데이터 JPA와 MyBatis 연동도 적절한 구성 아래 같은 계층을 사용합니다.
하지만 기술별 플러시 시점과 매핑 실패 차이는 통합 테스트로 고정해야 합니다.
JdbcTemplate 책임
템플릿은 연결 획득, 구문 생성, 예외 변환, 종료를 공통 흐름으로 실행합니다.
리포지토리는 SQL, 파라미터, 행 매핑 콜백만 제공합니다.
트랜잭션이 활성화된 경우 DataSourceUtils를 통해 현재 스레드에 묶인 연결에 참여하므로 리포지토리가 직접 새 연결을 열지 않습니다.
package board.jdbc;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import javax.sql.DataSource;
import org.springframework.jdbc.core.JdbcTemplate;
public final class JdbcPostRepository {
private final JdbcTemplate jdbc;
public JdbcPostRepository(DataSource dataSource) {
this.jdbc = new JdbcTemplate(dataSource);
}
public long save(
long authorId,
String title,
String content,
LocalDate createdOn
) {
jdbc.update("""
insert into posts(
author_id, title, content, created_on, version
) values (?, ?, ?, ?, 0)
""", authorId, title, content, createdOn);
return jdbc.queryForObject(
"select max(id) from posts where author_id = ?",
Long.class,
authorId);
}
public Optional<Row> find(long id, long authorId) {
List<Row> rows = jdbc.query("""
select id, author_id, title, content, created_on, version
from posts
where id = ? and author_id = ?
""", (result, rowNumber) -> new Row(
result.getLong("id"),
result.getLong("author_id"),
result.getString("title"),
result.getString("content"),
result.getObject("created_on", LocalDate.class),
result.getLong("version")), id, authorId);
return rows.stream().findFirst();
}
public record Row(
long id,
long authorId,
String title,
String content,
LocalDate createdOn,
long version
) { }
}운영 환경에서는 max(id)로 생성된 키를 찾지 않습니다.
이 예제의 저장 메서드는 반복 제거에 초점을 둔 것이며, 실제 구현은 KeyHolder나 데이터베이스의 RETURNING을 사용해 동시 삽입에서도 정확한 동일성을 받습니다.
템플릿이 SQL 설계 오류를 고쳐 주지는 않습니다.
JDBC 자원 캡슐화
RowMapper는 현재 행을 불변 값으로 완전히 복원해야 합니다.
매퍼가 ResultSet을 필드에 저장하거나 지연 반복자를 반환하면 콜백 종료 뒤 닫힌 리소스에 접근합니다.
큰 결과를 스트림으로 처리할 때는 스트림 종료 책임과 트랜잭션 범위를 API에 명시합니다.
queryForObject()는 정확히 한 행이라는 계약에 맞습니다.
없음이 정상인 조회를 예외 처리로 제어하면 카디널리티 의미가 흐립니다.
목록을 받고 findFirst()로 바꾸거나 DataAccessUtils.optionalResult()처럼 0 또는 1을 분명하게 표현합니다.
여러 행은 스키마 불변식 위반으로 드러내야 합니다.
배치 갱신도 일부 성공을 숨기지 않습니다.
드라이버가 반환한 갱신 개수와 예외 체인을 보존하고, 트랜잭션 안에서 전체 롤백할지 성공 행을 기록할지 사용 사례가 결정합니다.
템플릿의 편리함을 이유로 지나치게 큰 배치를 한 트랜잭션에 넣으면 잠금과 메모리 비용이 커집니다.
예외 변환 경계
JdbcTemplate이 만든 DuplicateKeyException을 서비스가 직접 처리할 수는 있지만 스키마 제약 조건 이름과 작업 의미를 서비스가 알게 됩니다.
어댑터의 공개 메서드가 Spring 예외를 애플리케이션 규칙으로 좁히면 다른 영속성 구현에서도 같은 정책을 유지할 수 있습니다.
번역은 가능한 한 작은 범위에서 합니다.
catch (DataAccessException) 하나로 모든 실패를 DuplicateTitle로 바꾸지 않고, 예상된 제약 조건만 확인합니다.
예상하지 못한 예외는 일반 PostStoreFailure로 감싸되 원인을 유지합니다.
프로그래밍 오류인 잘못된 SQL은 일시적으로 표시하지 않습니다.
Spring의 @Repository는 영속성 예외 변환 후처리기와 결합될 수 있습니다.
직접 EntityManager를 쓰는 JPA 어댑터에는 유용하지만 JdbcTemplate는 이미 변환을 수행합니다.
애노테이션을 붙였다는 이유로 도메인 예외가 생기는 것은 아닙니다.
실제 제약·롤백 검증
모의 JdbcTemplate가 특정 예외를 던지게 하면 예외 처리 분기는 검사할 수 있지만 SQLState와 스키마가 맞는지는 모릅니다.
H2 또는 운영 환경-호환 데이터베이스에 실제 고유 제약 조건을 만들고 두 번 삽입해 Spring 타입, 어댑터 타입, 원인 연쇄를 확인합니다.
실패 뒤 연결이 풀로 안전하게 돌아가는지도 중요합니다.
반복 요청에서 활성 개수가 계속 증가하면 콜백이나 스트림 종료 누락을 의심합니다.
트랜잭션 롤백 뒤 같은 연결의 자동 커밋, 읽기 전용, 격리 상태가 초기화되는지도 풀 통합 테스트 대상입니다.
연습 문제
request_key 고유 제약 조건과 check (char_length(trim(content)) between 1 and 5000) 확인 제약 조건을 가진 리포지토리를 구현하세요.
같은 요청 키는 DuplicateRequest로, 빈 본문과 길이 초과는 프로그래밍 또는 검증 결함으로 분류하고, 두 원인의 SQLState를 잃지 않는 H2 통합 테스트를 작성하세요.
해설 보기
제약 조건 이름을 확인할 수 없다면 하위 타입만으로 두 위반을 구별하지 못할 수 있습니다.
이때 입력 검증을 먼저 하더라도 데이터베이스 확인을 제거하지 말고, 알려지지 않은 무결성 실패는 과감하게 일반 저장 실패로 남깁니다.
package board.jdbc;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.DataIntegrityViolationException;
public final class PostFailurePolicy {
public static RuntimeException map(
String requestKey,
DataIntegrityViolationException failure
) {
if (failure instanceof DuplicateKeyException) {
return new DuplicateRequest(requestKey, failure);
}
return new InvalidStoredState(failure);
}
public static final class DuplicateRequest extends RuntimeException {
DuplicateRequest(String key, Throwable cause) {
super("duplicate request key: " + key, cause);
}
}
public static final class InvalidStoredState extends RuntimeException {
InvalidStoredState(Throwable cause) {
super("database rejected post state", cause);
}
}
}통합 테스트에서는 getMostSpecificCause()가 실제 SQLException인지 확인하고 SQLState를 기록합니다.
HTTP 응답에는 제약 조건 이름이나 SQL 문장을 포함하지 않습니다.