Search
Duplicate
📒

[Database Study] 03-2. 스프링 트랜잭션, 데이터 접근 예외

상태
수정중
수업
Database Study
주제
Transaction
연관 노트
3 more properties
참고

스프링 계층분리

NOTE
일반적으로 애플리케이션은 3개의 계층으로 나뉩니다. 서비스 계층은 핵심 비즈니스 로직을 담고 있어야 하며, 특정 기술(DB)에 종속되지 않아야 합니다. 이런식으로 계층을 나누는 것은 유지보수와 테스트를 쉽게 하기 위한 것입니다.
애플리케이션 계층 구조
데이터 접근 계층과 서비스 계층을 분리하려면, JDBC나 JPA와 같은 데이터 접근 기술에 종속되지 않게 개발해야 합니다. 이를 이해하기 은행 시스템의 이체 시스템의 로직을 작성해보겠습니다.
@Slf4j public class MemberRepositoryV2 { private final DataSource dataSource; public MemberRepositoryV2(DataSource dataSource) { this.dataSource = dataSource; } public Member save(Member member) throws SQLException { // .. } public Member findById(String memberId) throws SQLException { // .. } public void update(String memberId, int money) throws SQLException { // .. } public void delete(String memberId) throws SQLException { // .. } }
Java
복사
데이터 접근 계층
@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); memberRepository.update(con, toId, toMember.getMoney() + money); } }
Java
복사
서비스 계층 ( 불완전한 분리 )
송신자, 수신자를 DB에서 조회한뒤, 보내는 돈만큼 송신자와, 수신자의 계좌를 업데이트합니다.
만약 예외가 발생하는경우, 트랜잭션을 통해 롤백합니다.
예제코드는 지금 다음과 같은 문제점이 있습니다.
1.
구체적인 기술 의존성 문제: 트랜잭션 코드가 추가되어 DataSource를 사용하게 되면서, JDBC 기술에 의존하게 됩니다. 이는 향후 JPA 등 다른 기술로 변경하려 할 때 문제가 발생할 수 있습니다.
2.
예외 누수 문제: 비즈니스 로직을 사용하면서도, Repository에서 발생하는 예외(SQLException)가 노출됩니다.

Spring 트랜잭션

트랜잭션 추상화(트랜잭션 매니저)

