728x90
반응형
SMALL

참고자료

 

스프링 DB 1편 - 데이터 접근 핵심 원리 강의 | 김영한 - 인프런

김영한 | 백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니

www.inflearn.com

 

저번 포스팅에 이어서 진행하자. 저번 포스팅까지 트랜잭션에 대해 알아보았다. 트랜잭션을 사용하니 원자성을 유지할 수 있었으나 여러 문제점들이 있었는데 어떤 문제가 있었는지를 차근차근 알아보면서 그 문제점들을 스프링이 어떻게 완벽하게 해결해주는지 알아보자.

 

문제점들

애플리케이션 구조

여러가지 애플리케이션 구조가 있지만, 가장 단순하면서 많이 사용하는 방법은 역할에 따라 3가지 계층으로 나누는 것이다.

  • 프레젠테이션 계층
    • UI와 관련된 처리 담당
    • 웹 요청과 응답
    • 사용자 요청을 검증
    • 주 사용 기술: 서블릿과 HTTP 같은 웹 기술, 스프링 MVC
  • 서비스 계층
    • 비즈니스 로직을 담당
    • 주 사용 기술: 가급적 특정 기술에 의존하지 않고 순수 자바 코드로 작성
  • 데이터 접근 계층
    • 실제 데이터베이스에 접근하는 코드
    • 주 사용 기술: JDBC, JPA, Redis, ...

순수한 서비스 계층

여기서 가장 중요한 곳은 바로 핵심 비즈니스 로직이 있는 서비스 계층이다. 시간이 흘러서 UI(웹)와 관련된 부분이 변하고, 데이터 저장 기술을 다른 기술로 변경해도, 비즈니스 로직은 최대한 변경없이 유지되어야 한다. 이렇게 하려면 서비스 계층을 특정 기술에 종속적이지 않게 개발해야 한다. 위처럼 계층을 나눈 이유도 서비스 계층을 최대한 순수하게 유지하기 위한 목적이 크다. 기술에 종속적인 부분은 프레젠테이션 계층, 데이터 접근 계층에서 가지고 간다. 

  • 프레젠테이션 계층은 클라이언트가 접근하는 UI와 관련된 기술인 웹, 서블릿, HTTP와 관련된 부분을 담당해준다. 그래서 서비스 계층을 이런 UI와 관련된 기술로부터 보호해준다. 예를 들어서, HTTP API를 사용하다가 GRPC와 같은 기술로 변경해도 프레젠테이션 계층의 코드만 변경하고, 서비스 계층은 변경하지 않아도 된다.
  • 데이터 접근 계층은 데이터를 저장하고 관리하는 기술을 담당해준다. 그래서 JDBC, JPA와 같은 구체적인 데이터 접근 기술로부터 서비스 계층을 보호해준다. 예를 들어서 JDBC를 사용하다가 JPA로 변경해도 서비스 계층은 변경하지 않아도 된다. 물론 서비스 계층에서 데이터 접근 계층을 직접 접근하는 것이 아니라, 인터페이스를 제공하고 서비스 계층은 이 인터페이스에 의존하는 것이 좋다. 그래야 서비스 코드의 변경없이 JdbcRepositoryJpaRepository로 변경할 수 있다.

서비스 계층이 특정 기술에 종속되지 않기 때문에 비즈니스 로직을 유지보수 하기도 쉽고, 테스트하기도 쉽다. 정리하자면 서비스 계층은 가급적 비즈니스 로직만 구현하고 특정 구현 기술에 직접 의존해서는 안된다. 이렇게하면 향후 구현 기술이 변경될 때 변경의 영향 범위를 최소화할 수 있다.

 

 

그럼 서비스 계층을 순수하게 유지하는게 좋다는 것은 알았다. 지금까지 개발한 MemberService 코드들을 살펴보자.

MemberServiceV1

package cwchoiit.jdbc.service;

import cwchoiit.jdbc.domain.Member;
import cwchoiit.jdbc.repository.MemberRepositoryV1;
import lombok.RequiredArgsConstructor;

import java.sql.SQLException;

