SQLException이 DataIntegrityViolationException이 되기까지
왜 예외 변환이 필요한가
JPA를 쓰든 JDBC를 쓰든, 결국 DB와 통신하다 보면 예외가 발생한다. 유니크 제약 위반, 락 충돌, 연결 실패 등 다양한 상황이 있다.
문제는 이 예외들이 DB 벤더마다 형태가 다르다는 것이다.
유니크 제약 위반 에러코드
MySQL → 1062
PostgreSQL → 23505
Oracle → 1
Hibernate나 JPA도 나름의 예외 계층을 갖고 있지만, DB 에러코드 수준의 세밀한 구분까지는 해주지 않는다. 결국 개발자가 PersistenceException을 잡아서 getCause()를 타고 내려가며 에러코드를 직접 파싱해야 하는 상황이 생긴다.
Spring은 이 문제를 @Repository 하나로 해결한다.
예외가 변환되는 4단계

각 단계를 하나씩 살펴보자.
1단계 — DB 에러코드 (SQLException)
JDBC 드라이버가 DB로부터 받은 에러를 SQLException에 담아 던진다. getErrorCode()로 숫자 코드를, getSQLState()로 표준 SQL 상태 코드를 꺼낼 수 있다.
} catch (SQLException e) {
System.out.println(e.getErrorCode()); // 예: 1062 (MySQL 유니크 위반)
System.out.println(e.getSQLState()); // 예: 23000
}
이 단계에서는 어떤 추상화도 없다. 벤더 종속적인 숫자 코드가 그대로 노출된다.
2단계 — Hibernate 예외
Hibernate는 SQLException을 잡아서 자신의 예외 계층으로 wrapping한다.
JDBCException
├── ConstraintViolationException (제약 조건 위반)
├── LockAcquisitionException (락 획득 실패)
├── SQLGrammarException (문법 오류)
└── GenericJDBCException (그 외)
벤더별 에러코드를 어느 정도 해석해서 의미 있는 예외로 분류해주는 건 사실이다. 하지만 이건 Hibernate 구현체에 종속된 예외다. EclipseLink나 다른 JPA 구현체로 바꾸면 예외 타입 자체가 달라진다.
3단계 — JPA 표준 예외
Hibernate의 예외 클래스들은 JPA 표준 예외를 상속한다. 예를 들어 Hibernate의 ConstraintViolationException은 jakarta.persistence.PersistenceException의 서브클래스다. 덕분에 구현체를 바꿔도 JPA 표준 타입으로 잡을 수 있다.
PersistenceException ← JPA 표준 (jakarta.persistence)
└── ConstraintViolationException ← Hibernate 구현
단, JPA 표준 스펙이 커버하는 예외 종류 자체가 많지 않다. 유니크 제약 위반 같은 DB 수준의 에러는 결국 PersistenceException으로 뭉뚱그려진다.
// JPA만 있을 때 유니크 위반을 잡으려면
} catch (PersistenceException e) {
Throwable cause = e.getCause(); // HibernateException
Throwable root = cause.getCause(); // SQLException
if (root instanceof SQLException se) {
if (se.getErrorCode() == 1062) { // MySQL 유니크 위반
// 처리
}
}
}
지저분하고 MySQL에 종속적이다. 다른 DB로 바꾸는 순간 이 코드는 조용히 깨진다. Spring이 필요한 이유다.
4단계 — Spring DataAccessException
Spring이 이 문제를 해결하는 방식은 @Repository와 PersistenceExceptionTranslationPostProcessor의 조합이다. Spring Boot에서는 PersistenceExceptionTranslationAutoConfiguration이 이를 자동으로 등록해주므로 별도 설정은 필요 없다.
@Repository가 붙은 빈을 발견하면, Spring은 AOP 프록시를 씌운다. 이 프록시가 메서드 실행 중 발생한 JPA 예외를 가로채서 DataAccessException 계층으로 변환한다.
변환의 핵심은 Spring이 내부적으로 갖고 있는 DB 벤더별 에러코드 매핑 테이블이다. sql-error-codes.xml에 주요 DB의 에러코드가 등록되어 있고, 이를 기반으로 구체적인 예외로 매핑한다.
MySQL 1062 → DataIntegrityViolationException
MySQL 1205 → CannotAcquireLockException
MySQL 1045 → PermissionDeniedDataAccessException
PostgreSQL 23505 → DataIntegrityViolationException
결과적으로 개발자는 이렇게 쓸 수 있다.
// 어떤 DB를 쓰든 동일하게 잡힌다
} catch (DataIntegrityViolationException e) {
throw new DuplicateEmailException("이미 사용 중인 이메일입니다.");
}
@Repository가 그냥 마커 애노테이션처럼 보이지만, 예외 변환이라는 실질적인 역할을 한다. @Component만 붙이면 AOP 프록시가 씌워지지 않으므로 변환이 일어나지 않는다.
@Component // @Repository 아님 → 예외 변환 없음
public class UserRepository {
public void save(User user) {
em.persist(user); // 유니크 위반 발생 시
// PersistenceException이 그대로 던져진다
}
}
Spring Data JPA를 쓴다면
JpaRepository를 상속한 인터페이스를 쓰면 이 모든 걸 신경 쓸 필요가 없다. 구현체인 SimpleJpaRepository에 이미 @Repository가 붙어 있기 때문이다.
// SimpleJpaRepository 소스 (Spring Data JPA 내부)
@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
...
}
직접 EntityManager를 다루는 커스텀 Repository를 만들 때만 @Repository를 챙기면 된다.
정리
| 레이어 | 예외 | 특징 |
| DB | SQLException + 에러코드 |
벤더 종속, 숫자 코드 |
| Hibernate | JDBCException 계열 |
구현체 종속 |
| JPA 표준 | PersistenceException 계열 |
구현체 독립, 구체성 부족 |
| Spring | DataAccessException 계열 |
기술 중립, 구체적 |
Spring의 예외 변환은 단순한 래핑이 아니다. 벤더별 에러코드 매핑 테이블을 통해 JPA가 하지 못하는 구체적인 변환을 해주고, 어떤 기술 스택을 쓰든 일관된 예외 계층을 제공한다는 점에서 실질적인 가치가 있다.
'Spring Framework > Spring' 카테고리의 다른 글
| [Spring] Spring의 비동기 처리 — @Async부터 Virtual Thread 통합까지 (0) | 2026.03.16 |
|---|---|
| [Spring] Spring MVC + Tomcat vs Spring WebFlux + Netty, 그리고 Virtual Thread까지 (0) | 2026.02.27 |
| [Spring] Spring 캐시(Cache) 핵심정리 (2) | 2025.08.26 |