Search

스프링 DB 1편

H2 데이터베이스

스프링 부트에서 맞춰준 버전을 다운로드 받아서 설치
터미널을 통해 실행
권한 주기 : chmod 755 h2.sh
실행 : ./h2.sh
사용자명은 sa 입력, JDBC URL 확인 후 연결
최초 연결 : jdbc:h2:~/test
이후 : jdbc:h2:tcp://localhost/~/test
기본 정보 상수화
public class ConnectionConst { public static final String URL = "jdbc:h2:tcp://localhost/~/test"; public static final String USERNAME = "sa"; public static final String PASSWORD = ""; }
Java
복사
데이터베이스 연결
@Slf4j public class DBConnectionUtil { public static Connection getConnection() { try { Connection connection = DriverManager.getConnection(URL, USERNAME, PASSWORD); log.info("get connection={}, class={}", connection, connection.getClass()); return connection; } catch (SQLException e) { throw new IllegalStateException(e); } } }
Java
복사

JDBC 이해

애플리케이션을 개발할 때 중요한 데이터는 대부분 데이터베이스에 보관한다.
클라이언트가 애플리케이션 서버를 통해 데이터를 저장하거나 조회하면 애플리케이션 서버는 다음 과정을 통해서 데이터베이스를 사용한다.
커넥션 연결 : 주로 TCP/IP를 사용해서 커넥션을 연결한다.
SQL 전달 : 애플리케이션 서버는 DB가 이해할 수 있는 SQL을 연결된 커넥션을 통해 DB에 전달한다.
결과 응답 : DB는 전달된 SQL을 수행하고 결과를 응답한다. 애플리케이션 서버는 응답 결과를 활용한다.
문제는 각각의 데이터베이스마다 방법이 모두 다르다는 점이다.
데이터베이스를 다른 종류로 변경하면 애플리케이션 서버의 코드도 함께 변경해야 한다.
개발자 또한 각각의 데이터베이스마다 사용 방법을 새로 학습해야 한다.

JDBC 표준 인터페이스

JDBC는 자바에서 데이터베이스에 접속할 수 있도록 하는 자바 API로 쿼리하거나 업데이트하는 방법을 제공
java.sql.Conenction : 연결
java.sql.Statement : SQL을 담은 내용
java.sql.ResultSet : SQL 요청 응답
JDBC 인터페이스는 각각의 DB 벤더에서 구현한 라이브러리인 JDBC 드라이버를 사용해야 한다.
JDBC 인터페이스를 통해 2가지 문제점을 해결하였다.
데이터베이스를 다른 종류로 변경하면 애플리케이션 서버의 코드도 함께 변경해야 한다.
→ 다른 종류로 변경하려면 애플리케이션 서버의 코드를 그대로 유지한 채 JDBC 구현 라이브러리만 변경하면 된다.
개발자 또한 각각의 데이터베이스마다 사용 방법을 새로 학습해야 한다.
→ 개발자는 JDBC 표준 인터페이스 사용법만 학습하면 된다. 한 번 배워두면 수십개의 데이터베이스에 모두 동일하게 적용할 수 있다.
JDBC 커넥션 인터페이스와 구현
JDBC는 java.sql.Connection 표준 커넥션 인터페이스를 정의한다.
H2 데이터베이스 드라이버는 JDBC Connection 인터페이스를 구현한 org.h2.jdbc.JdbcConnection 구현체를 제공한다.
JDBC가 제공하는 DriveManager는 라이브러리에 등록된 DB 드라이버들을 관리하고 커넥션을 획득하는 기능을 제공한다.
1.
애플리케이션 로직에서 getConnection()을 호출
2.
DriveManager가 각 드라이버들에게 URL 정보 등을 넘겨 커넥션을 획득할 수 있는지 확인
3.
찾은 커넥션 구현체가 클라이언트에 반환

SQL Mapper와 ORM 기술

JDBC는 오래된 기술이고 복잡하다. 그래서 최근엔 JDBC를 편리하게 사용하는 기술을 사용한다.
SQL Mapper
장점
JDBC를 편리하게 사용하도록 도와준다
SQL 응답 결과를 객체로 편리하게 변환해준다.
JDBC의 반복 코드를 제거해준다.
단점
개발자가 SQL을 직접 작성해야한다.
대표 기술
스프링 JdbcTemplate, MyBatis
ORM
ORM은 객체를 RDB 테이블과 매핑해주는 기술이다.
개발자는 반복적인 SQL을 직접 작성하지 않고 ORM 기술이 SQL을 동적으로 만들어 실행한다.
추가로 각각의 데이터베이스마다 다른 SQL을 사용하는 문제도 중간에서 해결해준다.
대표 기술 : JPA, Hibernate, EclipseLink
SQL Mapper VS ORM
SQL Mapper는 SQL만 직접 작성하면 나머지는 SQL Mapper가 대신 해주므로 SQL만 작성할 줄 알면 금방 배워서 사용할 수 있다.
ORM은 SQL 자체를 작성하지 않아도 되어서 개발 생산성이 매우 높아진다. 하지만 실무에서 사용하려면 깊이있게 학습해야 한다.