@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("이체중 예외 발생");
        }
    }
}
  • V1은 특정 기술에 거의 종속적이지 않고 순수한 비즈니스 로직만 존재한다.
  • 특정 기술과 관련된 코드가 거의 없어서 코드가 깔끔하고, 유지보수 하기 쉽다. 보면, MemberRepository에만 의존하고 있는데 이것 역시 특정 기술에 의존하는게 아니라 인터페이스에 의존하고 이 인터페이스를 구현한 구현체를 얼마든 갈아끼워도 상관이 없게 설계하면 된다.
  • 향후 비즈니스 로직의 변경이 필요하면 이 부분을 변경하면 된다.
  • 물론, 남은 문제가 있다 여기서도. 바로 SQLException을 의존하고 있다는 것. 이건 MemberRepository에서 올라오는 예외인데 원래라면 이 예외는 저 Repository에서 처리하는 게 맞다. 이것도 이후에 알아보자. 지금은 이 코드는 그저 예시를 위한 코드일뿐이다.

 

다음은 트랜잭션을 적용한 V2 코드를 살펴보자.

MemberServiceV2

package cwchoiit.jdbc.service;

import cwchoiit.jdbc.domain.Member;
import cwchoiit.jdbc.repository.MemberRepositoryV1;
import cwchoiit.jdbc.repository.MemberRepositoryV2;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;

@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 connection = dataSource.getConnection();

        try {
            connection.setAutoCommit(false);

            bizLogic(connection, fromId, toId, money);

            connection.commit();
        } catch (Exception e) {
            connection.rollback();
            throw new IllegalStateException(e);
        } finally {
            releaseConnection(connection);
        }
    }

    private void bizLogic(Connection connection, String fromId, String toId, int money) throws SQLException {
        Member fromMember = memberRepository.findById(connection, fromId);
        Member toMember = memberRepository.findById(connection, toId);

        memberRepository.update(connection, fromId, fromMember.getMoney() - money);

        validation(toMember);

        memberRepository.update(connection, toId, toMember.getMoney() + money);
    }

    private void releaseConnection(Connection connection) {
        if (connection != null) {
            try {
                connection.setAutoCommit(true);
                connection.close(); // 커넥션 풀을 사용할땐 close()가 반납
            } catch (Exception e) {
                log.error("error, ", e);
            }
        }
    }

    private void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체중 예외 발생");
        }
    }
}
  • 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작하는 것이 좋다.
  • 그런데 문제는 트랜잭션을 사용하기 위해서 javax.sql.DataSource, java.sql.Connection, java.sql.SQLException 같은 JDBC 기술에 의존해야 한다는 점이다.
  • 트랜잭션을 사용하기 위해 JDBC 기술에 의존하고 있는 서비스 계층이다. 결과적으로 비즈니스 로직보다 JDBC를 사용해서 트랜잭션을 처리하는 코드가 더 많다.
  • 향후 JDBC에서 JPA로 기술을 변경하면 서비스 코드도 모두 함께 변경해야 한다.
  • 핵심 비즈니스 로직과 JDBC 기술이 섞여 있어서 유지보수가 어렵다.

문제를 정리해보면

  • 트랜잭션 문제
  • 예외 누수 문제
  • JDBC 반복 문제

이렇게 크게 세가지가 있다. 

 

트랜잭션 문제

가장 큰 문제는 트랜잭션을 적용하면서 생긴 다음과 같은 문제들이다.

  • JDBC 구현 기술이 서비스 계층에 누수되는 문제
    • 트랜잭션을 적용하기 위해 JDBC 구현 기술이 서비스 계층에 누수되었다.
    • 서비스 계층은 순수해야 한다. 구현 기술을 변경해도 서비스 계층 코드는 최대한 유지할 수 있어야 한다.
    • 서비스 계층은 특정 기술에 종속되지 않아야 한다. 지금까지 그렇게 열심히 노력해서 데이터 접근 계층으로 JDBC 관련 코드를 모았다(Repository 클래스에서 UPDATE, INSERT, DELETE, SELECT를 하는 등). 그런데 트랜잭션을 적용하면서 결국 서비스 계층에 JDBC 구현 기술의 누수가 발생했다.
  • 트랜잭션 동기화 문제
    • 같은 트랜잭션을 유지하기 위해 커넥션을 파라미터로 넘겨야 한다.
    • 이때 파생되는 문제도 있다. 똑같은 기능도 트랜잭션용 기능과 트랜잭션을 유지하지 않아도 되는 기능으로 분리해야 한다.
  • 트랜잭션 적용 반복 문제
    • 트랜잭션 적용 코드를 보면 반복이 많다. `try - catch - finally`...

 

예외 누수

  • 데이터 접근 계층의 JDBC 구현 기술 예외가 서비스 계층으로 전파되고 있다.
  • SQLException은 체크 예외이기 때문에 데이터 접근 계층을 호출한 서비스 계층에서 해당 예외를 잡아서 처리하거나 명시적으로 throws를 통해서 다시 던져야 한다.
  • SQLException은 JDBC 전용 기술이다. 향후 JPA나 다른 데이터 접근 기술을 사용하면, 그에 맞는 다른 예외로 변경해야 하고, 결국 서비스 코드도 모두 수정해야 한다.

 

