Search

스프링과 데이터베이스

Created
2024/11/06 05:03
상태
Done
태그
spring
Database

JDBC

JDBC의 등장 이유

일반적으로 애플리케이션에서 중요한 데이터는 위와 같이 서버를 통해 데이터베이스 저장하고, 필요할 때 조회하거나 수정하여 데이터를 다룬다.
서버가 데이터베이스에서 데이터를 저장하거나 꺼내는 방법은 이처럼
1.
TCP/IP를 사용해 커넥션을 연결하고
2.
작성된 SQL을 전달하여 명령을 수행하도록 하고
3.
그 결과를 응답으로 받는다.
하지만 사용하던 데이터베이스가 달라지게 되면, 커넥션을 연결하는 방법부터 전달해야하는 SQL, 결과를 응답 받는 방법이 모두 달라지게 된다. 그에 따라 애플케이션 서버에서 개발된 데이터베이스 코드도 변경되야 하고, 변경되는 데이터베이스의 커넥션 연결, SQL, 결과 응답 방법을 새로 학습해야한다.
이러한 문제를 해결하기 위해 JDBC 표준 인터페이스라는 자바 표준이 등장하게 된다.

JDBC 표준 인터페이스

JDBC(Java Database Connectivity)는 자바에서 데이터베이스에 접속할 수 있도록 표준 인터페이스를 정의한 자바 API이다.
JDBC는 위와 같이 다음 3가지 기능을 표준 인터페이스로 정의하여 제공한다.
java.sql.Connection : 데이터베이스 연결
java.sql.Statement : SQL을 담은 내용
java.sql.ResultSet : SQL 요청에 대한 응답
이처럼 자바는 표준 인터페이스를 정의하여 제공하고, 각각의 DB 벤더에서 각자의 DB에 맞도록 구현하여 라이브러리로 제공한다.
이와 같이 각 회사에서 제공하는 JDBC 구현 라이브러리를 JDBC 드라이버라 부른다.
JDBC를 통해 개발자는 JDBC의 표준 인터페이스 사용법만 학습하면, 수많은 데이터베이스에 모두 동일하게 적용할 수 있고 데이터베이스 변경 시 코드도 바꿔야 하는 문제에서 벗어날 수 있게 되었다.

JDBC와 최신 데이터 접근 기술

JDBC는 1977년에 출시된 오래된 기술이고 사용하는 방법도 복잡하다.
때문에 최근에는 JDBC를 직접 사용하기보다는, JDBC를 편리하게 사용할 수 있도록 도와주는 SQL Mapper나 ORM 같은 다양한 기술들이 사용된다.
SQL Mapper
SQL Mapper는 SQL의 응답 결과를 객체로 변환해주거나, JDBC의 반복 코드를 제거하여 편리하게 사용할 수 있도록 도와준다. 하지만 개발자가 직접 SQL을 작성해야한다는 단점이 있다.
SQL Mapper의 대표적인 기술로는 스프링의 JdbcTemplateMyBatis가 있다.
ORM
ORM은 객체 자체를 관계형 데이터베이스의 테이블과 매핑해주는 기술로, ORM을 활용하면 개발자는 SQL을 직접 작성하지 않아도 ORM에서 동적으로 SQL을 생성하여 실행해준다.
ORM 기술로는 JPA와 하이버네이트, 이클립스 링크가 있고, JPA는 자바의 ORM 표준 인터페이스로 하이버네이트와 이클립스 링크가 JPA를 구현한 기술이다.
SQL Mapper와 ORM 두 기술 모두 각각의 장단점이 있다.
SQL Mapper는 SQL만 직접 작성하면 나머지 번거로운 일은 SQL Mapper가 대신 해결해주기 때문에, SQL만 작성할 줄 알면 금방 배워서 사용할 수 있다.
반면 ORM 기술은 SQL을 직접 작성할 일이 없어 개발 생산성이 높아지지만, 편리한 기술은 아니므로 실무에서 사용하기 위해서는 깊게 학습할 필요가 있다.

데이터베이스 연결

JDBC를 사용해 실제 데이터베이스(H2)에 연결하는 방법은 다음과 같다.
import java.sql.Connection; import java.sql.DriverManger; import java.sql.SQLException; ... @Slf4j public class DBConnectionUtil { public static Connection getConnection() { try { Connection connection = DriverManager.getConnection("jdbc:h2:tcp//localhost/~/test", "sa", ""); log.info("get connection={}", connection); log.info("class={}", connection.getClass()); return connection; } catch (SQLException e) { throw new IllegalStateException(e); } } }
Java
복사
이처럼 JDBC가 제공하는 DriverManager.getConnection(…)을 사용하면, 라이브러리에 있는 데이터베이스 드라이버를 찾아 해당 드라이버로 데이터베이스와 직접 커넥션을 맺고 그 결과를 반환해준다.
get connection=conn0: url=jdbc:h2:tcp://localhost/~/test user=SA class=class org.h2.jdbc.JdbcConnection
Plain Text
복사
실행 결과를 보면, class=class org.h2.jdbc.JdbcConnection와 같이 데이터베이스가 제공하는 전용 커넥션을 확인할 수 있다.
해당 커넥션은 JDBC 표준 커넥션 인터페이스인 java.sql.Connection 인터페이스를 구현하고 있다.
JDBC가 제공하는 DriverManager는 위처럼 라이브러리에 등록된 데이터베이스 드라이버들을 관리하고, 실제 커넥션을 요청하여 획득하는 기능을 제공한다.
커넥션 획득 요청 시 전달하는 URL, username, password 정보들을 가지고, DriverManager는 등록된 드라이버들에 해당 정보들로 커넥션을 획득할 수 있는지 확인한다. 커넥션을 획득하면 해당 커넥션을 반환하고, 획득할 수 없다면 다음 드라이버에게 순서가 넘어간다.

JDBC의 CRUD