CRUD 구현

도메인 객체 생성
@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
복사
CREATE
@Slf4j @Repository public class MemberRepositoryV0 { 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
복사
READ
@Slf4j @Repository public class MemberRepositoryV0 { 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.getInt("money")); return member; } else { throw new NoSuchElementException("member not found memberId=" + memberId); } } catch (SQLException e) { log.error("error", e); throw e; } finally { close(con, pstmt, rs); } } }
Java
복사
UPDATE
@Slf4j @Repository public class MemberRepositoryV0 { public void update(String memberId, int money) throws SQLException { String sql = "update member set money = ? where member_id = ?"; Connection con = null; PreparedStatement pstmt = null; ResultSet rs = null; try { con = DBConnectionUtil.getConnection(); pstmt = con.prepareStatement(sql); pstmt.setInt(1, money); pstmt.setString(2, memberId); int resultSize = pstmt.executeUpdate(); log.info("resultSize={}", resultSize); } catch (SQLException e) { log.error("db error", e); throw e; } finally { close(con, pstmt, rs); } } }
Java
복사
DELETE
@Slf4j @Repository public class MemberRepositoryV0 { public void delete(String memberId) throws SQLException { String sql = "delete 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); int resultSize = pstmt.executeUpdate(); log.info("resultSize={}", resultSize); } catch (SQLException e) { log.error("db error", e); throw e; } finally { close(con, pstmt, rs); } } }
Java
복사
커넥션 획득
미리 만든 DriveManager.getConnection을 이용해 커넥션을 획득하는 DBConnectionUtil 사용
SQL 작성
String sql 에 데이터베이스에 전달할 SQL 작성 (값이 들어갈 곳은 ?로 작성한다)
connection.prepareStatement(sql) 을 이용해 SQL을 준비
pstmt.setXXX(n, XXX) 을 사용하면 n번째 ? 에 XXX값을 넣는다.
실행 후 결과 확인
pstmt.executeUpdate()를 호출해 준비된 SQL을 데이터베이스에 전달
조회는 executeQuery()를 호출한다.
이 메서드들은 영향 받은 DB row 수를 반환한다.
ResultSet을 통해 executeXXX() 의 결과를 받아 조회할 수 있다.
next()는 데이터의 존재에 따라 boolean을 반환하며 다음 데이터를 조회하도록 커서가 이동된다.
데이터 조회 결과가 여러개라면 while문을 사용하여 조회한다.
리소스 정리
private void close(Connection con, Statement stmt, ResultSet rs) { if(rs != null) { try { rs.close(); } catch (SQLException e) { log.error("error", e); } } if(stmt != null) { try { stmt.close(); } catch (SQLException e) { log.error("error", e); } } if(con != null) { try { con.close(); } catch (SQLException e) { log.error("error", e); } } }
Java
복사
쿼리를 실행하고 나면 꼭 리소스 정리를 해주어야 한다.
예외가 발생해도 항상 수행되어야 하므로 finally 구문으로 작성해야 한다.
연결을 종료할 땐 실행 순서의 역순으로 종료해야 한다.

커넥션 풀

데이터베이스가 커넥션을 획득하는 과정
1.
애플리케이션 로직이 DB 드라이버를 통해 커넥션을 조회
2.
DB 드라이버는 DB와 TCP/IP 커넥션을 연결 (3 way handshake 와 같은 TCP/IP 연결)
3.
연결되면 DB 드라이버가 DB에 ID, PW, 기타 정보를 전달
4.
DB는 ID, PW를 통해 내부 인증을 완료하고 내부에 DB 세션을 생성한 후 응답을 보낸다
5.
DB 드라이버는 커넥션 객체를 생성해서 클라이언트에 반환
→ 커넥션을 새로 만드는 것은 과정도 복잡하고 시간과 리소스도 많이 소모된다.
커넥션 풀의 초기화
커넥션을 획득할 때의 문제를 해결하기 위해 커넥션을 미리 생성해두고 사용하는 커넥션 풀을 사용한다.
애플리케이션을 시작할 때 커넥션 풀은 필요한 만큼 미리 생성해서 풀에 보관한다. (기본값은 보통 10개)
커넥션 풀에 들어 있는 커넥션은 TCP/IP로 DB와 연결이 되어 있기 때문에 즉시 사용할 수 있다.
커넥션 풀의 사용
애플리케이션 로직에서 이제 DB 드라이버를 통해 새로운 커넥션을 획득하는 것이 아닌 이미 생성된 커넥션을 객체 참조로 가져다 쓰기만 하면 된다.
커넥션을 모두 사용하고 나면 커넥션을 종료하는 것이 아니라 그대로 커넥션 풀에 반환한다.
오픈소스 커넥션 풀
commons-dbcp2, tomcat-jdbc pool, HikariCP 등이 있지만 최근 실무에선 주로 HikariCP를 사용

DataSource

커넥션을 얻는 방법을 추상화
DriverManager를 사용하다 HikariCP를 사용하기 위해선 코드가 변경되어야 한다.
이런 문제를 해결하기 위해 자바는 javax.sql.DataSource 라는 인터페이스를 제공한다.
이제 DataSource 인터페이스에만 의존하도록 애플리케이션 로직을 작성하면 된다.
커넥션 풀 구현 기술을 변경하고 싶으면 해당 구현체로 갈아끼우기만 하면 된다.
public interface DataSource extends CommonDataSource, Wrapper { Connection getConnection() throws SQLException; Connection getConnection(String username, String password) throws SQLException; }
Java
복사
커넥션 풀들은 DataSource를 구현하며 DriverManager 또한 DriverManagerDataSource를 통해 DataSource를 사용할 수 있다.
설정과 사용의 분리
DriverManager 는 getConnection()을 호출할 때마다 URL, USERNAME, PASSWORD를 입력해야 했다.
하지만 DataSource는 단순히 getConnection()을 호출하기만 하면 된다.
이로써 레포지토리는 DataSource만 의존하고 속성은 모르기 때문에 분리가 가능해진다.
HikariCP 커넥션 풀
@Test void dataSourceConnectionPool() throws SQLException, InterruptedException { HikariDataSource dataSource = new HikariDataSource(); dataSource.setJdbcUrl(URL); dataSource.setUsername(USERNAME); dataSource.setPassword(PASSWORD); dataSource.setMaximumPoolSize(10); dataSource.setPoolName("MyPool"); useDataSource(dataSource); }
Java
복사
HikariCP 라이브러리는 스프링에서 기본적으로 제공하며 별도의 쓰레드를 통해 커넥션을 생성한다.
setXXX() 등의 메서드를 통해 URL, USERNAME, PASSWORD, 커넥션 수 등을 설정할 수 있다.
외부에서는 DataSource를 주입 받아서 사용하기만 하면 된다.
DriverManagerDataSource VS HikariDataSource
//DriverManagerDataSource 로그 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 //HikariDataSource 로그 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
Java
복사
DriverManagerDataSource를 사용하면 conn0부터 번호를 통해 항상 새로운 커넥션이 생성된다.
커넥션 풀(HikariDataSource)를 사용하면 conn0 커넥션이 재사용되는 것을 확인할 수 있다.

JdbcUtils

스프링은 JDBC를 편리하게 다룰 수 있는 JdbcUtils 라는 편의 메서드를 제공한다.
JdbcUtils.closeXXX() 메서드를 사용하면 커넥션을 좀 더 편리하게 닫을 수 있다.
private void close(Connection con, Statement stmt, ResultSet rs) { JdbcUtils.closeResultSet(rs); JdbcUtils.closeStatement(stmt); JdbcUtils.closeConnection(con); }
Java
복사

트랜잭션

하나의 작업을 안전하게 처리하도록 보장하는 것을 뜻한다.
트랜잭션은 ACID를 보장해야 한다.
원자성(Atomicity) : 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공하거나 모두 실패해야 한다.
일관성(Consistency) : 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. 예를 들어 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 한다.
격리성(Isolation) : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다. 예를 들어 동시에 같은 데이터를 수정하지 못하도록 해야 한다. 격리성은 동시성과 관련된 성능 이슈로 인해 트랜잭션 격리 수준을 선택할 수 있다.
지속성(Durability) : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용올 복구해야 한다.
트랜잭션 격리 수준
READ UNCOMMITED (커밋되지 않은 읽기)
READ COMMITED (커밋된 읽기)
REPEATABLE READ (반복 가능한 읽기)
SERIALIZABLE (직렬화 가능)

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

사용자는 WAS나 DB 접근 툴 같은 클라이언트를 사용해서 데이터베이스 서버에 접근할 수 있다.
클라이언트는 데이터베이스 서버에 연결을 요청하고 커넥션을 맺게 되면 데이터베이스 서버는 내부에 세션이라는 것을 만든다. 그리고 앞으로 해당 커넥션을 통한 모든 요청은 이 세션을 통해서 실행하게 된다.
세션은 트랜잭션을 시작하고 커밋 또는 롤백을 통해 트랜잭션을 종료한다. 그리고 이후에 새로운 트랜잭션을 다시 시작할 수 있다.
사용자가 커넥션을 닫거나 DBA가 세션을 강제로 종료하면 세션은 종료된다.
커넥션 풀이 10개의 커넥션을 생성하면 세션도 10개 만들어진다.

커밋과 롤백

커밋을 호출하기 전까지는 임시로 데이터를 저장하는 것이다. 따라서 해당 트랜잭션을 시작한 세션에게만 변경 데이터가 보이고 다른 세션에게는 변경 데이터가 보이지 않는다.
데이터 변경 쿼리를 실행하고 데이터베이스에 결과를 반영하려면 commit을 호출하고 반영하고 싶지 않으면 rollback을 호출한다.

자동 커밋과 수동 커밋

set autocommit true; //자동 커밋 모드 설정 set autocommit false; //수동 커밋 모드 설정
Java
복사
자동 커밋으로 설정하면 각각의 쿼리 실행 직후에 자동으로 커밋을 호출한다. 따라서 커밋이나 롤백을 직접 호출하지 않아도 되는 편리함이 있다.
하지만 쿼리를 하나하나 실행할 때마다 자동으로 커밋이 되어버리기 때문에 우리가 원하는 트랜잭션 기능을 제대로 사용할 수 없다.
수동 커밋 모드로 설정하는 것을 트랜잭션을 시작한다고 표현할 수 있다.

DB 락

세션1이 트랜잭션을 시작하고 데이터를 수정하는 동안 커밋을 수행하지 않은 상태에서 세션2에서 동시에 같은 데이터를 수정하게 되면 트랜잭션의 원자성이 깨지게 된다.
심지어 세션1이 중간에 롤백을 하게되면 세션2는 잘못된 데이터를 수정하는 문제가 발생한다.
이 문제를 해결하기 위해선 세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 막아야 한다.
set autocommit true; delete from member; insert into member(member_id, money) values ('memberA',10000);
SQL
복사
//세션1 set autocommit false; update member set money=500 where member_id = 'memberA';
SQL
복사
//세션2 SET LOCK_TIMEOUT 60000; set autocommit false; update member set money=1000 where member_id = 'memberA';
SQL
복사
//세션1 commit;
SQL
복사
//세션2 commit;
SQL
복사
보통 데이터를 조회할 때는 락을 획득하지 않고 바로 데이터를 조회할 수 있다.
하지만 트랜잭션 종료 시점까지 해당 데이터를 다른 곳에서 변경하지 못하도록 강제로 막아야 할 때 조회에도 락을 사용할 수 있다.
SELECT문에 for update를 붙이면 조회와 동시에 락도 획득할 수 있다.
//세션1 set autocommit false; select * from member where member_id='memberA' for update;
SQL
복사

트랜잭션 적용

트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다.
비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문이다.
트랜잭션을 시작하려면 커넥션이 필요한데 결국 서비스 계층에서 커넥션을 만들고 트랜잭션 커밋 이후에 커넥션을 종료해야 한다.
트랜잭션을 사용하려면 트랜잭션을 사용하는 동안은 같은 커넥션을 유지해야 한다. 그래야 같은 세션을 사용할 수 있다.
같은 커넥션을 유지하기 위해서 가장 단순한 방법은 커넥션을 파라미터로 전달해서 사용하는 것이다.
getConnection()을 호출하면 같은 커넥션을 사용할 수 없기 때문에 사용하면 안된다!
쿼리 후 close()로 커넥션을 릴리즈해버리면 같은 커넥션을 사용할 수 없기 때문에 사용하면 안된다!
→ 커넥션 생성과 릴리즈는 리포지토리가 아닌 서비스로직 시작과 끝에서 해야한다.
@Slf4j @RequiredArgsConstructor public class MemberServiceV2 { private final MemberRepositoryV2 memberRepository; private final DataSource dataSource; 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(fromId, fromMember.getMoney() - money); validation(toMember); memberRepository.update(toId, toMember.getMoney() + money); } private void release(Connection con) { if(con != null) { try { con.setAutoCommit(true); con.close(); } catch (Exception e) { log.info("error", e); } } } private void validation(Member toMember) { if(toMember.getMemberId().equals("ex")) { throw new IllegalStateException("이체 중 예외 발생"); } } }
Java
복사
트랜잭션을 시작하기 위해 setAutoCommit(false)를 호출한다.
비즈니스 로직 실행 중 예외가 발생하면 catch를 통해 rollback()을 호출한다.
비즈니스 로직이 끝나면 finally를 통해 커넥션을 안전하게 종료한다.
커넥션 풀을 사용하는 경우 커넥션을 종료하면 커넥션 풀로 반환하게 되는데
setAutoCommit(false)인 상태로 반환하면 다른 요청에서 커넥션을 받았을 때도 유지되기 때문에
true로 변경해서 반환하는 것이 안전하다.

여태까지의 문제점

트랜잭션 문제
JDBC 구현 기술이 서비스 계층에 누수되는 문제 (서비스 계층은 순수해야 한다.)
트랜잭션 동기화 문제 (같은 트랜잭션을 유지하기 위해 커넥션을 파라미터로 넘겨야 한다.)
트랜잭션 적용 반복 문제 (try-catch-finally…)
예외 누수
데이터 접근 계층의 JDBC구현 기술 예외가 서비스 계층으로 전파된다 (SQLException과 같은 예외)
JDBC 반복 문제
순수한 JDBC의 코드들은 유사한 코드의 반복이 너무 많다 (con, pstmt, rs, try-catch …)

트랜잭션 추상화

트랜잭션을 사용하는 코드는 데이터 접근 기술마다 다르기 때문에 기술에 의존적이어서 기술이 변경되면 코드가 함께 변경되어야 한다.
이 문제를 해결하기 위해 트랜잭션 기능을 추상화하도록 인터페이스가 만들어졌다.
org.springframework.transaction.PlatformTransactionManager
DataSourceTransactionManager 와 JpaTransactionManager는 큰 기능 차이가 없다.
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() : 트랜잭션을 롤백한다.

트랜잭션 동기화

스프링이 제공하는 트랜잭션 매니저는 트랜잭션 추상화, 리소스 동기화의 2가지 역할을 한다.
트랜잭션 추상화 : Service는 PlatformTransactionManager 인터페이스만을 의존한다.
리소스 동기화 : 트랜잭션 동기화 매니저를 통한 트랜잭션을 유지하기 위한 데이터베이스 커넥션 동기화
트랜잭션 동기화 매니저
트랜잭션 매니저 내부에서 사용하며 ThreadLocal을 사용해서 커넥션을 동기화해준다.
ThreadLocal을 사용하기 때문에 멀티쓰레드 상황에서 안전하게 커넥션을 동기화 할 수 있다.
(ThreadLocal을 사용하면 각 쓰레드마다 별도의 저장소가 부여된다.)
동작 방식
1.
트랜잭션 매니저가 데이터소스를 통해 커넥션을 만들고 트랜잭션 시작
2.
트랜잭션 매니저가 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관
3.
리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내 사용 (파라미터 전달 안해도 됨)
4.
트랜잭션이 종료되고 트랜잭션 매니저는 트랜잭션 동기화에 보관된 커넥션을 통해 트랜잭션을 종료하고 커넥션도 닫는다

트랜잭션 매니저

1.
Service에서 getTransaction()을 호출해서 트랜잭션을 시작한다.
2.
트랜잭션 매니저가 내부에서 데이터소스를 사용해 데이터베이스 커넥션을 생성한다.
3.
커넥션을 수동 커밋 모드로 변경해서 실제 데이터베이스 트랜잭션을 시작한다.
4.
커넥션을 트랜잭션 동기화 매니저에 보관한다.
5.
트랜잭션 동기화 매니저는 쓰레드로컬에 커넥션을 보관한다. (멀티쓰레드세이프)
6.
Service는 비즈니스 로직을 실행하며 Repository의 메서드를 호출 (파라미터 전달 X)
7.
Repository 메서드는 트랜잭션이 시작된 커넥션이 필요하므로 DataSourceUtils.getConnection()으로 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다.
8.
획득한 커넥션을 사용해서 SQL을 데이터베이스에 전달해서 실행한다.
9.
비즈니스 로직이 끝나고 트랜잭션을 종료하기 위해 커밋이나 롤백한다.
10.
커밋이나 롤백하기 위해 트랜잭션 동기화 매니저에게 동기화된 커넥션을 획득한다.
11.
획득한 커넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.
12.
전체 리소스를 정리한다.
트랜잭션 동기화 매니저와 쓰레드로컬을 정리
setAutoCommit 을 true로 되돌려 커넥션 풀을 고려
connection.close()를 호출해서 커넥션을 종료 (또는 커넥션 풀에 반환)

트랜잭션 매니저 사용

//MemberRepository.java private void close(Connection con, Statement stmt, ResultSet rs) { JdbcUtils.closeResultSet(rs); JdbcUtils.closeStatement(stmt); DataSourceUtils.releaseConnection(con, dataSource); } private Connection getConnection() throws SQLException { Connection con = DataSourceUtils.getConnection(dataSource); log.info("get connection={}, class={}", con, con.getClass()); return con; }
Java
복사
dataSource.getConnection → DataSourceUtils.getConnection()
트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 가져온다.
트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 새로운 커넥션을 생성해 가져온다.
JdbcUtils.closeConnection(con) → DataSourceUtils.releaseConnection(con, dataSource)
트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해준다.
트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다.
//MemberService.java @Slf4j @RequiredArgsConstructor public class MemberServiceV3_1 { //private final DataSource dataSource; private final PlatformTransactionManager transactionManager; private final MemberRepositoryV3 memberRepository; public void accountTransfer(String fromId, String toId, int money) throws SQLException { //Connection con = dataSource.getConnection(); TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition()); try { //con.setAutoCommit(false); //bizLogic(con, fromId, toId, money); //con.commit(); bizLogic(fromId, toId, money); transactionManager.commit(status); } catch (Exception e) { //con.rollback(); transactionManager.rollback(status); throw new IllegalStateException(e); } //} finally { //release(con); //} }
Java
복사
private final PlatformTransactionManager transactionManager;
dataSource 대신 트랜잭션 매니저를 주입 받는다.
(JDBC 구현체를 사용하기 때문에 DataSourceTransactionManager를 주입 받는다.)
transactionManager.getTransaction(new DefaultTransactionDefinition());
트랜잭션을 시작한다.
new DefaultTransactionDefinition()을 통해 트랜잭션과 관련된 옵션을 지정할 수 있다.
getTransaction()은 TransactionStatus를 반환하며 트랜잭션의 상태 정보가포함되어 있다.
commit(), rollback()
커밋과 롤백을 하며 사용하기 위해 TransactionStatus가 필요하다.

트랜잭션 템플릿

트랜잭션을 추상화하고 동기화했음에도 try-catch를 포함한 커밋, 롤백 코드가 반복된다.
템플릿 콜백 패턴을 사용해 해결할 수 있다.
스프링은 TransactionTemplate라는 템플릿 클래스를 제공해 템플릿 콜백 패턴을 사용한다.
//TransactionTemplate가 구현하는 인터페이스 public interface TransactionOperations { @Nullable <T> T execute(TransactionCallback<T> action) throws TransactionException; default void executeWithoutResult(Consumer<TransactionStatus> action) throws TransactionException { execute(status -> { action.accept(status); return null; }); } static TransactionOperations withoutTransaction() { return WithoutTransactionOperations.INSTANCE; } }
Java
복사
execute() : 응답 값이 있을 때 사용한다.
executeWithoutResult() : 응답 값이 없을 때 사용한다.
@Slf4j @RequiredArgsConstructor public class MemberServiceV3_2 { //private final PlatformTransactionManager transactionManager; private final TransactionTemplate transactionTemplate; private final MemberRepositoryV3 memberRepository; public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) { this.transactionTemplate = new TransactionTemplate(transactionManager); this.memberRepository = memberRepository; } public void accountTransfer(String fromId, String toId, int money) throws SQLException { transactionTemplate.executeWithoutResult((status) -> { try { bizLogic(fromId, toId, money); } catch (SQLException e) { throw new IllegalStateException(e); } }); } //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
복사
TransactionTemplate를 사용하려면 트랜잭션 매니저가 필요하기 때문에 생성자를 통해 주입 받았다.
트랜잭션 템플릿을 사용해 getTransaction, commit, rollback 코드를 모두 제거할 수 있다.
(람다에서는 체크예외를 밖으로 던질 수 없어 언체크드예외로 바꿔주었다.)

트랜잭션 AOP

트랜잭션 추상화, 동기화, 템플릿을 사용했지만 아직까지도 서비스 계층에 트랜잭션 코드가 남아있다.
스프링 AOP와 프록시를 통해 해결할 수 있다.
스프링은 트랜잭션 AOP를 처리하기 위한 @Transactional 어노테이션을 제공한다.
메서드와 클래스에 붙여도 되며 클래스에 사용하면 public 메서드들에 트랜잭션 AOP가 적용된다.
@Transactional을 사용하게 되면 프록시가 적용되며 EnhancerBySpringCGLIB 으로 바뀌게 된다.
@Slf4j public class MemberServiceV3_3 { //private final TransactionTemplate transactionTemplate; private final MemberRepositoryV3 memberRepository; public MemberServiceV3_3(MemberRepositoryV3 memberRepository) { //this.transactionTemplate = new TransactionTemplate(transactionManager); this.memberRepository = memberRepository; } @Transactional public void accountTransfer(String fromId, String toId, int money) throws SQLException { bizLogic(fromId, toId, money); //transactionTemplate.executeWithoutResult((status) -> { //try { //bizLogic(fromId, toId, money); //} catch (SQLException e) { //throw new IllegalStateException(e); //} //}); }
Java
복사
//MemberServiceTest.java @Slf4j @SpringBootTest class MemberServiceV3_4Test { public static final String MEMBER_A = "memberA"; public static final String MEMBER_B = "memberB"; public static final String MEMBER_EX = "ex"; @Autowired private MemberRepositoryV3 memberRepository; @Autowired private MemberServiceV3_3 memberService; @TestConfiguration static class TestConfig { private final DataSource dataSource; TestConfig(DataSource dataSource) { this.dataSource = dataSource; } @Bean MemberRepositoryV3 memberRepositoryV3() { return new MemberRepositoryV3(dataSource); } @Bean MemberServiceV3_3 memberServiceV3_3() { return new MemberServiceV3_3(memberRepositoryV3()); } } ...
Java
복사
트랜잭션 AOP를 적용한 후 테스트하려면 스프링 컨테이너가 필요하기 때문에 @SpringConfiguration을 사용해 테스트에서 스프링 컨테이너를 생성해 사용한다.
테스트 안에서 빈을 등록하기 위해 내부 클래스를 만들어 @TestConfiguration을 사용해 빈을 추가한다.

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

데이터소스와 트랜잭션 매니저를 사용하려면 직접 스프링 빈으로 등록해야 했었다.
스프링 부트는 이걸 자동으로 등록해주며 application.properties 설정만 하면 사용할 수 있다.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test spring.datasource.username=sa spring.datasource.password=
Java
복사
이 때 등록된 라이브러리를 보고 거기에 맞는 트랜잭션 매니저가 등록된다.

자바의 예외

Object : 예외도 객체이다. 모든 객체의 최상위 부모는 Object이므로 예외의 최상위 부모도 Object이다.
Throwable : Exception과 Error의 최상위 부모이다.
Error는 catch로 잡으면 안된다. 그렇기 때문에 Throwable을 예외로 잡아선 안된다.
Exception : 체크 예외
애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외이다.
RuntimeException을 제외한 Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다.
RuntimeException : 컴파일러가 체크하지 않는 언체크 예외, 런타임 예외
Error : 메모리 부족이나 시스템 오류와 같이 애플리케이션에서 복구 불가능한 시스템 예외이다.
예외에 대한 2가지 기본 규칙
예외는 잡아서 처리하거나 던져야 한다.
예외를 잡거나 던질 때 지정한 예외뿐만 아니라 그 예외의 자식들도 함께 처리된다.
Exception을 catch로 잡으면 그 하위 예외들도 모두 잡을 수 있다.
Exception을 throws로 던지면 그 하위 예외들도 모두 던질 수 있다.
(Exception을 사용할 경우 중요한 예외에 대한 체크를 할 수 없어지므로 안티패턴이다.)

체크 예외

예외를 잡아서 처리하지 않으면 항상 throws에 던지는 예외를 선언해야 한다.
//체크 예외 생성 (Exception을 상속받아서 예외를 만들면 체크 예외가 된다.) static class MyCheckedException extends Exception { public MyCheckedException(String message) { super(message); } } //잡아서 처리 public void callCatch() { try { repository.call(); } catch (MyCheckedExcpetion e) { log.info("예외 처리 message={}", e.getMessage(), e); } } //던져서 처리 public void callThrow() throws MyCheckedExcpetion { repository.call(); }
Java
복사
장점
개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아주는 안전 장치이다.
단점
모든 체크 예외를 반드시 잡거나 던져야하므로 번거롭다.
복구 불가능한 예외인 경우에도 어쩔 수 없이 throws를 통해 던져야한다.
특정 라이브러리에서 던지는 체크 예외를 처리하느라 의존적이게 돼 변경하기가 어렵다.

언체크 예외

에외를 잡아서 처리하지 않아도 throws를 생략할 수 있다.
//언체크 예외 생성 (RuntimeException을 상속받아서 예외를 만들면 언체크 예외가 된다.) static class MyUnCheckedException extends RuntimeException { public MyUnCheckedException(String message) { super(message); } } //잡아서 처리 public void callCatch() { try { repository.call(); } catch (MyUnCheckedException e) { log.info("예외 처리, message={}", e.getMessage(), e); } } //던져서 처리 public void callThrow() { //throws 생략 가능 repository.call(); }
Java
복사
장점
신경쓰고 싶지 않은 언체크 예외나 복구 불가능한 예외를 무시할 수 있다.
throws 선언을 생략할 수 있다.
체크 예외처럼 예외에 강제로 의존하지 않아도 되므로 의존관계가 생기지 않는다.
변경에 대한 영향 범위가 최소화된다.
단점
개발자가 실수로 예외를 누락할 수 있다. (문서화의 필요성)
→ 기본적으로 언체크 예외를 사용하고 체크 예외는 비즈니스 로직상 의도적으로 던질 때만 사용하자.

예외 포함과 스택 트레이스

@Test void printEx() { Controller controller = new Controller(); try { controller.request(); } catch (Exception e) { log.info("ex", e); //마지막 파라미터에 e를 통해 기존 예외 포함 } } //예외 미포함 스택 트레이스 [Test worker] INFO hello.jdbc.exception.basic.UncheckedAppTest - ex hello.jdbc.exception.basic.UncheckedAppTest$RuntimeSQLException: null at hello.jdbc.exception.basic.UncheckedAppTest$Repository.call(UncheckedAppTest.ja va:61) at hello.jdbc.exception.basic.UncheckedAppTest$Service.logic(UncheckedAppTest.java :45) //예외 포함 스택 트레이스 [Test worker] INFO hello.jdbc.exception.basic.UncheckedAppTest - ex hello.jdbc.exception.basic.UncheckedAppTest$RuntimeSQLException: java.sql.SQLException: ex at hello.jdbc.exception.basic.UncheckedAppTest$Repository.call(UncheckedAppTest.ja va:61) at hello.jdbc.exception.basic.UncheckedAppTest$Service.logic(UncheckedAppTest.java :45) at hello.jdbc.exception.basic.UncheckedAppTest$Controller.request(UncheckedAppTest .java:35) at hello.jdbc.exception.basic.UncheckedAppTest.printEx(UncheckedAppTest.java:24) Caused by: java.sql.SQLException: ex at hello.jdbc.exception.basic.UncheckedAppTest$Repository.runSQL(UncheckedAppTest. java:66) at hello.jdbc.exception.basic.UncheckedAppTest$Repository.call(UncheckedAppTest.ja va:59)
Java
복사
예외를 전환할 때는 꼭 기존 예외를 포함해야 한다. (마지막 파라미터에 기존 예외를 넣어준다.)

인터페이스와 체크 예외

인터페이스는 구현 기술에 대한 의존성을 제거해 구현 기술을 변경하기 편하게 해준다.
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; }
Java
복사
인터페이스의 구현체에서 체크 예외를 던진다면 인터이스 메서드에도 예외 선언을 해주어야 한다.
→ 특정 기술에 종속되지 않아야 하는 순수한 인터페이스가 종속적으로 변하는 문제점이 생긴다.
//MyDbException.java public class MyDbException extends RuntimeException { } //Repository.java ... } catch (SQLException e) { throw new MyDbException(e); } finally { close(con, pstmt, null); } ...
Java
복사
이렇게 체크 예외를 직접 만든 언체크 예외로 변환해서 던지면 순수한 인터페이스를 유지할 수 있다.
→ 체크 예외를 catch로 잡아서 언체크 예외를 던지면 된다.

커스텀 예외 처리

직접 만든 예외에 대해서 특정 예외에 대해선 잡아서 처리하고 싶다면 어떻게 구분할 수 있을까?
데이터베이스에서 똑같은 SQLException을 보내더라도 오류 코드를 활용해 구분할 수 있다.
e.getErrorCode()를 통해 확인하며 데이터베이스마다 정의된 오류 코드가 다르다.
//Repository.java } catch (SQLException e) { if(e.getErrorCode() == 23505) { throw new MyDuplicateKeyException(e); } throw new MyDbException(); //Service.java 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; } }
Java
복사
오류 코드를 확인해 우리가 처리하길 원하는 코드라면 따로 만들어둔 예외를 던지게끔 한다.
Service에선 해당 예외에 대한 처리 로직을 짜둔다.

스프링의 예외 추상화

데이터베이스가 전달하는 오류의 종류는 매우 많을뿐더러 데이터베이스마다 오류 코드가 모두 다르다.
스프링은 데이터 접근 계층에 대한 수십 가지 예외를 정리해서 일관된 예외 계층을 제공한다.
DataAccessException는 RuntimeException을 상속받는 최상위 예외이다.
Transient : 일시적인 예외들로 동일한 작업을 다시 시도했을 때 성공하 가능성이 있는 예외이다.
NonTransient : 일시적이지 않은 예외들로 동일한 작업을 다시 시도했을 때 실패하는 예외이다.
SQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource); DataAccessException resultEx = exTranslator.translate("select", sql, e);
Java
복사
여러 기술을 사용할 때 발생하는 예외를 스프링이 제공하는 예외로 변환해주는 역할도 제공한다.
SQLExceptionTranslator는 인터페이스, SQLErrorCodeSQLExceptionTranslator는 구현체이다.
translate(설명, sql, 발생한 예외) 를 호출하면 적절한 스프링 예외로 변환해서 반환해준다.
sql-error-codes.xml 이라는 파일에 데이터베이스마다 오류 코드에 대한 예외를 정의해놓았다.

JdbcTemplate

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
복사
Repository의 코드를 보면 JDBC를 사용하기 위한 코드가 반복되는걸 확인할 수 있다.
public Member save(Member member) { String sql = "insert into member(member_id, money) values (?, ?)"; template.update(sql, member.getMemberId(), member.getMoney()); return member; }
Java
복사
JdbcTemplate를 주입 받아 사용하게 되면 sql를 작성하고 template()를 호출하기만 하면 된다.
private void close(Connection con, Statement stmt, ResultSet rs) { JdbcUtils.closeResultSet(rs); JdbcUtils.closeStatement(stmt); DataSourceUtils.releaseConnection(con, dataSource); } private Connection getConnection() throws SQLException { Connection con = DataSourceUtils.getConnection(dataSource); log.info("get connection={}, class={}", con, con.getClass()); return con; }
Java
복사
뿐만 아니라 트랜잭션을 위한 커넥션 동기화와 커넥션 종료 그리고 스프링 예외 변환기도 자동으로 실행한다.