JDBC 반복 문제

  • 지금까지 작성한 MemberRepository 코드는 순수한 JDBC 코드를 사용했다.
  • 근데 이 코드들은 유사한 코드의 반복이 너무 많다.
    • `try - catch - finally`
    • 커넥션을 열고, PreparedStatement를 사용하고, 결과를 매핑하고, 실행하고, 리소스를 정리하고.

 

 

이런 여러 문제가 있는데, 스프링은 지금 말한 모든 문제를 모두 아름답게 해결해준다. 이제 이 문제를 스프링을 사용해서 하나씩 해결해보자!

 

트랜잭션 추상화

현재 서비스 계층은 트랜잭션을 사용하기 위해서 JDBC 기술에 의존하고 있다. 향후 JDBC에서 JPA와 같은 다른 데이터 접근 기술로 변경하면, 서비스 계층의 트랜잭션 관련 코드도 모두 함께 수정해야 한다.

 

무슨 말이냐면, 구현 기술에 따라 트랜잭션 사용방법이 다 다르다.

  • JDBC: `con.setAutoCommit(false)`
  • JPA: `transaction.begin()`

JDBC 트랜잭션 의존

 

JDBC 기술 → JPA 기술로 변경

 

이렇게 JDBC 기술을 사용하다가 JPA로 기술을 변경하게 되면, 서비스 계층의 코드도 JPA 기술을 사용하도록 함께 수정해야 한다.

이런 문제를 해결하려면 트랜잭션 기능을 추상화하면 된다. 아주 단순하게 생각하면 다음과 같은 인터페이스를 만들어서 사용하면 된다.

public interface TxManager {
    begin();
    commit();
    rollback();
}
  • 트랜잭션은 사실 단순하다. 트랜잭션을 시작하고, 비즈니스 로직이 수행되고, 끝나면 커밋또는 롤백을 하면 된다.
  • 그리고 이 트랜잭션 매니저 인터페이스를 기반으로 아래와 같이 각각의 기술에 맞는 구현체를 만들면 된다.
    • JdbcTxManager: JDBC 트랜잭션 기능을 제공하는 구현체
    • JpaTxManager: JPA 트랜잭션 기능을 제공하는 구현체

  • 서비스는 특정 트랜잭션 기술에 직접 의존하는 것이 아니라, TxManager라는 추상화된 인터페이스에 의존한다.
  • 이제 원하는 구현체를 DI를 통해서 주입하면 된다. 예를 들어서 JDBC 트랜잭션 기능이 필요하면 JdbcTxManager를 서비스에 주입하고, JPA 트랜잭션 기능으로 변경해야 하면 JpaTxManager를 주입하면 된다. 
  • 클라이언트 서비스는 인터페이스에 의존하고 DI를 사용한 덕분에 OCP 원칙을 지키게 되었다. 이제 트랜잭션을 사용하는 서비스 코드를 전혀 변경하지 않고, 트랜잭션 기술을 마음껏 변경할 수 있다.

 

스프링의 트랜잭션 추상화

스프링은 이미 이런 고민을 다 해두었다. 우리는 스프링이 제공하는 트랜잭션 추상화 기술을 사용하면 된다. 심지어 데이터 접근 기술에 따른 트랜잭션 구현체도 대부분 만들어두어서 가져다가 사용만 하면 된다.

  • 스프링의 트랜잭션 추상화의 핵심은 PlatformTransactionManager 인터페이스이다.
  • org.springframework.transaction.PlatformTransactionManager
package org.springframework.transaction;

import org.springframework.lang.Nullable;

public interface PlatformTransactionManager extends TransactionManager {

	TransactionStatus getTransaction(@Nullable TransactionDefinition definition) throws TransactionException;

	void commit(TransactionStatus status) throws TransactionException;

	void rollback(TransactionStatus status) throws TransactionException;

}
  • getTransaction(): 트랜잭션을 시작한다. 이름이 getTransaction인 이유는 기존에 이미 진행중인 트랜잭션이 있는 경우 해당 트랜잭션에 참여할 수 있기 때문이다. 참고로 트랜잭션 참여, 전파에 대한 부분은 뒤에서 설명한다. 지금은 단순히 트랜잭션을 시작하는 것으로 이해하면 된다.
  • commit(): 트랜잭션을 커밋한다.
  • rollback(): 트랜잭션을 롤백한다.

