1. 체크 예외와 인터페이스
1.1. 서비스 계층의 순수성 유지 필요성
서비스 계층은 비즈니스 로직을 담당하는 핵심 계층으로, 가능한 한 특정 구현 기술에 의존하지 않고 순수하게 유지해야 한다. 이전까지 해결한 트랜잭션 문제와 더불어, 예외 처리에 대한 의존성도 해결해야 완전한 순수성을 달성할 수 있다.
현재 문제 상황:
public class MemberServiceV3_3 {
@Transactional
public void accountTransfer(String fromId, String toId, int money)
throws SQLException { // ❌ SQLException에 의존
bizLogic(fromId, toId, money);
}
}
서비스 계층이 SQLException이라는 JDBC 전용 예외에 의존하고 있다. 이는 서비스 계층의 순수성을 해치는 요소이다.
1.2. 인터페이스 도입의 필요성
구현 기술을 쉽게 변경할 수 있도록 리포지토리에 인터페이스를 도입해보자:

// 순수한 비즈니스 인터페이스
public interface MemberRepository {
Member save(Member member);
Member findById(String memberId);
void update(String memberId, int money);
void delete(String memberId);
}
장점:
- 서비스는 인터페이스에만 의존
- 구현 기술 변경 시 서비스 코드 수정 불필요
- 테스트용 가짜 구현체(Mock) 사용 용이
1.3. 체크 예외와 인터페이스의 문제점
하지만 체크 예외를 사용하면 인터페이스 도입이 어려워진다:
// ❌ 체크 예외를 사용하는 인터페이스
public interface MemberRepositoryEx {
Member save(Member member) throws SQLException;
Member findById(String memberId) throws SQLException;
void update(String memberId, int money) throws SQLException;
void delete(String memberId) throws SQLException;
}
문제점 분석:
- 인터페이스 오염: 인터페이스가 SQLException이라는 JDBC 전용 예외에 의존하게 됨
- 구현 기술 변경 어려움: 다른 기술(JPA, NoSQL)로 변경 시 인터페이스도 함께 수정해야 함
- 상위 타입 규칙: 구현 클래스는 인터페이스에서 선언한 예외 또는 그 하위 타입만 던질 수 있음
// 구현 클래스
public class MemberRepositoryV3 implements MemberRepositoryEx {
@Override
public Member save(Member member) throws SQLException {
// JDBC 코드
throw new SQLException("데이터베이스 오류");
}
}
// 다른 기술로 변경하려면?
public class JpaMemberRepository implements MemberRepositoryEx {
@Override
public Member save(Member member) throws SQLException { // ❌ JPA는 SQLException을 던지지 않음
// JPA 코드
throw new PersistenceException("JPA 오류"); // 컴파일 오류!
}
}
1.4. 런타임 예외와 인터페이스의 조화
런타임 예외는 인터페이스에 이런 문제를 일으키지 않는다:
// ✅ 런타임 예외를 사용하는 인터페이스
public interface MemberRepository {
Member save(Member member); // throws 선언 없음
Member findById(String memberId);
void update(String memberId, int money);
void delete(String memberId);
}
// JDBC 구현
public class JdbcMemberRepository implements MemberRepository {
@Override
public Member save(Member member) {
try {
// JDBC 코드
} catch (SQLException e) {
throw new DataAccessException(e); // 런타임 예외로 전환
}
}
}
// JPA 구현 (동일한 인터페이스 구현 가능)
public class JpaMemberRepository implements MemberRepository {
@Override
public Member save(Member member) {
try {
// JPA 코드
} catch (PersistenceException e) {
throw new DataAccessException(e); // 런타임 예외로 전환
}
}
}
2. 런타임 예외 적용
2.1. 커스텀 런타임 예외 정의
먼저 애플리케이션 전반에서 사용할 런타임 예외를 정의한다:
package hello.jdbc.repository.ex;
/**
* 데이터베이스 관련 예외의 최상위 클래스
* RuntimeException을 상속받아 언체크 예외로 만듦
*/
public class MyDbException extends RuntimeException {
// 다양한 생성자 제공
public MyDbException() {
}
public MyDbException(String message) {
super(message);
}
public MyDbException(String message, Throwable cause) {
super(message, cause); // 원인 예외 포함
}
public MyDbException(Throwable cause) {
super(cause); // 원인 예외 포함
}
}
2.2. MemberRepository 인터페이스 정의
순수한 비즈니스 인터페이스를 정의한다:
package hello.jdbc.repository;
import hello.jdbc.domain.Member;
public interface MemberRepository {
Member save(Member member);
Member findById(String memberId);
void update(String memberId, int money);
void delete(String memberId);
}
2.3. MemberRepositoryV4_1 구현 (예외 전환)
이제 체크 예외를 런타임 예외로 전환하는 구현체를 만들어보자:
package hello.jdbc.repository;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.ex.MyDbException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.support.JdbcUtils;
import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;
/**
* 예외 누수 문제 해결
* 체크 예외를 런타임 예외로 변경
* MemberRepository 인터페이스 사용
* throws SQLException 제거
*/
@Slf4j
public class MemberRepositoryV4_1 implements MemberRepository {
private final DataSource dataSource;
public MemberRepositoryV4_1(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
// 체크 예외를 런타임 예외로 전환
throw new MyDbException(e); // ✅ 원인 예외 포함
} finally {
close(con, pstmt, null);
}
}
@Override
public Member findById(String memberId) {
String sql = "select * from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery();
if (rs.next()) {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else {
throw new NoSuchElementException("member not found memberId=" + memberId);
}
} catch (SQLException e) {
// 체크 예외를 런타임 예외로 전환
throw new MyDbException(e); // ✅ 원인 예외 포함
} finally {
close(con, pstmt, rs);
}
}
@Override
public void update(String memberId, int money) {
String sql = "update member set money=? where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
// 체크 예외를 런타임 예외로 전환
throw new MyDbException(e); // ✅ 원인 예외 포함
} finally {
close(con, pstmt, null);
}
}
@Override
public void delete(String memberId) {
String sql = "delete from member where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
// 체크 예외를 런타임 예외로 전환
throw new MyDbException(e); // ✅ 원인 예외 포함
} finally {
close(con, pstmt, null);
}
}
// 리소스 관리 메서드들 (DataSourceUtils 사용)
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
DataSourceUtils.releaseConnection(con, dataSource);
}
private Connection getConnection() {
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={} class={}", con, con.getClass());
return con;
}
}
핵심 변경사항:
- 예외 전환 패턴
} catch (SQLException e) { throw new MyDbException(e); // 원인 예외 포함 } - 원인 예외 포함의 중요성
- new MyDbException(e): 원인 예외 포함 → 디버깅 가능
- new MyDbException(): 원인 예외 미포함 → 디버깅 불가능
2.4. 잘못된 예외 전환 예시
// ❌ 잘못된 예: 원인 예외 미포함
} catch (SQLException e) {
throw new MyDbException(); // 원인 정보 소실
// 또는
throw new MyDbException("데이터베이스 오류"); // 원인 정보 소실
}
// ✅ 올바른 예: 원인 예외 포함
} catch (SQLException e) {
throw new MyDbException(e); // 원인 예외 포함
// 또는
throw new MyDbException("데이터 저장 실패", e); // 메시지 + 원인 예외
}
로그 비교:
// 원인 예외 포함 시
MyDbException: 데이터 저장 실패
Caused by: java.sql.SQLException: Duplicate entry 'hello' for key 'PRIMARY'
at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:965)
... 30 more
// 원인 예외 미포함 시
MyDbException: 데이터 저장 실패
at MemberRepositoryV4_1.save(MemberRepositoryV4_1.java:45)
... 20 more
// 원래 무슨 오류였는지 알 수 없음!
2.5. 순수한 서비스 계층 완성
이제 서비스 계층은 완전히 순수해졌다:
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
/**
* 예외 누수 문제 해결
* SQLException 제거
* MemberRepository 인터페이스 의존
*/
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV4 {
private final MemberRepository memberRepository;
@Transactional
public void accountTransfer(String fromId, String toId, int money) {
// ✅ 더 이상 SQLException 선언 없음
bizLogic(fromId, toId, money);
}
private void bizLogic(String fromId, String toId, int money) {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
변화의 의미:
- throws SQLException 제거 → JDBC 기술 의존성 제거
- MemberRepository 인터페이스 의존 → 구현 기술 변경 용이
- 완전한 순수 비즈니스 로직
2.6. 테스트 코드
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepository;
import hello.jdbc.repository.MemberRepositoryV4_1;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.PlatformTransactionManager;
import javax.sql.DataSource;
import java.sql.SQLException;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* 예외 누수 문제 해결
* SQLException 제거
* MemberRepository 인터페이스 의존
*/
@Slf4j
@SpringBootTest
class MemberServiceV4Test {
public static final String MEMBER_A = "memberA";
public static final String MEMBER_B = "memberB";
public static final String MEMBER_EX = "ex";
@Autowired
MemberRepository memberRepository;
@Autowired
MemberServiceV4 memberService;
@AfterEach
void after() throws SQLException {
memberRepository.delete(MEMBER_A);
memberRepository.delete(MEMBER_B);
memberRepository.delete(MEMBER_EX);
}
@TestConfiguration
static class TestConfig {
private final DataSource dataSource;
public TestConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
MemberRepository memberRepository() {
return new MemberRepositoryV4_1(dataSource); // 단순 예외 변환
}
@Bean
MemberServiceV4 memberServiceV4() {
return new MemberServiceV4(memberRepository());
}
}
@Test
void AopCheck() {
log.info("memberService class={}", memberService.getClass());
log.info("memberRepository class={}", memberRepository.getClass());
Assertions.assertThat(AopUtils.isAopProxy(memberService)).isTrue();
Assertions.assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
}
@Test
@DisplayName("정상 이체")
void accountTransfer() {
// given
Member memberA = new Member(MEMBER_A, 10000);
Member memberB = new Member(MEMBER_B, 10000);
memberRepository.save(memberA);
memberRepository.save(memberB);
// when
memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);
// then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberB = memberRepository.findById(memberB.getMemberId());
assertThat(findMemberA.getMoney()).isEqualTo(8000);
assertThat(findMemberB.getMoney()).isEqualTo(12000);
}
@Test
@DisplayName("이체중 예외 발생")
void accountTransferEx() {
// given
Member memberA = new Member(MEMBER_A, 10000);
Member memberEx = new Member(MEMBER_EX, 10000);
memberRepository.save(memberA);
memberRepository.save(memberEx);
// when
assertThatThrownBy(() ->
memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
.isInstanceOf(IllegalStateException.class);
// then
Member findMemberA = memberRepository.findById(memberA.getMemberId());
Member findMemberEx = memberRepository.findById(memberEx.getMemberId());
// memberA의 돈이 롤백 되어야함
assertThat(findMemberA.getMoney()).isEqualTo(10000);
assertThat(findMemberEx.getMoney()).isEqualTo(10000);
}
}
2.7. 현재까지의 성과와 남은 문제
성과:
- ✅ 서비스 계층의 순수성 달성
- ✅ 인터페이스를 통한 구현 기술 분리
- ✅ 체크 예외 의존성 제거
남은 문제:
- 모든 예외가 MyDbException으로 통합되어 구분 불가
- 특정 예외 상황(예: 키 중복)에 대한 복구 로직 구현 어려움
- 데이터베이스별 오류 코드 차이 처리 필요
3. 데이터 접근 예외 직접 만들기
3.1. 구체적인 예외 상황의 필요성
모든 데이터베이스 예외를 MyDbException 하나로 처리하면 다음과 같은 문제가 있다:
// 서비스 계층
public void registerMember(String memberId) {
try {
memberRepository.save(new Member(memberId, 0));
} catch (MyDbException e) {
// 무슨 오류인지 알 수 없음!
// 키 중복? 문법 오류? 연결 오류?
log.error("데이터베이스 오류", e);
throw e;
}
}
특히 키 중복 오류와 같은 경우에는 비즈니스 로직에서 복구가 필요할 수 있다:
public void registerMember(String memberId) {
try {
memberRepository.save(new Member(memberId, 0));
} catch (DuplicateKeyException e) { // 특정 예외 처리
// 키 중복이면 다른 ID 생성
String newMemberId = generateNewId(memberId);
memberRepository.save(new Member(newMemberId, 0));
}
}
3.2. 데이터베이스 오류 코드 이해
데이터베이스는 오류 발생 시 고유한 오류 코드를 반환한다:
try {
// 데이터베이스 작업
} catch (SQLException e) {
int errorCode = e.getErrorCode(); // 데이터베이스별 오류 코드
String sqlState = e.getSQLState(); // SQL 표준 상태 코드
// ...
}
데이터베이스별 키 중복 오류 코드:
- H2: 23505
- MySQL: 1062
- PostgreSQL: 23505
- Oracle: 1
3.3. 구체적인 예외 클래스 계층 구성
package hello.jdbc.repository.ex;
/**
* 데이터베이스 예외의 최상위 클래스
*/
public class MyDbException extends RuntimeException {
public MyDbException() {}
public MyDbException(String message) { super(message); }
public MyDbException(String message, Throwable cause) { super(message, cause); }
public MyDbException(Throwable cause) { super(cause); }
}
/**
* 키 중복 예외
*/
public class MyDuplicateKeyException extends MyDbException {
public MyDuplicateKeyException() {}
public MyDuplicateKeyException(String message) { super(message); }
public MyDuplicateKeyException(String message, Throwable cause) { super(message, cause); }
public MyDuplicateKeyException(Throwable cause) { super(cause); }
}
/**
* 데이터 무결성 위반 예외 (널 제약조건, 외래키 등)
*/
public class MyDataIntegrityViolationException extends MyDbException {
public MyDataIntegrityViolationException() {}
public MyDataIntegrityViolationException(String message) { super(message); }
public MyDataIntegrityViolationException(String message, Throwable cause) { super(message, cause); }
public MyDataIntegrityViolationException(Throwable cause) { super(cause); }
}
3.4. 예외 변환 로직 구현
package hello.jdbc.exception.translator;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.ex.MyDbException;
import hello.jdbc.repository.ex.MyDuplicateKeyException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Random;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.springframework.jdbc.support.JdbcUtils.closeConnection;
import static org.springframework.jdbc.support.JdbcUtils.closeStatement;
public class ExTranslatorV1Test {
Repository repository;
Service service;
@BeforeEach
void init() {
DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
repository = new Repository(dataSource);
service = new Service(repository);
}
@Test
void duplicateKeySave() {
service.create("myId");
service.create("myId"); // 같은 ID 저장 시도 (키 중복 발생)
}
@Slf4j
@RequiredArgsConstructor
static class Service {
private final Repository repository;
public void create(String memberId) {
try {
repository.save(new Member(memberId, 0));
log.info("saveId={}", memberId);
} catch (MyDuplicateKeyException e) {
// 키 중복 예외를 특별히 처리
log.info("키 중복, 복구 시도");
String retryId = generateNewId(memberId);
log.info("retryId={}", retryId);
repository.save(new Member(retryId, 0));
} catch (MyDbException e) {
// 다른 데이터베이스 예외는 일반 처리
log.info("데이터 접근 계층 예외", e);
throw e;
}
}
private String generateNewId(String memberId) {
return memberId + new Random().nextInt(10000);
}
}
@RequiredArgsConstructor
static class Repository {
private final DataSource dataSource;
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = dataSource.getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
// H2 데이터베이스 키 중복 오류 코드
if (e.getErrorCode() == 23505) {
throw new MyDuplicateKeyException(e);
}
// 그 외의 데이터베이스 예외
throw new MyDbException(e);
} finally {
closeStatement(pstmt);
closeConnection(con);
}
}
}
}
실행 결과:
Service - saveId=myId
Service - 키 중복, 복구 시도
Service - retryId=myId492
3.5. 예외 변환 상세 분석
리포지토리의 예외 변환 로직:
} catch (SQLException e) {
// H2 데이터베이스 키 중복 오류 코드
if (e.getErrorCode() == 23505) {
throw new MyDuplicateKeyException(e); // 구체적인 예외
}
throw new MyDbException(e); // 일반적인 예외
}
서비스의 예외 처리 로직:
public void create(String memberId) {
try {
repository.save(new Member(memberId, 0));
log.info("saveId={}", memberId);
} catch (MyDuplicateKeyException e) {
// 1. 키 중복: 복구 시도
log.info("키 중복, 복구 시도");
String retryId = generateNewId(memberId);
log.info("retryId={}", retryId);
repository.save(new Member(retryId, 0));
} catch (MyDbException e) {
// 2. 다른 데이터베이스 오류: 로깅 후 재던짐
log.info("데이터 접근 계층 예외", e);
throw e;
}
// 3. 성공: 정상 흐름 계속
}
3.6. 현재 구현의 한계