drop table member if exists cascade; create table member ( member_id varchar(10), money integer not null default 0, primary key (member_id) );
SQL
복사
@Data public class Member { private String memberId; private int money; public Member() { } public Member(String memberId, int money) { this.memberId = memberId; this.money = money; } }
Java
복사
이와 같이 member 테이블과 member 객체를 만들어두고, 이를 통해 데이터를 저장하거나 조회해보자.
JDBC는 기본적으로 SQL을 작성 후, connection을 얻고, statement에 파라미터를 바인딩하고, result set을 사용하여 응답을 받아서 데이터를 조회, 수정, 삭제한다.
private void close(Connection con, Statement stmt, ResultSet rs) { if (rs != null) { try { rs.close(); } catch (SQLException e) { log.info("error", e); } } if (stmt != null) { try { stmt.close(); } catch (SQLException e) { log.info("error", e); } } if (conn != null) { try { con.close(); } catch (SQLException e) { log.info("error", e); } } }
Java
복사
JDBC에서는 쿼리를 실행 후에 반드시 리소스를 정리해야한다. 정리할 때는 위와 같이 획득했던 역순으로 정리를 해야하고, 정리하지 않으면 추후 커넥션이 끊어지지 않는 리소스 누수가 발생해 커넥션 부족 문제가 생길 수 있다. 때문에 finally 구문을 통해서 리소스를 정해준다.
저장
@Slf4j public class MemberRepository { public Member save(Member member) throws SQLException { String sql = "insert into member(member_id, money) values(?, ?)"; Connection con = null; PreparedStatement pstmt = null; try { con = DBConnectionUtil.getConnection(); pstmt = con.prepareStatement(sql); pstmt.setString(1, member.getMemberId()); pstmt.setInt(2, member.getMoney()); pstmt.executeUpdate(); return member; } catch (SQLException e) { log.error("db error", e); throw e; } finally { close(con, pstmt, null); } } }
Java
복사
저장 로직은 위와 같이 insert SQL을 작성하고, con.prepareStatement(sql)을 통해 statement를 받아온다.
얻어온 statement에 pstmt.setString(1, ...)pstmt.setInt(2, ...)와 같이 SQL과 함께 전달할 파라미터를 바인딩한다. 여기서 PreparedStatement는 Statement의 자식 타입인데, ?를 통한 파라미터 바인딩을 지원하여 SQL Injection 공격을 예방할 수 있다.
이후 pstmt.executeUpdate()를 통해 준비된 SQL을 실제 데이터베이스에 전달하여 쿼리를 실행시킨다.
조회
public Member findById(String memberId) throws SQLException { String sql = "select * from member where member_id = ?"; Connection con = null; PreparedStatement pstmt = null; ResultSet rs = null; try { con = DBConnectionUtil.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.getString("money")); return member; } else { throw new NoSuchElementException("member not found memberId=" + memberId); } } catch (SQLException e) { log.error("db error", e); throw e; } finally { close(con, pstmt, rs); } }
Java
복사
조회 로직도 SQL을 제외하면 저장 로직과 거의 동일하지만, rs = pstmt.executeQuery()를 통해 쿼리 수행 결과를 result set에 받아온다는 점이 다르다.
여기서 result set은 위처럼 최초의 상태에는 데이터의 바로 앞을 가리키고 있기 때문에, rs.next()를 한 번은 호출해야 데이터를 조회할 수 있다.
수정
public void update(String memberId, int money) throws SQLException { String sql = "update member set money = ? where member_id = ?"; Connection con = null; PreparedStatement pstmt = null; try { con = DBConnectionUtil.getConnection(); pstmt = con.prepareStatement(sql); pstmt.setString(1, money); pstmt.setInt(2, memberId); pstmt.executeUpdate(); } catch (SQLException e) { log.error("db error", e); throw e; } finally { close(con, pstmt, null); } }
Java
복사
수정은 저장 로직과 거의 동일하다.
삭제
public void delete(String memberId) throws SQLException { String sql = "delete from member where member_id = ?"; Connection con = null; PreparedStatement pstmt = null; try { con = DBConnectionUtil.getConnection(); pstmt = con.prepareStatement(sql); pstmt.setString(1, memberId); pstmt.executeUpdate(); } catch (SQLException e) { log.error("db error", e); throw e; } finally { close(con, pstmt, null); } }
Java
복사
삭제 역시 SQL 쿼리 내용만 변경되고 내용은 거의 동일하다.

커넥션 풀과 데이터 소스

Connection Pool

우리가 위에서 JDBC를 통해 사용한 방법은 쿼리가 필요할 때마다, 커넥션을 획득하고 쿼리를 수행하고 그 결과를 받아오는 방식이였다.
문제는 데이터베이스 커넥션을 획득하는데 다음과 같은 복잡한 과정을 거친다는 것이다.
1.
DB 드라이버를 통해 커넥션을 조회
2.
TCP/IP(3 way handshake)를 통해 커넥션 연결
3.
ID, PW와 기타 부가정보를 DB에 전달
4.
ID, PW를 통해 내부 인증 및 내부 DB 세션 생성
5.
커넥션 생성 완료 응답
6.
DB 드라이버가 커넥션 객체를 생성하여 클라리언트에 반환
이와 같은 과정은 과정도 복잡하고 시간도 매우 많이 소요되며, TCP/IP 커넥션을 생성하기 위해 리소스를 매번 사용해야된다. 데이터베이스마다 커넥션을 생성하는 시간은 제각각이지만, 빠른 것은 수 ms ~ 느리면 수십 ms 이상 걸리기도 한다.
이러한 문제를 해결하기 위해, 커넥션을 미리 생성해 보관하고 커넥션이 필요한 경우 하나씩 꺼내서 부여하는 커넥션 풀 방식이다.
커넥션 풀은 이처럼 애플리케이션을 시작하는 시점에 필요한 만큼 커넥션을 확보하여 풀에 보관한다. 서비스와 서버 스펙마다 다르지만 일반적으로 커넥션 풀은 10개 정도의 커넥션을 보관한다.
커넥션 풀의 커넥션들은 연결된 상태를 유지하기 때문에, 언제든 SQL을 즉시 전달할 수 있는 상태이다. 위와 같이 SQL을 요청이 발생하면 커넥션 풀에서 커넥션 중 하나를 꺼내 SQL을 수행하고, 그 결과를 받은 후 커넥션을 종료하는 것이 아니라 다시 커넥션을 커넥션 풀에 반환한다.
커넥션 풀은 속도적인 이점 외에도, 서버당 최대 커넥션 수를 제한할 수 있어 DB에 연결이 무한정 생성되는 것을 막아주는 효과도 있다. 이처럼 커넥션 풀을 사용하는 이점이 매우 크기 때문에, 실무에서는 항상 커넥션 풀을 기본으로 사용한다.
대표적인 커넥션 풀 오픈소스로는 commons-dbcp2, tomcat-jdbc pool, HikariCP 등이 있다. 스프링 부트 2.0부터는 기본 커넥션 풀로 hikariCP를 제공한다.

DataSource

커넥션을 획득하는 방법은 DriverManager를 직접 사용하거나, 여러 커넥션 풀을 사용하는 등 다양한 방법이 존재한다.
하지만 위와 같이 DriverManager를 사용하다 HikariCP로 변경하게 되면, 그에 상응하는 애플리케이션 코드도 같이 변경되어야 한다.
Java에서는 이 문제를 해결하기 위해 커넥션을 획득하는 방법에 대해서도 추상화를 진행하였다.
이처럼 커넥션 연결하는 방법에 대한 것을 javax.sql.DataSource라는 인터페이스로 추상화하여 제공한다.
대부분 커넥션 풀은 DataSource 인터페이스에 대한 구현체를 제공하고 있고, 개발자는 구현체에 의존하는 것이 아닌 DataSource 인터페이스에 의존하도록 애플리케이션 코드를 작성하면 된다. 또한 이로 인해 커넥션 풀 획득 방식이 변경되더라도 애플리케이션 코드의 변경이 필요 없어지게 되었다.

DataSource 사용하기

