체크 예외와 인터페이스
서비스 계층은 특정 구현 기술에 의존하지 않고 순수하게 유지하는것이 좋다. 이렇게 하려면 예외에 대한 의존도 함께 해결해야 한다.
서비스가 처리할 수 없는 SQLException에 대한 의존성을 제거하려면 이전에 배운것 처럼 런타임 예외로 전환해서 서비스 계층에 던지면 된다.

인터페이스 도입
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);
}
특정 기술에 종속되지 않은 순수한 인터페이스를 기반으로 특정 기술을 사용하는 구현체를 만들면 된다.
왜 이전엔 인터페이스를 사용하지 않았나?
SQLException 체크 예외 때문이다. 결국 인터페이스에도 해당 체크 예외가 선언되어야 하기 때문에 특정 기술에 종속되는 인터페이스가 되어버린다. 다른 기술로 변경하게 된다면 이 인터페이스 자체도 변경해야 하기 때문에 인터페이스를 사용할 이유가 없어지는 것이다.
런타임 예외 적용
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);
}
MyDbException 런타임 예외
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);
}
}
MemberRepositoryV4_1
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);
}
}
catch에서 SQLException을 MyDbException(e); 이라는 런타임 예외로 변환해서 던지는 부분이 핵심이다.
반드시 기존 예외를 포함할 것. 진짜 원인을 찾기 위해 객체를 담아야한다.
MemberServiceV4
package hello.jdbc.service;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import java.sql.SQLException;
// 예외 누수 문제 해결,
// SQLException 제거,
// MemberRepository 인터페이스 의존
@Slf4j
public class MemberServiceV4 {
// private final PlatformTransactionManager transactionManager;
private final MemberRepository memberRepository;
public MemberServiceV4(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@Transactional
public void accountTransfer(String fromId, String toId, int money) {
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 static void validation(Member toMember) {
if (toMember.getMemberId().equals("ex")) {
throw new IllegalStateException("이체중 예외 발생");
}
}
}
메서드에 throw SQLException이 제거되었다. 순수한 서비스가 완성된 것이다.
체크예외를 런타임 예외로 변환하면서 인터페이스와 서비스 계층의 순수성을 유지할 수 있게 되었다.
향후 다른 구현 기술로 변경하더라도 서비스 계층의 코드 변경없이 유지할 수 있다.
데이터 접근 예외 직접 만들기
데이터베이서 오류에 따라서 특정 예외는 복구하고 싶을 수 있다.
예를 들어 회원가입시 DB에 같은 ID가 있다면 뒤에 숫자를 붙여서 새로운 ID를 만들어야 하는 경우로 가정해보자.
"hello"로 시도했는데 중복이라면 "hello12345"처럼 가입하는 것이다.
데이터를 DB에 저장할 때 같은 ID가 이미 데이터베이스에 저장되어 있다면, 데이터베이스는 오류 코드를 반환하고, 이 오류 코드를 받은 JDBC드라이버는 SQLException을 던진다. 그리고 SQLException에는 데이터베이스가 제공하는 errorCode가 들어있다.

서비스 계층에서 위의 예외 복구를 하기 위해선 키 중복 오류를 확인할 수 있어야한다. 리포지토리는 SQLException을 서비스 계층에 던지고, 서비스 계층은 이 예외의 오류 코드를 확인해서 새로운 ID를 다시 만들어 저장하면 된다.
그런데 SQLException의 오류코드를 활용하려고 서비스 계층에 던지면 SQLexception이라는 JDBC 기술에 의존하게 되면서, 순수한 서비스 계층이 아니게 된다. 이를 해결하기 위해 리포지토리에서 예외를 변환해서 던지면 된다.
SQLException -> MyDuplicateKeyException
package hello.jdbc.repository.ex;
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);
}
}
ExTranslatorV1Test
package hello.jdbc.exception.translator;
import hello.jdbc.connection.ConnectionConst;
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 org.springframework.jdbc.support.JdbcUtils;
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.*;
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");
}
@Slf4j
@RequiredArgsConstructor
static class Service {
private final Repository repository;
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) value(?, ?)";
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 db
if (e.getErrorCode() == 23505) {
throw new MyDuplicateKeyException(e);
}
throw new MyDbException(e);
} finally {
JdbcUtils.closeStatement(pstmt);
JdbcUtils.closeConnection(con);
}
}
}
}
Service()에 catch가 두개 있는 이유는, 이런게 가능하다는것을 보여주기 위함이다. 복구할 수 없는 예외는 공통 처리로직에서 로그를 남기는것이 좋다.
코드는 쉽다. 23505를 찾으면 MyDuplicateKeyException을 던지고, 서비스 계층에서 catch로 잡아서 해결하면 된다.
정리하자면
-SQL ErrorCode로 DB에서 어떤 오류가 있는지 확인할 수 있다.
- 예외 변환을 통해 순수한 서비스 계층에서 예외를 복구할 수 있었다.
남은 문제
- DB 변경시 ErrorCode가 다를 경우 코드를 변경해야하는 문제 발생
스프링 예외 추상화 이해
스프링은 앞서 설명한 문제들을 해결하기 위해 데이터 접근과 관련된 예외를 추상화해서 제공한다.