문제점:
- 데이터베이스 의존성: H2의 오류 코드(23505)에 의존
- 확장성 문제: 새로운 데이터베이스 추가 시 모든 오류 코드 매핑 필요
- 유지보수 어려움: 수십 가지 오류 코드를 모두 처리해야 함
// 각 데이터베이스별 처리 필요
if (isH2Database()) {
if (e.getErrorCode() == 23505) { // H2 키 중복
throw new MyDuplicateKeyException(e);
} else if (e.getErrorCode() == 42000) { // H2 문법 오류
throw new MyBadSqlGrammarException(e);
}
// ... 수십 가지 오류 코드
} else if (isMySQLDatabase()) {
if (e.getErrorCode() == 1062) { // MySQL 키 중복
throw new MyDuplicateKeyException(e);
} else if (e.getErrorCode() == 1064) { // MySQL 문법 오류
throw new MyBadSqlGrammarException(e);
}
// ... 수십 가지 오류 코드
}
// ... 다른 데이터베이스들
4. 스프링 예외 추상화 이해
4.1 스프링의 데이터 접근 예외 계층
스프링은 다양한 데이터 접근 기술(JDBC, JPA, MyBatis 등)의 예외를 일관된 계층 구조로 추상화했다:

스프링은 다양한 데이터 접근 기술(JDBC, JPA, MyBatis 등)의 예외를 일관된 계층 구조로 추상화했다:
주요 예외 설명:
- DuplicateKeyException: 키 중복 오류
- BadSqlGrammarException: SQL 문법 오류
- DataIntegrityViolationException: 데이터 무결성 위반
- DataAccessResourceFailureException: 데이터베이스 연결 실패
- CannotAcquireLockException: 락 획득 실패
- DeadlockLoserDataAccessException: 데드락 발생
- QueryTimeoutException: 쿼리 타임아웃
4.2. 스프링 예외 변환기 (SQLExceptionTranslator)
스프링은 SQLExceptionTranslator를 제공하여 데이터베이스별 오류 코드를 스프링의 예외 계층으로 자동 변환한다:
package hello.jdbc.exception.translator;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator;
import org.springframework.jdbc.support.SQLExceptionTranslator;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
public class SpringExceptionTranslatorTest {
DataSource dataSource;
@BeforeEach
void init() {
dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
}
@Test
void sqlExceptionErrorCode() {
String sql = "select bad grammar"; // 문법 오류가 있는 SQL
try {
Connection con = dataSource.getConnection();
PreparedStatement stmt = con.prepareStatement(sql);
stmt.executeQuery();
} catch (SQLException e) {
assertThat(e.getErrorCode()).isEqualTo(42122); // H2 문법 오류 코드
int errorCode = e.getErrorCode();
log.info("errorCode={}", errorCode);
log.info("error", e);
}
}
@Test
void exceptionTranslator() {
String sql = "select bad grammar"; // 문법 오류가 있는 SQL
try {
Connection con = dataSource.getConnection();
PreparedStatement stmt = con.prepareStatement(sql);
stmt.executeQuery();
} catch (SQLException e) {
assertThat(e.getErrorCode()).isEqualTo(42122);
// 스프링 예외 변환기 사용
SQLExceptionTranslator exTranslator =
new SQLErrorCodeSQLExceptionTranslator(dataSource);
// SQLException을 스프링 DataAccessException으로 변환
DataAccessException resultEx = exTranslator.translate("select", sql, e);
log.info("resultEx", resultEx);
assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
}
}
}
4.3. 스프링의 오류 코드 매핑 원리
스프링은 sql-error-codes.xml 파일을 통해 데이터베이스별 오류 코드를 매핑한다:
<!-- org.springframework.jdbc.support.sql-error-codes.xml -->
<beans>
<!-- H2 데이터베이스 -->
<bean id="H2" class="org.springframework.jdbc.support.SQLErrorCodes">
<property name="badSqlGrammarCodes">
<value>42000,42001,42101,42102,42111,42112,42121,42122,42132</value>
</property>
<property name="duplicateKeyCodes">
<value>23001,23505</value>
</property>
<property name="dataIntegrityViolationCodes">
<value>22001,22003,22012,22018,22025,23000</value>
</property>
<!-- ... 다른 오류 코드들 -->
</bean>
<!-- MySQL 데이터베이스 -->
<bean id="MySQL" class="org.springframework.jdbc.support.SQLErrorCodes">
<property name="badSqlGrammarCodes">
<value>1054,1064,1146</value>
</property>
<property name="duplicateKeyCodes">
<value>1062</value>
</property>
<property name="dataIntegrityViolationCodes">
<value>630,839,840,893,1169,1215,1216,1217,1364,1366,1451,1452,1557</value>
</property>
<!-- ... 다른 오류 코드들 -->
</bean>
<!-- ... 다른 데이터베이스들 -->
</beans>
동작 원리:
- SQLErrorCodeSQLExceptionTranslator 생성 시 현재 DataSource의 데이터베이스 정보 확인
- 해당 데이터베이스의 오류 코드 매핑 정보 로드
- SQLException의 오류 코드를 확인하여 적절한 DataAccessException으로 변환
4.4 스프링 예외 추상화의 장점
- 일관된 예외 계층: 모든 데이터 접근 기술에서 동일한 예외 계층 사용
- 데이터베이스 독립성: 데이터베이스 변경 시 예외 처리 코드 수정 불필요
- 의미 있는 예외: 비즈니스 로직에서 이해하기 쉬운 예외 이름
- 복구 가능성: 특정 예외에 대한 복구 로직 구현 용이
5. 스프링 예외 추상화 적용
5.1 MemberRepositoryV4_2 구현 (스프링 예외 변환기 사용)
이제 스프링의 예외 변환기를 사용하도록 리포지토리를 개선해보자:
package hello.jdbc.repository;
import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.support.JdbcUtils;
import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator;
import org.springframework.jdbc.support.SQLExceptionTranslator;
import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;
/**
* SQLExceptionTranslator 추가
*/
@Slf4j
public class MemberRepositoryV4_2 implements MemberRepository {
private final DataSource dataSource;
private final SQLExceptionTranslator exTranslator;
public MemberRepositoryV4_2(DataSource dataSource) {
this.dataSource = dataSource;
this.exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
}
@Override
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(?, ?)";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate();
return member;
} catch (SQLException e) {
// 스프링 예외 변환기 사용
throw exTranslator.translate("save", sql, e);
} finally {
close(con, pstmt, null);
}
}
@Override
public Member findById(String memberId) {
String sql = "select * from member where member_id = ?";
Connection con = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
rs = pstmt.executeQuery();
if (rs.next()) {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
} else {
throw new NoSuchElementException("member not found memberId=" + memberId);
}
} catch (SQLException e) {
// 스프링 예외 변환기 사용
throw exTranslator.translate("findById", sql, e);
} finally {
close(con, pstmt, rs);
}
}
@Override
public void update(String memberId, int money) {
String sql = "update member set money=? where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setInt(1, money);
pstmt.setString(2, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
// 스프링 예외 변환기 사용
throw exTranslator.translate("update", sql, e);
} finally {
close(con, pstmt, null);
}
}
@Override
public void delete(String memberId) {
String sql = "delete from member where member_id=?";
Connection con = null;
PreparedStatement pstmt = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
pstmt.executeUpdate();
} catch (SQLException e) {
// 스프링 예외 변환기 사용
throw exTranslator.translate("delete", sql, e);
} finally {
close(con, pstmt, null);
}
}
// 리소스 관리 메서드들 (DataSourceUtils 사용)
private void close(Connection con, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
DataSourceUtils.releaseConnection(con, dataSource);
}
private Connection getConnection() {
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={} class={}", con, con.getClass());
return con;
}
}
5.2. 서비스 계층의 예외 처리 개선
이제 서비스 계층에서는 스프링의 의미 있는 예외를 사용할 수 있다:
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV4_2 {
private final MemberRepository memberRepository;
@Transactional
public void accountTransfer(String fromId, String toId, int money) {
bizLogic(fromId, toId, money);
}
public void registerMember(String memberId) {
try {
memberRepository.save(new Member(memberId, 0));
log.info("회원 등록 성공: {}", memberId);
} catch (DuplicateKeyException e) {
// 스프링의 DuplicateKeyException 사용
log.warn("키 중복: {}", memberId);
String newMemberId = memberId + "_" + System.currentTimeMillis();
memberRepository.save(new Member(newMemberId, 0));
log.info("새로운 ID로 등록: {}", newMemberId);
} catch (DataAccessException e) {
// 모든 데이터 접근 예외 처리
log.error("데이터 접근 오류", e);
throw new IllegalStateException("데이터베이스 오류", e);
}
}
private void bizLogic(String fromId, String toId, int money) {
Member fromMember = memberRepository.findById(fromId);
Member toMember = memberRepository.findById(toId);
memberRepository.update(fromId, fromMember.getMoney() - money);
validation(toMember);
memberRepository.update(toId, toMember.getMoney() + money);
}
private void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
5.3. 테스트 설정 변경
@Configuration
static class TestConfig {
private final DataSource dataSource;
public TestConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
@Bean
MemberRepository memberRepository() {
// return new MemberRepositoryV4_1(dataSource); // 단순 예외 변환
return new MemberRepositoryV4_2(dataSource); // 스프링 예외 변환
}
@Bean
MemberServiceV4_2 memberServiceV4_2() {
return new MemberServiceV4_2(memberRepository());
}
}
5.4. 다양한 예외 상황 테스트
@Test
void duplicateKeyExceptionTest() {
// given
String memberId = "duplicateId";
memberRepository.save(new Member(memberId, 1000));
// when & then
assertThatThrownBy(() -> memberRepository.save(new Member(memberId, 2000)))
.isInstanceOf(DuplicateKeyException.class);
}
@Test
void badSqlGrammarExceptionTest() {
// 잘못된 SQL 실행 시 BadSqlGrammarException 발생
// 실제 구현에서는 이런 SQL을 실행하지 않지만, 예시를 위해
assertThatThrownBy(() -> executeInvalidSql())
.isInstanceOf(BadSqlGrammarException.class);
}
@Test
void dataIntegrityViolationExceptionTest() {
// null 불가 컬럼에 null 저장 시도
assertThatThrownBy(() -> memberRepository.save(new Member(null, 1000)))
.isInstanceOf(DataIntegrityViolationException.class);
}
5.5. 스프링 예외 추상화의 실무 적용
5.5.1. 전역 예외 처리기
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DuplicateKeyException.class)
public ResponseEntity<ErrorResponse> handleDuplicateKey(DuplicateKeyException e) {
log.warn("키 중복 오류", e);
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse("이미 사용중인 ID입니다."));
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ResponseEntity<ErrorResponse> handleDataIntegrity(DataIntegrityViolationException e) {
log.warn("데이터 무결성 위반", e);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse("데이터 무결성 제약조건을 위반했습니다."));
}
@ExceptionHandler(BadSqlGrammarException.class)
public ResponseEntity<ErrorResponse> handleBadSqlGrammar(BadSqlGrammarException e) {
log.error("SQL 문법 오류", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("시스템 오류가 발생했습니다."));
}
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<ErrorResponse> handleDataAccess(DataAccessException e) {
log.error("데이터 접근 오류", e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("데이터베이스 오류가 발생했습니다."));
}
}
5.5.2. 비즈니스 예외와의 조합
public class MemberService {
public void registerMember(MemberDto dto) {
// 비즈니스 검증
if (!isValidEmail(dto.getEmail())) {
throw new BusinessException("유효하지 않은 이메일 형식입니다.");
}
try {
// 데이터베이스 작업
memberRepository.save(dto.toEntity());
} catch (DuplicateKeyException e) {
// 데이터베이스 수준의 중복 → 비즈니스 예외로 변환
throw new BusinessException("이미 가입된 이메일입니다.", e);
} catch (DataIntegrityViolationException e) {
// 데이터 무결성 위반 → 비즈니스 예외로 변환
throw new BusinessException("필수 정보가 누락되었습니다.", e);
}
}
}
'Spring > DB' 카테고리의 다른 글
| [Advanced-2] 트랜잭션 전파(1): 기본 (0) | 2026.01.04 |
|---|---|
| [Advanced-1] 예외와 트랜잭션 커밋, 롤백 (0) | 2026.01.04 |
| [Basic-5] 자바 예외 이해 (0) | 2026.01.03 |
| [Basic-4] 스프링과 문제 해결 - 트랜잭션 (0) | 2026.01.02 |
| [Basic-3] 트랜잭션 이해 (0) | 2026.01.02 |