앞으로, PlatformTransactionManager 인터페이스와 구현체를 포함해서 트랜잭션 매니저로 줄여서 이야기 하겠다.

 

트랜잭션 동기화

스프링이 제공하는 트랜잭션 매니저는 크게 2가지 역할을 한다.

  • 트랜잭션 추상화
  • 리소스 동기화

트랜잭션 추상화

바로 위에서 이야기 했다. PlatformTransactionManager를 사용해서 트랜잭션 기술을 추상화한다는 이야기.

 

리소스 동기화

트랜잭션을 유지하려면, 트랜잭션의 시작부터 끝까지 같은 데이터베이스 커넥션을 유지해야 한다. 결국 같은 커넥션을 동기화하기 위해서 이전에는 파라미터로 커넥션을 전달하는 방법을 사용했는데 이 방법은 코드가 지저분해지는 것은 물론이고, 커넥션을 넘기는 메서드와 넘기지 않는 메서드를 중복해서 만들어야 하는 등 여러가지 단점들이 있다.

 

커넥션과 세션

  • 이 그림을 보면 알 수 있듯, 하나의 커넥션이 하나의 세션을 가지고 있고 이 세션안에서 작업을 해야 하나의 작업으로 간주된다. 즉, 트랜잭션의 시작부터 끝까지 같은 데이터베이스 커넥션을 유지해야 하나의 작업에서 롤백과 커밋이 제대로 작동한다는 의미이다. 

트랜잭션 매니저와 트랜잭션 동기화 매니저

  • 그럼 트랜잭션을 동기화해야 하는데 스프링은 트랜잭션 동기화 매니저를 제공한다. 이것은 쓰레드 로컬(ThreadLocal)을 사용해서 커넥션을 동기화해준다. 트랜잭션 매니저는 내부에서 이 트랜잭션 동기화 매니저를 사용한다.
  • 트랜잭션 동기화 매니저는 쓰레드 로컬을 사용하기 때문에 멀티쓰레드 환경에서 안전하게 커넥션을 동기화할 수 있다. 따라서, 커넥션이 필요하면 트랜잭션 동기화 매니저를 통해 커넥션을 획득하면 된다. 따라서 이전처럼 파라미터로 커넥션을 넘기지 않아도 된다.

동작 방식

  1. 트랜잭션을 시작하려면 커넥션이 필요하다. 트랜잭션 매니저는 데이터소스를 통해 커넥션을 만들고 트랜잭션을 시작한다.
  2. 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  3. 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 따라서 파라미터로 커넥션을 전달하지 않아도 된다. 
  4. 트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료하고 커넥션도 닫는다.
쓰레드 로컬을 사용하면 각각의 쓰레드마다 별도의 저장소가 부여된다. 따라서 해당 쓰레드만 해당 데이터에 접근할 수 있다.

 

 

트랜잭션 문제 해결 - 트랜잭션 매니저1

이제 본격적으로 애플리케이션 코드에 트랜잭션 매니저를 적용해보자.

MemberRepositoryV3

package cwchoiit.jdbc.repository;

import cwchoiit.jdbc.domain.Member;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.support.JdbcUtils;

import javax.sql.DataSource;
import java.sql.*;
import java.util.NoSuchElementException;

/**
 * 트랜잭션 - 트랜잭션 매니저
 * DataSourceUtils.getConnection()
 * DataSourceUtils.releaseConnection()
 */
@Slf4j
@RequiredArgsConstructor
public class MemberRepositoryV3 {

    private final DataSource dataSource;

    public Member save(Member member) throws SQLException {
        String sql = "INSERT INTO member (member_id, money) VALUES (?, ?)";

        Connection connection = null;
        PreparedStatement stmt = null;

        try {
            connection = getConnection();
            stmt = connection.prepareStatement(sql);

            stmt.setString(1, member.getMemberId());
            stmt.setInt(2, member.getMoney());
            stmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            log.error("DB Error", e);
            throw e;
        } finally {
            close(connection, stmt, null);
        }
    }

