[Basic-6] 스프링과 문제 해결 - 예외 처리, 반복

2026. 1. 3. 22:56·Spring/DB

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;
}

문제점 분석:

  1. 인터페이스 오염: 인터페이스가 SQLException이라는 JDBC 전용 예외에 의존하게 됨
  2. 구현 기술 변경 어려움: 다른 기술(JPA, NoSQL)로 변경 시 인터페이스도 함께 수정해야 함
  3. 상위 타입 규칙: 구현 클래스는 인터페이스에서 선언한 예외 또는 그 하위 타입만 던질 수 있음
// 구현 클래스
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;
    }
}

핵심 변경사항:

  1. 예외 전환 패턴
    } catch (SQLException e) {
        throw new MyDbException(e);  // 원인 예외 포함
    }
    
  2. 원인 예외 포함의 중요성
    • 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("이체중 예외 발생");
        }
    }
}

변화의 의미:

  1. throws SQLException 제거 → JDBC 기술 의존성 제거
  2. MemberRepository 인터페이스 의존 → 구현 기술 변경 용이
  3. 완전한 순수 비즈니스 로직

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. 현재까지의 성과와 남은 문제

성과:

  1. ✅ 서비스 계층의 순수성 달성
  2. ✅ 인터페이스를 통한 구현 기술 분리
  3. ✅ 체크 예외 의존성 제거

남은 문제:

  1. 모든 예외가 MyDbException으로 통합되어 구분 불가
  2. 특정 예외 상황(예: 키 중복)에 대한 복구 로직 구현 어려움
  3. 데이터베이스별 오류 코드 차이 처리 필요

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. 현재 구현의 한계

문제점:

  1. 데이터베이스 의존성: H2의 오류 코드(23505)에 의존
  2. 확장성 문제: 새로운 데이터베이스 추가 시 모든 오류 코드 매핑 필요
  3. 유지보수 어려움: 수십 가지 오류 코드를 모두 처리해야 함
// 각 데이터베이스별 처리 필요
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 등)의 예외를 일관된 계층 구조로 추상화했다:

주요 예외 설명:

  1. DuplicateKeyException: 키 중복 오류
  2. BadSqlGrammarException: SQL 문법 오류
  3. DataIntegrityViolationException: 데이터 무결성 위반
  4. DataAccessResourceFailureException: 데이터베이스 연결 실패
  5. CannotAcquireLockException: 락 획득 실패
  6. DeadlockLoserDataAccessException: 데드락 발생
  7. 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>

동작 원리:

  1. SQLErrorCodeSQLExceptionTranslator 생성 시 현재 DataSource의 데이터베이스 정보 확인
  2. 해당 데이터베이스의 오류 코드 매핑 정보 로드
  3. SQLException의 오류 코드를 확인하여 적절한 DataAccessException으로 변환

4.4 스프링 예외 추상화의 장점

  1. 일관된 예외 계층: 모든 데이터 접근 기술에서 동일한 예외 계층 사용
  2. 데이터베이스 독립성: 데이터베이스 변경 시 예외 처리 코드 수정 불필요
  3. 의미 있는 예외: 비즈니스 로직에서 이해하기 쉬운 예외 이름
  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
'Spring/DB' 카테고리의 다른 글
  • [Advanced-2] 트랜잭션 전파(1): 기본
  • [Advanced-1] 예외와 트랜잭션 커밋, 롤백
  • [Basic-5] 자바 예외 이해
  • [Basic-4] 스프링과 문제 해결 - 트랜잭션
h6bro
h6bro
백엔드 개발자의 기술 블로그
  • h6bro
    Jun's Tech Blog
    h6bro
  • 전체
    오늘
    어제
    • 분류 전체보기 (250) N
      • Java (18)
        • Core (9)
        • Design Pattern (9)
      • Spring (80)
        • Core (24)
        • MVC (6)
        • DB (10)
        • JPA (26)
        • Monitoring (3)
        • Security (11)
        • WebSocket (0)
      • Database (33)
        • Redis (15)
        • MySQL (18)
      • MSA (25) N
        • MSA 기본 (11)
        • MSA 아키텍처 (14) N
      • Kafka (30)
        • Core (18)
        • Connect (12)
      • ElasticSearch (11)
        • Search (11)
        • Logging (0)
      • Test (4)
        • k6 (4)
      • Docker (9)
      • CI&CD (10)
        • GitHub Actions (6)
        • ArgoCD (4)
      • Kubernetes (18)
        • Core (12)
        • Ops (6)
      • Cloud Engineering (4)
        • AWS Infrastructure (3)
        • AWS EKS (1)
        • Terraform (0)
      • Project (8)
        • LinkFolio (1)
        • Secondhand Market (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

    • Cloud Engineering 포스팅 정리
  • 인기 글

  • 태그

    ㅈ
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
h6bro
[Basic-6] 스프링과 문제 해결 - 예외 처리, 반복
상단으로

티스토리툴바