Spring/DB

스프링 DB 1편 -4장. 스프링과 문제 해결 - 트랜잭션

광터틀 2022. 9. 19. 13:00

스프링 DB 1편 -4장. 스프링과 문제 해결 - 트랜잭션 (인프런 - 김영한 강사님) 

 

애플리케이션 구조는 주로 다음과 같다. 

프레젠테이션 계층 

- @Controller. UI 관련 처리. 웹 요청 응답, 사용자 요청 검증 

- 주 사용 기술 : 서블릿과 HTTP 같은 웹기술, 스프링 MVC 사용 

 

서비스 계층 

- @Service. 비즈니스 로직 담당 

- 주 사용 기술 : 가급적 특정 기술에 의존하지 않고 순수 자바코드로 작성함 

 

데이터 접근 계층 

- @Repository. 실제 데이터베이스에 접근하는 코드 

- 주 사용 기술 : JDBC, JPA, File, Redis, Mongo 

 

 

가장 중요한 계층은 핵심 비즈니스 로직이 들어있는 서비스 계층이다. 시간이 흘러서 UI와 관련된 부분이 변하고 데이터 저장 기술을 다른 기술로 변경해도 비즈니스 로직은 건들지 말아야 한다. 

(UI와 관련하여 HTTP API를 사용하다가 GRPC 같은 기술로 변경해도 프레젠테이션 계층 코드만 변경하고, 

데이터 접근 기술과 관련하여 JDBC를 사용하다가 JPA로 변경해도 데이터 접근 계층에서만 변경하게 해야 한다.) 

 

즉, 서비스 계층은 특정기술에 종속적이지 않게 개발해야 한다. 

(기술에 종속적인 부분은 프레젠테이션 계층, 데이터 접근 계층에서 가지고 가도록 해야한다.) 

 


 

우리가 개발한 애플리케이션에는 3가지 문제점이 있다. 

 

1. 트랜잭션 문제 

트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작하는 것이 좋다.

하지만 서비스 계층에 트랜잭션을 적용하니 서비스 계층에 JDBC 구현 기술의 누수가 발생했다. 지금까지 열심히 데이터 접근 계층으로 JDBC 관련 코드를 모아 놓은 것이 쓸모 없어진다. 

또한 트랜잭션 동기화 문제(커넥션을 파라미터로 넘기는데 트랜잭션용 기능과 트랜잭션을 유지하지 않아도 되는 기능을 나눠야 한다.)와 적용 반복 문제 (반복 코드가 많다.try, catch, finally...) 도 있다. 

 

 

2. 예외 누수 문제 

데이터 접근 계층의 JDBC 구현 기술 예외가 서비스 계층으로 전파된다. SQLException은 체크 예외이므로 데이터 접근 계층을 호출한 서비스 계층에서 해당 예외를 잡아서 처리하거나 던져야 하는데 SQLException이 JDBC 전용 기술이므로 이후에 다른 데이터 접근 기술로 변경하면 예외도 변경해야 한다. 결국 서비스 코드도 수정해야 한다. 

 

 

3. JDBC 반복 문제 

MemberRepository 작성시 순수 JDBC를 사용했다. 

try, catch, finally... 커넥션 열고 pstmt 사용하고 결과 매핑하고 실행하고 커넥션 리소스 정리하고. 

유사한 코드가 너무 많이 반복된다. 

 

트랜잭션을 추상화하면, 즉 인터페이스를 만들면 이를 해결할 수 있다. 

 


 

트랜잭션 추상화 

 

트랜잭션을 추상화하여 서비스 계층의 OCP 문제를 해결하자. 

근데 이미 스프링은 트랜잭션 추상화를 지원한다. 

스프링이 제공하는 PlatformTransactionManager 인터페이스를 사용하면 된다.

@Service는 이 인터페이스에만 의존한다. JDBC, JPA, 하이버네이트, 기타 트랜잭션에 관련된 구현체도 이미 스프링이 제공한다. 

 


 

트랜잭션 동기화 

 

지금까지는 트랜잭션을 유지하기 위해 트랜잭션 시작부터 끝까지 같은 데이터베이스 커넥션을 유지해야 했다. 이를 위해 파라미터로 커넥션을 전달했다. 

이는 코드가 지저분해지고 커넥션을 넘기는 메서드와 넘기지 않는 메서드를 중복해서 만들어야 하는 단점 등이 있다. 

스프링이 제공하는 트랜잭션 동기화 매니저를 사용하면 이를 해결할 수 있다. 

 


1. 트랜잭션 매니저가 데이터 소스를 통해 커넥션을 만들고 트랜잭션을 시작한다. 
2. 트랜잭션이 시작된 커넥션은 트랜잭션 동기화 매니저에 보관된다. 
3. 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. (즉, 파라미터로 커넥션을 전달하지 않는다.) 
4. 트랜잭션이 종료 시, 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고 커넥션도 닫는다. 

 


 

트랜잭션 매니저 적용하기 

 

리포지토리 클래스 

DataSourceUtils.getConnection() 

위 메서드는 다음과 같이 동작한다. 

트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환한다. 

트랜잭션 동기화 매니저가 관리하는 커넥션이 없으면 새로운 커넥션을 생성해서 반환한다. 

 

DataSourceUtils.releaseConnection() 

close() 메서드에서 DataSourceUtils.releaseConnection()을 사용하도록 변경된 부분이 있다. 

DataSourceUtils.releaseConnection()은 커넥션을 바로 닫지 않는다. 

트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해준다. 