    public Member findById(String memberId) throws SQLException {
        String sql = "SELECT * FROM member WHERE member_id = ?";

        Connection connection = null;
        PreparedStatement stmt = null;
        ResultSet rs = null;

        try {
            connection = getConnection();
            stmt = connection.prepareStatement(sql);
            stmt.setString(1, memberId);

            rs = stmt.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 with id = " + memberId);
            }
        } catch (SQLException e) {
            log.error("DB Error", e);
            throw e;
        } finally {
            close(connection, stmt, rs);
        }
    }

    public void update(String memberId, int money) throws SQLException {
        String sql = "UPDATE member SET money = ? WHERE member_id = ?";

        Connection connection = null;
        PreparedStatement stmt = null;

        try {
            connection = getConnection();
            stmt = connection.prepareStatement(sql);

            stmt.setInt(1, money);
            stmt.setString(2, memberId);
            int resultSize = stmt.executeUpdate();
            log.info("resultSize = {}", resultSize);
        } catch (SQLException e) {
            log.error("DB Error", e);
            throw e;
        } finally {
            close(connection, stmt, null);
        }
    }

    public void delete(String memberId) throws SQLException {
        String sql = "DELETE FROM member WHERE member_id = ?";

        Connection connection = null;
        PreparedStatement stmt = null;
        try {

            connection = getConnection();
            stmt = connection.prepareStatement(sql);

            stmt.setString(1, memberId);
            stmt.executeUpdate();
        } catch (SQLException e) {
            log.error("DB Error", e);
            throw e;
        } finally {
            close(connection, stmt, null);
        }
    }

    private void close(Connection connection, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        // 주의! 트랜잭션 동기화를 제대로 사용하려면 DataSourceUtils 를 사용해야 한다.
        DataSourceUtils.releaseConnection(connection, dataSource);
    }

    private Connection getConnection() throws SQLException {
        // 주의! 트랜잭션 동기화를 제대로 수행하려면, DataSourceUtils 를 사용해야 한다.
        Connection connection = DataSourceUtils.getConnection(dataSource);
        log.info("get connection = {}, class = {}", connection, connection.getClass());
        return connection;
    }
}
  • 우선 동일한 커넥션을 사용하기 위해 파라미터로 전달해야 했던 메서드는 모두 제거했다.
  • getConnection() → 이제 커넥션을 획득할때 DataSourceUtils를 사용해서 커넥션을 가져온다. 이 녀석은 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환하고, 없으면 새로운 커넥션을 생성해서 반환한다. 우리는 트랜잭션 매니저를 사용해야 하니까 당연히 이렇게 가져와야 한다.
  • close() → close()라는 메서드에서 자원을 정리하는데 커넥션의 경우, DataSourceUtils를 사용해서 releaseConnection()을 호출하고 있다. 이 부분을 주의해야 하는데, 만약 `connection.close()`로 커넥션을 직접 닫아버리면 커넥션이 유지가 되지 않는 문제가 발생한다. 커넥션은 이후 로직은 물론이고 트랜잭션이 종료될때까지 살아있어야 한다. 그러니까 커넥션을 레포지토리에서 닫으면 안된다는 얘기다. 그럼 releaseConnection()은 무엇을 할까?
    • 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해준다. 
    • 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다.
  • 트랜잭션을 종료(롤백, 커밋)하는 시점은 트랜잭션을 시작하는 부분인 서비스 레이어에서 일어나야 한다! 

 

MemberServiceV3_1

package cwchoiit.jdbc.service;

import cwchoiit.jdbc.domain.Member;
import cwchoiit.jdbc.repository.MemberRepositoryV3;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;

import java.sql.SQLException;

@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 transaction = transactionManager.getTransaction(new DefaultTransactionDefinition());

        try {
            bizLogic(fromId, toId, money);
            transactionManager.commit(transaction);
        } catch (Exception e) {
            transactionManager.rollback(transaction);
            throw new IllegalStateException(e);
        }
    }

    private void bizLogic(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("이체중 예외 발생");
        }
    }
}
  • PlatformTransactionManager를 주입받는 서비스 클래스가 생겼다. 이제 트랜잭션을 사용하는 기술이 JDBC이든, JPA이든 상관없이 구현체를 갈아끼워 주입만 시켜주면 이 서비스 코드는 수정할 필요가 전혀없이 기술을 변경할 수 있다.
  • transactionManager.getTransaction()으로 트랜잭션을 시작했다. 반환값은 TransactionStatus 타입을 반환하는데 현재 트랜잭션의 상태 정보가 포함되어 있다. 이 반환값은 이후 롤백 또는 커밋을 할 때 필요하다.
  • new DefaultTransactionDefinition() 이 녀석은 트랜잭션과 관련된 옵션을 지정할 수 있는데 자세한 내용은 뒤에서 설명한다.
  • transactionManager.commit(transaction) → 트랜잭션 내 모든 로직이 정상적으로 수행되면 커밋을 호출한다.
  • transactionManager.rollback(transaction) → 트랜잭션 내 로직 중 하나라도 실패하면 롤백을 호출한다.

 