DriverManager 방식
@Test void driverManger() throws SQLException { Connection con1 = DriverManager.getConnection(URL, USERNAME, PASSWORD); Connection con2 = DriverManager.getConnection(URL, USERNAME, PASSWORD); }
Java
복사
기존에 DriverManager에서 커넥션을 획득하는 방식은 위와 같다.
@Test void dataSourceDriverManager() throws SQLException { DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD); Connection con1 = dataSource.getConnection(); Connection con2 = dataSource.getConnection(); }
Java
복사
DataSource를 사용한 DriverManager로 커넥션을 획득하는 방식은 위와 같다. 기존의 코드와 비슷해보이지만, 기존에는 커넥션을 획득할 때마다 여러 파라미터를 전달해야했지만 DataSource 방식은 처음 객체 생성 시에만 넘기고 이후에는 간단하게 커넥션을 얻어올 수 있다.
큰 차이는 아니지만, 이를 통해 필요한 속성들을 넘겨주는 설정 부분과 커넥션을 얻어오는 사용 부분을 분리할 수 있다. 이를 통해 URL, USERNAME, PASSWORD와 같은 속성에 의존하지 않아도 되고, 애플리케이션 개발 시 이러한 설정들을 한 곳에 모아 더 명확하고 유지보수에 용이하게 할 수 있다.
get connection=conn0: url=jdbc:h2:.. user=SA class=class org.h2.jdbc.JdbcConnection get connection=conn1: url=jdbc:h2:.. user=SA class=class org.h2.jdbc.JdbcConnection get connection=conn2: url=jdbc:h2:.. user=SA class=class org.h2.jdbc.JdbcConnection get connection=conn3: url=jdbc:h2:.. user=SA class=class org.h2.jdbc.JdbcConnection get connection=conn4: url=jdbc:h2:.. user=SA class=class org.h2.jdbc.JdbcConnection get connection=conn5: url=jdbc:h2:.. user=SA class=class org.h2.jdbc.JdbcConnection
Plain Text
복사
여러 쿼리를 실행하는 로직의 로그를 살펴보면, 새로운 커넥션을 생성하여 conn0 ~ conn5 번호를 부여받아 사용하는 것을 볼 수 있다.
커넥션 풀 방식
@Test void dataSourceConnectionPool() throws SQLException { // 커넥션 풀링 : HikariProxyConnection(Proxy) -> JdbcConnection(Target) HikariDataSource dataSource = new HikariDataSource(); dataSource.setJdbcUrl(URL); dataSource.setUsername(USERNAME); dataSource.setPassword(PASSWORD); dataSource.setMaximumPoolSize(10); dataSource.setPoolName("MyPool"); Connection con1 = dataSource.getConnection(); Connection con2 = dataSource.getConnection(); }
Java
복사
HikariDataSource도 DataSource를 구현하고 있고, 위처럼 여러 설정들을 지정할 수 있다. 로그를 분석해보면, MyPool - After adding stats (total=10, active=2, idle=8, waiting=0)와 같이 2개의 커넥션을 사용하고 있고 8개를 사용 가능한 것을 볼 수 있다.
get connection=HikariProxyConnection@xxxxxxxx1 wrapping conn0: url=jdbc:h2:... user=SA get connection=HikariProxyConnection@xxxxxxxx2 wrapping conn0: url=jdbc:h2:... user=SA get connection=HikariProxyConnection@xxxxxxxx3 wrapping conn0: url=jdbc:h2:... user=SA get connection=HikariProxyConnection@xxxxxxxx4 wrapping conn0: url=jdbc:h2:... user=SA get connection=HikariProxyConnection@xxxxxxxx5 wrapping conn0: url=jdbc:h2:... user=SA get connection=HikariProxyConnection@xxxxxxxx6 wrapping conn0: url=jdbc:h2:... user=SA
Plain Text
복사
HikariCP 사용 시에는 이처럼 커넥션을 생성하는 것이 아니라, conn0을 재사용 하는 것을 볼 수 있다. 이 때 HikariProxyConnection 객체가 달라지는 것은, Hikari에서 커넥션을 프록시로 감싸서 반환하는데 매번 새로운 프록시 객체를 생성하기 때문이다.

트랜잭션

트랜잭션 개념 이해

트랜잭션은 번역하면 거래라는 의미로, 데이터베이스에서의 트랜잭션은 하나의 거래를 안전하게 처리하도록 보장해주는 것을 의미한다. 예를 들어 5000원을 계좌 이체한다고 했을 때,
1.
A의 잔고를 5000원 감소
2.
B의 잔고를 5000원 증가
이와 같이 2가지 작업이 합쳐져 하나의 거래가 된다. 하지만 1번 과정은 성공했는데 2번 과정에서 문제가 발생하면, A의 계좌 잔고만 5000원이 감소되는 문제가 발생한다.
데이터베이스가 제공하는 트랜잭션은 1번과 2번 모두 성공해야 저장하고, 중간에 문제가 발생하여 실패하게 되면 거래 이전의 상태로 되돌아갈 수 있도록 한다. 이와 같이 모든 작업이 성공해서 데이터베이스에 반영하는 것을 commit이라 하고, 작업 중 하나라도 실패해서 거래 이전으로 되돌리는 것을 rollback이라 한다.

트랜잭션의 ACID

데이터베이스의 트랜잭션은 ACID라 불리는 다음의 4가지 원칙을 보장해야한다.
원자성(Atomicity) : 트랜잭션 내에서 실행한 작업들은 모두 성공하거나 모두 실패해야 한다.
일관성(Consistency) : 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다.
일관성(Isolation) : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리해야한다.
지속성(Durability) : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다.
트랜잭션의 격리성을 완전히 보장하려면 항상 모든 트랜잭션을 순서대로 하나씩 실행해야하는데, 이렇게 동작하면 동시 처리 성능이 매우 저하된다. 때문에 ANSI 표준에서 트랜잭션 격리 수준(Isolation level)을 다음 4단계로 나누어 정의해두었다.
READ UNCOMMITED
READ COMMITED
REPEATABLE READ
SERIALIZABLE

데이터베이스 연결 구조와 DB 세션

사용자는 WAS나 DB 접근 툴을 통해 데이터베이스 서버에 접근할 수 있는데, 이렇게 데이터베이스에 접근할 수 있는 클라이언트는 데이터베이스 서버에 연결을 요청하고 커넥션을 맺는다. 그 과정에서 데이터베이스 서버는 내부에 세션을 생성하고, 해당 커넥션을 통한 모든 요청은 생성된 세션을 통해서 실행하게 된다. 이러한 세션은 데이터베이스 서버에서 세션을 강제로 종료하거나, 사용자가 커넥션을 닫으면 종료된다.
만약 WAS에서 커넥션 풀을 통해 10개의 커넥션을 생성하게 되면, 세션도 10개 만들어진다.

자동 커밋 / 수동 커밋

자동 커밋
set autocommit true; // 자동 커밋 모드 설정 insert into member(member_id, money) values ('data1', 1000); insert into member(member_id, money) values ('data2', 1000);
SQL
복사
자동 커밋은 각각의 쿼리 실행 후 자동으로 커밋을 호출하여 쿼리 결과를 데이터베이스에 반영한다. 따라서 커밋이나 롤백을 직접 호출하지 않아도 된다는 장점이 있다. 하지만 쿼리 실행마다 커밋이 수행되기 때문에, 트랜잭션 기능을 제대로 사용할 수 없다.
수동 커밋
set autocommit false; // 수동 커밋 모드 설정 insert into member(member_id, money) values ('data1', 1000); insert into member(member_id, money) values ('data2', 1000); commit; // rollback;
SQL
복사
수동 커밋은 위와 같이 설정 이후 반드시 commit 혹은 rollback을 호출해야 한다.
일반적으로 자동 커밋이 기본으로 설정되어 있기 때문에, 수동 커밋을 설정하는 것으로 트랜잭션을 시작한다고 표현할 수 있다. 참고로 이런 자동/수동 커밋을 설정하면, 해당 세션에서는 커밋이나 롤백 이후로도 계속 유지된다.

DB Lock

세션1에서 데이터를 수정하는 동안, 세션2에서 동시에 같은 데이터를 수정하게 되면 여러가지 문제가 발생한다. 이런 문제를 방지하려면 세션이 트랜잭션 내에서 데이터를 수정하는 동안 커밋이나 롤백 전까지 다른 세션해서 해당 데이터를 수정할 수 없게 막아야 한다. 데이터베이스에는 이러한 기능을 Lock을 통해 제공한다.
데이터베이스는 특정 레코드(데이터)에 대한 락을 제공하여,
해당 데이터를 수정하는 경우에 해당 트랜잭션에 락을 부여한다.
락을 가지고 있지 않은 다른 트랜잭션이 해당 데이터에 접근하려고하면, 락을 얻을 때까지 대기하게 된다.
트랜잭션이 커밋되면 가지고 있던 락을 반납하게 되고,
대기하고 있던 다른 트랜잭션에서 락을 얻어
데이터를 수정할 수 있게 된다.
이러한 락은 기본적으로 updatedelete와 같은 데이터 변경 시에 획득하여 처리하게 된다. 반면 일반적인 조회는 락을 획득하지 않아도 수행되는데, 가끔 데이터 조회 시 락을 획득해야하는 상황이 있을 수 있다. 그런 경우에는 select ~ for update 구문을 사용하면, 조회 시점에 락을 획득하여 다른 트랜잭션에서 해당 데이터를 수정할 수 없게 막을 수 있다.
이러한 락을 획득하기 위해 대기하는 과정은 데이터베이스의 설정된 락 타임아웃 시간만큼 기다리고, 해당 시간 내에 락을 획득하지 못하면 락 타임아웃 오류가 발생한다. set lock_timeout <miliseconds> 구문을 통해 락 타임아웃 시간을 조정할 수 있다.

JDBC로 트랜잭션 적용하기

먼저 계좌 이체에 대한 로직을 해보자.
@RequiredArgsConstructor public class MemberServiceV1 { private final MemberRepositoryV1 memberRepository; public void accountTransfer(String fromId, String toId, int money) throws SQLException { 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("이체중 예외 발생"); } } }
Java
복사
이와 같이 로직을 작성하게 되면, 기본 설정이 자동 커밋으로 되어있기 때문에 validation 메서드에서 예외가 발생하더라도 fromMember의 돈은 감소된 채로 저장 될 것이다.
@Test @DisplayName("이체중 예외 발생") void accountTransferEx() throws SQLException { //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의 돈만 2000원 줄었고, ex의 돈은 10000원 그대로이다. assertThat(findMemberA.getMoney()).isEqualTo(8000); assertThat(findMemberEx.getMoney()).isEqualTo(10000); }
Java
복사
테스트 코드를 작성해보면, 이처럼 memberA의 돈이 2000원 차감된 8000원으로 작성해야 테스트가 통과된다.
이러한 문제를 해결하기 위해서는 수동 커밋을 설정하고, 위처럼 비즈니스 로직을 트랜잭션으로 감싸주어야 한다.
여기서 중요한 것은 각 커넥션마다 세션이 생성되기 때문에, 트랜잭션의 시작부터 종료까지 하나의 커넥션으로 쿼리를 수행해야 한다는 것이다.
public Member findById(Connection con, String memberId) throws SQLException { ... } public void update(Connection con, String memberId, int money) throws SQLException { ... }
Java
복사
때문에 위와 같이 기존의 Repository 로직에 외부에서 파라미터로 Connection을 받아와서 사용하도록 변경해야한다.
@Slf4j @RequiredArgsConstructor public class MemberServiceV2 { private final DataSource dataSource; private final MemberRepositoryV2 memberRepository; public void accountTransfer(String fromId, String toId, int money) throws SQLException { Connection con = dataSource.getConnection(); try { con.setAutoCommit(false); //트랜잭션 시작 //비즈니스 로직 bizLogic(con, fromId, toId, money); con.commit(); //성공시 커밋 } catch (Exception e) { con.rollback(); //실패시 롤백 throw new IllegalStateException(e); } finally { release(con); } } private void bizLogic(Connection con, String fromId, String toId, int money) throws SQLException { Member fromMember = memberRepository.findById(con, fromId); Member toMember = memberRepository.findById(con, toId); memberRepository.update(con, fromId, fromMember.getMoney() - money); validation(toMember); memberRepository.update(con, toId, toMember.getMoney() + money); } private void validation(Member toMember) { if (toMember.getMemberId().equals("ex")) { throw new IllegalStateException("이체중 예외 발생"); } } private void release(Connection con) { if (con != null) { try { con.setAutoCommit(true); //커넥션 풀 고려 con.close(); } catch (Exception e) { log.info("error", e); } } } }
Java
복사
비즈니스 로직에서는 커넥션을 얻어온 후 트랜잭션 반납 후에 close 하는 로직과, 트랜잭션을 위한 수동 커밋 설정과 로직 이후 커밋, 실패 시 롤백을 try-catch 구문으로 구현해야 한다.
@Test @DisplayName("이체중 예외 발생") void accountTransferEx() throws SQLException { //given Member memberA = new Member("memberA", 10000); Member memberEx = new 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); }
Java
복사
마찬가지로 테스트 코드를 작성해보면, 트랜잭션 덕분에 로직 도중 실패하더라도 모든 데이터가 정상적으로 초기화 되어 10000원 그대로 남아있게 된다.
하지만 위 비즈니스 로직 코드를 보면 알 수 있듯, 서비스 계층의 코드가 지저분하고 복잡해지게 된다. 추가적으로 트랜잭션동안 하나의 커넥션을 유지하는 코드 또한 유지보수의 어려움을 높인다.

스프링의 트랜잭션

애플리케이션 구조

애플리케이션 구조에는 여러가지 있지만, 가장 단순하고 자주 사용되는 구조는 이와 같이 프레젠테이션 계층 - 서비스 계층 - 데이터 접근 계층 구조로 된 3-layer 구조이다.
프레젠테이션 계층
UI와 관련된 처리 담당
웹 요청과 응답
사용자 요청을 검증
서블릿이나 HTTP와 같은 웹 기술, 스프링 MVC
서비스 계층
비즈니스 로직을 담당
가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성
데이터 접근 계층
데이터베이스 접근 담당
JDBC, JPA, Redis, Mongo 등
여기서 프레젠테이션 계층의 UI나 웹 기술, 데이터 접근 계층의 기술이 다른 기술로 변경되더라도, 서비스 계층의 비즈니스 로직은 최대한 변경없이 유지되어야 한다. 그렇지 않다면 비즈니스 로직을 기술이 변경될 때마다, 로직을 고쳐야하고 유지보수하거나 테스트를 작성하기에 어려움을 겪게 된다. 이를 위해서는 서비스 계층을 특정 기술에 종속적이지 않게 개발할 필요가 있다.
위의 MemberService의 비즈니스 로직을 보면, V1의 경우 SQLException을 제외한 모든 코드가 순수 자바 코드로 이루어져 있다. 반면 V2의 경우 DataSource, Connection, SQLExcpetion 등 JDBC 기술에 의존하고 있다.

트랜잭션 문제점들

위에서 언급한 문제를 포함하여, 지금까지의 애플리케션의 문제점은 다음과 같다.
트랜잭션 문제
JDBC 구현 기술이 데이터 전달 계층을 넘어 서비스 계층까지 누수
트랜잭션을 동기화하기 위해 커넥션을 서비스 계층에서 획득
try-catch-finally와 같은 코드의 반복
예외 누수 문제
데이터 접근 계층의 SQLException과 같은 구현 기술 예외가 서비스 계층으로 전파
JDBC 반복 문제
try-catch-finally와 커넥션 획득-PreparedStatement 설정-결과 매핑-커넥션 정리 과정에서의 코드 반복
스프링에서는 서비스 계층을 순수하게 유지하면서 위의 문제들을 해결할 수 있는 다양한 방법과 기술을 제공한다.

트랜잭션 추상화

// JDBC 트랜잭션 Connection con = dataSource.getConnection(); try { con.setAutoCommit(false); bizLogic(con, fromId, toId, money); con.commit(); } catch (Exception e) { con.rollback(); throw new IllegalStateException(e); } finally { release(con); } // JPA 트랜잭션 EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook"); EntityManager em = emf.createEntityManager(); EntityTransaction tx = em.getTransaction(); try { tx.begin(); logic(em); tx.commit(); } catch (Exception e) { tx.rollback(); } finally { em.close(); emf.close(); }
Java
복사
이와 같이 트랜잭션을 사용하는 코드는 데이터 접근 기술마다 상이하다.
때문에 JDBC 트랜잭션을 사용하는 코드에서
JPA와 같이 다른 트랜잭션 기술로 변경하는 경우, 서비스 계층의 코드는 정상 동작을 하지 않고 수정이 필요하다.
이 문제를 해결하기 위해서 스프링에서는 트랜잭션을 PlatformTransactinoManager라는 인터페이스로 추상화 해두었다.
public interface PlatformTransactionManager extends TransactionManager { TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException; void commit(TransactionStatus status) throws TransactionException; void rollback(TransactionStatus status) throws TransactionException; }
Java
복사
getTransaction : 트랜잭션 시작 혹은 참여
commit : 트랜잭션 커밋
rollback : 트랜잭션 롤백
트랜잭션 매니저를 사용하는 방법은 다음과 같다.
private final PlatformTransactionManager transactionManager; ... 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); } }
Java
복사
먼저 트랜잭션 매니저를 주입 받아야 한다. JDBC를 사용하기 위해서는 DataSourceTransactionManager를 주입 받으면 되고, JPA를 사용하기 위해서는 JpaTransactionManager를 주입 받으면 된다.
getTransaction 메서드를 통해 트랜잭션을 시작하고, 생성 시 넘겨주는 DefaultTransactionDefinition 객체에 트랜잭션과 관련된 옵션을 지정할 수 있다.
커밋과 롤백은 transactionManager.commit(status)transactionManager.rollback(status)를 호출하면 된다.

트랜잭션 동기화

트랜잭션 매니저를 통해 트랜잭션 시작부터 끝까지 동일한 커넥션을 유지하는 리소스 동기화도 수행할 수 있다.
스프링에서 트랜잭션 매니저 내부의 트랜잭션 동기화 매니저를 제공하여, 스레드 로컬(ThreadLocal)을 사용해 커넥션을 동기화한다. 트랜잭션 동기화 매니저가 커넥션을 관리하는 순서는 다음과 같다.
1.
트랜잭션 매니저가 데이터소스를 통해 커넥션을 생성 및 트랜잭션 시작, 수동 커밋 설정, 생성된 커넥션을 트랜잭션 동기화 매니저에 보관
2.
트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내어 사용
3.
커밋이나 롤백으로 트랜잭션 종료 시 트랜잭션 동기화 매니저에 저장된 커넥션으로 트랜잭션 종료 및 트랜잭션 닫기
트랜잭션 동기화 매니저를 사용하면, 기존의 파라미터를 통해 서비스에서 레포지토리에 DataSource를 넘겨주던 방식은 모두 필요 없어지게 된다. 대신 다음과 같이 레포지토리의 생성자에서 주입받은 dataSource를 통해 커넥션을 얻거나 반환하면 된다.
// 커넥션 획득 DataSourceUtils.getConnection(dataSource); // 커넥션 반납 DataSourceUtils.releaseConnection(con, dataSource);
Java
복사
DataSourceUtils를 통해 커넥션을 획득하는 경우, 트랜잭션 동기화 매니저가 관리하는 커넥션이 있다면 해당 커넥션을 반환한다. 없다면 새로운 커넥션을 생성하여 반환한다.
DataSourceUtils를 통해 커넥션을 반납하는 경우, 트랜잭션 동기화 매니저가 커넥션을 닫지 않고 유지하다가 트랜잭션 종료 시 닫는다.

트랜잭션 템플릿

트랜잭션을 사용하는 코드에는 성공 시 commit, 실패 시 rollback을 호출하는 부분이 항상 반복되어 작성된다. 이를 템플릿 콜백 패턴을 통해 해결할 수 있다.
템플릿 콜백 패턴은 스프링 프레임워크에서 DI(의존성 주입)에서 사용되는 전략 패턴으로, 다음과 같은 순서로 실행된다.
1.
콜백 함수를 넘겨주며 템플릿을 호출
2.
템플릿이 정해진 흐름(Workflow)에 따라 작업 수행
3.
템플릿 내부에서 참조 변수 생성 후 콜백 실행
4.
콜백 결과를 통해 템플릿의 남은 작업(Workflow) 수행
5.
템플릿 종료 후 결과 반환
이와 같은 템플릿 콜백 패턴을 통해 성공 시 commit 하는 로직과 실패 시 rollback 하는 부분을 템플릿으로 처리하여 코드의 반복을 줄일 수 있다.
전략 패턴 전략패턴은 런타임(실행) 중에 알고리즘 전략을 선택해 객체 동작을 실시간으로 바꿀 수 있도록 만드는 디자인 패턴이다.
콜백(Callback) 콜백은 다른 메서드에 오브젝트 형태의 매개변수로 넘겨주는 실행 가능한 코드를 말한다. 자바에서는 람다 함수를 통해 콜백을 구현할 수 있다.
public class TransactionTemplate { private PlatformTransactionManager transactionManager; public <T> T execute(TransactionCallback<T> action){..} void executeWithoutResult(Consumer<TransactionStatus> action){..} }
Java
복사
스프링에서는 템플릿 콜백 패턴을 위해 TransactionTemplate라는 템플릿 클래스를 제공한다. 반환값이 있는 경우에는 execute, 없는 경우에는 executeWithoutReesult를 통해 템플릿을 호출한다.
@Slf4j public class MemberService { private final TransactionTemplate txTemplate; private final MemberRepository memberRepository; public MemberService(PlatformTransactionManager transactionManager, MemberRepository memberRepository) { this.txTemplate = new TransactionTemplate(transactionManager); this.memberRepository = memberRepository; } public void accountTransfer(String fromId, String toId, int money) { txTemplate.executeWithoutResult((status) -> { try { bizLogic(fromId, toId, money); } catch (SQLException e) { throw new IllegalStateException(e); } }); } ... }
Java
복사
트랜잭션 템플릿을 사용하면 이와 같이 커밋과 롤백에 대한 반복적인 코드를 제거할 수 있다. txTemplate의 콜백으로 비즈니스 로직을 넘겨주어 템플릿으로 처리하는 방식이다.
생성자에서 TransactionTemplate을 주입 받는게 아니라, PlatformTransactionManager를 주입 받는 이유는 더 유연한 사용을 위함이다. 싱글톤으로 생성되지 않는다는 단점이 있지만, 트랜잭션 매니저를 더 유연하게 바꿀 수 있기 때문에 많이들 이와 같이 사용한다.

트랜잭션 AOP

트랜잭션 템플릿까지 도입하여 많이 개선했지만, 여전히 트랜잭션을 처리하는 로직이 포함되어 있고, 비즈니스 로직과 트랜잭션을 처리하는 로직이 한 곳에 있으면 유지보수하기 어려워진다. 이를 스프링 AOP를 통해 프록시를 도입하면 해결할 수 있다.
스프링 AOP 기능을 사용하여 @Aspect, @Advice, @Pointcut을 사용하여 트랜잭션을 처리하는 AOP를 직접 만들 수 있다. 하지만 트랜잭션은 중요한 기능이며 무척 많이 사용되는 기능이기 때문에, 스프링에서는 AOP와 프록시를 통해 트랜잭션과 관련된 모든 기능을 제공할 수 있도록 만들어두었다.
@Slf4j @RequiredArgsConstructor public class MemberService { private final MemberRepository memberRepository; @Transactional public void accountTransfer(String fromId, String toId, int money) throws SQLException [ bizLogic(fromId, toId, money); } ... }
Java
복사
위와 같이 @Transactional 애노테이션 하나만 추가해주면, 해당 메서드를 호출하기 전에 트랜잭션을 시작하고 메서드 종료 이후 결과(예외 발생 여부)에 따라 트랜잭션을 커밋하거나 롤백한다.
기존의 로직은 서비스 계층에 트랜잭션 코드가 포함되어 있는 상태이다.
트랜잭션 프록시를 만들어 트랜잭션을 처리하도록 하여 서비스 계층에서 트랜잭션 코드를 분리하는 방식이다. AOP 프록시는 서비스를 상속받는 프록시 객체를 생성하고, 해당 프록시는 트랜잭션 시작과 종료 시에 처리하는 로직을 담당하고 그 사이에 서비스 계층을 호출하여 비즈니스 로직을 처리한다.
@Transactional 애노테이션을 통한 트랜잭션 관리는 여태까지 작성된 테스트 코드에서는 잘 동작하지 않을 것이다. 그 이유는 서비스나 레포지토리를 빈으로 등록하지 않고 직접 생성하여 사용해왔기 때문에, 스프링에서 프록시를 통한 처리를 수행할 때 스프링 컨테이너에 등록된 빈이 없어 찾지 못했기 때문이다.
@SpringBootTest class MemberServiceV3_3Test { ... @Autowired MemberRepositoryV3 memberRepository; @Autowired MemberServiceV3_3 memberService; @TestConfiguration static class TestConfig { @Bean DataSource dataSource() { return new DriverManagerDataSource(URL, USERNAME, PASSWORD); } @Bean PlatformTransactionManager transactionManager() { return new DataSourceTransactionManager(dataSource()); } @Bean MemberRepositoryV3 memberRepositoryV3() { return new MemberRepositoryV3(dataSource()); } @Bean MemberServiceV3_3 memberServiceV3_3() { return new MemberServiceV3_3(memberRepositoryV3()); } } ... }
Java
복사
따라서 서비스와 레포지토리 등을 빈으로 등록하거나, 이와 같이 @AutoWired@TestConfiguration을 통해 직접 테스트에서 사용될 수 있게 빈으로 등록하는 과정이 필요하다.
호출된 서비스와 레포지토리를 직접 찍어보면,
memberService class=class hello.jdbc.service.MemberServiceV3_3$$EnhancerBySpringCGLIB$$... memberRepository class=class hello.jdbc.repository.MemberRepositoryV3
Plain Text
복사
이처럼 레포지토리는 그냥 일반 객체인데 반해, 스프링 AOP를 통한 트랜잭션이 적용된 서비스는 EnhancerBySpringCGLIB와 같이 프록시 객체인 것을 확인할 수 있다.
트랜잭션 AOP가 사용된 전체 흐름은 다음과 같다.
이처럼 스프링에서 제공하는 @Transactional 애노테이션을 통해 편리하게 트랜잭션을 사용하는 것을 선언적 트랜잭션 관리라 한다. 반대로 위에서 우리가 작성했었던 트랜잭션 관련 코드를 직접 작성하는 방식을 프로그래밍 방식 트랜잭션 관리라 한다.
선언적 트랜잭션 관리가 프로그래밍 방식에 비해 훨씬 간편하고 실용적이기 때문에, 실무에서는 대부분 선언적 트랜잭션 관리를 사용하고 프로그래밍 방식은 테스트 시 가끔 사용되고 거의 사용되지 않는다.

스프링 부트로 자동 리소스 등록

@Bean DataSource dataSource() { return new DriverManagerDataSource(URL, USERNAME, PASSWORD); } @Bean PlatformTransactionManager transactionManager() { return new DataSourceTransactionManager(dataSource()); }
Java
복사
위에서 작성했던 코드처럼, 이전에는 데이터소스와 트랜잭션 매니저를 개발자가 직접 스프링 빈으로 등록해서 사용하거나 XML로 등록하여 사용해야 했다.
하지만 스프링 부트가 나오면서 이러한 부분을 자동화 할 수 있게 되었다.
spring: datasource: url: "jdbc:h2:tcp://localhost/~/test" username: "sa" password: ""
YAML
복사
이와 같이 설정 파일에 관련 속성을 지정해두면, 스프링 부트가 참고하여 데이터소스와 트랜잭션 매니저를 자동으로 생성해준다.
물론 위처럼 설정 파일에 속성을 추가했더라도, 코드로 직접 데이터소스와 트랜잭션 매니저를 등록하여 사용할 수 있다. 그런 경우에는 스프링 부트가 자동으로 등록하지 않는다.

예외 처리

Java의 기본 예외

스프링이 제공하는 예외에 대한 추상화를 이해하기 위해서는 자바의 예외에 대한 이해가 필요하다.
Object : 자바에서는 예외도 객체로 다루기 때문에 Object를 상속 받는다.
Throwable : 최상위 예외로 Exception과 Error를 상속한다.
Error : 메모리 부족이나 스택 오버플로우 같은 복구 불가능한 시스템 오류로, Error를 catch로 잡아 처리해서는 안된다. 같은 이유로 Throwable도 개발자가 직접 처리해서는 안된다.
Exception : 애플리케이션 로직에서 사용되는 실질적 최상위 예외이다. RuntimeException을 제외하고, 모든 하위 예외는 컴파일 시 체크되는 Checked 예외이다.
RuntimeException : 컴파일러가 체크하지 않는 Unchecked 예외로, 런타임 예외라고도 불린다. RuntimeException과 그 하위 모든 예외를 포함한다.
참고로 Error도 Uncheck 예외이다.

Java의 예외 규칙

예외에 대해서는 try-catch를 통해 직접 잡아서 처리하거나, 처리할 수 없다면 밖으로 해당 예외를 전파해야 한다.
예외를 잡아서 처리하면 애플리케이션 로직이 정상 흐름으로 되돌아온다.
예외를 잡아서 처리하는 경우, 기본적으로 지정한 예외를 처리할 뿐만 아니라 해당 예외의 자식 예외들도 모두 함께 처리된다.
예외를 처리하지 못하면 콜스택을 따라 밖으로 예외가 계속해서 전파된다.
예외를 처리하지 못하여 자바의 main 스레드까지 전파된다면 예외 로그를 출력하면서 시스템이 종료된다. 웹 애플리케이션의 경우, WAS가 예외를 받아서 처리하여 서버가 내려가지 않고 개발자가 지정해둔 오류 페이지로 보여주게 된다.

체크 예외

위에서 설명했듯 체크 예외는 컴파일러가 체크하는 예외로, 반드시 잡아서 처리하거나 밖으로 전파하도록 선언해야한다.
static class MyCheckedException extends Exception { public MyCheckedException(String message) { super(message); } } static class Repository { public void call() throws MyCheckedException { throw new MyCheckedException("ex"); } }
Java
복사
이와 같이 체크 예외를 발생시키는 로직이 있다면,
static class Service { Repository repository = new Repository(); public void catchException() { try { repository.call(); } catch (MyCheckedException e) { log.info("예외 처리, message={}", e.getMessage(), e); } } public void throwException() throws MyCheckedException { respository.call(); } }
Java
복사
체크 예외는 위처럼 try-catch로 잡아서 처리하거나 throws를 통해 밖으로 전파해야한다. 그렇지 않으면 컴파일러가 오류를 보여주며 애플리케이션 실행이 되지 않는다.
체크 예외는 컴파일러가 예외를 잡아주므로, 개발자가 실수로 예외를 누락하지 않을 수 있다. 하지만 모든 예외를 직접 잡거나 던지도록 처리해야하기 때문에, 번거롭고 신경쓰고 싶지 않은 예외까지 모두 챙겨야 한다는 단점이 있다.

언체크 예외

언체크 예외는 컴파일러가 확인하지 않는 예외로, 체크 예외와 달리 throws를 생략하더라도 자동으로 해당 예외를 밖으로 전파한다.
static class MyUncheckedException extends RuntimeException { public MyUncheckedException(String message) { super(message); } } static class Repository { public void call() throws MyUncheckedException { throw new MyUncheckedException("ex"); } }
Java
복사
마찬가지로 언체크 예외를 발생시키는 로직에서,
static class Service { Repository repository = new Repository(); public void catchException() { try { repository.call(); } catch (MyCheckedException e) { log.info("예외 처리, message={}", e.getMessage(), e); } } public void throwException() { respository.call(); } }
Java
복사
try-catch를 통해 해당 예외를 잡아서 처리할 수 있는 것을 동일하다. 하지만 throws를 생략했음에도 제대로 컴파일이 되고, 실제 예외가 발생한다면 자동으로 밖으로 해당 예외를 전파한다.
물론 throws를 직접 추가하여, 해당 예외가 발생함을 알려줄 수 있다. 이 경우 개발자가 IDE에서 좀 더 편리하게 인지할 수 있다.
언체크 예외는 생략 가능하기 때문에, 신경쓰고 싶지 않는 예외를 처리하지 않고 무시할 수 있다. 하지만 반대로 개발자가 실수로 예외를 누락하는 경우가 있을 수 있다.

체크/언체크(런타임) 예외 활용하기

위에서도 언급했지만, 체크 예외는 컴파일러가 예외 누락을 확인해주기 때문에 실수로 예외를 놓치는 것을 막아준다.
예외를 잡아서 처리한다면 괜찮지만, 예외를 처리할 방법이 없는 경우 위처럼 예외를 전파해야 한다. 이 과정에서 불리는 모든 콜스택에서 해당 예외들을 throws에 추가해야한다. 예외가 한두개라면 괜찮을지라도, 이러한 체크 예외들이 많아진다면 상당히 번거로운 일이 될 것이다. 그렇다고 해서 이를 Exception을 통해 일괄적으로 처리한다면, 특정 체크 예외에 대해서 누락이 발생해도 알 수 없어진다. 체크 예외의 장점을 버리는 것이다.
추가적으로 이러한 발생하는 대부분의 예외는 복구 불가능한 경우가 많다. 예를 들면 SQLException의 경우 데이터베이스에서 문제가 있으면 발생하는데, 이는 런타임 중에는 복구하는 것이 거의 불가능하다.
또한 위의 SQLException과 ConnectException의 경우 JDBC나 네트워크 기술에 의존하는 예외인데, 만약 JDBC에서 JPA로 바꾸면 SQLException을 전파하는 모든 곳에서 JPAException으로 바꾸어주어야 한다.
이러한 이유들로 인해, 체크 예외와 런타임 예외를 사용하는 기본 원칙을 다음 2가지로 두는 것이 좋다.
기본적으로 언체크(런타임) 예외만 사용하자.
계좌 이체 실패나 결제 실패와 같은 중요 비즈니스 로직에서만 체크 예외를 사용하자.
만약 위 상황에서 런타임 예외를 사용하면,
static class RuntimeSQLException extends RuntimeException { public RuntimeSQLException(Throwable cause) { super(cause); } } static class Repository { public void call() { try { runSQL(); } catch (SQLException e) { throw new RuntimeSQLException(e); } } } static class Service { Repository repository = new Repository(); public void logic() { repository.call(); } }
Java
복사
중간 과정인 컨트롤러와 서비스에서는 해당 예외들을 신경쓰지 않아도 된다.
이로 인해 매번 발생하는 모든 예외를 throws를 할 필요도 없고 특정 기술에 의존하는 예외들을 추가해둘 필요도 없어져 기술 변경 시 해당 영역의 코드만 수정하면 된다.
스택 트레이스 위 상황처럼 예외를 잡아서 변환하여 던질 때는 Throwable cause를 통해 기존의 예외를 반드시 포함해야한다.
public void call() { try { runSQL(); } catch (SQLException e) { throw new RuntimeSQLException(e); //기존 예외(e) 포함 } }
Java
복사
그래야 예외가 발생하면서 전달된, 그 예외가 발생된 원인이나 위치들을 포함하여 로그로 남길 수 있게 된다.
public void call() { try { runSQL(); } catch (SQLException e) { throw new RuntimeSQLException(); //기존 예외(e) 제외 } }
Java
복사
만약 이와 같이 기존 예외를 포함하지 않으면, 어떤 예외가 발생했는지만 알 수 있고 해당 예외를 해결하기 위한 예외 발생 위치나 원인을 분석하기 어려워진다.
/** * Make an instance managed and persistent. * @param entity entity instance * @throws EntityExistsException if the entity already exists. * @throws IllegalArgumentException if the instance is not an * entity * @throws TransactionRequiredException if there is no transaction when * invoked on a container-managed entity manager of that is of type * <code>PersistenceContextType.TRANSACTION</code> */ public void persist(Object entity);
Java
복사
추가적으로 이러한 런타임 예외는 예외 누락할 가능성이 있기 때문에, 위처럼 예외를 문서화 해두어 인지할 수 있도록 만드는 것이 중요하다.
/** * Issue a single SQL execute, typically a DDL statement. * @param sql static SQL to execute * @throws DataAccessException if there is any problem */ void execute(String sql) throws DataAccessException;
Java
복사
또는 런타임 예외를 throws에 추가하는 것도 가능하기 때문에, 이처럼 선언을 통해 어떤 예외가 발생하는지를 보여주는 것도 좋은 방법이다.

기존 코드에 적용하기

기존의 JDBC에 의존하는 예외인 SQLException을 런타임 예외로 변환하여 적용해보자.
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); } }
Java
복사
RuntimeException을 상속하는 커스텀 런타임 예외를 생성하고,
@Slf4j public class MemberRepositoryV4_1 { ... 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); } }
Java
복사
이와 같이 기존의 SQLException을 새로 생성한 예외로 변환하여 던지도록 수정한다.
public class MemberServiceV4 { ... @Transactional public void accountTransfer(String fromId, String toId, int money) { bizlogic(fromId, toId, money); } ... }
Java
복사
이를 통해 서비스 계층에서의 SQLException 의존을 해결할 수 있다.

예외 복구 처리

오류에 따라서 특정 오류는 직접 복구 처리를 하고 싶을 수 있다.
위의 예시에서는 SQLException이 발생하는 경우 중 ID가 중복되는 경우 뒤에 임의의 숫자를 붙여 가입 시키는 로직을 추가해보자.
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); } }
Java
복사
먼저 키 중복에 대한 예외를 새로 생성해주고,
public Member save(Member member) { ... try { ... } catch (SQLException e) { if (e.getErrorCode() == 23505) { throw new MyDuplicateKeyException(e); } throw new MyDbException(e); } finally { ... } }
Java
복사
레포지토리에서 예외의 에러 코드를 확인 후 키 중복 에러면 생성한 에러를 던진다.
DB마다 오류 코드가 다르지만, H2 데이터베이스의 경우 키 중복 오류는 23505이다.(MySQL은 1062)
public void create(String memberId) { try { repository.save(new Member(memberId, 0)); log.info("saveId={}", memberId); } catch (MyDuplicateKeyException e) { String retryId = memberId + new Random().nextInt(10000); repository.save(new Member(retryId, 0)); log.info("saveId={}", retryId); } catch (MyDbException e) { throw e; } }
Java
복사
이를 서비스 계층에서 받아 복구 시도 로직을 추가하면 된다.