트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다. 

 

 

서비스 클래스 

private final PlatformTransactionManager transactionManager;

을 통해서 트랜잭션을 주입받는다. 

 

 

전체 흐름 그림으로 보기 

 

클라이언트의 요청으로 서비스 로직을 실행한다. 

1. 서비스 계층에서 transactionManager.getTransaction()을 호출해서 트랜잭션을 시작한다. 

2. 트랜잭션 매니저는 내부에서 데이터소스를 사용해서 커넥션을 생성한다. 

3. con.setAutoCommit(false)를 통해 수동 커밋 모드로 변경하여 트랜잭션을 시작한다. 

4. 커넥션을 트랜잭션 동기화 매니저에 보관한다. 

5. 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관한다. 따라서 멀티 쓰레드 환경에 안전하게 커넥션을 보관할 수 있다. 

 

6. 서비스는 비즈니스 로직을 실행하면서 리포지토리의 메서드들을 호출한다. (커넥션을 파라미터로 전달하지 않는다.) 

7. 리포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요하다. DataSourceUtils.getConnection(dataSource)를 통해 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 

8. 획득한 커넥션을 사용해서 SQL을 데이터베이스에 전달해서 실행한다. 

 

9. transactionManager.commit() or .rollback()을 통해서 비즈니스 로직이 끝나면 트랜잭션을 종료한다. 

10. 트랜잭션을 종료하려면 동기화된 커넥션이 필요하다. 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다. 

11. 획득한 커넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다. 

12. 전체 리소스를 정리한다. 

(쓰레드 로컬은 사용 후 꼭 정리해야 한다. 

con.setAutoCommit(true)로 되돌린다. 커넥션 풀을 고려해야 한다. 

con.close()를 호출하여 커넥션을 종료 혹은 커넥션풀에 반환한다.) 

 

지금까지 했던 트랜잭션 추상화를 통해 이제 서비스 코드는 JDBC 기술에 의존하지 않는다. 

 


 

트랜잭션 템플릿 

 

이번엔 트랜잭션 템플릿을 통해서 반복되는 코드를 줄여보자. 

 

1
2
3
4
5
6
7
8
try {
//비즈니스 로직
bizLogic(fromId, toId, money);
transactionManager.commit(status); //성공시 커밋
catch (Exception e) {
 transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
cs

 

위 코드가 계속 반복된다.

성공시 커밋, 실패시 롤백 코드는 트랜잭션을 사용할 때마다 계속 반복되는 코드이다. 템플릿을 사용해서 줄여보자. 

스프링은 TransactionTemplate 라는 템플릿 클래스를 제공한다. 

이를 사용하면 위 코드는 아래와 같이 변한다. 

 

1
2
3
4
5
6
7
8
txTemplate.executeWithoutResult((status) -> {
try {
//비즈니스 로직
bizLogic(fromId, toId, money);
 } catch (SQLException e) {
throw new IllegalStateException(e);
 }
});
cs

 

이로서 커밋, 롤백 부분이 사라졌다. SQLException 체크예외를 던질 수 없어서 언체크 예외로 바꾸어 던지도록 전환한다. 

 

트랜잭션 템플릿 덕분에 반복되는 코드를 지울 수는 있으나 사실상 크게 변화한게 없어 보인다... 

 

또한 서비스 로직에 비즈니스 로직과 트랜잭션 처리 로직이 함께 있다. 

서비스 로직에는 비즈니스 로직만 있어야 한다. 트랜잭션 코드는 따로 빼야한다.

스프링 AOP를 통해 프록시를 도입하여 이를 해결하자.  

 


 

스프링 AOP

 

스프링 AOP를 통해 프록시를 도입하여 서비스 계층에 순수 비즈니스 로직만 남겨보자. 

(지금 스프링 AOP와 프록시에 대해서 자세히 이해하지 못하더라도, @Transactional을 사용하면 스프링이 AOP를 사용해서 트랜잭션을 편리하게 처리해준다 정도로 이해하자.) 

 

 

<프록시 도입 전> 

 

<프록시 도입 후> 

 

이전에 했던 트랜잭션 매니저 또는 트랜잭션 템플릿을 사용해서 트랜잭션 관련 코드를 직접 작성하는 것이 프로그래밍 방식의 트랜잭션 관리라 한다. 

@Transactional 애노테이션 하나만 선언해서 매우 편리하게 트랜잭션을 적용하는 것을 선언적 트랜잭션 관리라 한다. 이를 통해 트랜잭션 관련 코드를 순수한 비즈니스 로직에서 제거할 수 있다. 훨씬 간편하고 실용적이기 때문에 실무에서는 대부분 선언적 트랜잭션 관리를 사용한다. 

 


 

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

 

밑의 코드 블럭에서 위는 직접 데이터 소스와 트랜잭션 매니저를 등록하는 코드, 밑은 스프링 부트 자동 리소스를 이용하여 자동으로 등록하는 코드이다. 

아래와 같이 스프링부트의 자동 리소스 등록을 이용하면 편리하게 등록할 수 있다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@TestConfiguration
static class TestConfig {
    @Bean
    DataSource dataSource() {
        return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
    }
    @Bean
    PlatformTransactionManager transactionManager() {
        return new DataSourceTransactionManager(dataSource());
    }
 
-------------------------------------------------------------
 
@TestConfiguration
    static class TestConfig {
    private final DataSource dataSource;
    public TestConfig(DataSource dataSource) {
    this.dataSource = dataSource;
    }
 
cs