MemberServiceV3_1Test

package cwchoiit.jdbc.service;

import cwchoiit.jdbc.domain.Member;
import cwchoiit.jdbc.repository.MemberRepositoryV2;
import cwchoiit.jdbc.repository.MemberRepositoryV3;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.PlatformTransactionManager;

import java.sql.SQLException;

import static cwchoiit.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

class MemberServiceV3_1Test {

    public static final String MEMBER_A = "memberA";
    public static final String MEMBER_B = "memberB";
    public static final String MEMBER_EX = "ex";

    private MemberRepositoryV3 memberRepository;
    private MemberServiceV3_1 memberService;

    @BeforeEach
    void beforeEach() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        memberRepository = new MemberRepositoryV3(dataSource);

        PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        memberService = new MemberServiceV3_1(transactionManager, memberRepository);
    }

    @AfterEach
    void afterEach() throws SQLException {
        memberRepository.delete(MEMBER_A);
        memberRepository.delete(MEMBER_B);
        memberRepository.delete(MEMBER_EX);
    }

    @Test
    @DisplayName("정상 이체")
    void accountTransfer() throws SQLException {
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_B, 10000);

        memberRepository.save(memberA);
        memberRepository.save(memberB);

        memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);

        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberB.getMemberId());

        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(12000);
    }

    @Test
    @DisplayName("이체 중 예외")
    void accountTransfer_ex() throws SQLException {
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberEx = new Member(MEMBER_EX, 10000);

        memberRepository.save(memberA);
        memberRepository.save(memberEx);

        assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());

        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }
}
  • 이제 테스트 코드를 통해 제대로 수행되는지 확인하자. 가장 중요한 부분은 초기화 코드이다.
@BeforeEach
void beforeEach() {
    DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
    memberRepository = new MemberRepositoryV3(dataSource);

    PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
    memberService = new MemberServiceV3_1(transactionManager, memberRepository);
}
  • 우리는 지금 JDBC 기술을 사용중이므로, JDBC용 트랜잭션 매니저인 DataSourceTransactionManager를 구현체로 사용해야 한다. 그리고 이 구현체를 MemberServiceV3_1에 주입한다. 
  • 트랜잭션 매니저는 내부에서 데이터소스를 통해 커넥션을 획득하므로 DataSource가 필요하다.
  • 테스트를 실행하면 정상적으로 수행되는 것을 확인할 수 있다.

트랜잭션 문제 해결 - 트랜잭션 매니저2

그림으로 트랜잭션 매니저의 전체 동작 흐름을 자세히 이해해보자.

트랜잭션 매니저 - 트랜잭션 시작

  • 클라이언트의 요청으로 서비스 로직을 실행한다.
  • 서비스 계층에서 transactionManager.getTransaction()을 호출해서 트랜잭션을 시작한다.
  • 트랜잭션을 시작하려면 먼저 데이터베이스 커넥션이 필요하다. 트랜잭션 매니저는 내부에서 데이터소스를 사용해서 커넥션을 생성한다.
  • 커넥션을 수동 커밋 모드로 변경해서 실제 데이터베이스 트랜잭션을 시작한다.
  • 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  • 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관한다. 따라서, 멀티쓰레드 환경에 안전하게 커넥션을 보관할 수 있다.

트랜잭션 매니저 - 로직 실행

  • 서비스는 비즈니스 로직을 실행하면서 레포지토리의 메서드들을 호출한다. 이때, 이제는 커넥션을 파라미터로 전달하지 않는다.
  • 레포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요하다. 레포지토리는 DataSourceUtils.getConnection()을 사용해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 이 과정을 통해서 자연스럽게 같은 커넥션을 사용하고, 트랜잭션도 유지된다.
  • 획득한 커넥션을 사용해서 SQL을 데이터베이스에 전달해서 실행한다.

트랜잭션 매니저 - 트랜잭션 종료

  • 비즈니스 로직이 끝나고 트랜잭션을 종료한다. 트랜잭션은 커밋하거나 롤백하면 종료된다.
  • 트랜잭션을 종료하려면 동기화된 커넥션이 필요하다. 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다.
  • 획득한 커넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.
  • 전체 리소스를 정리한다.
    • 트랜잭션 동기화 매니저를 정리한다. 쓰레드 로컬은 사용 후 꼭 정리해야 한다.
    • conn.setAutoCommit(true)로 되돌린다. 커넥션 풀을 고려해야 한다.
    • conn.close()를 호출해서 커넥션을 종료한다. 커넥션 풀을 사용하는 경우, conn.close()를 호출하면 커넥션 풀에 반환된다.

 