스프링 예외 추상화

위의 복구 로직은 데이터베이스가 변경될 때마다 ErrorCode도 모두 변경해주어야 하는 문제가 있다. 데이터베이스마다 다르지만, 데이터베이스가 전달하는 오류 코드는 수십, 수백 가지가 넘기 때문에 이를 매번 바꾸는 것은 어려운 일이다.
스프링에서는 이러한 문제를 해결하기 위해 데이터 접근과 관련된 예외를 추상화하여 제공한다.
이러한 각각의 예외는 특정 기술에 종속적이지 않게 설계되어 있어, JDBC든 JPA든 스프링이 제공하는 예외를 사용한다면 기술이 바뀌어도 수정할 필요가 없다.
스프링에서 제공하는 예외의 최상위는 RuntimeException을 상속받은 DateAccessException이다. DataAccessException은 크게 NonTransient와 Transient 2가지로 구분된다.
NonTransient : 일시적이지 않은 예외들로, 같은 동작을 반복해서 실행해도 실패하는 예외이다. SQL 문법 오류, 데이터베이스 제약 조건 위배 등이 있다.
Transient : 일시적인 예외들로, 같은 동작을 반복해서 실행하면 성공할 가능성이 있다. 쿼리 타임아웃이나, 락 관련 오류 등이 있다.
스프링에서는 추가적으로 이러한 예외들을 편리하게 사용할 수 있도록 예외 변환기를 제공한다. 이를 통해 위의 복구 로직을 수정해보자.
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); } }
Java
복사
이처럼 스프링이 제공하는 예외 변환기의 translate 메서드에 적절한 설명과 sql, 발생한 SQLException을 전달하면, 적절한 스프링 데이터 접근 예외로 변환해서 반환해준다.
이를 통해 특정 기술에 종속적이지 않게 만들어, 데이터베이스가 변경되더라도 복구 시도 처리와 같은 로직에서 수정이 필요하지 않도록 만들 수 있다.
이러한 일이 가능한 이유는 org.springframwork.jdbc.support.sql-error-codes.xml 파일에 있다.
<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> </bean> <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> </bean>
XML
복사
XML에 각 데이터베이스마다 예외 코드들을 변환해야하는 예외의 이름과 매핑해두고 이를 활용해 변환하는 것이다.

JDBC 반복 해결하기

이제 마지막 문제인 JDBC를 사용함으로써 발생하는 커넥션 연결, 파라미터 바인딩, 커넥션 회수 과정을 줄여보자.
위 문제도 트랜잭션 AOP와 마찬가지로 템플릿 콜백 패턴을 사용해서 해결한다. 스프링은 이러한 JDBC의 반복 문제를 해결하기 위해 JdbcTemplate라는 템플릿을 제공한다.
@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); } @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); }
Java
복사
JdbcTemplate을 사용하면 기존의 Connection을 얻어오는 과정부터 커넥션 회수까지의 반복을 모두 해결해주기 때문에, 위와 같이 코드가 굉장히 간결하고 깔끔해진다.

참고