NOTE
서비스 계층에서 트랜잭션을 적용하면서 JDBC의 구현 기술이 해당 계층으로 누출되고 있습니다. 만약 다른 데이터 접근 기술로 변경이 필요할 경우, 서비스 계층의 트랜잭션 관련 코드를 전부 수정해야 합니다.
트랜잭션의 코드는 구현 기술마다 다르다.
이 문제를 해결하기 위해서는 트랜잭션 기능을 추상화하면된다. 아주 단순하게 아래와 같은 인터페이스를 만들어서 사용하면 된다.
TxManager라는 추상화 인터페이스에 의존
public interface TxManager { begin(); // 트랜잭션 시작 commit(); // 커밋 rollback(); // 롤백 }
Java
복사
Spring은 트랜잭션 기술의 공통점을 담은 트랜잭션 추상화 기술을 제공하여, 종속적인 코드를 사용하지 않고 일관되게 트랜잭션을 처리할 수 있게 해줍니다.
PlatformTransactionManger 등록
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
복사

트랜잭션 동기화

NOTE
트랜잭션을 유지하기 위해서는 트랜잭션의 시작부터 끝까지 동일한 DB 커넥션을 유지해야 합니다. 이전에는 동일한 커넥션을 유지하기 위해 파라미터로 전달했지만, 스프링은 트랜잭션 동기화 매니저를 제공하여 커넥션을 동기화해줍니다.
커넥션과 세션
트랜잭션 매니저 흐름도
스프링은 트랜잭션 동기화 매니저를 제공하여, 쓰레드 로컬을 사용해 커넥션을 동기화합니다.
트랜잭션 동기화 매니저는 쓰레드 로컬을 활용하므로, 멀티쓰레드 상황에서도 안전하게 커넥션을 동기화할 수 있습니다. 커넥션이 필요할 때는 트랜잭션 동기화 매니저를 통해 획득하면 됩니다.

동작 방식

1.
트랜잭션을 시작하려면 connection이 필요합니다. 트랜잭션 매니저는 DataSource를 통해 connection을 생성하고 트랜잭션을 시작합니다.
2.
트랜잭션 매니저는 트랜잭션이 시작된 connection을 트랜잭션 동기화 매니저에 보관합니다.
3.
Repository는 트랜잭션 동기화 매니저에 보관된 connection을 사용합니다.
4.
트랜잭션이 종료되면, 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 이용해 트랜잭션을 종료하고 커넥션을 닫습니다.

스레드 로컬

NOTE
스레드 로컬은 스레드 마다 별도의 내부 저장소를 제공하는 객체이다!
public class ThreadLocalService { private ThreadLocal<String> nameStore = new ThreadLocal<>(); // ...
Java
복사
ThreadLocal<String> 사용
객체의 필드를 여러 스레드가 공유하는 것이 동시성 문제의 원인이므로, 필드를 스레드 로컬 객체로 두면 동시성 문제를 해결할 수 있다!

주의할점

해당 스레드가 스레드 로컬을 모두 사용하고 나면 remove()로 로컬의 값을 지워야한다!
WAS는 스레드 풀 방식으로 동작하는데, 스레드 반환시 remove로 스레드 로컬을 초기화시킨 후 반환하지 않으면, 다음 요청에서 이전 요청의 데이터를 접근할 수 있다.

트랜잭션 매니저, 트랜잭션 템플릿

NOTE
public class MemberRepositoryV3 { private final DataSource dataSource; public MemberRepositoryV3(DataSource dataSource) { this.dataSource = dataSource; } // .. 이전과 동일 private void close(Connection con, Statement stmt, ResultSet rs) { // releaseConnection: 동기화된 트랜잭션 대기 DataSourceUtils.releaseConnection(con, dataSource); } private Connection getConnection() throws SQLException { // getConnection: 관리중인 커넥션이 있다면 사용, 없다면 생성 Connection con = DataSourceUtils.getConnection(dataSource); return con; } }
Java
복사
레포지토리 - 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
DataSourceUtils.getConnection(): 트랜잭션 동기화 매니저가 관리하는 connection이 있다면 해당 connection을 반환하고, 없년 경우 새로운 connection을 생성합니다.
DataSourceUtils.releaseConnection(): connection을 바로 닫지 않고 트랜잭션을 사용하기 위해 동기화된 connection이 있다면 유지하고, 없다면 닫는다.
@Slf4j @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); } } // ... }
Java
복사
서비스 - 트랜잭션 매니저 사용
전체 흐름도
트랜잭션 매니저를 사용하면 트랜잭션을 사용하는 로직에서 특정 패턴이 반복되는것을 확인할 수 있다.
//트랜잭션 시작 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
복사
트랜잭션 시작하고, 비즈니스 로직을 실행하고, 성공과 실패에 따른 커밋와 롤백의 패턴은 각각의 서비스에서 반복되며, 바뀌는건 비즈니스 로직뿐이다.
이 경우, 템플릿 콜백 패턴을 사용하면 반복문제를 해결할 수 있다.
public class TransactionTemplate { private PlatformTransactionManager transactionManager; // 반환값이 있을 때 public <T> T execute(TransactionCallback<T> action){..} // 반환값이 없을 때 void executeWithoutResult(Consumer<TransactionStatus> action){..} }
Java
복사
@Slf4j public class MemberServiceV3_2 { // 트랜잭션 템플릿 private final TransactionTemplate txTemplate; private final MemberRepositoryV3 memberRepository; public MemberServiceV3_2(PlatformTransactionManager transactionManager, MemberRepositoryV3 memberRepository) { this.txTemplate = new TransactionTemplate(transactionManager); this.memberRepository = memberRepository; } public void accountTransfer(String fromId, String toId, int money) throws SQLException { txTemplate.executeWithoutResult((status) -> { // status는 TransactionStatus의 객체이며, 진행상태를 나타냅니다. try { bizLogic(fromId, toId, money); // 비즈니스 로직 호출 } catch (SQLException e) { // SQLException을 RuntimeException으로 변환하여 던짐 throw new IllegalStateException(e); } }); } // .. }
Java
복사
TransactionTemplate 사용
트랜잭션 템플릿의 기본 동작은 다음과 같습니다:
비즈니스 로직이 정상 수행되면 commit을 수행합니다.
언체크 예외가 발생하면 rollback을 수행합니다.
TransactionStatus는 다음의 메소드가 있습니다.
setRollbackOnly(): 트랜잭션을 롤백 전용으로 설정합니다. 이 메서드를 호출하면 트랜잭션이 커밋되지 않고 롤백됩니다.
isNewTransaction(): 현재 트랜잭션이 새 트랜잭션인지 여부를 반환합니다.
isCompleted(): 트랜잭션이 완료되었는지 여부를 반환합니다.
트랜잭션 템플릿은 내부적으로 TransactionManager를 사용하며, try~catch, Commit, Rollback 등의 처리를 일괄적으로 수행합니다. 많은 부분이 개선되었지만, 서비스 계층이 여전히 Transaction에 의존하게 됩니다 ( txTemplate.execute() ). 서비스 계층에서 트랜잭션을 사용하지 않게 되면, 해당 코드를 모두 수정해야 합니다.

트랜잭션 AOP

NOTE
Spring에서는 AOP의 @Transaction을 통해 트랜잭션을 사용할 수 있습니다.
public Object invoke(MethodInvoation invoation) throws Throwable { // 트랜잭션 시작 TransactionStatus status = this.transactionManager.getTransaction(new DefaultTransactionDefinition()); try { Object ret = invoation.proceed(); // 비즈니스 로직 this.transactionManager.commit(status); return ret; } catch (Exception e) { this.transactionManager.rollback(status); throw e; } }
Java
복사
프록시 도입 전
public void accountTransfer(String fromId, String toId, int money){ txTemplate.executeWithoutResult((status) -> { try{ bizLogic(money, toId, fromId); } catch (SQLException e){ throw new IllegalStateException(e); } }); }
Java
복사
프록시 도입 전
프록시 도입 후
@Transactional public void accountTransfer(String fromId, String toId, int money) throws SQLException { bizLogic(money, toId, fromId); }
Java
복사
프록시 도입 후 (순수 비즈니스 로직만 남게됨)
트랜잭션을 처리하는 객체와, 비즈니스 로직 처리 객체를 명확하게 분리할 수 있다
AOP로 프록시를 생성하고, 프록시는 공통 관심사를 프록시 객체 내에서 처리해준다
1.
클라이언트가 비즈니스 로직을 호출합니다.
2.
비즈니스 로직은 프록시 객체를 호출합니다.
3.
프록시 객체는 트랜잭션을 시작합니다. 프록시 객체는 내부적으로 트랜잭션 매니저를 가지고 있으며, 이는 스프링 컨테이너를 통해 주입받습니다.
4.
트랜잭션 매니저getTransaction()을 호출합니다. 이 때, 트랜잭션 매니저는 dataSource로부터 connection을 가지고 옵니다. 가져온 connectionsetAutoCommit false를 설정합니다.
5.
트랜잭션 매니저는 설정된 connection트랜잭션 동기화 매니저에 저장합니다.
6.
프록시 객체는 실제 비즈니스 로직을 호출합니다. 비즈니스 로직은 Repository를 호출합니다.
7.
Repository는 DB와 통신하기 위해 트랜잭션이 필요합니다. 이 때, Repository는 내부적으로 가지고 있는 DataSourceUtils.getConnection()을 통해 트랜잭션 동기화 매니저로부터 동기화된 connection을 받아옵니다.
8.
모든 과정이 끝나면 반환되며, 트랜잭션 프록시 객체에서 Commit/RollBack을 처리합니다.

스프링 부트의 자동 리소스(DataSource, TrasactionManager) 등록

NOTE
스프링 부트는 DataSource, TransactionManager 를 스프링 빈에 dataSource, transactionManager라는 이름으로 자동등록 해준다!
@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
복사
기존코드 - 직접등록
@TestConfiguration static class TestConfig { // 자동 등록 private final DataSource dataSource; public TestConfig(DataSource dataSource) { this.dataSource = dataSource; } @Bean MemberRepositoryV3 memberRepositoryV3() { return new MemberRepositoryV3(dataSource); } @Bean MemberServiceV3_3 memberServiceV3_3() { return new MemberServiceV3_3(memberRepositoryV3()); } }
Java
복사
변경코드 - 자동등록

DataSource

spring.datasource.url=jdbc:h2:tcp://localhost/~/test spring.datasource.username=sa spring.datasource.password=
Java
복사
DataSource
스프링 부트는 dataSource를 빈에 자동으로 등록합니다. 이때 applicationdatasource의 속성값들을 사용해서 등록합니다.

트랜잭션 매니저

스프링 부트는 적절한 트랜잭션 매니저(PlatformTransactionManager)을 자동으로 스프링 빈에 등록해주며 자동으로 등록되는 이름은 transactionManager입니다.
트랜잭션 매니저는 DB접근 기술을 어떤것을 사용하냐에 따라 등록되는 구현체가 달라집니다.
JDBC 라이브러리 존재 → DataSourceTransactionManager 등록
JPA 라이브러리 존재 → JpaTransactionManager 등록
둘다 사용한다 → JpaTransactionManager

체크 예외와 인터페이스

NOTE
서비스 계층은 특정 구현 기술에 의존하지 않는 것이 바람직합니다. 그러나 레포지토리가 예외를 던질 경우, 서비스 계층에서 처리해야 합니다. 이 문제를 해결하기 위해, 레포지토리의 체크 예외를 런타임 예외로 변경하여 던지는 방법을 사용할 수 있습니다!
MemberRepository 인터페이스를 도입하여 구현 기술을 쉽게 변경할 수 있도록 하고, 실제 코드에 런타임 예외를 사용하도록 적용해봅시다.
public interface MemberRepository { Member save(Member member); Member findById(String memberId); void update(String memberId, int money); void delete(String memberId); }
JavaScript
복사
Repository 인터페이스
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
복사
체크 예외 → 언체크 예외
@Slf4j public class MemberRepositoryV4_1 implements MemberRepository { private final DataSource dataSource; public MemberRepositoryV4_1(DataSource dataSource) { this.dataSource = dataSource; } @Override public Member save(Member member) { String sql = "insert into member(member_id, money) values(?, ?)"; try { // .. } catch (SQLException e) { throw new MyDbException(e); // 체크 => 언체크 예외 } } // .. }
Java
복사
@Slf4j @RequiredArgsConstructor public class MemberServiceV4 { private final 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 void validation(Member toMember) { if (toMember.getMemberId().equals("ex")) { throw new IllegalStateException("이체중 예외 발생"); } } }
Java
복사
Repository 계층에서 던지는 SQLException 부분이 제거된것을 확인할 수 있다.

데이터 접근 예외 직접 만들기

NOTE
데이터베이스 오류에 따른 특정 예외를 복구하려는 경우가 있을 수 있습니다. 예를 들어, 회원 가입 시 데이터베이스에 동일한 ID가 존재하면, ID 뒤에 새로운 ID를 생성해야 한다고 가정해 봅시다.
데이터 중복(23505) 코드 반환
에러 코드의 경우 구현하는 DB 드라이버에 따라 달라질 수 있습니다.
'hello'라는 ID로 가입을 시도했지만, 이미 같은 ID가 존재한다면 'hello12345'와 같이 임의의 숫자를 붙여서 가입합니다. JDBC 드라이버는 특정 코드의 SQLException을 생성하고 던집니다. 코드를 확인하고, 우리가 처리하고자 하는 예외인 경우에만 추가적인 로직을 작성합니다.
// 언체크 예외 public class MyDbException extends RuntimeException { // ... } // 중복키 언체크 예외 public class MyDuplicateKeyException extends MyDbException { // .. }
Java
복사
Exception
try { // ... } catch (SQLException e) { // 중복키 문제인 경우 중복키 예외를 던진다. if (e.getErrorCode() == 23505) { throw new MyDuplicateKeyException(e); } // 이외의 예외는 런타임 예외로 변환만 한다. throw new MyDbException(e); }
Java
복사
Repository 계층
public void create(String memberId) { try { repository.save(new Member(memberId, 0)); log.info("saveId={}", memberId); } // 중복키 예외인 경우 catch (MyDuplicateKeyException e) { log.info("키 중복, 복구 시도"); String retryId = generateNewId(memberId); log.info("retryId={}", retryId); repository.save(new Member(retryId, 0)); } // 단순 예외인 경우 catch (MyDbException e) { log.info("데이터 접근 계층 예외", e); throw e; } } // 특정 숫자를 붙여서 ID생성 private String generateNewId(String memberId) { return memberId + new Random().nextInt(10000); }
Java
복사
Service 계층
에외 변환을 통해 SQLException을 특정 기술에 의존하지 않는 직접 만든 예외인 MyDuplicateKeyException으로 변환할 수 있었습니다.
하지만 SQL ErrorCode는 각각의 데이터베이스 마다 다르며, 결과적으로 DB의 기술이 변경되면 ErrorCode의 로직에 대해서도 모두 수정해야 합니다.

스프링 데이터 접근 예외 계층

NOTE
스프링 프레임워크는 데이터 접근 계층에서 발생하는 다양한 예외를 일관된 예외 계층 구조로 추상화하여 제공합니다. 이를 통해 서비스 계층은 특정 기술에 종속되지 않고, 예외를 효과적으로 처리하고 복구할 수 있습니다.
스프링은 데이터 접근과 관련된 예외를 DataAccessException을 최상위 클래스로 하는 계층 구조로 제공하며, 크게 2가지로 구분됩니다.
스프링 데이터 접근 예외 계층
1.
NonTransient 예외: 일시적이지 않으며, 동일한 SQL을 사용해도 실패하는 예외입니다. 예를 들어 SQL 문법 오류나 데이터 베이스 제약조건 위배등에 있습니다.
@Test public void testNonTransientException() { String username = "duplicateUser"; // 첫 번째 사용자 생성 memberService.createUser(username); // 두 번째 사용자 생성 시도 (중복된 username으로 인해 예외 발생 예상) assertThrows(DataIntegrityViolationException.class, () -> { memberService.createUser(username); }); }
Java
복사
2.
Transient 예외: 일시적이며, 동일한 SQL을 다시 시도하면 성공할 가능성이 있는 예외입니다. 쿼리 타임아웃이나 Lock관련 예외가 있습니다.
@Test public void testTransientException() { String username = "transientUser"; assertThrows(RuntimeException.class, () -> { memberService.createUserTransient(username); }); }
Java
복사

스프링 & JPA 예외 계층구조

JPA자체도 예외 변환기를 가지고 있으며, JPA 표준에서도 여러 예외 클래스를 가지고 있지만 스프링은 이를 더 세밀하게 구분하기위해 JPA예외를 스프링의 데이터 접근 예외 계층으로 변환합니다.
JPA 예외 계층 구조
PersistenceException (모든 JPA 예외의 최상위 클래스)
EntityExistsException
EntityNotFoundException
OptimisticLockException
RollbackException
TransactionRequiredException
NoResultException
등등...
스프링 데이터 접근 예외 계층
DataAccessException (모든 스프링 데이터 접근 예외의 최상위 클래스)
DataIntegrityViolationException
DuplicateKeyException
OptimisticLockingFailureException
CannotAcquireLockException
DeadlockLoserDataAccessException
PessimisticLockingFailureException