정리를 하자면

  • 트랜잭션 추상화 덕분에 서비스 코드는 이제 JDBC 기술에 의존하지 않고, PlatformTransactionManager에 의존한다. 따라서, 향후 JDBC에서 JPA로 기술을 변경해도 DataSourceTransactionManager에서 JpaTransactionManager로 구현체만 갈아끼워주면 서비스 코드에는 아무런 변경도 할 필요가 없다.
  • 트랜잭션 동기화 매니저 덕분에 커넥션을 파라미터로 넘기지 않아도 된다.

트랜잭션 문제 해결 - 트랜잭션 템플릿

아직 남은 문제가 있다. 위 코드를 살펴보면 다음과 같은 패턴이 반복된다.

public void accountTransfer(String fromId, String toId, int money) throws SQLException {

    TransactionStatus transaction = transactionManager.getTransaction(new DefaultTransactionDefinition());

    try {
        bizLogic(fromId, toId, money);
        transactionManager.commit(transaction);
    } catch (Exception e) {
        transactionManager.rollback(transaction);
        throw new IllegalStateException(e);
    }
}
  • 트랜잭션을 시작한다. 
  • 성공하면 커밋한다.
  • 실패하면 롤백한다.
  • try - catch 구문
  • 모든 서비스에서 이 형태의 코드가 반복될 것이다. 

이럴때 템플릿 콜백 패턴을 활용하면 이런 문제를 해결할 수가 있다.

 

트랜잭션 템플릿

템플릿 콜백 패턴을 적용하려면 템플릿을 제공하는 클래스를 작성해야 한다. 그런데 스프링은 또 우리를 위해 TransactionTemplate이라는 템플릿 클래스를 제공한다.

public class TransactionTemplate {
  private PlatformTransactionManager transactionManager;
  public <T> T execute(TransactionCallback<T> action){..}
  void executeWithoutResult(Consumer<TransactionStatus> action){..}
}
  • execute() → 응답값이 있을 때 사용한다.
  • executeWithoutResult() → 응답값이 없을 때 사용한다.

MemberServiceV3_2

package cwchoiit.jdbc.service;

import cwchoiit.jdbc.domain.Member;
import cwchoiit.jdbc.repository.MemberRepositoryV3;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.DefaultTransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;

import java.sql.SQLException;

@Slf4j
public class MemberServiceV3_2 {

    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);
            }
        });
    }

    private void bizLogic(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("이체중 예외 발생");
        }
    }
}
  • TransactionTemplate을 사용하려면 TransactionManager가 필요하다. 생성자에서 TransactionManager를 주입 받으면서 TransactionTemplate을 생성했다.
  • 트랜잭션 템플릿 사용한 코드를 보자.
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);
        }
    });
}
  • 굉장히 깔끔하게 딱 비즈니스 로직만 작성해도 되게끔 변경됐다.
  • 트랜잭션을 시작하거나, 커밋, 롤백하는 부분도 다 없어졌다.
  • 트랜잭션 템플릿의 기본 동작은 비즈니스 로직이 정상 수행되면 커밋하고, 언체크 예외가 발생하면 롤백한다. 그 외의 경우(체크 예외가 발생하는 경우나 정상적으로 끝나는 경우)에는 커밋한다. 기본 흐름이 그렇다. 이 또한 변경할수도 있다.
  • 그런데 살짝 아쉬운 부분은 bizLogic 자체가 SQLException을 던지기 때문에 그것을 잡아줘야 하는 try - catch 구문이 들어간다. 만약 로직을 언체크 예외로 작성했다면 이 부분도 없어질 것이다. 이건 뒤에서 차차 알아보자.

 

정리를 하자면

  • 트랜잭션 템플릿을 사용하니 반복하는 코드를 최대한 제거할 수 있었다.
  • 하지만, 여전히 서비스 로직에 비즈니스 로직뿐만 아니라 트랜잭션을 처리하는 기술인 트랜잭션 템플릿 코드가 함께 포함되어 있다.
  • 즉, 두 관심사가 동시에 로직에 쓰여지고 있다는 말인데 이걸 어떻게 깔끔하게 서비스 코드에는 비즈니스 로직만 남겨두게 할 수 있을까?

 