- 각각의 예외는 특정 기술에 종속적이지 않게 설계되어있기 때문에, 서비스 계층에서도 스프링이 제공하는 예외를 사용하면 된다.
- DataAccessException은 데이터 접근 계층의 최상위 예외이다. 당연히 런타임예외 (언체크)이다.
- DataAccessException은 크게 두가지로 구분하는데, NonTransient, Transient 예외이다.
Transient : 일시적, 동일한 SQL을 다시 시도할 경우 성공할 가능성이 있음. 예로 쿼리 타임아웃, 락 등 DB 상태가 좋아지거나 락이 풀렸을 경우에 다시 시도하면 성공 가능
NonTransient : 일시적이지 않음. SQL을 반복해서 실행하면 무조건 실패. (SQL 문법 오류, 제약조건 위배 등)
스프링이 제공하는 예외 변환기
package hello.jdbc.exception.translator;
import hello.jdbc.connection.ConnectionConst;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
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 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.*;
@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);
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);
SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
//BadSqlGrammarException
DataAccessException resultEx = exTranslator.translate("select", sql, e);
log.info("resultEx", resultEx);
assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
}
}
}
sqlExsceptionErrorCode() : 직접 확인하는 방법. 현실성이 없음. 하나하나 예외를 확인하긴 어렵고, DB마다 에러코드가 다른점도 해결해야하기때문에 예외 변환기를 사용한다.
exceptionTranslator() : 스프링이 제공하는 SQL 예외 변환기를 사용한 코드
1. new SQLErorrCodeSQLExceptionTranslator : dataSource에 대한 DB 벤더 이름 조회 후, 내부적으로 /org/springframework/jdbc/support/sql-error-codes.xml을 로드해서 DB 이름에 맞는 SQLErrorCodes 객체 구성
2. translate("taskName", sql, e) : 변환기가 e.getErrorCode() 를 보고, xml에서 로드한 매핑 정보 확인.
매칭 되는 코드가 있으면 그에 맞는 DataAccessException 하위 예외로 전환. (위 코드에선 badSqlGrammarException)
즉 SQLErorrCodeSQLExceptionTranslator가 DB 정보 확인 후 세팅. translate에 파라미터로 담긴 e의 객체를 분석 후 맞는 예외로 변환해준다.
이로써 특정 기술에 종속적인 SQLException같은 예외를 직접 사용하지 않을 수 있다.
스프링 예외 추상화 적용
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 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 resultSet = null;
try {
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, memberId);
ResultSet 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, resultSet);
}
}
@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);
int resultSize = pstmt.executeUpdate();
log.info("resultSize={}", resultSize);
} 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);
}
}
private void close(Connection connection, Statement stmt, ResultSet rs) {
JdbcUtils.closeResultSet(rs);
JdbcUtils.closeStatement(stmt);
// 주의 트랜잭션 동기화 사용하려면 DataSourceUtils 사용
DataSourceUtils.releaseConnection(connection, dataSource);
}
private Connection getConnection() throws SQLException {
// 주의. 트랜잭션 동기화 사용하려면 DataSourceUtils 사용
Connection con = DataSourceUtils.getConnection(dataSource);
log.info("get connection={}, class={}", con, con.getClass());
return con;
}
}
의존성 주입부분과 생성자 부분을 확인해보자
catch부분은 이제 SQLExceptionTranslator를 사용해서 처리할 수 있게 되었다.
이렇게 스프링이 예외를 추상화해준 덕에 서비스 계층은 특정 리포지토리 구현 기술과 예외에 종속적이지 않게 되었다.
다시 DI를 제대로 활용하게 된 것이다.
JDBC 반복 문제 해결 - JdbcTemplate
리포지토리의 코드를 보면 반복되는 코드가 많다. 커넥션 조회, PreparedStatement생성 및 파라미터 바인딩, 쿼리 실행, 결과, 예외 변환, 리소스 종료가 모두 반복이다. 이것은 jdbcTemplate을 사용하면 해결된다.
package hello.jdbc.repository;
import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import javax.sql.DataSource;
// JdbcTemplate 사용
@Slf4j
public class MemberRepositoryV5 implements MemberRepository {
private final JdbcTemplate template;
public MemberRepositoryV5(DataSource dataSource) {
this.template = new JdbcTemplate(dataSource);
}
@Override
public Member save(Member member) {
String sql = "insert into member(member_id, money) values (?, ?)";
template.update(sql, member.getMemberId(), member.getMoney());
return member;
}
@Override
public Member findById(String memberId) {
String sql = "select * from member where member_id = ?";
return template.queryForObject(sql, memberRowMapper(), memberId);
}
private RowMapper<Member> memberRowMapper() {
return (rs, rowNum) -> {
Member member = new Member();
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
return member;
};
}
@Override
public void update(String memberId, int money) {
String sql = "update member set money=? where member_id=?";
template.update(sql, money, memberId);
}
@Override
public void delete(String memberId) {
String sql = "delete from member where member_id=?";
template.update(sql, memberId);
}
}
코드가 엄청 줄어든걸 볼 수 있다. 현재 강의에선 줄어든다 정도에만 초점을 맞추라고 함
findById부분이 이해가 안가서 검색 결과
RowMapper는 한 행(row) -> 객체(Object) 변환기
DB에서 꺼낸 ResultSet 데이터를 스프링이 바로 쓸수 있게 Member 같은 도메인 객체로 만들어주는 중간 단계.
- 언제 사용? : JdbcTemplate.query() , queryForObject() 실행 시
- 무엇을 반환? : ResultSet의 각 Row를 변환한 Member 객체
- 왜 필요? : JDBC는 ResultSet을 일일이 getString, getInt 해야하지만 RowMapper는 그 과정을 캡슐화해서 깔끔하게 처리
- 장점? : SQL 실행 결과를 도메인 객체 리스트나 단일 객체로 쉽게 매핑 가능
핵심 요약
1) 서비스 계층 “순수성” 유지
- 서비스는 비즈니스 로직만 담고, 기술(JDBC, JPA 등)·예외에 의존하지 않게 설계.
- 이를 위해 인터페이스(Repository) 도입 + 예외 추상화 필요.
2) 체크 예외 → 런타임 예외로 전환
- SQLException 같은 체크 예외를 런타임 예외(ex. MyDbException)로 감싸 던짐(원인 예외 포함!).
- 장점: 서비스/컨트롤러에 기술 예외 의존 제거, throws 전파 제거 → 코드 단순화.
3) 예외 복구가 필요한 케이스만 도메인화
- 예: 중복 키는 MyDuplicateKeyException 같은 의미있는 런타임 예외로 변환 → 서비스에서 복구 로직(아이디 재생성 등) 수행 가능.
- 복구 불가/공통 예외는 전역 처리(@ControllerAdvice)로 일관성 있게 처리.
4) 스프링의 예외 추상화 사용
- 스프링이 제공하는 최상위 예외: DataAccessException (런타임).
- SQLErrorCodeSQLExceptionTranslator 가 DB 벤더별 에러코드 → 적절한 DataAccessException 하위 예외로 변환(예: BadSqlGrammarException).
- DB 변경 시에도 서비스/컨트롤러 영향 최소화.
5) 트랜잭션은 AOP로(@Transactional)
- 서비스에 @Transactional 적용 → 스프링이 프록시로 트랜잭션 시작/커밋/롤백 관리.
- 내부적으로 PlatformTransactionManager + TransactionSynchronizationManager가 커넥션 바인딩 관리.
- 리포지토리는 DataSourceUtils.getConnection/release 사용해 동일 트랜잭션 커넥션을 공유.
6) JDBC 반복문제 - JdbcTemplate
- 커넥션 획득, 준비/바인딩, 실행, 예외 변환, 자원 정리 반복 제거.
- query / queryForObject + RowMapper 로 Row → 도메인 객체 매핑을 깔끔하게 처리.
7) 구조 정리
- Repository 인터페이스: 기술 독립적 메서드 시그니처(체크 예외 없음).
- 구현체(V4_2): SQLExceptionTranslator로 예외 변환, DataSourceUtils로 트랜잭션 연동.
- 서비스(V4): @Transactional + 순수 비즈니스 로직만.
'Spring' 카테고리의 다른 글
| 데이터 접근 기술 - 테스트 (0) | 2025.10.20 |
|---|---|
| 데이터 접근 기술 - 스프링 JdbcTemplate (0) | 2025.10.18 |
| 자바 예외 이해 (0) | 2025.10.16 |
| 트랜잭션 2 (0) | 2025.10.13 |
| 트랜잭션 (0) | 2025.10.02 |
