# JDBC 이해
# JDBC 등장 배경
데이터베이스마다 커넥션 연결, SQL 전달, 결과 응답 방식이 모두 달랐다.
이로 인해 DB를 교체하면 앱 코드 전체를 수정해야 했고, DB마다 사용법을 새로 익혀야 했다.
이 문제를 해결하기 위해 JDBC(Java Database Connectivity) 표준이 등장했다.
# JDBC 표준 인터페이스
JDBC는 다음 3가지를 표준 인터페이스로 정의한다.
java.sql.Connection— DB 연결java.sql.Statement— SQL 전달java.sql.ResultSet— 결과 응답
각 DB 벤더는 이 인터페이스를 구현한 JDBC 드라이버를 라이브러리로 제공한다.
덕분에 앱 로직은 JDBC 표준 인터페이스에만 의존하면 되고, DB를 교체해도 드라이버만 바꾸면 된다.
JDBC 드라이버란 자바에서 데이터베이스에 접근하기 위한 표준 API를 말한다.
표준화의 한계
JDBC 코드는 그대로 유지되지만, SQL은 DB마다 다르다 (예: 페이징 문법).
이 문제는 JPA를 사용하면 상당 부분 해결된다.
# 최신 데이터 접근 기술
JDBC를 직접 쓰는 것은 복잡하고 반복이 많아서, 실무에서는 주로 아래 기술을 사용한다.
| 분류 | 특징 | 대표 기술 |
|---|---|---|
| SQL Mapper | SQL 직접 작성, 반복 코드 제거 | JdbcTemplate, MyBatis |
| ORM | SQL 자동 생성, 생산성 높음, 학습 곡선 있음 | JPA (Hibernate) |
중요
SQL Mapper든 ORM이든 내부적으로는 모두 JDBC를 사용한다.
JDBC 동작 원리를 알아야 문제 발생 시 근본 원인을 찾을 수 있다.
# DriverManager 커넥션 흐름
Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD);
DriverManager.getConnection()호출- 등록된 드라이버 목록을 순서대로 순회하며 URL을 체크
- 처리 가능한 드라이버가 실제 DB에 연결하고 커넥션 반환
jdbc:h2:...로 시작하면 H2 드라이버가, jdbc:mysql:...이면 MySQL 드라이버가 처리한다.
# 드라이버 자동 등록
예전에는 코드에서 직접 등록해야 했다:
Class.forName("org.h2.Driver"); // 클래스 로딩하면서 드라이버 등록
Java 6부터 SPI(Service Provider Interface) 메커니즘으로 자동 등록된다.
각 드라이버 jar 파일 안에 아래 파일이 포함되어 있다:
META-INF/services/java.sql.Driver
JVM 시작 시 클래스패스의 jar들을 스캔해서 이 파일을 발견하면 DriverManager에 자동 등록한다.
즉, build.gradle에 의존성만 추가하면 별도 코드 없이 드라이버가 등록된다.
runtimeOnly 'com.h2database:h2' // H2
runtimeOnly 'mysql:mysql-connector-java' // MySQL
runtimeOnly 'org.postgresql:postgresql' // PostgreSQL
여러 드라이버가 동시에 등록된 경우, 각 드라이버의 acceptsURL() 메서드가 URL 앞부분을 보고 자신이 처리할 수 있는지 판단한다:
jdbc:mysql://... → MySQL 드라이버가 처리
jdbc:postgresql://... → PostgreSQL 드라이버가 처리
자바는 표준 인터페이스만 제공하고, 각 DB 진영별로 실제 구현체를 제공한다.
# DB URL 구조
URL은 DB 서버 정보를 형식에 맞게 옮겨 적는 것이다.
jdbc:[드라이버]://[DB서버주소]:[포트]/[DB이름]
│ │ │ │
│ │ │ └── DB 이름 (직접 생성한 DB)
│ │ └── 호스트:포트
│ └── 드라이버 식별자 (고정값)
└── JDBC 프로토콜
| 항목 | 결정 기준 |
|---|---|
| 드라이버 식별자 | 사용하는 DB에 따라 고정값 (mysql, postgresql, h2 등) |
| 서버 주소 | 로컬이면 localhost, 클라우드면 해당 엔드포인트 |
| 포트 | DB 설치 시 설정된 포트. 대부분 기본값 그대로 사용 |
| DB 이름 | CREATE DATABASE mydb로 만들어둔 이름 |
| DB | URL 예시 | 기본 포트 |
|---|---|---|
| H2 | jdbc:h2:tcp://localhost/~/test | 9092 |
| MySQL | jdbc:mysql://localhost:3306/mydb | 3306 |
| PostgreSQL | jdbc:postgresql://localhost:5432/mydb | 5432 |
스프링 부트 설정 예시
spring.datasource.url=jdbc:mysql://localhost:3306/mydb
spring.datasource.username=root
spring.datasource.password=1234
민감 정보 관리
운영 환경의 비밀번호는 파일에 직접 쓰지 않고 환경변수로 주입하는 게 일반적이다.
spring.datasource.password=${DB_PASSWORD}
환경별 설정 분리 (실무 패턴)
application.properties # 공통 설정
application-local.properties # 로컬 DB
application-prod.properties # 운영 DB
# JDBC 개발 - 등록 (INSERT)
String sql = "insert into member(member_id, money) values(?, ?)";
con = getConnection();
pstmt = con.prepareStatement(sql);
pstmt.setString(1, member.getMemberId());
pstmt.setInt(2, member.getMoney());
pstmt.executeUpdate(); // 영향받은 row 수 반환
- setString, setInt와 같은 메서드의 첫 번째 파라미터 값은 쿼리의
?에 들어갈 위치를 지정하는 것이다.
리소스 정리 필수
finally 블록에서 ResultSet → Statement → Connection 역순으로 반드시 닫아야 한다.
누락 시 커넥션이 계속 유지되어 커넥션 부족 장애가 발생한다.
SQL Injection 방지
PreparedStatement의 ? 바인딩 방식을 사용해야 SQL Injection을 예방할 수 있다.
Statement로 문자열을 직접 이어붙이는 방식은 사용하지 않는다.
# JDBC 개발 - 조회 (SELECT)
rs = pstmt.executeQuery(); // ResultSet 반환
if (rs.next()) {
member.setMemberId(rs.getString("member_id"));
member.setMoney(rs.getInt("money"));
}
executeUpdate()— INSERT/UPDATE/DELETE에 사용executeQuery()— SELECT에 사용,ResultSet반환rs.next()— 커서를 다음 행으로 이동. 데이터가 있으면true, 없으면false- 쿼리시
SELECT member_id, money ..와 같이 지정하면 member_id, money라는 이름으로 데이터가 저장된다. - 이때
rs.getString("member_id")와 같이 조회를 할 수 있다.
- 쿼리시
- 단건 조회는
if, 다건 조회는while사용
# JDBC 개발 - 수정/삭제
등록과 동일하게 executeUpdate()를 사용한다. 반환값은 영향받은 row 수.
// UPDATE
String sql = "update member set money=? where member_id=?";
// DELETE
String sql = "delete from member where member_id=?";
# 커넥션 풀과 DataSource 이해
# 커넥션을 매번 새로 생성하면 생기는 문제
커넥션 하나를 만들 때마다 아래 과정이 전부 실행된다:
- DB 드라이버가 DB에 TCP/IP 연결 (3-way handshake 발생)
- ID/PW 전달 및 DB 내부 인증
- DB 세션 생성
- 커넥션 객체 반환
SQL 실행 시간 외에 커넥션 생성 시간이 추가되어 응답 속도에 영향을 준다.
# 커넥션 풀
앱 시작 시점에 커넥션을 미리 생성해두고 재사용하는 방식이다.
[앱 시작]
커넥션 풀 → DB에 커넥션 10개 미리 생성 (TCP/IP 연결 상태 유지)
[요청 발생]
앱 로직 → 풀에서 커넥션 꺼내 사용 → 사용 후 풀에 반납 (종료 X)
핵심 포인트
close()를 호출해도 실제로 커넥션을 끊는 게 아니라 풀에 반납하는 것이다.
커넥션은 살아있는 상태로 반납해야 한다.
풀 사이즈 설정
적절한 커넥션 수는 서비스 특징과 서버 스펙에 따라 다르므로 성능 테스트를 통해 결정해야 한다.
기본값은 보통 10개이며, 최대 커넥션 수를 제한해 DB를 보호하는 효과도 있다.
커넥션 풀 초기화는 별도 쓰레드에서 실행된다. 앱 실행 속도에 영향을 주지 않기 위해서다.
실무에서는 커넥션 풀 구현체로 HikariCP를 사용한다. 스프링 부트 2.0부터 기본 내장되어 있다.
# DataSource 이해
커넥션을 얻는 방법이 다양하다 (DriverManager, HikariCP, DBCP2 등).
만약 DriverManager에서 HikariCP로 변경하면 앱 코드도 함께 변경해야 하는 문제가 생긴다.
이를 해결하기 위해 자바는 javax.sql.DataSource 인터페이스를 제공한다.
public interface DataSource {
Connection getConnection() throws SQLException;
}
대부분의 커넥션 풀은 DataSource를 이미 구현해두었다.
앱 로직은 DataSource 인터페이스에만 의존하면 되고, 구현체를 교체해도 코드 변경이 없다.
DriverManager와 DataSource
DriverManager는 DataSource를 구현하지 않는다.
스프링은 이 문제를 해결하기 위해 DriverManagerDataSource라는 래퍼 클래스를 제공한다.
덕분에 DriverManager도 DataSource 방식으로 사용할 수 있다.
# DataSource 추상화
DataSource는 커넥션을 획득하는 방법을 추상화한 인터페이스다. 핵심 메서드는 getConnection() 하나다.
구현체마다 내부 동작이 다르다:
// DriverManagerDataSource → 매번 새 커넥션 생성
// HikariDataSource → 풀에서 커넥션 꺼내 반환
dataSource.getConnection(); // 호출하는 쪽은 동일
앱 로직은 DataSource 인터페이스에만 의존하기 때문에, 외부에서 주입하는 구현체만 바꾸면 코드 수정 없이 교체할 수 있다.
DriverManager와의 관계
DriverManager는 원래 DataSource와 무관한 별개의 static 메서드다.
스프링이 DriverManagerDataSource라는 래퍼를 제공해서 DataSource 방식으로 사용할 수 있게 해준다.
내부적으로는 DriverManager를 호출하므로 동작은 동일하게 매번 새 커넥션을 생성한다.
# 설정과 사용의 분리
DriverManager는 커넥션을 획득할 때마다 URL, USERNAME, PASSWORD를 전달해야 한다.
DataSource는 객체 생성 시점에 한 번만 설정하고, 이후에는 getConnection()만 호출한다.
// DriverManager - 호출할 때마다 파라미터 필요
DriverManager.getConnection(URL, USERNAME, PASSWORD);
DriverManager.getConnection(URL, USERNAME, PASSWORD);
// DataSource - 설정은 한 번, 사용은 단순하게
DataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
dataSource.getConnection(); // 파라미터 불필요
dataSource.getConnection();
Repository는 DataSource만 주입받으면 되고, URL/USERNAME/PASSWORD를 몰라도 된다.
설정은 한 곳에서, 사용은 여러 곳에서 하는 구조가 명확하게 분리된다.
# HikariCP 적용 예시
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(URL);
dataSource.setUsername(USERNAME);
dataSource.setPassword(PASSWORD);
dataSource.setMaximumPoolSize(10);
# DriverManagerDataSource vs HikariDataSource 로그 비교
// DriverManagerDataSource - 매번 새 커넥션
get connection=conn0 ...
get connection=conn1 ...
get connection=conn2 ...
// HikariDataSource - 커넥션 재사용
get connection=HikariProxyConnection wrapping conn0 ...
get connection=HikariProxyConnection wrapping conn0 ...
get connection=HikariProxyConnection wrapping conn0 ...
HikariCP는 conn0을 반납받아 재사용하기 때문에 항상 같은 커넥션 번호가 나온다.
HikariProxyConnection은 실제 커넥션(JdbcConnection)을 감싼 프록시 객체다.
사실상 HikariCP의 스레드 풀 관리 방식이 정석이기 때문에 실무에서 DriverManager를 직접 쓸 일은 거의 없다.
# DI + OCP
MemberRepository가 DataSource 인터페이스에만 의존하기 때문에,
DriverManagerDataSource → HikariDataSource로 교체해도 Repository 코드는 전혀 변경하지 않아도 된다.
# 트랜잭션 이해
# 트랜잭션이란
여러 작업을 하나의 작업처럼 묶어서 처리하는 단위다.
모두 성공하면 커밋(Commit), 하나라도 실패하면 롤백(Rollback)한다.
계좌이체 예시:
- A 잔고 5000원 감소
- B 잔고 5000원 증가
1번만 성공하고 2번이 실패하면 A의 돈만 사라지는 심각한 문제가 생긴다.
트랜잭션은 이런 상황에서 1번을 롤백해서 원래 상태로 복구해준다.
# ACID
| 속성 | 설명 |
|---|---|
| 원자성(Atomicity) | 모두 성공하거나 모두 실패 |
| 일관성(Consistency) | 무결성 제약 조건 항상 만족 |
| 격리성(Isolation) | 동시 실행 트랜잭션이 서로 영향 안 줌 |
| 지속성(Durability) | 성공한 트랜잭션 결과는 영구 저장 |
격리 수준
격리성을 완벽히 보장하면 성능이 나빠지기 때문에 4단계로 나눈다.
실무에서는 보통 READ COMMITTED 를 기본으로 사용한다.
- READ UNCOMMITTED → READ COMMITTED → REPEATABLE READ → SERIALIZABLE
# DB 세션
커넥션을 맺으면 DB 서버 내부에 세션이 생성된다.
SQL 실행, 트랜잭션 시작/커밋/롤백은 모두 이 세션을 통해 이루어진다.
커넥션 풀 10개 생성 → DB 세션도 10개 생성
# 자동 커밋 vs 수동 커밋
-- 자동 커밋 (기본값) - 쿼리 실행마다 즉시 커밋
set autocommit true;
-- 수동 커밋 - 트랜잭션 시작을 의미
set autocommit false;
insert ...;
insert ...;
commit; -- 또는 rollback;
오토 커밋 주의
오토 커밋 상태에서 계좌이체 도중 실패하면 첫 번째 쿼리만 커밋되어버려서 데이터 정합성이 깨진다.
트랜잭션이 필요한 작업은 반드시 수동 커밋 모드를 사용해야 한다.
# 커밋 전 데이터 격리
커밋 전까지는 변경 데이터가 해당 세션에서만 보인다.
다른 세션에서는 커밋 이후에야 조회할 수 있다.
세션1: insert → (커밋 전)
세션1 조회: 데이터 보임
세션2 조회: 데이터 안 보임
세션1: commit →
세션1 조회: 데이터 보임
세션2 조회: 데이터 보임
# DB 락
동시에 같은 데이터를 수정하면 원자성이 깨질 수 있다.
DB는 락(Lock) 으로 이 문제를 해결한다.
세션1: 락 획득 → update 실행 → 커밋 → 락 반납
세션2: 락 대기 → (세션1 커밋 후) 락 획득 → update 실행
락 대기 시간을 초과하면 락 타임아웃 오류가 발생한다:
SET LOCK_TIMEOUT 60000; -- 60초 설정
일반 조회는 락 없이 바로 읽을 수 있다.
조회 시점에 락이 필요하면 SELECT FOR UPDATE를 사용한다:
-- 조회하면서 동시에 락 획득
select * from member where member_id='memberA' for update;
SELECT FOR UPDATE 사용 시점
조회한 데이터로 중요한 계산을 수행하는 동안, 다른 세션이 해당 데이터를 변경하면 안 될 때 사용한다.
# 트랜잭션 적용 위치
트랜잭션은 서비스 계층에서 시작해야 한다.
비즈니스 로직이 잘못되면 관련된 모든 작업을 함께 롤백해야 하기 때문이다.
서비스 계층: 커넥션 생성 → 트랜잭션 시작(autocommit false)
→ 비즈니스 로직 수행 (Repository 호출)
→ 성공: commit / 실패: rollback
→ 커넥션 종료
같은 커넥션 유지 필수
트랜잭션을 사용하는 동안은 같은 커넥션을 유지해야 같은 세션을 사용할 수 있다.
서비스에서 생성한 커넥션을 Repository 메서드에 파라미터로 전달하는 방식으로 구현한다.
// 서비스 계층
Connection con = dataSource.getConnection();
con.setAutoCommit(false); // 트랜잭션 시작
try {
bizLogic(con, fromId, toId, money); // 커넥션 파라미터로 전달
con.commit();
} catch (Exception e) {
con.rollback();
} finally {
con.setAutoCommit(true); // 풀 반납 전 기본값 복구
con.close();
}
커넥션 풀 반납 시 주의
수동 커밋 상태로 커넥션을 풀에 반납하면 다음 사용자에게 영향을 줄 수 있다.
반납 전에 반드시 setAutoCommit(true)로 원복해야 한다.
# 스프링과 문제 해결 - 트랜잭션
# 애플리케이션 계층 구조
스프링 애플리케이션은 크게 3계층으로 나뉜다.
| 계층 | 어노테이션 | 역할 |
|---|---|---|
| 프레젠테이션 | @Controller | HTTP 요청/응답, 입력 검증 |
| 서비스 | @Service | 핵심 비즈니스 로직 |
| 데이터 접근 | @Repository | DB 접근 (JDBC, JPA 등) |
핵심 원칙
서비스 계층은 특정 기술에 종속되지 않는 순수 자바 코드여야 한다. 기술이 바뀌어도 서비스 로직은 그대로 유지되어야 한다.
# 트랜잭션 적용으로 인한 문제점 3가지
1. 트랜잭션 문제
- JDBC 구현 기술이 서비스 계층에 누수됨 (
Connection,DataSource,SQLException)- 트랜잭션 시작 주체가 서비스 계층이기 때문
- 커넥션 시작 및 커밋 / 롤백이 서비스 계층에 종속
- 같은 트랜잭션 유지를 위해 커넥션을 파라미터로 넘겨야 함
try/catch/finally반복- 예외 발생시 롤백을 위한 catch
2. 예외 누수 문제
SQLException(JDBC 전용 체크 예외)이 서비스 계층으로 전파됨- 기술 변경 시 예외도 함께 바꿔야 해서 서비스 코드가 수정됨
3. JDBC 반복 문제
- 커넥션 열기 →
PreparedStatement→ 결과 매핑 → 리소스 정리 패턴이 반복됨
트랜잭션으로 인해 오염된 서비스 계층을 순수하게 유지하기 위한 방법들을 알아보자.
# 트랜잭션 추상화
구현 기술마다 트랜잭션 시작 방식이 다르다.
// JDBC
con.setAutoCommit(false);
// JPA
transaction.begin();
스프링은 PlatformTransactionManager 인터페이스로 이를 추상화한다.
public interface PlatformTransactionManager extends TransactionManager {
TransactionStatus getTransaction(TransactionDefinition definition);
void commit(TransactionStatus status);
void rollback(TransactionStatus status);
}
| 구현체 | 용도 |
|---|---|
DataSourceTransactionManager | JDBC |
JpaTransactionManager | JPA (DataSource 기능도 포함) |
HibernateTransactionManager | Hibernate |
TIP
기술 변경 시 서비스 코드는 그대로, DI 주입 구현체만 바꾸면 된다. OCP 원칙을 지킨다.
# 트랜잭션 동기화
파라미터로 커넥션을 전달하는 방식의 문제를 해결하기 위해 트랜잭션 동기화 매니저를 사용한다.
ThreadLocal을 사용해 커넥션을 보관 → 멀티스레드 안전- 리포지토리는
DataSourceUtils.getConnection()으로 동기화된 커넥션을 꺼내 사용
// 이렇게 하면 안됨 - ThreadLocal 모름, 매번 새 커넥션
con = dataSource.getConnection();
// 이렇게 해야 함 - ThreadLocal 확인해서 트랜잭션 커넥션 재사용
con = DataSourceUtils.getConnection(dataSource);
주의
con.close()를 직접 호출하면 트랜잭션이 끊긴다. 반드시 DataSourceUtils.releaseConnection()을 사용해야 한다.
# 트랜잭션 매니저 적용 (V3_1)
서비스 계층에서 PlatformTransactionManager를 주입받아 사용한다. 트랜잭션 추상화를 실질적으로 적용한 모습이다.
@RequiredArgsConstructor
public class MemberServiceV3_1 {
private final PlatformTransactionManager transactionManager;
private final MemberRepositoryV3 memberRepository;
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
bizLogic(fromId, toId, money);
transactionManager.commit(status);
} catch (Exception e) {
transactionManager.rollback(status);
throw new IllegalStateException(e);
}
}
}
# 트랜잭션 템플릿 (V3_2)
try/catch/finally + 커밋/롤백 반복 코드를 TransactionTemplate으로 제거한다.
public class MemberServiceV3_2 {
private final TransactionTemplate txTemplate;
public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) {
this.txTemplate = new TransactionTemplate(transactionManager);
}
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
txTemplate.executeWithoutResult((status) -> {
try {
bizLogic(fromId, toId, money);
} catch (SQLException e) {
throw new IllegalStateException(e); // 체크 예외 → 언체크 변환
}
});
}
}
- 정상 수행 → 자동 커밋
- 언체크 예외 발생 → 자동 롤백
- 체크 예외 발생 → 커밋 (주의!)
한계
트랜잭션 템플릿을 써도 서비스 코드에 트랜잭션 관련 기술 로직이 남아 있다. 순수한 비즈니스 로직만 남기려면 AOP가 필요하다.
# 트랜잭션 AOP - @Transactional (V3_3)
@Transactional 하나로 트랜잭션 관련 코드를 서비스에서 완전히 제거한다. 내부적으로 트랜잭션 매니저도 주입받아 추상화 문제까지 해소한다.
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV3_3 {
private final MemberRepositoryV3 memberRepository;
@Transactional
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
bizLogic(fromId, toId, money); // 순수 비즈니스 로직만
}
}
스프링은 @Transactional이 붙은 클래스/메서드에 CGLIB 기반 프록시를 자동 생성한다.
클라이언트 → AOP 프록시(트랜잭션 시작/종료) → 실제 서비스(비즈니스 로직) → 리포지토리
테스트에서 프록시 적용 확인:
Assertions.assertThat(AopUtils.isAopProxy(memberService)).isTrue();
@Transactional 사용 팁
- 메서드에 붙이면 해당 메서드에만 적용
- 클래스에 붙이면
public메서드 전체에 적용 - 테스트 시
@SpringBootTest가 있어야 AOP 프록시가 동작함
# 체크 예외와 서비스 계층 순수성
서비스 계층을 순수하게 유지하려면 리포지토리에서 SQLException 같은 체크 예외를 언체크 예외로 감싸서 던져야 한다. 그래야 bizLogic이나 서비스 메서드에서 throws SQLException이 사라지고, @Transactional만 붙여도 예외 발생 시 자동으로 롤백 + 리소스 정리까지 처리되는 깔끔한 구조가 된다.
// 리포지토리에서 체크 예외를 언체크로 변환
try {
...
} catch (SQLException e) {
throw new RuntimeException(e);
}
# 선언적 vs 프로그래밍 방식 트랜잭션
| 방식 | 수단 | 실무 사용 |
|---|---|---|
| 선언적 | @Transactional | ✅ 주로 사용 |
| 프로그래밍 방식 | TransactionManager, TransactionTemplate | 테스트에서 간혹 사용 |
# 스프링 부트 자동 리소스 등록
스프링 부트는 application.properties 설정만으로 DataSource와 PlatformTransactionManager를 자동으로 빈 등록한다.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
- 자동 등록
DataSource:HikariDataSource(커넥션 풀) - 자동 등록
TransactionManager: JDBC면DataSourceTransactionManager, JPA면JpaTransactionManager - 개발자가 직접 빈을 등록하면 자동 등록은 비활성화됨
# 자바 예외 이해
# 예외 계층 구조
Object
└── Throwable
├── Error (시스템 오류 - 잡으면 안됨)
└── Exception (체크 예외)
└── RuntimeException (언체크 예외)
WARNING
Throwable이나 Exception을 통째로 catch하면 Error까지 잡히거나 중요한 예외를 놓칠 수 있다. 필요한 예외만 명확하게 잡자.
# 체크 예외 vs 언체크 예외
| 체크 예외 | 언체크 예외 | |
|---|---|---|
| 상속 | Exception | RuntimeException |
| 컴파일러 강제 | O | X |
| throws 선언 | 필수 | 생략 가능 |
| 예 | SQLException, IOException | NullPointerException, IllegalStateException |
# 체크 예외의 문제점
체크 예외를 사용하면 두 가지 문제가 생긴다.
1. 복구 불가능한 예외
SQLException, ConnectException 같은 시스템 예외는 서비스나 컨트롤러에서 복구할 방법이 없다. 그런데 체크 예외라서 어쩔 수 없이 throws를 달아야 한다.
2. 의존 관계 문제
처리도 못하면서 throws SQLException을 선언해야 하니, 서비스/컨트롤러가 JDBC 기술에 의존하게 된다. 나중에 JPA로 바꾸면 모든 계층의 throws 선언도 같이 바꿔야 한다.
throws Exception은 안된다
throws Exception으로 퉁치면 모든 체크 예외 검증이 무효화된다. 중요한 예외를 놓쳐도 컴파일러가 잡아주지 않는다.
# 언체크 예외 활용 - 예외 전환
리포지토리에서 체크 예외를 언체크 예외로 변환해서 던지면 서비스/컨트롤러는 throws 선언 없이 깔끔해진다.
// 리포지토리
public void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException(e); // 반드시 기존 예외(e)를 포함해야 함
}
}
// 서비스 - throws 선언 불필요
public void logic() {
repository.call();
networkClient.call();
}
기술이 변경되어도 공통 예외 처리 한 곳만 수정하면 되고, 서비스/컨트롤러는 건드릴 필요가 없다.
# 예외 전환 시 기존 예외 포함 필수
DANGER
예외를 전환할 때 기존 예외를 포함하지 않으면 스택 트레이스에서 원인을 확인할 수 없다.
// 올바름 - 원인 추적 가능
throw new RuntimeSQLException(e);
// 잘못됨 - DB 에러 원인이 사라짐
throw new RuntimeSQLException();
# 실무 원칙
기본적으로 언체크 예외를 사용하고, 체크 예외는 반드시 처리해야 하는 비즈니스 예외에만 사용한다.
체크 예외 사용 예) 계좌 이체 실패, 포인트 부족, 로그인 ID/PW 불일치
런타임 예외는 놓칠 수 있기 때문에 중요한 예외는 throws에 명시하거나 Javadoc으로 문서화해둔다.
복구 불가능한 시스템 예외 (DB 다운, 네트워크 오류 등) → 언체크로 변환해서 공통 처리에 맡기고, 서비스/컨트롤러는 신경 끄기
어차피 서비스 계층에서는 DB 다운과 같은 시스템 레벨 에러를 처리할 수 없기 때문에, 사용자에게는 일반적인 메시지로 피드백을 줄 수밖에 없다.
사용자에게 의미 있는 피드백이 가능한 비즈니스 예외 (잔액 부족, 로그인 실패 등) → 체크 예외로 만들어서 서비스에서 잡아 처리하거나 컨트롤러까지 올려서 사용자에게 적절한 메시지 전달
# 스프링과 문제 해결 - 예외 처리, 반복
# 핵심 흐름
JDBC의 체크 예외(SQLException)가 서비스 계층까지 누수되면, 서비스가 특정 기술(JDBC)에 종속된다. 이를 해결하기 위한 단계별 전략은 다음과 같다.
- 체크 예외(
SQLException) → 런타임 예외로 전환해 서비스 계층 순수성 확보 - DB 오류 코드별로 직접 예외를 만들어 복구 가능하게 처리
- 스프링 예외 추상화 + 예외 변환기로 DB 종속성 제거
JdbcTemplate으로 반복 코드 제거
# 체크 예외와 인터페이스 문제
- 리포지토리는 인터페이스(
MemberRepository)로 추상화해 구현 기술을 DI로 교체할 수 있게 한다. - 그런데
SQLException은 체크 예외라서, 인터페이스 메서드에도throws SQLException을 선언해야 한다. - 결국 인터페이스가 JDBC 기술에 오염된다. 기술 변경 시 인터페이스까지 바꿔야 하므로 추상화 의미가 사라진다.
- 런타임 예외는 인터페이스에 선언할 필요가 없으므로 기술 종속에서 자유롭다.
실무 포인트
인터페이스는 특정 구현 기술에 종속되지 않은 순수한 형태로 유지하는 것이 목표다. 체크 예외는 이 목표를 방해하므로 런타임 예외로 전환한다.
# 런타임 예외 적용 (예외 변환)
리포지토리에서 SQLException을 잡아 런타임 예외로 변환해서 던진다.
public class MyDbException extends RuntimeException {
public MyDbException(Throwable cause) {
super(cause);
}
// 생성자 오버로딩 생략
}
@Override
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(?, ?)";
try {
// ... 커넥션, 파라미터 바인딩, executeUpdate
} catch (SQLException e) {
throw new MyDbException(e); // 기존 예외를 반드시 포함
} finally {
close(con, pstmt, null);
}
}
반드시 기존 예외를 포함할 것
throw new MyDbException(e) 처럼 원인 예외(e)를 생성자에 넘겨야 한다.
throw new MyDbException() 으로 원인을 버리면 스택 트레이스에서 진짜 원인(문법 오류 등)을 추적할 수 없어 장애 분석이 불가능해진다.
서비스 계층은 인터페이스에만 의존하므로 메서드에서 throws SQLException이 사라지고, 트랜잭션도 @Transactional로 처리한다.
@Slf4j
@RequiredArgsConstructor
public class MemberServiceV4 {
private final MemberRepository memberRepository;
@Transactional
public void accountTransfer(String fromId, String toId, int money) {
bizLogic(fromId, toId, money);
}
}
# 데이터 접근 예외 직접 만들기
MyDbException 하나만 던지면 예외를 구분할 수 없어 복구가 불가능하다. DB의 errorCode를 활용해 특정 상황(예: 키 중복)을 구분한다.
SQLException.getErrorCode()로 DB가 반환한 오류 코드 확인- 키 중복 코드: H2
23505, MySQL1062(DB마다 다름) - 키 중복일 때만 던질 전용 예외를
MyDbException을 상속해서 만든다 → 의미 있는 예외 계층 형성
public class MyDuplicateKeyException extends MyDbException {
public MyDuplicateKeyException(Throwable cause) {
super(cause);
}
}
} catch (SQLException e) {
if (e.getErrorCode() == 23505) { // h2 키 중복
throw new MyDuplicateKeyException(e);
}
throw new MyDbException(e);
}
서비스는 직접 만든 예외로 복구를 시도한다(특정 기술에 종속되지 않음).
try {
repository.save(new Member(memberId, 0));
} catch (MyDuplicateKeyException e) {
String retryId = generateNewId(memberId); // 새 ID로 재시도(복구)
repository.save(new Member(retryId, 0));
} catch (MyDbException e) {
throw e; // 복구 불가 예외는 공통 처리부로 전달
}
한계
오류 코드는 DB마다 다르고, 락·문법 오류 등 수백 가지 코드가 있다. 모든 케이스마다 예외를 직접 만드는 것은 비현실적이다.
# 스프링 예외 추상화
스프링은 데이터 접근 예외를 기술 독립적으로 추상화한 일관된 예외 계층을 제공한다.
- 최상위:
org.springframework.dao.DataAccessException(런타임 예외) Transient계열: 일시적 오류. 재시도 시 성공 가능 (쿼리 타임아웃, 락 등)NonTransient계열: 재시도해도 실패 (SQL 문법 오류, 제약조건 위배 등 →BadSqlGrammarException,DuplicateKeyException)
JDBC/JPA 어떤 기술을 쓰든 스프링이 발생 예외를 위 계층으로 변환해준다.
# 스프링 예외 변환기
오류 코드를 직접 분기하지 않고 SQLExceptionTranslator로 자동 변환한다.
SQLExceptionTranslator exTranslator =
new SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("save", sql, e);
translate(설명, 실행한 sql, SQLException)→ 적절한 스프링 데이터 접근 예외 반환- DB별 오류 코드 매핑은
org.springframework.jdbc.support.sql-error-codes.xml에 정의되어 있어 10개 이상 주요 RDBMS를 지원한다.
리포지토리에 적용:
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(?, ?)";
try {
// ...
} catch (SQLException e) {
throw exTranslator.translate("save", sql, e);
} finally {
close(con, pstmt, null);
}
}
스프링 종속성
스프링 예외를 쓰면 스프링에 대한 종속성은 생기지만, 이는 실용적인 선택이다. 완전히 제거하려면 예외를 직접 정의·변환해야 하나 비현실적이다.
# JdbcTemplate으로 반복 제거
커넥션 조회/동기화, PreparedStatement 생성·바인딩, 쿼리 실행, 결과 바인딩, 예외 변환, 리소스 종료의 반복을 템플릿 콜백 패턴으로 제거한다. JdbcTemplate은 트랜잭션 커넥션 동기화와 예외 변환기도 자동 처리한다.
public class MemberRepositoryV5 implements MemberRepository {
private final JdbcTemplate template;
public MemberRepositoryV5(DataSource dataSource) {
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;
};
}
}
리포지토리 구현 빈만 교체하면 서비스/테스트 변경 없이 적용된다(MemberRepositoryV4_1 → V4_2 → V5).
# 재시도 가능한 예외 처리
런타임 예외는 컴파일러가 잡기를 강제하지 않으므로, "어떤 예외를 잡아 재시도할지"를 개발자가 판단해야 한다. 모든 예외를 암기하는 대신 구조와 도구로 범위를 좁히는 것이 핵심이다.
스프링은 재시도 가능한 예외를 TransientDataAccessException 계층으로 묶어 제공한다. "일시적이라 재시도하면 성공할 수 있다"는 의미가 타입 계층 자체에 담겨 있어, 구체 예외(QueryTimeoutException, PessimisticLockingFailureException, OptimisticLockingFailureException 등)를 일일이 알지 못해도 상위 카테고리로 묶어서 잡을 수 있다.
try {
repository.update(...);
} catch (TransientDataAccessException e) {
// 일시적 오류 → 재시도 대상
}
어떤 예외가 실제로 터지는지는 처음부터 다 알 필요가 없다. 정상 케이스로 먼저 구현한 뒤, 공통 예외 처리부(@ControllerAdvice)에서 잡히지 않은 예외를 모두 로깅하게 해두면 운영 중 실제 발생 예외가 드러난다. 그중 재시도가 의미 있는 것만 골라 대응을 추가하는 방식이 실용적이다. 미리 전부 방어하지 말고 관측 가능하게 만든 뒤 필요한 것만 대응한다.
재시도가 필요하면 직접 try-catch 루프를 짜기보다 검증된 도구(Spring Retry)를 쓴다. 어떤 예외에 재시도할지 타입으로 선언하면 횟수·백오프 같은 부분을 라이브러리가 처리해, 무한 재시도나 백오프 누락 같은 실수를 막아준다.
@Retryable(
retryFor = TransientDataAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 200)
)
public void transfer(...) { ... }
재시도는 멱등한 작업에만 안전
재시도는 같은 작업을 여러 번 실행해도 결과가 같은 멱등(idempotent) 연산에만 적용해야 한다.
"잔액을 5000으로 설정"은 몇 번 해도 안전하지만, "잔액에서 5000 차감"은 재시도가 중복 차감을 일으킨다. 재시도 대상으로 삼기 전에 그 연산이 여러 번 실행돼도 괜찮은지 먼저 확인할 것.
정리
- 외우지 말고
TransientDataAccessException같은 상위 카테고리로 의미 단위로 잡는다. - 미리 다 막지 말고 로깅으로 관측한 뒤 필요한 것만 대응한다.
- 재시도 로직은 Spring Retry 같은 도구에 맡기되, 멱등성을 먼저 확인한다.