트랜잭션 문제 해결 - 트랜잭션 AOP 이해

  • 지금까지 트랜잭션을 편리하게 처리하기 위해 트랜잭션 추상화도 도입하고, 추가로 반복적인 트랜잭션 로직을 해결하기 위해 트랜잭션 템플릿도 도입했다.
  • 트랜잭션 템플릿 덕분에 트랜잭션을 처리하는 반복적인 코드는 해결할 수 있었다. 하지만, 서비스 계층에 순수한 비즈니스 로직만 남긴다는 목표는 아직 달성하지 못했다.
  • 이럴때, 스프링 AOP를 통해 프록시를 도입하면 문제를 깔끔하게 해결할 수 있다.

 

프록시를 통한 문제 해결

프록시 도입 전

프록시를 도입하기 전에는 이렇듯 서비스의 로직에서 트랜잭션을 직접 시작한다.

 

프록시 도입 후

프록시를 사용하면 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 서비스 객체를 명확하게 분리할 수 있다.

이 내용의 깊은 부분은 스프링 AOP에 대해 완전히 이해해야 한다. 그 부분에 대한 내용 역시 포스팅으로 정리해 두었다. 

 

트랜잭션 문제 해결 - 트랜잭션 AOP 적용

MemberServiceV3_3

package cwchoiit.jdbc.service;

import cwchoiit.jdbc.domain.Member;
import cwchoiit.jdbc.repository.MemberRepositoryV3;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;

import java.sql.SQLException;

@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);
    }

    private void bizLogic(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("이체중 예외 발생");
        }
    }
}
  • 순수한 비즈니스 로직만 남기고, 트랜잭션 관련 코드는 모두 제거됐다.
  • 스프링이 제공하는 트랜잭션 AOP를 적용하기 위해 @Transactional 애노테이션을 추가했다.
  • @Transactional 애노테이션은 메서드에 붙여도 되고, 클래스에 붙여도 된다. 클래스에 붙이면 외부에서 호출 가능한 public 메서드가 AOP 적용 대상이 된다.

 

MemberServiceV3_3Test

package cwchoiit.jdbc.service;

import cwchoiit.jdbc.domain.Member;
import cwchoiit.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.transaction.PlatformTransactionManager;

import javax.sql.DataSource;
import java.sql.SQLException;

import static cwchoiit.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Slf4j
@SpringBootTest
class MemberServiceV3_3Test {

    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 TestContextConfiguration {
        @Bean
        DataSource dataSource() {
            return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        }

        @Bean
        PlatformTransactionManager transactionManager() {
            return new DataSourceTransactionManager(dataSource());
        }

        @Bean
        MemberRepositoryV3 memberRepository() {
            return new MemberRepositoryV3(dataSource());
        }

        @Bean
        MemberServiceV3_3 memberService() {
            return new MemberServiceV3_3(memberRepository());
        }
    }

    @AfterEach
    void afterEach() throws SQLException {
        memberRepository.delete(MEMBER_A);
        memberRepository.delete(MEMBER_B);
        memberRepository.delete(MEMBER_EX);
    }

    @Test
    @DisplayName("정상 이체")
    void accountTransfer() throws SQLException {
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_B, 10000);

        memberRepository.save(memberA);
        memberRepository.save(memberB);

        memberService.accountTransfer(memberA.getMemberId(), memberB.getMemberId(), 2000);

        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberB.getMemberId());

        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(12000);
    }

    @Test
    @DisplayName("이체 중 예외")
    void accountTransfer_ex() throws SQLException {
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberEx = new Member(MEMBER_EX, 10000);

        memberRepository.save(memberA);
        memberRepository.save(memberEx);

        assertThatThrownBy(() -> memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberEx = memberRepository.findById(memberEx.getMemberId());

        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberEx.getMoney()).isEqualTo(10000);
    }
}
  • 스프링 AOP를 적용한 테스트를 하기 위해서는 @SpringBootTest 애노테이션을 붙여줘야 테스트를 실행할 때 스프링이 띄워진다. 그리고 @Autowired 애노테이션으로 필요한 빈들을 주입받을 수 있다.
  • @TestConfiguration 애노테이션은 테스트 안에서 내부 설정 클래스를 만들어서 사용하면서 이 애노테이션을 붙이면, 스프링 부트가 자동으로 만들어주는 빈들에 추가적으로 필요한 스프링 빈들을 등록하고 테스트를 수행할 수 있다.
  • 테스트를 실행해보면, 정상적으로 수행되는 것을 확인할 수 있다.

 

 

728x90
반응형
LIST

+ Recent posts