Skip to content

스프링부트 강의 | JDBC, 커넥션 풀, 데이터소스, 트랜잭션, 스프링 예외 추상화

Notifications You must be signed in to change notification settings

nickhealthy/inflearn-Spring-DB1

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

53 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

인프런 강의

해당 저장소의 README.md는 인프런 김영한님의 SpringBoot 강의 시리즈를 듣고 Spring 프레임워크의 방대한 기술들을 복기하고자 공부한 내용을 가볍게 정리한 것입니다.

문제가 될 시 삭제하겠습니다.

해당 프로젝트에서 배우는 내용

  • 섹션 1 | JDBC 이해
  • 섹션 2 | 커넥션풀과 데이터소스 이해
  • 섹션 3 | 트랜잭션 이해
  • 섹션 4 | 스프링과 문제 해결 - 트랜잭션
  • 섹션 5 | 자바 예외 이해
  • 섹션 6 | 스프링과 문제 해결 - 예외 처리, 반복

섹션 1 | JDBC 이해

애플리케이션을 개발할 때 중요한 데이터는 대부분 데이터베이스에 보관한다.

애플리케이션 서버는 다음 과정을 통해 데이터베이스를 사용한다.

스크린샷 2024-03-16 오후 2 36 20

  1. 커넥션 연결: 주로 TCP/IP를 사용해서 커넥션을 연결한다.
  2. SQL 전달: 애플리케이션 서버는 DB가 이해할 수 있는 SQL을 연결된 커넥션을 통해 DB에 전달한다.
  3. 결과응답: DB는 전달된 SQL을 수행하고, 그 결과를 응답하한다. 애플리케이션 서버는 응답 결과를 활용한다.

JDBC 표준 인터페이스 등장 이유

다음과 같은 2가지 큰 문제점이 있다. 아래와 같은 문제를 해결하기 위해 JDBC가 등장하게 된다.

  1. 데이터베이스를 다른 종류의 데이터베이스로 변경하면 애플리케이션 서버에 개발된 데이터베이스 사용 코드도 함께 변경해야 한다.
  2. 개발자가 각각의 데이터베이스마다 커넥션 연결, SQL 전달, 그리고 그 결과를 응답 받는 방법을 새로 학습해야 한다.

JDBC(Java Database Connectivity)는 자바에서 데이터베이스에 접속할 수 있도록 하는 자바 API이다. JDBC는 데이터베이스에서 자료를 쿼리하는나 업데이트 하는 방법을 제공한다.

  • 자바는 아래와 같이 같이 표준 인터페이스를 정의해두었는데, 개발자는 이 표준 인터페이스를 사용해서 개발하면 된다.
  • 인터페이스만 있다고 개발이 가능한 것은 아니고, 각 DB 벤더에서 자신의 DB에 맞도록 구현해서 라이브러리로 제공하는데, 이것을 JDBC 드라이버라고 한다.

대표적으로 다음 3가지 기능을 표준 인터페이스로 정의해서 제공한다.

  • java.sql.Connection - 연결
  • java.sql.Statement - SQL을 담은 내용
  • java.sql.ResultSet - SQL 요청 응답

스크린샷 2024-03-16 오후 2 45 23

정리

JDBC의 등장으로 다음과 같은 문제가 해결되었다.

  1. 데이터베이스를 다른 종류의 데이터베이스로 변경하더라도 애플리케이션 로직은 이제 JDBC 표준 인터페이스에만 의존하므로, 다른 종류의 데이터베이스로 변경하려면 JDBC 구현 라이브러리만 변경하면 된다.
  2. 개발자가 각각의 데이터베이스마다 커넥션 연결, SQL 전달, 그리고 그 결과를 응답 받는 방법을 새로 학습하지 않고 JDBC 표준 인터페이스 사용법만 학습하면 된다.

표준화의 한계점

JDBC의 등장으로 많은 것이 편리해졌지만, 각각의 데이터베이스마다 SQL, 데이터타입 등의 일부 사용법이 다르다. ANSI SQL이라는 표준이 있긴 하지만 일반적인 부분만 공통했기 때문에 한계가 있다. 결국 데이터베이스를 변경하면 JDBC 코드는 변경하지 않아도 되지만 SQL은 해당 데이터베이스에 맞도록 변경해야한다.

JDBC와 최신 데이터 접근 기술

JDBC로 인해 많은 것이 편해졌지만 여전히 사용하는 방법이 복잡하다. 그래서 최근에는 JDBC를 직접 사용하기 보단 JDBC를 편리하게 사용하는 다양한 기술이 존재하는데, 대표적으로 SQL Mapper와 ORM 기술로 나눌 수 있다.

스크린샷 2024-03-16 오후 3 10 15

  • SQL Mapper
    • 장점: JDBC를 편리하게 사용하도록 도와준다.
      • SQL 응답 결과를 객체로 편리하게 반환해준다.
      • JDBC의 반복 코드를 제거해준다.
    • 단점: 개발자가 직접 SQL을 작성해야한다.
    • 대표 기술: 스프링 JdbcTemplate, MyBatis

스크린샷 2024-03-16 오후 3 13 06

  • ORM 기술
    • ORM은 객체를 관계형 데이터베이스 테이블과 매핑해주는 기술이다. 이 기술 덕분에 개발자는 반복적인 SQL을 직접 작성하지 않고, ORM 기술이 개발자 대신에 SQL을 동적으로 만들어 실행해준다.
    • 대표 기술: JPA, 하이버네이트, 이클립스링크
    • JPA는 자바 진영의 ORM 표준 인터페이스이고, 이것을 구현한 것이 하이버네이트와 이클립스 링크 등의 구현 기술이 있다.

SQL Mapper vs ORM 기술

  • SQL Mapper는 SQL만 직접 작성하면 나머지 번거로운 일은 SQL Mapper가 대신 해결해준다. SQL만 작성할 줄 알면 금방 배워서 사용할 수 있다.
  • ORM 기술은 SQL 자체를 작성하지 않아도 되어서 개발 생산성이 매우 높아진다. 편리한 반면 쉬운 기술은 아니므로 실무에서 사용하려면 깊이있게 학습해야 한다.
  • 이런 기술들도 내부에서는 모두 JDBC를 사용하므로, JDBC가 어떻게 동작하는지 기본 원리를 알아두어야 한다.

데이터베이스 연결

H2 데이터베이스를 사용하였다.

[ConnectionConst] - 데이터베이스에 접속하는데 필요한 기본 정보를 상수로 정의

package hello.jdbc.connection;

public abstract class ConnectionConst {
    public static final String URL = "jdbc:h2:tcp://localhost/~/test";
    public static final String USERNAME = "sa";
    public static final String PASSWORD = "";
}

[DBConnectionUtil] - JDBC를 사용해서 실제 데이터베이스에 연결하는 코드

  • DriverManager.getConnection(): 데이터베이스에 연결하려면 JDBC가 제공하는 해당 메서드를 사용하면 된다.
    • 해당 메서드를 사용하면 데이터베이스 드라이버를 찾아서 해당 드라이버가 제공하는 커넥션을 반환해준다.
    • 여기서는 H2 데이터베이스 드라이버가 작동해서 실제 데이터베이스와 커넥션을 맺고 그 결과를 반환해준다.
package hello.jdbc.connection;

import lombok.extern.slf4j.Slf4j;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

import static hello.jdbc.connection.ConnectionConst.*;

/**
 * JDBC를 사용해서 실제 데이터베이스에 연결하는 코드
 */
@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);
        }
    }
}

[DBConnectionUtilTest] - 데이터베이스 연결 테스트

package hello.jdbc.connection;

import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

import java.sql.Connection;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

@Slf4j
class DBConnectionUtilTest {

    @Test
    void connection() {
        Connection connection = DBConnectionUtil.getConnection();
        assertThat(connection).isNotNull();
    }
}

실행 결과

  • class=class org.h2.jdbc.JdbcConnection: H2 데이터베이스 드라이버가 제공하는 H2 전용 커넥션이다.
    • 물론 이 커넥션은 JDBC 표준 커넥션 인터페이스인 java.sql.Connection 인터페이스를 구현하고 있음
DBConnectionUtil - get connection=conn0: url=jdbc:h2:tcp://localhost/~/test
 user=SA, class=class org.h2.jdbc.JdbcConnection

JDBC DriverManager 연결 이해

다형성처럼 각 벤더사의 JDBC 드라이버는 java.sql.Connection 표준 커넥션 인터페이스를 구현체를 제공하고 있다.

DriverManager 커넥션 요청 흐름

스크린샷 2024-03-16 오후 3 26 44

JDBC가 제공하는 DriverManager는 라이브러리에 등록된 DB 드라이버들을 관리하고, 커넥션을 획득하는 기능을 제공한다.

  1. 애플리케이션 로직에서 커넥션이 필요하면 DriverManager.getConnection()dmf ghcnf
  2. DriverManager는 라이브러리에 등록된 드라이버 목록을 자동으로 인식한다. 이 드라이버들에게 순서대로 다음 정보를 넘겨 커넥션을 획득할 수 있는지 확인한다.
    • URL: 예) jdbc:h2:tcp://localhost/~/test
    • 이름, 비밀번호 등 접속에 필요한 추가 정보
    • 각각의 드라이버는 URL 정보를 체크해서 자신이 처리할 수 있는 요청인지 확인한다. 드라이버 자신이 처리할 수 있는 요청이면 실제 데이터베이스에 연결해서 커넥션을 획득하고 이 커넥션을 클라이언트에게 반환한다.
      • 예를 들어 jdbc:h2로 시작하면 h2 데이터베이스에 접근하기 위한 규칙이다.
  3. 이렇게 찾은 커넥션을 클라이언트에게(애플리케이션) 반환한다.

JDBC 개발 - 등록, 조회, 수정, 삭제

JDBC를 사용해서 회원(Member) 데이터를 데이터베이스에 관리하는 기능을 개발한다.

해당 실습을 진행하기 위해선 데이터베이스에 member 테이블이 먼저 생성되어 있어야 한다.

[Member] - member 테이블에 데이터를 저장하고 조회할 때 사용한다.

  • 회원의 ID와 해당 회원이 소지한 금액을 표현하는 클래스
package hello.jdbc.domain;

import lombok.Data;

@Data
public class Member {

    private String memberId;
    private int money;

    public Member() {
    }

    public Member(String memberId, int money) {
        this.memberId = memberId;
        this.money = money;
    }
}

[MemberRepositoryV0] - 회원 등록(JDBC를 사용하여 데이터베이스에 저장)

  • getConnection(): 이전에 만들어 둔 DBConnectionUtil를 통해 데이터베이스 커넥션을 획득한다.
  • con.prepareStatement(sql);: 데이터베이스에 전달한 SQL과 파라미터로 전달할 데이터들을 준비한다.
  • pstmt.executeUpdate();: Statement를 통해 준비된 SQL을 커넥션을 통해 실제 데이터베이스에 전달한다.
  • pstmt.executeQuery();: 데이터를 조회할 땐 해당 메서드를 사용하며, 반환 결과로 ResultSet에 담아 반환한다.
  • ResultSet은 내부에 있는 커서를 이동해서 다음 데이터를 조회할 수 있다.
    • rs.next(): 최초의 커서는 데이터를 가리키고 있지 않기 때문에 rs.next를 최초 한번은 호출해야 데이터를 조회 가능하다.
package hello.jdbc.repository;

import hello.jdbc.connection.DBConnectionUtil;
import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;

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

@Slf4j
public class MemberRepositoryV0 {

    private static Connection getConnection() {
        return DBConnectionUtil.getConnection();
    }

    public Member save(Member member) throws SQLException {
        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) {
            log.error("db error", e);
            throw e;
        }
    }

    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 = 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("db error", e);
            throw e;
        } finally {
            close(con, pstmt, rs);
        }
    }

    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 = 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, null);

        }
    }

    public void delete(String memberId) throws SQLException {
        String sql = "delete from member where member_id = ?";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);
            pstmt.executeUpdate();

        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        }
    }

    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 (con != null) {
            try {
                con.close();
            } catch (SQLException e) {
                log.info("error", e);
            }
        }

    }
}

리소스 정리

  • 리소스 정리를 하지 않게 되면, 리소스 누수가 발생하게 되는데 결과적으로 커넥션 부족으로 장애가 발생할 수 있다.
  • 따라서 리소스는 항상 수행해야하므로 finally 키워드에 작성해야하며, 리소스를 정리해야 할 땐 생성된 순서의 역순으로 자원을 해제해야 한다.
    • 위의 예제에서는 Connection을 통해 PreparedStatement을 만들었기 때문에 리소스를 반환할 땐 PreparedStatement -> Connection 순으로 리소스를 해제해야한다.

참고

PreparedStatementStatement의 자식 타입인데, ?를 통한 파라미터 바인딩을 가능하게 해준다. SQL Injection 공격을 예방하려면 PreparedStatement를 통한 파라미터 바인딩 방식을 사용해야한다.

[MemberRepositoryV0Test] - 회원 등록, 조회, 수정, 삭제

package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;

import java.sql.SQLException;
import java.util.NoSuchElementException;

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

@Slf4j
class MemberRepositoryV0Test {

    MemberRepositoryV0 repository = new MemberRepositoryV0();

    @Test
    void crud() throws SQLException {
        Member member = new Member("memberV3", 10000);
        repository.save(member);

        // findById
        Member findMember = repository.findById(member.getMemberId());
        log.info("findMember = {}", findMember);
        assertThat(findMember).isEqualTo(member);

        // update: money: 10000 -> 20000
        repository.update(member.getMemberId(), 20000);
        Member updateMember = repository.findById(member.getMemberId());
        assertThat(updateMember.getMoney()).isEqualTo(20000);

        // delete
        repository.delete(member.getMemberId());
        assertThatThrownBy(() -> repository.findById(member.getMemberId())).isInstanceOf(NoSuchElementException.class);
    }
}

참고

테스트는 반복해서 실행 가능한 것이 중요하다. 테스트 중간에 오류가 발생해서 삭제 로직을 수행할 수 없다면 테스트를 반복해서 실행할 수 없다. 따라서 해당 코드는 좋은 코드라 볼 수 없고, 트랜잭션을 활용하면 문제를 깔끔히 해결할 수 있다고 한다.

섹션 2 | 커넥션풀과 데이터소스 이해

커넥션 풀 이해

스크린샷 2024-03-16 오후 7 50 32

데이터베이스 커넥션을 획득하는 과정에서는 다음과 같은 복잡한 과정을 거친다.

  1. 애플리케이션 로직은 DB 드라이버를 통해 커넥션을 조회한다.
  2. DB 드라이버는 DB와 TCP/IP 커넥션을 연결한다. 물론 이 과정에서 3 way handshake 같은 TCP/IP 연결을 위한 네트워크 동작이 발생한다.
  3. DB 드라이버는 TCP/IP 커넥션이 연결되면 ID,PW와 기타 부가정보를 DB에 전달한다.
  4. DB는 ID, PW를 통해 내부 인증을 완료하고, 내부에 DB 세션을 생성한다.
  5. DB는 커넥션 생성이 완료되었다는 응답을 보낸다.
  6. DB 드라이버는 커넥션 객체를 생성해서 클라이언트에 반환한다.

이러한 프로세스를 거치기때문에 결과적으로 애플리케이션 성능에 영향을 미치게되며, 사용자에게 좋지 않은 경험을 줄 수 있다.

이러한 문제를 해결하기 위한 것이 커넥션 풀이라는 방법이다.

  1. 애플리케이션을 시작하는 시점에 커넥션 풀은 필요한 만큼 커넥션을 미리 확보해서 풀에 보관한다.
  2. 커넥션 풀에 들어 있는 커넥션은 TCP/IP로 DB와 커넥션이 연결되어 있는 상태이기 때문에 언제든지 즉시 SQL을 DB에 전달할 수 있다.
  3. 애플리케이션은 이제 DB 드라이버를 통해 새로운 커넥션을 획득하는 것이 아닌, 생성되어 있는 커넥션을 객체 참조로 가져다 쓰면 된다.
  4. 커넥션을 모두 사용하고 나면 이제는 커넥션을 종료하는 것이 아닌, 다음에 다시 사용할 수 있도록 해당 커넥션을 그대로 커넥션 풀에 반환하면 된다.

스크린샷 2024-03-16 오후 7 55 06

스크린샷 2024-03-16 오후 7 55 14

스크린샷 2024-03-16 오후 7 58 27

DataSource 이해

커넥션을 얻는 방법은 JDBC DriverManager를 직접 사용하거나, 커넥션 풀을 사용하는 등 다양한 방법이 존재한다.

스크린샷 2024-03-16 오후 8 07 59

애플리케이션 로직에서 DriverManager를 사용하다가 HikariCP 같은 커넥션 풀을 사용하도록 변경하려면 애플리케이션 코드도 함께 변경해야 한다. 의존관계가 DriverManager에서 HikariCP로 변경되기 때문이다.

  • 자바에서는 이러한 문제를 해결하기 위해 DataSource라는 제공하며, 커넥션을 획득하는 방법을 추상화 하는 인터페이스이다.

스크린샷 2024-03-16 오후 8 09 50

정리

  • 대부분의 커넥션 풀은 DataSource 인터페이스를 이미 구현했으므로, 애플리케이션은 DataSource 인터페이스에만 의존하도록 구현하면 된다.
    • 다른 커넥션 풀을 사용하고 싶다면 해당 구현체로 갈아끼우기만 하면 된다.
  • DriverManagerDataSource를 인터페이스로 사용하지 않아 직접 고쳐야했는데, 스프링은 이런 문제를 해결하기 위해 DriverManagerDataSource라는 DataSource를 구현한 클래스를 제공한다.

DataSource 예제1 - DriverManager

JDBC가 제공하던 DriverManagerDataSource를 구현한 스프링이 제공하는 DriverManagerDataSource의 차이점을 알아보자

[ConnectionTest] - 각각 커넥션을 얻는 방법의 차이점

  • DriverManager는 커넥션 객체를 얻을 때마다 설정 정보를 입력해야 하지만, 스프링이 제공하는 DriverManagerDataSource는 설정 정보를 한번만 입력해두고 DataSource(부모)객체를 통해서만 객체를 생성할 수 있다.
    • 설정과 사용의 분리로 인해 향후 변경에 더 유연하게 대처할 수 있다.
package hello.jdbc.connection;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

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

import static hello.jdbc.connection.ConnectionConst.*;

@Slf4j
public class ConnectionTest {

    /**
     * DriverManager가 커넥션을 획득하는 방법
     * OUTPUT:
     * 20:17:30.836 [Test worker] INFO hello.jdbc.connection.ConnectionTest -- connection = conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class org.h2.jdbc.JdbcConnection
     * 20:17:30.838 [Test worker] INFO hello.jdbc.connection.ConnectionTest -- connection = conn1: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class org.h2.jdbc.JdbcConnection
     */
    @Test
    void driverManager() throws SQLException {
        Connection con1 = DriverManager.getConnection(URL, USERNAME, PASSWORD);
        Connection con2 = DriverManager.getConnection(URL, USERNAME, PASSWORD);
        log.info("connection = {}, class = {}", con1, con1.getClass());
        log.info("connection = {}, class = {}", con2, con2.getClass());

    }

    /**
     * 스프링이 제공하는 DataSource가 적용된 DriverManagerDataSource가 커넥션을 획득하는 방법
     * - 기존 코드와 비슷하지만 DataSource를 통해서 커넥션을 획득할 수 있으며, 설정과 사용의 분리가 명확하게 되어있다.
     * OUTPUT:
     * 20:20:36.537 [Test worker] INFO hello.jdbc.connection.ConnectionTest -- connection = conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class org.h2.jdbc.JdbcConnection
     * 20:20:36.540 [Test worker] INFO hello.jdbc.connection.ConnectionTest -- connection = conn1: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class org.h2.jdbc.JdbcConnection
     */
    @Test
    void dataSourceDriverManager() throws SQLException {
        // DriverManagerDataSource - 항상 새로운 커넥션 획득
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        useDataSource(dataSource);
    }

    private void useDataSource(DataSource dataSource) throws SQLException {
        Connection con1 = dataSource.getConnection();
        Connection con2 = dataSource.getConnection();
        log.info("connection = {}, class = {}", con1, con1.getClass());
        log.info("connection = {}, class = {}", con2, con2.getClass());

    }
}

DataSource 예제2 - 커넥션 풀

이번에는 DataSource를 통해 커넥션 풀을 사용하는 예제를 해보자

[ConnectionTest] - 데이터소스 커넥션 풀 추가

  • 커넥션 풀에서 커넥션을 생성하는 작업은 애플리케이션 실행 속도에 영향을 주지 않기 위해 별도의 쓰레드에서 작동한다.
    • 만약 애플리케이션에서 커넥션 풀을 생성하기 위해 같은 쓰레드를 사용한다면, 커넥션 풀을 모두 생성하기 위해서 애플리케이션은 동작하지 않을 것이다.
  • 커넥션 풀 최대 사이즈를 10으로 지정하고, 풀의 이름을 MyPool이라고 지정했다.
package hello.jdbc.connection;

import com.zaxxer.hikari.HikariDataSource;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

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

import static hello.jdbc.connection.ConnectionConst.*;

@Slf4j
public class ConnectionTest {

    /**
     * HikariCP를 사용하여 커넥션 풀을 획득하는 방법
     * - 커넥션 풀에서 커넥션을 생성하는 작업은 애플리케이션 실행 속도에 영향을 주지 않기 위해 별도의 쓰레드에서 작동한다.
     * @throws SQLException
     * @throws InterruptedException
     */
    @Test
    void dataSourceConnectionPool() throws SQLException, InterruptedException {
        // 커넥션 풀링: HikariProxyConnection(Proxy) -> JdbcConnection(Target)
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(URL);
        dataSource.setUsername(USERNAME);
        dataSource.setPassword(PASSWORD);
        dataSource.setMaximumPoolSize(10);
        dataSource.setPoolName("MyPool");

        useDataSource(dataSource);
        Thread.sleep(1000); // 커넥션 풀에서 커넥션 생성 시간 대기

    }

    private void useDataSource(DataSource dataSource) throws SQLException {
        Connection con1 = dataSource.getConnection();
        Connection con2 = dataSource.getConnection();
        log.info("connection = {}, class = {}", con1, con1.getClass());
        log.info("connection = {}, class = {}", con2, con2.getClass());

    }
}

실행 결과

  • [MyPool connection adder] - 커넥션 풀에 커넥션을 채우기 위한 별도의 쓰레드
  • [Test worker] - Test를 수행하는 쓰레드
  • 커넥션 획득에 로그를 보면 com.zaxxer.hikari.pool.HikariProxyConnection 클래스를 사용하고 있는데, 해당 클래스는 HikariCP 커넥션 풀에서 관리하는 실제 커넥션이며, wrapping 형태로 안에 JDBC 커넥션을 포함하고 있다.
# 커넥션 풀 초기화 정보 출력
20:37:39.612 [Test worker] DEBUG com.zaxxer.hikari.HikariConfig --
                MyPool - configuration:
20:37:39.621 [Test worker] DEBUG com.zaxxer.hikari.HikariConfig --
                jdbcUrl.........................jdbc:h2:tcp://localhost/~/test
20:37:39.622 [Test worker] DEBUG com.zaxxer.hikari.HikariConfig --
                minimumIdle.....................10
20:37:39.622 [Test worker] DEBUG com.zaxxer.hikari.HikariConfig --
                password........................<masked>
20:37:39.622 [Test worker] DEBUG com.zaxxer.hikari.HikariConfig --
                poolName........................"MyPool"


# 커넥션 풀 전용 쓰레드가 커넥션 풀에 커넥션을 10개 채움
20:37:39.669 [Test worker] INFO  com.zaxxer.hikari.HikariDataSource --
                MyPool - Start completed.
20:37:39.675 [MyPool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool --
                MyPool - Added connection conn1: url=jdbc:h2:tcp://localhost/~/test user=SA
20:37:39.692 [MyPool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool --
                MyPool - Added connection conn2: url=jdbc:h2:tcp://localhost/~/test user=SA
20:37:39.774 [MyPool housekeeper] DEBUG com.zaxxer.hikari.pool.HikariPool --
                MyPool - Pool stats (total=8, active=2, idle=6, waiting=0)
20:37:39.789 [MyPool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool --
                MyPool - Added connection conn8: url=jdbc:h2:tcp://localhost/~/test user=SA
20:37:39.807 [MyPool connection adder] DEBUG com.zaxxer.hikari.pool.HikariPool --
                MyPool - Added connection conn9: url=jdbc:h2:tcp://localhost/~/test user=SA

# 커넥션 풀에서 커넥션 획득 1
20:37:39.675 [Test worker] INFO  h.jdbc.connection.ConnectionTest --
                connection = HikariProxyConnection@379121284 wrapping conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class com.zaxxer.hikari.pool.HikariProxyConnection
                
# 커넥션 풀에서 커넥션 획득 2
20:37:39.677 [Test worker] INFO  h.jdbc.connection.ConnectionTest --
                connection = HikariProxyConnection@2031377754 wrapping conn1: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class com.zaxxer.hikari.pool.HikariProxyConnection

DataSource 적용

이번에는 애플리케이션에 DataSource 를 적용해보자.

[MemberRepositoryV1] - DataSource를 통해 의존성 주입, JdbcUtils 사용으로 close 간소화

  • 외부에서 DataSource를 주입 받아 사용하기 때문에 DriverManagerDataSource, HikariDataSource를 변경해서 사용하더라도 코드의 변경이 없다. (DI + OCP)
  • 스프링에서 제공하는 JdbcUtils 사용으로 JDBC를 더 간편하게 사용할 수 있다.
package hello.jdbc.repository;

import hello.jdbc.connection.DBConnectionUtil;
import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.support.JdbcUtils;

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

/**
 * JDBC - DataSource 사용, JdbcUtils 사용
 * - 스프링은 JDBC를 편리하게 다룰 수 있는 JdbcUtils라는 편의 메서드를 제공한다.
 * - JdbcUtils을 사용하면 커넥션을 좀 더 편리하게 close 할 수 있다.
 */
@Slf4j
public class MemberRepositoryV1 {

    private final DataSource dataSource;

    // 외부에서 DataSource를 주입 받아서 사용한다. 이를 통해 구현체가 달라지더라도 코드를 변경할 일이 없다.
    public MemberRepositoryV1(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    private Connection getConnection() throws SQLException {
        Connection con = dataSource.getConnection();
        log.info("get connection = {}, class = {}", con, con.getClass());
        return con;
    }

  	// save()
  	// findById()
  	// update()
  	// delete()

    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        JdbcUtils.closeConnection(con);

    }
}

[MemberRepositoryV1Test] - DataSource 사용 테스트

package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

import javax.sql.DataSource;
import java.sql.Driver;
import java.sql.SQLException;
import java.util.NoSuchElementException;

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

@Slf4j
class MemberRepositoryV1Test {

    MemberRepositoryV1 repository;

    @BeforeEach
    void beforeEach() {
        // 기본 DriverManager - 항상 새로운 커넥션 획득
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        repository = new MemberRepositoryV1(dataSource);

    }

    @Test
    void crud() throws SQLException {
        Member member = new Member("memberV3", 10000);
        repository.save(member);

        // findById
        Member findMember = repository.findById(member.getMemberId());
        log.info("findMember = {}", findMember);
        assertThat(findMember).isEqualTo(member);

        // update: money: 10000 -> 20000
        repository.update(member.getMemberId(), 20000);
        Member updateMember = repository.findById(member.getMemberId());
        assertThat(updateMember.getMoney()).isEqualTo(20000);

        // delete
        repository.delete(member.getMemberId());
        assertThatThrownBy(() -> repository.findById(member.getMemberId())).isInstanceOf(NoSuchElementException.class);
    }
}

실행 결과 - DriverManagerDataSource

  • 매번 새로운 커넥션을 획득하는 것을 확인할 수 있다.
22:09:53.397 [Test worker] DEBUG o.s.j.d.DriverManagerDataSource --
                Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/test]
22:09:53.442 [Test worker] INFO  h.jdbc.repository.MemberRepositoryV1 --
                get connection = conn0: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class org.h2.jdbc.JdbcConnection
22:09:53.461 [Test worker] DEBUG o.s.j.d.DriverManagerDataSource --
                Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/test]
22:09:53.463 [Test worker] INFO  h.jdbc.repository.MemberRepositoryV1 --
                get connection = conn1: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class org.h2.jdbc.JdbcConnection
22:09:53.469 [Test worker] INFO  h.j.r.MemberRepositoryV1Test --
                findMember = Member(memberId=memberV3, money=10000)
22:09:53.491 [Test worker] DEBUG o.s.j.d.DriverManagerDataSource --
                Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/test]
22:09:53.494 [Test worker] INFO  h.jdbc.repository.MemberRepositoryV1 --
                get connection = conn2: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class org.h2.jdbc.JdbcConnection
22:09:53.496 [Test worker] INFO  h.jdbc.repository.MemberRepositoryV1 --
                resultSize = 1
22:09:53.496 [Test worker] DEBUG o.s.j.d.DriverManagerDataSource --
                Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/test]
22:09:53.500 [Test worker] INFO  h.jdbc.repository.MemberRepositoryV1 --
                get connection = conn3: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class org.h2.jdbc.JdbcConnection
22:09:53.507 [Test worker] DEBUG o.s.j.d.DriverManagerDataSource --
                Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/test]
22:09:53.510 [Test worker] INFO  h.jdbc.repository.MemberRepositoryV1 --
                get connection = conn4: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class org.h2.jdbc.JdbcConnection
22:09:53.511 [Test worker] DEBUG o.s.j.d.DriverManagerDataSource --
                Creating new JDBC DriverManager Connection to [jdbc:h2:tcp://localhost/~/test]
22:09:53.512 [Test worker] INFO  h.jdbc.repository.MemberRepositoryV1 --
                get connection = conn5: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class org.h2.jdbc.JdbcConnection

실행 결과 - HikariCP 사용

  • DriverManagerDataSource와는 달리 커넥션을 사용하고 다시 커넥션 풀에 적재 후 재사용하기 때문에 쿼리를 처리하고 거의 같은 커넥션을 사용하는 것을 확인할 수 있다.
22:19:11.238 [Test worker] INFO  h.jdbc.repository.MemberRepositoryV1 --
                get connection = HikariProxyConnection@1594039997 wrapping conn1: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class com.zaxxer.hikari.pool.HikariProxyConnection
22:19:11.256 [Test worker] INFO  h.j.r.MemberRepositoryV1Test --
                findMember = Member(memberId=memberV3, money=10000)
                
22:19:11.276 [Test worker] INFO  h.jdbc.repository.MemberRepositoryV1 --
                get connection = HikariProxyConnection@659059448 wrapping conn1: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class com.zaxxer.hikari.pool.HikariProxyConnection
22:19:11.279 [Test worker] INFO  h.jdbc.repository.MemberRepositoryV1 --
                resultSize = 1
                
22:19:11.279 [Test worker] INFO  h.jdbc.repository.MemberRepositoryV1 --
                get connection = HikariProxyConnection@124494140 wrapping conn1: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class com.zaxxer.hikari.pool.HikariProxyConnection

22:19:11.281 [Test worker] INFO  h.jdbc.repository.MemberRepositoryV1 --
                get connection = HikariProxyConnection@635288507 wrapping conn1: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class com.zaxxer.hikari.pool.HikariProxyConnection
                
22:19:11.282 [Test worker] INFO  h.jdbc.repository.MemberRepositoryV1 --
                get connection = HikariProxyConnection@593447952 wrapping conn2: url=jdbc:h2:tcp://localhost/~/test user=SA, class = class com.zaxxer.hikari.pool.HikariProxyConnection

섹션 3 | 트랜잭션 이해

트랜잭션 - 개념 이해

데이터를 단순히 파일에 저장해도 되지만 데이터베이스에 저장하는 이유는 여러가지가 있지만, 가장 대표적인 이유는 바로 데이터베이스는 트랜젹션이라는 개념을 지원하기 때문이다.

트랜잭션이란

데이터베이스에서 트랜잭션은 하나의 거래를 안전하게 처리하도록 보장해주는 것을 의미한다. 데이터베이스의 상태를 변환시키는 하나의 논리적 기능을 수행하기 위한 작업의 단위 또는 한꺼번에 모두 수행되어야 할 일련의 연산들을 의미한다.

  • 모든 작업이 성공해서 데이터베이스에 정상 반영하는 것을 커밋(Commit)이라 하고,
  • 작업 중 하나라도 실패해서 커밋 이전 상태로 되돌리는 것을 롤백(Rollback)이라 한다.

트랜잭션 ACID

트랜잭션은 ACID라 하는 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)를 보장해야한다.

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

트랜잭션 격리 수준 - Isolation level

아래 단계로 내려갈수록 격리 수준은 높아짐을 의미하며, 성능과도 큰 연관이 있기 때문에 주로 READ COMMITED(커밋된 읽기 수준으로 설정을 한다고 한다.)

  • READ UNCOMMITED(커밋되지 않은 읽기)
  • READ COMMIITED(커밋된 읽기)
  • REPEATABLE READ(반복 가능한 읽기)
  • SERIALIZABLE(직렬화 가능)

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

image

트랜잭션을 더 자세히 이해하기 위해 데이터베이스 서버 연결 구조와 DB 세션에 대해 알아보자

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

트랜잭션 - DB 예제 1 - 개념이해

트랜잭션의 동작 개념을 큰 그림으로 이해해보자

트랜잭션 사용법

  • 데이터 변경 쿼리를 실행하고 데이터베이스에 그 결과를 반영하려면 커밋 명령어인 commit을 호출하고, 결과를 반영하고 싶지 않으면 롤백 명령어인 rollback을 호출하면 된다.
  • 커밋을 호출하기 전까지는 임시로 데이터를 저장하는 것이다. 따라서 트랜잭션을 시작한 세션(사용자)에게만 변경 데이터가 보이고 다른 세션에게는 변경 데이터가 보이지 않는다.
  • 등록, 수정, 삭제 모두 같은 원리로 동작한다.

예시 1 - 커밋하지 않았을 경우

세션1, 세션2 사용자 모두 가운데 있는 테이블을 조회하게 되면 해당 데이터가 그대로 조회된다.

image
  • 세션1 사용자가 트랜잭션을 시작하고, 신규 데이터를 DB에 추가하며 아직 커밋은 하지 않은 상태이다.
  • 새로운 데이터는 임시 상태로 저장된다.
  • 이때 세션1은 select 쿼리로 신규 데이터를 조회할 수 있지만, 세션2는 select 쿼리로 신규 데이터를 조회할 수 없다.
image

커밋하지 않은 데이터를 다른 곳에서 조회할 수 있으면 어떤 문제가 발생할까?

예를 들어 세션1이 신규 데이터를 커밋하지 않은 상태에서 세션2는 데이터를 조회하게 되었을 때 보이게 된다면, 세션2는 신규 데이터를 통해 어떤 로직을 수행할 수 있다. 하지만 세션1이 이때 신규 데이터를 롤백하게 된다면 신규 데이터는 다시 사라지게 되고 이때 데이터 정합성에 큰 문제가 발생하게 된다.

예시 1 - 커밋했을 경우/커밋하지 않고 롤백했을 경우

세션1이 신규 데이터를 추가한 이후 커밋을 했기 때문에 세션2에서도 동일하게 결과를 조회할 수 있다.

image
  • 세션1이 신규 데이터를 추가 후 커밋 대신 롤백을 수행하였다.(위 그림과 이어지는 내용이 아니고 별개의 내용임)
  • 세션1, 세션2 모두 트랜잭션을 시작하기 직전의 상태로 복구된다.
image

트랜잭션 - DB 예제2 - 자동 커밋, 수동 커밋

이번에는 자동 커밋과 수동 커밋에 대해 알아보자

예제에 사용되는 스키마는 다음과 같다.

 drop table member if exists;
 create table member (
     member_id varchar(10),
     money integer not null default 0,
     primary key (member_id)
);

자동 커밋

  • 데이터베이스에 자동 커밋/수동 커밋이라는 개념이 존재하는데 자동 커밋으로 설정하면 각각의 쿼리 실행 직후에 자동으로 커밋을 호출한다.
  • 이는 편리한 기능이기도 하나 쿼리를 하나하나 실행할 때마다 자동으로 커밋되어 버리기 때문에 우리가 원하는 트랜잭션 기능을 제대로 사용할 수 없다.
  • 따라서 commit, rollback을 직접 호출하면서 트랜잭션 기능을 제대로 수행하려면 자동 커밋을 끄고 수동 커밋을 사용해야 한다.
  • 참고로 수동 커밋 모드나 자동 커밋 모드는 한번 설정하면 해당 세션(커넥션)에서는 계속 유지된다. 중간에 변경하는 것은 가능하다.

자동 커밋 설정

set autocommit true; //자동 커밋 모드 설정
insert into member(member_id, money) values ('data1',10000); //자동 커밋
insert into member(member_id, money) values ('data2',10000); //자동 커밋

수동 커밋 설정

set autocommit false; //수동 커밋 모드 설정
 insert into member(member_id, money) values ('data3',10000);
 insert into member(member_id, money) values ('data4',10000);
commit; //수동 커밋

DB 락 - 개념 이해

세션1과 세션2가 동시에 같은 데이터를 수정하고 싶은 경우가 있다. 이럴 때 어느 쪽에서 작업을 수행중인데 다른 세션에서 동일한 데이터를 변경하거나 롤백을 시도하는 경우 데이터 정합성에 문제가 발생할 수 있게 된다.

이런 문제를 해결하기 위해 데이터베이스는 락(lock)이라는 개념을 제공한다.

예제 - 락의 작동 방식

image
  • 세션1이 트랜잭션을 시작한다. 먼저 들어온 요청이기 때문에 락을 선취한다.
  • 세션1은 memberA의 money를 500으로 수정하려고 한다.
image
  • 세션2가 트랜잭션을 시도한다. 세션2도 memberA의 money를 1000으로 수정하려고 한다.
  • 이때 세션2는 락을 가질 수 없기 때문에 데이터 변경이 불가능하며, 락이 돌아올 때까지 대기하게 된다.
  • 락을 가질 때까지 무한정 대기하는 것은 아니며, 락 대기 시간이 넘어가게 되면 락 타임아웃 오류가 발생하게 된다.
image
  • 세션1은 트랜잭션 작업을 마치고 커밋을 수행하게 된다.
image
  • 트랜잭션이 종료되었으므로 락도 함께 반납하게 된다.
image
  • 트랜잭션을 수행하기 위해 대기중이던 세션2가 락을 획득하게 된다.
image
  • 세션2는 트랜잭션을 시작해 memberA의 money 상태를 1000으로 변경한다.
image
  • 세션2는 커밋을 수행하고 트랜잭션이 종료되었으므로 락을 함께 반납하며 끝나게 된다.

DB 락 - 조회

일반적인 조회는 락을 사용하지 않는다.

데이터베이스마다 다르지만, 보통 데이터를 조회할 땐 락을 획득하지 않고 바로 데이터를 조회할 수 있다. 예를 들어 세션1이 락을 획득하고 데이터를 변경하고 있더라도, 세션2에서는 데이터를 조회할 수 있다.

하지만 조회 시점에 락이 필요한 경우도 있는데, 트랜잭션 종료 시점까지 해당 데이터를 다른 곳에서 변경하지 못하도록 강제로 막아야할 때 사용한다.

  • 예를 들어 애플리케이션 로직에서 memberA의 금액을 조회한 다음 이 금액 정보로 애플리케이션에서 어떤 계산을 수행해야 한다. 그런데 이 계산이 돈과 관련된 매우 중요한 계산이어서 계산을 완료할 때까지 memberA의 금액을 다른 곳에서 변경하면 안된다. 이럴 때 조회 시점에 락을 획득하면 된다.

  • 즉, 조회된 데이터를 이용해서 어떤 계산을 하려고 할 때 정말 중요한 데이터라면 락을 걸어야 한다.

조회와 락

  • 데이터를 조회할 때도 락을 획득하고 싶다면 select for update 구문을 사용하면 된다.
  • 이렇게 하면 세션1이 조회 시점에 락을 가져가버리기 때문에 다른 세션에서 해당 데이터를 변경할 수 없다.

예제 - 조회와 락

실습을 위해 데이터베이스에 기본 데이터 입력

set autocommit true;
delete from member;
insert into member(member_id, money) values ('memberA',10000);

세션1에서 데이터를 조회하면서 동시에 선택한 로우의 락을 획득한다.

  • 세션1은 트랜잭션이 종료할 때까지 memberA의 로우의 락을 보유하게 된다.
set autocommit false;
select * from member where member_id='memberA' for update;

세션2에서 데이터를 변경하고 싶다. 데이터를 변경하려면 락이 필요하다.

  • 세션1이 memberA의 로우의 락을 획득했기 떄문에 세션2는 락을 획득할 때까지 대기한다.
  • 세션1이 트랜잭션을 종료하면 세션2가 락을 획득하고 데이터를 변경한다.
set autocommit false;
update member set money=500 where member_id = 'memberA';

세션1 커밋

  • 세션2는 대기하고 있다가 세션1이 커밋됨과 동시에 트랜잭션을 수행하게 된다.[아래 사진 참고]
commit;

세션2 커밋

  • 세션2도 커밋하여 데이터를 정상적으로 반영하게 된다.
commit;

실행결과

  • 세션1은 select..for update 구문을 통해 조회와 동시에 락을 획득하게 된다.
  • 오른쪽이 세션2인데 락을 획득하지 못하여 update 쿼리를 수행하지 못하고 대기상태에 있게 된다.
image
  • 세션1에서 커밋 이후 곧바로 세션2가 update 쿼리를 정상적으로 실행한 모습이다.
  • 세션2는 이후 커밋을 통해 트랜잭션을 정상적으로 종료할 수 있다.
image

트랜잭션 - 적용1

이제 애플리케이션에 트랜잭션을 적용해보자. 우선 트랜잭션을 사용하기 전 앞서 트랜잭션 없이 단순하게 계좌이체 비즈니스 로직만 구현할 것이다.

예제 - 트랜잭션 없이 단순 계좌이체 구현

[MemberServiceV1] - 계좌이체 서비스 구현

  • fromId, toId 회원을 조회하여 fromID가 toId에게 계좌이체를 하는 시나리오이다.
  • 예외 상황을 테스트하기 위해 toId가 "ex"인 경우 예외를 발생한다.
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.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("이체중 예외 발생");
        }
    }
}

[MemberServiceV1Test] - 계좌이체 테스트 진행

  • 정상 이체 테스트 - accountTransfer()
    • 예외가 발생하지 않았기 때문에 정상적으로 memberA는 2000원 감소, memberB는 2000원이 증가하였다.
  • 비정상 이체 테스트 - accountTransferEx()
    • memberA가 memberEx로 계좌이체를 하는 부분에서 ex 회원은 예외가 발생하게 했으므로 예외가 발생하고, 다음 구분인 memberEx의 계좌에 2000원이 추가되지 않게 된다.
    • 하지만 memberA의 금액만 2000원 감소하였으므로 큰 문제가 발생하게 되었다.
package hello.jdbc.service;

import hello.jdbc.connection.ConnectionConst;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import org.assertj.core.api.Assertions;
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.DriverManagerDataSource;

import java.sql.SQLException;

import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

/**
 * 기본 동작, 트랜잭션이 없어서 문제 발생
 */
class MemberServiceV1Test {

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

    private MemberRepositoryV1 memberRepository;
    private MemberServiceV1 memberService;

    @BeforeEach
    void beforeEach() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        memberRepository = new MemberRepositoryV1(dataSource);
        memberService = new MemberServiceV1(memberRepository);
    }

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

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

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

        // then
        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 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: 계좌이체는 실패한다. memberA의 돈만 2000원 줄어든다.
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(8000);
        assertThat(findMemberB.getMoney()).isEqualTo(10000);
    }
}

이러한 문제를 해결하기 위해 트랜잭션의 ACID 원칙을 적용하는 것이 필요하다.

트랜잭션 - 적용2

앞서 발생한 문제를 해결하기 위해 트랜잭션을 적용해보자. 애플리케이션에서 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야한다. 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 인해 문제가 되는 부분을 함께 롤백해야 하기 때문이다.

이때 아래와 같은 몇가지 고려사항이 있다.

  • 트랜잭션을 하려면 결국 커넥션과 세션이 필요하다. 즉, 서비스 계층에서 커넥션을 만들고, 트랜잭션 커밋 이후에 커넥션을 종료해야 한다.
  • 애플리케이션에서 DB 트랜잭션을 사용하려면 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야 한다. 그래야 같은 세션을 사용하며 트랜잭션을 유지할 수 있다.
    • 하나의 세션이 하나의 트랜잭션을 관리하므로 하나의 작업을 트랜잭션으로 처리하려면 하나의 커넥션을 유지해야 한다는 뜻이다.
image

예제 - 트랜잭션 적용 | 같은 커넥션 유지를 위해 파라미터 사용

트랜잭션을 사용하기 위해 같은 커넥션을 유지하려면 어떻게 해야할까? 가장 쉬운 방법은 커넥션을 파라미터로 전달해서 같은 커넥션이 사용되도록 유지하면 된다.

[MemberRepositoryV2] - 같은 커넥션 유지를 위해 파라미터 추가(Connection con), 커넥션 새로 생성 코드 삭제(getConnection()), 커넥션 유지를 위한 커넥션 종료(close()) 삭제

  • 앞서 테스트 코드에서 findById(), update() 메서드를 통해 계좌이체를 테스트하였다.
  • 따라서 위의 메서드에 같은 커넥션이 사용되도록 메서드 시그니처를 바꾼다.(Connection con) 추가
  • 같은 커넥션을 사용해야 하므로 커넥션을 새로 생성하는 getConnection() 메서드는 사라져야한다. 즉, 서비스 계층에서 커넥션을 한번만 생성하고 계속 파라미터로 넘겨서 같은 커넥션을 유지해야 한다.
  • 또한 서비스 계층에서 비즈니스 로직을 처리하기 위해 다양한 메서드를 사용하여야 하고, 커넥션을 계속 유지하고 사용되어야 하므로 커넥션을 종료하는 코드를 데이터를 처리하는 Repository 레이어에서 종료하면 안된다.
    • 쉽게 말해, 서비스 계층에서 커넥션을 종료해야 하므로 Repository 레이어에서 커넥션을 종료하는 코드가 들어가선 안된다.
package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.support.JdbcUtils;

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

/**
 * JDBC - ConnectionParam
 */
@Slf4j
public class MemberRepositoryV2 {

    private final DataSource dataSource;

    // 외부에서 DataSource를 주입 받아서 사용한다. 이를 통해 구현체가 달라지더라도 코드를 변경할 일이 없다.
    public MemberRepositoryV2(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    private Connection getConnection() throws SQLException {
        Connection con = dataSource.getConnection();
        log.info("get connection = {}, class = {}", con, con.getClass());
        return con;
    }

    public Member save(Member member) throws SQLException {
        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) {
            log.error("db error", e);
            throw e;
        }
    }

    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 = 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("db error", e);
            throw e;
        } finally {
            close(con, pstmt, rs);
        }
    }

    public Member findById(Connection con, String memberId) throws SQLException {
        String sql = "select * from member where member_id = ?";

        PreparedStatement pstmt = null;
        ResultSet rs = null;

        try {
            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("db error", e);
            throw e;
        } finally {
            // connection은 여기서 닫지 않는다.
            JdbcUtils.closeResultSet(rs);
            JdbcUtils.closeStatement(pstmt);
        }
    }

    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 = 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, null);

        }
    }

    public void update(Connection con, String memberId, int money) throws SQLException {
        String sql = "update member set money = ? where member_id = ?";

        PreparedStatement pstmt = null;

        try {
            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 {
            // connection은 여기서 닫지 않는다.
            JdbcUtils.closeStatement(pstmt);
        }
    }

    public void delete(String memberId) throws SQLException {
        String sql = "delete from member where member_id = ?";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);
            pstmt.executeUpdate();

        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        }
    }

    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        JdbcUtils.closeConnection(con);

    }
}

[MemberServiceV2] - 비즈니스 로직 & 트랜잭션 연동 로직을 작성

  • 같은 커넥션을 사용하기 위해 커넥션을 생성하고, 그 커넥션을 필요한 곳에 파라미터로 넘긴다.(getConnection())
  • 트랜잭션을 시작하기 위해 자동커밋 모드를 비활성화 해야한다. (con.setAutoCommit(false); // 트랜잭션 시작)
  • 성공시 커밋(con.commit())
  • 실패시 롤백(con.rollback())
  • 수동 커밋 모드를 자동 커밋 모드로 변경 후 커넥션 종료: release()
    • 커넥션 풀을 사용하게 되면 con.close()를 호풀하더라도 종료되는 것이 아닌 풀에 반납된다.
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.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 con = dataSource.getConnection();
        try {
            con.setAutoCommit(false); // 트랜잭션 시작
            // 비즈니스 로직
            bizLogic(con, fromId, toId, money);
            con.commit(); // 성공시 커밋
        } catch (Exception e) {
            con.rollback(); // 실패시 롤백
            throw new IllegalStateException(e);
        } finally {
            // 커넥션 사용 이후 안전하게 종료
            // 커넥션 풀을 사용하면 con.close를 호출해도 커넥션이 종료되는 것이 아니라 풀에 반납된다.
            release(con);
        }

    }

    private void release(Connection con) {
        if (con != null) {
            try {
                con.setAutoCommit(true); // 커넥션 풀 고려
                con.close();
            } catch (Exception e) {
                log.info("error", e);
            }
        }
    }

    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("이체중 예외 발생");
        }
    }
}

[MemberServiceV2Test] - 계좌이체 테스트(정상 이체, 비정상 이체)

  • 정상 이체는 당연히 잘 수행된다.
  • 비정상 이체
    • 중간에 로직을 수행하다 "ex" 유저가 들어와서 예외가 발생하게 되고, 아래 로직은 수행하지 않게 된다.
    • 서비스 계층에서 트랜잭션이 정상적이지 않으면 롤백을 통해 데이터를 원복 시킨다.
    • 트랜잭션 덕분에 모든 데이터를 정상적으로 초기화 할 수 있게 되었다. 결과적으로 계좌이체를 수행하기 직전으로 돌아가게 되었다.
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV1;
import hello.jdbc.repository.MemberRepositoryV2;
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.DriverManagerDataSource;

import java.sql.SQLException;

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

/**
 * 트랜잭션 - 커넥션 파라미터 전달 방식 동기화
 */
class MemberServiceV2Test {

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

    private MemberRepositoryV2 memberRepository;
    private MemberServiceV2 memberService;

    @BeforeEach
    void beforeEach() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        memberRepository = new MemberRepositoryV2(dataSource);
        memberService = new MemberServiceV2(dataSource, memberRepository);
    }

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

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

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

        // then
        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 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: 계좌이체 로직을 실행한다.
        // memberEx 회원의 ID는 ex 이므로 중간에 예외가 발생한다.
        assertThatThrownBy(() ->
                memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

        // then: memberA의 돈이 롤백 되어야함
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberB.getMoney()).isEqualTo(10000);
    }
}

섹션 4 | 스프링과 문제 해결 - 트랜잭션

문제점들

애플리케이션의 구조

image

프레젠테이션 계층

  • UI와 관련된 처리 담당
  • 웹 요청과 응답
  • 사용자 요청을 검증
  • 주 사용 기술: 서블릿과 HTTP 같은 웹 기술, 스프링 MVC

서비스 계층

  • 비즈니스 로직을 담당
  • 주 사용 기술: 가급적 특정 기술에 의존하지 않고, 순수 자바 코드로 작성

데이터 접근 계층

  • 실제 데이터베이스에 접근하는 코드
  • 주 사용 기술: JDBC, JPA, File, Redis, Mongo...

이중에서 가장 중요한 곳은 비즈니스 핵심 로직이 들어있는 서비스 계층이다.

  • 서비스 계층은 특정 기술에 종속적이지 않게 개발해야 한다.
    • 이렇게 계층을 나눈 이유도 서비스 계층을 최대한 순수하게 유지하기 위한 목적이 가장 크다.
    • 기술적인 부분은 프레젠테이션 계층, 데이터 접근 계층에서 가지고 간다.
  • 서비스 계층은 가급적 비즈니스 로직만 구현하고 특정 구현 기술에 직접 의존해서는 안된다.
    • 이렇게 구현하면 기술이 변경될 때 변경의 영향 범위를 최소화 할 수 있다.

예시 - 트랜잭션을 적용한 코드

[MemberServiceV2]

  • 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작하는 것이 좋다.
  • 문제는 트랜잭션을 사용하기 위해 다음과 같은 JDBC 기술에 의존하는 것이다.
    • javax.sql.DataSource;
    • java.sql.Connection;
    • java.sql.SQLException;
  • 이렇게 JDBC 기술에 의존하면 JPA 같은 다른 기술로 바꾸어 사용하게 될 때 서비스 코드도 모두 변경해야한다.
  • 따라서 핵심 비즈니스 로직을 유지보수 하기 어려워진다.
package hello.jdbc.service;
 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 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);
    }
}

문제점 정리

트랜잭션 문제

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

  • JDBC 구현 기술이 서비스 계층에 누수되는 문제
    • 트랜잭션을 적용하기 위해 JDBC 구현 기술이 서비스 계층에 누수되었다.
  • 트랜잭션 동기화 문제
    • 같은 트랜잭션을 유지하기 위해 커넥션을 파라미터로 넘기고 있다.
  • 트랜잭션 적용 반복 문제
    • 트랜잭션 적용 코드를 보면 반복이 많다. try, catch, finally

예외 누수

  • 데이터 접근 계층의 JDBC 구현 기술 예외가 서비스 계층으로 전파된다.
  • SQLException은 체크 예외이기 때문에 데이터 접근 계층을 호출한 서비스 계층에서 해당 예외를 잡아서 처리하거나 명시적으로 throws를 통해 다시 밖으로 던져야 한다.

JDBC 반복 문제

  • 커넥션을 열고 PreparedStatement를 사용하고, 결과를 매핑하는 하고 리소스를 정리 하는 과정들의 반복이다.

트랜잭션 추상화

문제점

현재 서비스 계층은 트랜잭션을 사용하기 위해 JDBC 기술에 의존하고 있다. 구현 기술마다 트랜잭션을 사용하는 방법이 달라 서비스 계층을 유지보수 하기 힘든데, 이를 해결하기 위해 트랜잭션 추상화가 필요하다.

기술 의존 시 발생하는 문제

JDBC와 JPA가 사용하는 트랜잭션 코드는 다르다. 따라서 기술 변경 시 서비스 계층의 코드도 JPA 기술을 사용하도록 함께 수정해야 한다.

JDBC 트랜잭션 사용

image

JDBC -> JPA 변경

image

스프링이 제공하는 트랜잭션 추상화

위와 같은 문제를 해결하기 위해 트랜잭션 기능을 인터페이스를 만들어 추상화를 하면 되지만, 스프링은 이미 트랜잭션 추상화 기술을 제공하고 있다.

  • 서비스 계층은 특정 기술에 의존하는 것이 아니라 원하는 구현체를 DI를 통해 주입하면 된다.
  • 클라이언트인 서비스 계층은 인터페이스에 의존하고, DI를 사용한 덕분에 OCP 원칙을 지킬 수 있다.
image

스프링 트랜잭션 추상화의 핵심은 PlatformTransactionManager 인터페이스다.

  • getTransaction(): 트랜잭션을 시작
  • commit(): 트랜잭션 커밋
  • rollback(): 트랜잭션 롤백
package org.springframework.transaction;

public interface PlatformTransactionManager extends TransactionManager {
   TransactionStatus getTransaction(@Nullable TransactionDefinition definition)
           throws TransactionException;
   void commit(TransactionStatus status) throws TransactionException;
   void rollback(TransactionStatus status) throws TransactionException;
}

트랜잭션 동기화

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

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

리소스 동기화

트랜잭션을 유지하려면 트랜잭션의 시작부터 끝까지 같은 데이터베이스 커넥션을(세션) 유지해야한다. 이전에는 트랜잭션 동기화를 위해 파라미터로 같은 커넥션을 전달하는 방법을 사용했지만, 코드가 지저분해지는 것은 물론이고 코드의 중복 또한 발생하게 되는 문제점들이 있다.

스프링이 제공하는 트랜잭션 매니저

  • 쓰레드 로컬(ThreadLocal)을 사용해서 커넥션을 동기화해준다.
    • 트랜잭션 매니저 내부에서 트랜잭션 동기화 매니저(TransactionSynchronizationManager)를 사용한다.
    • 트랜잭션 동기화 매니저에 쓰레드 로컬을 정의해서 사용한다.
  • 트랜잭션 동기화 매니저는 쓰레드 로컬을 사용하기 때문에 멀티쓰레드 환경에서 안전하게 커넥션을 동기화 할 수 있다.
    • 따라서 커넥션을 여기서 획득하면 되며, 파라미터로 커넥션을 연결하는 식으로 트랜잭션 동기화를 할 필요가 없다.
image

트랜잭션 매니저의 동작 방식

  1. 트랜잭션을 시작하려면 커넥션이 필요하다. 트랜잭션 매니저는 DataSource를 통해 커넥션을 만들고 트랜잭션을 시작한다.
  2. 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 매니저에 보관한다.
  3. 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다. 따라서 파라미터로 커넥션을 전달할 필요가 없다.
  4. 트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션 종료, 커넥션 연결도 닫게 된다.

참고

쓰레드 로컬을 사용하면 각각의 쓰레드마다 별도의 저장소가 부여된다. 따라서 해당 쓰레드만 해당 데이터에 접근할 수 있다.

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

트랜잭션 매니저를 애플리케이션 코드에 적용해보자

  • 트랜잭션 매니저를 사용하게 되면 특정 기술의 트랜잭션에 의존하지 않고, 추상화와 동기화를 지원해준다고 했다.

예시

[MemberRepositoryV3] - 커넥션을 파라미터로 전달하는 부분이 모두 제거되었다.

  • V2 버전에서는 findById(), update()에 파라미터로 커넥션을 넘겨줬었다.
  • DataSourceUtils.getConnection()
    • 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환하고, 없는 경우 새로운 커넥션을 생성해서 반환한다.
  • DataSourceUtils.releaseConnection()
    • 해당 메서드를 사용하면 커넥션을 바로 닫는 것이 아니다.
    • 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고 그대로 유지해준다.
    • 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다.
package hello.jdbc.repository;

import hello.jdbc.domain.Member;
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
public class MemberRepositoryV3 {

    private final DataSource dataSource;

    // 외부에서 DataSource를 주입 받아서 사용한다. 이를 통해 구현체가 달라지더라도 코드를 변경할 일이 없다.
    public MemberRepositoryV3(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    private Connection getConnection() throws SQLException {
        // 주의! 트랜잭션 동기화를 사용하려면 DataSoureUtils를 사용해야 한다.
        // 트랜잭션 동기화 매니저가 관리하는 커넥션이 있으면 해당 커넥션을 반환한다.
        // 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 새로운 커넥션을 생성해서 반환한다.
        Connection con = DataSourceUtils.getConnection(dataSource);
        log.info("get connection = {}, class = {}", con, con.getClass());
        return con;
    }

    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        // 주의! 트랜잭션 동기화를 사용하려면 DataSourceUtils를 사용해야 한다.
        // 트랜잭션을 사용하기 위해 동기화된 커넥션은 커넥션을 닫지 않고, 그대로 유지해준다.(즉, 해당 메서드를 사용하더라도 커넥션을 바로 닫는 것이 아니다.)
        // 트랜잭션 동기화 매니저가 관리하는 커넥션이 없는 경우 해당 커넥션을 닫는다.
        DataSourceUtils.releaseConnection(con, dataSource);
    }

    public Member save(Member member) throws SQLException {
        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) {
            log.error("db error", e);
            throw e;
        }
    }

    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 = 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("db error", e);
            throw e;
        } finally {
            close(con, pstmt, rs);
        }
    }

    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 = 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, null);

        }
    }


    public void delete(String memberId) throws SQLException {
        String sql = "delete from member where member_id = ?";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);
            pstmt.executeUpdate();

        } catch (SQLException e) {
            log.error("db error", e);
            throw e;
        }
    }
}

[MemberServiceV3_1] - 트랜잭션 매니저를 사용하는 서비스 코드

  • private final PlatformTransactionManager transactionManager;
    • 트랜잭션 매니저를 주입 받는다.
    • 지금은 JDBC 기술을 사용하기 때문에 테스트 코드에서 DataSourceTransactionManager 구현체를 주입 받아야 한다.
  • transactionManager.getTransaction()
    • 트랜잭션을 시작한다.
    • TransactionStatus를 반환하게 되는데, 현재 트랜잭션의 상태 정보가 포함되어 있다. 이후 트랜잭션 커밋, 롤백할 때 필요하다.
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.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;

/**
 * 트랜잭션 - 트랜잭션 매니저
 * 서비스 계층을 단순화하고 커네션을 추상화 및 동기화를 진행하기 위해 트랜잭션 매니저 사용(PlatformTransactionManager 객체를 사용)
 */
@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 {

        // 트랜잭션 시작, status는 현재 트랜잭션의 상태 정보가 포함되어 있다. 이후 트랜잭션을 커밋, 롤백할 때 필요하다.
        // new DefaultTransactionDefinition - 트랜잭션과 관련된 옵션을 설정할 수 있다. 자세한 내용은 뒤에서 진행..
        TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            // 비즈니스 로직
            bizLogic(fromId, toId, money);
            transactionManager.commit(status); // 성공시 커밋
        } catch (Exception e) {
            transactionManager.rollback(status); // 실패시 롤백
            throw new IllegalStateException(e);
        } // 트랜잭션 매니저를 사용하게 되면 트랜잭션이 종료된 것이므로 commit, rollback에 내부적으로 트랜잭션을 종료하는 로직이 들어가 있다.

    }


    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("이체중 예외 발생");
        }
    }
}

[MemberServiceV3_1Test] - 트랜잭션 매니저를 적용한 테스트

  • new DataSourceTransactionManager(dataSource);
    • JDBC 기술을 사용하므로, JDBC용 트랜잭션 매니저를 구현체로 사용한다.
    • 트랜잭션 매니저는 데이터소스를 통해 커넥션을 생성하므로 DataSource가 필요하다.
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV2;
import hello.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 hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
 * 트랜잭션 - 트랜잭션 매니저(PlatformTransactionManager)
 * 결과적으로 서비스 계층을 단순화하고 하나의 기술에 종속되는 것을 막기 위해
 * 트랜잭션 매니저인 PlatformTransactionManager(인터페이스)를 사용하여 추상화와 동기화까지 처리할 수 있게 되었다.
 * - 기존에는 하나의 트랜잭션을 유지하기 위해 Connection을 파라미터로 받아서 처리를 진행하였지만, 불필요한 파라미터가 없어졌다.
 * - 서비스 계층은 최대한 유지보수가 단순해야하며 코드가 변경되지 않도록 하는 것이 중요한데 트랜잭션 매니저를 사용함으로서 추상화을 이룰 수 있게 되었고, 서비스 계층이 DB 기술에 의존도를 낮추게 되었다.
 */
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);
        // 서비스 계층을 단순화하고, 커넥션을 추상화 및 동기화를 진행하기 위해 트랜잭션 매니저 사용(PlatformTransactionManager 객체를 사용)
        // 또한 트랜잭션 매니저는 데이터소스를 통해 커넥션을 생성하므로 DataSource 인자가 필요하다.
        // new DataSourceTransactionManager - 지금은 JDBC 기술을 사용하기 때문에 트랜잭션 매니저(인터페이스)의 JBBC 구현체인 객체를 사용함
        PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        memberRepository = new MemberRepositoryV3(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 {
        // given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_B, 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberB);

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

        // then
        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 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: 계좌이체 로직을 실행한다.
        // memberEx 회원의 ID는 ex 이므로 중간에 예외가 발생한다.
        assertThatThrownBy(() ->
                memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

        // then: memberA의 돈이 롤백 되어야함
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberB.getMoney()).isEqualTo(10000);
    }
}

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

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

트랜잭션 시작

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

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

로직 실행

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

트랜잭션 종료

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

정리

  • 트랜잭션 매니저의 트랜잭션 추상화 덕분에 서비스 코드는 이제 JDBC 기술에 의존하지 않는다.
    • 기술 변경 시 의존 관계 주입만 해당 기술의 구현체로 변경하면 된다.
  • 트랜잭션 동기화 매니저 덕분에 커넥션을 파라미터로 넘기지 않아도 트랜잭션을 유지할 수 있다.

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

트랜잭션을 사용하는 로직을 살펴보면 다음과 같은 패턴이 반복된다.

  • 다른 서비스에서 트랜잭션을 시작하려면 try, catch, finally를 포함한 성공 시 커밋, 실패 시 롤백 코드가 반복될 것이다.

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

// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
        try {
            // 비즈니스 로직
            bizLogic(fromId, toId, money);
            transactionManager.commit(status); // 성공시 커밋
        } catch (Exception e) {
            transactionManager.rollback(status); // 실패시 롤백
            throw new IllegalStateException(e);
        }
    }
}

스프링의 트랜잭션 템플릿

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

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

예제 - 트랜잭션 템플릿을 사용해서 반복되는 부분을 제거

[MemberServiceV3_2]

  • 트랜잭션 템플릿을 사용하려면 트랜잭션 매니저가 필요하다.
    • 생성자에서 트랜잭션 매니저를 주입 받으면서 트랜잭션 템플릿을 생성한다.
  • 트랜잭션 템플릿 덕분에 트랜잭션을 시작하고, 커밋하거나 롤백하는 코드가 모두 제거되었다.
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.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 {

    // TransactionTemplate을 사용하려면 transactionManager가 필요하다.
    // 생성자에서 transactionManager를 주입 받으면서 TransactionTemplate을 생성했다.
    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) -> {
            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("이체중 예외 발생");
        }
    }
}

[MemberServiceV3_2Test]

  • 테스트 코드는 트랜잭션 매니저를 적용했을 때(V3_1)과 같다.
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.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 hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
 * 트랜잭션 - 트랜잭션 템플릿
 */
class MemberServiceV3_2Test {

    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_2 memberService;

    @BeforeEach
    void beforeEach() {
        // 커넥션을 생성
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        // 서비스 계층을 단순화하고, 커넥션을 추상화 및 동기화를 진행하기 위해 트랜잭션 매니저 사용(PlatformTransactionManager 객체를 사용)
        // 또한 트랜잭션 매니저는 데이터소스를 통해 커넥션을 생성하므로 DataSource 인자가 필요하다.
        // new DataSourceTransactionManager - 지금은 JDBC 기술을 사용하기 때문에 트랜잭션 매니저(인터페이스)의 JBBC 구현체인 객체를 사용함
        PlatformTransactionManager transactionManager = new DataSourceTransactionManager(dataSource);
        memberRepository = new MemberRepositoryV3(dataSource);
        memberService = new MemberServiceV3_2(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 {
        // given
        Member memberA = new Member(MEMBER_A, 10000);
        Member memberB = new Member(MEMBER_B, 10000);
        memberRepository.save(memberA);
        memberRepository.save(memberB);

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

        // then
        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 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: 계좌이체 로직을 실행한다.
        // memberEx 회원의 ID는 ex 이므로 중간에 예외가 발생한다.
        assertThatThrownBy(() ->
                memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

        // then: memberA의 돈이 롤백 되어야함
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberB.getMoney()).isEqualTo(10000);
    }
}

정리

  • 트랜잭션 템플릿 덕분에 트랜잭션을 사용할 때 반복하는 코드를 제거할 수 있다.
  • 하지만 아직도 서비스 레이어인데 비즈니스 로직 뿐만 아니라 트랜잭션을 처리하는 기술 로직이 함께 포함되어 있다.
    • 이 문제는 다음 스프링이 제공하는 트랜잭션 AOP를 통해 해결한다.

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

지금까지 트랜잭션을 편리하게 처리하기 위해 다음과 같은 내용을 진행했다.

  • 트랜잭션 추상화도 도입하여 특정 기술에 의존하지 않음
  • 반복적인 트랜잭션 로직을 해결하기 위해 트랜잭션 템플릿을 도입

하지만 아직 서비스 계층에 순수한 비즈니스 로직만 남기는 것을 달성하지 못했는데, 이럴 때 스프링 AOP를 통해 프록시를 도입하면 문제를 해결할 수 있다.

프록시를 통한 문제 해결

프록시를 도입하기 전에는 기존처럼 서비스 로직에서 트랜잭션을 직접 시작했다.

image

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

  • 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져간다.
  • 또한 트랜잭션을 시작한 이후 실제 서비스를 대신 호출한다.
  • 따라서 서비스 계층에는 순수한 비즈니스 로직만 남길 수 있다.
image

스프링이 제공하는 트랜잭션 AOP

  • 스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공한다. 스프링부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링 빈들도 자동으로 등록해준다.
  • 개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션만 붙여주면 된다.
    • 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해준다.

@Aspect, @Advice, @Pointcut 등의 스프링 프록시 개념은 고급편에서 다룬다고 한다. 지금은 @Transactional 애노테이션을 붙이면 스프링이 AOP를 사용해서 트랜잭션을 편리하게 처리해준다 정도로 이해하고 넘어가려고 한다.

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

트랜잭션 AOP를 이용해서 서비스 계층에 비즈니스 로직 외 트랜잭션 코드들이 있는 문제를 해결해보자

예제

[MemberServiceV3_3] - @Trasactional 애노테이션 추가

  • 순수한 비즈니스 로직만 남기고, 트랜잭션 관련 코드는 모두 제거됨
  • 스프링이 제공하는 트랜잭션 AOP를 적용하기 위해 @Trasactional 애노테이션을 추가
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;

import java.sql.SQLException;

/**
 * 트랜잭션 - @Transactional AOP
 * 해당 애노테이션을 통해 순수한 비즈니스 로직만 남기고, 트랜잭션 관련 코드는 모두 제거할 수 있음
 * - 스프링이 제공하는 트랜잭션 AOP를 적용하기 위해 @Transactional 애노테이션을 추가
 */
@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("이체중 예외 발생");
        }
    }
}

[MemberServiceV3_3Test]

  • @SpringBootTest: 스프링 AOP를 적용하려면 스프링 컨테이너가 필요하다. 해당 애노테이션이 있으면 테스트 시 스프링부트를 통해 스프링 컨테이너를 생성한다.
  • @TestConfiguration: 테스트 안에서 내부 설정 클래스를 만들어서 이 애노테이션을 붙이면, 스프링부트가 자동으로 만들어주는 빈들에 추가로 필요한 스프링 빈들을 등록하고 테스트를 수행할 수 있다.
  • 스프링이 제공하는 트랜잭션 AOP는 스프링 빈에 등록된 트랜잭션 매니저를 찾아 사용하기 때문에 트랜잭션 매니저를 스프링 빈으로 등록해두어야 한다.
package hello.jdbc.service;

import hello.jdbc.connection.ConnectionConst;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV0;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
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.aop.support.AopUtils;
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 hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
 * 트랜잭션 - @Transactional AOP
 */
@Slf4j
/**
 * @SpringBootTest
 * 스프링 AOP를 적용하려면 스프링 컨테이너가 필요하다.
 * 해당 애노테이션이 있으면 테스트 시 스프링 부트를 통해 스프링 컨테이너를 생성한다.
 */
@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;

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

    /**
     * @TestConfiguration
     * 테스트 안에서 내부 설정 클래스를 만들어서 사용하면서 이 애노테이션을 붙이면,
     * 스프링 부트가 자동으로 만들어주는 빈들에 추가로 필요한 스프링 빈들을 등록하고 테스트할 수 있다.
     */
    @TestConfiguration
    static class TestConfig {
        @Bean
        DataSource dataSource() {
            return new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        }

        @Bean
        PlatformTransactionManager transactionManager() {
            /**
             * new DataSourceTransactionManager
             * 트랜잭션 매니저를 스프링 빈으로 등록한다.
             * 스프링이 제공하는 트랜잭션 AOP는 스프링 빈에 등록된 트랜잭션 매니저를 찾아서 사용하기 때문에 트랜잭션 매니저를 스프링 빈으로 등록해두어야 한다.
             */
            return new DataSourceTransactionManager(dataSource());
        }

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

        @Bean
        MemberServiceV3_3 memberServiceV3_3() {
            return new MemberServiceV3_3(memberRepositoryV3());
        }

    }

    @Test
    void AopCheck() {
        log.info("memberService class = {}", memberService.getClass());
        log.info("memberRepository class = {}", memberRepository.getClass());
        assertThat(AopUtils.isAopProxy(memberService)).isTrue();
        assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
    }

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

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

        // then
        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 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: 계좌이체 로직을 실행한다.
        // memberEx 회원의 ID는 ex 이므로 중간에 예외가 발생한다.
        assertThatThrownBy(() ->
                memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

        // then: memberA의 돈이 롤백 되어야함
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberB.getMoney()).isEqualTo(10000);
    }
}

AOP 프록시 적용 확인

@Test
void AopCheck() {
    log.info("memberService class = {}", memberService.getClass());
    log.info("memberRepository class = {}", memberRepository.getClass());
    assertThat(AopUtils.isAopProxy(memberService)).isTrue();
    assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
}

실행 결과

  • memberService는 프록시(CGLIB)이 적용된 것을 확인할 수 있다.
    • memberRepository는 @Trasactional(AOP) 적용하지 않았기 때문에 프록시가 적용되지 않는다.
memberService class = class hello.jdbc.service.MemberServiceV3_3$$SpringCGLIB$$0
memberRepository class = class hello.jdbc.repository.MemberRepositoryV3

트랜잭션 문제 해결 - 트랜잭션 AOP 정리

트랜잭션 AOP가 사용된 전체흐름을 정리해보자

image

  1. 클라이언트의 요청이 들어오게 되면 AOP 프록시에서 트랜잭션을 시작한다.
  2. 이때 스프링 컨테이너에서 트랜잭션 매니저 빈을 찾아 트랜잭션 매니저를 획득한다.
  3. 획득한 트랜잭션 매니저를 통해 트랜잭션을 시작하게 된다.
  4. 데이터소스를 이용해 커넥션을 생성한다.
  5. 트랜잭션을 시작하기 위해 수동 커밋 모드로 변경한다.
  6. 동기화를 위해 커넥션을 쓰레드 로컬을 사용하는 트랜잭션 동기화 매니저에 내부적으로 보관하게 된다.
  7. AOP 프록시에서 실제 서비스를 대신 호출해준다.
  8. 서비스 로직에서 리포지토리를 호출하게 된다.
  9. 리포지토리는 트랜잭션 동기화 매니저에서 필요한 커넥션을 획득하게 되고(DataSourceUtils.getConnection), 커넥션을 통해 데이터베이스에 SQL을 실행하게 된다.
  10. 성공하면 커밋, 런타임 오류가 발생하게 되면 롤백을 수행하며 트랜잭션을 종료하게 된다.

선언적 트랜잭션 관리 vs 프로그래밍 방식 트랜잭션 관리

  • 선언적 트랜잭션 관리(Declarative Transaction Management)

    • @Transactional 애노테이션 하나만 선언해서 매우 펴닐하게 트랜잭션을 적용하는 것을 선언적 트랜잭션 관리라고 한다.
    • 실무에서는 거의 대부분이 이 방식을 사용한다고 한다.
  • 프로그래밍 방식 트랜잭션 관리(Programmatic Transaction Management)

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

정리

  • 스프링이 제공하는 선언적 트랜잭션 관리 덕분에 트랜잭션 관련 코드를 순수한 비즈니스 로직에서 제거할 수 있었다.
  • 개발자는 트랜잭션이 필요한 곳에 @Transactional 애노테이션 하나만 추가하면 나머지는 스프링 트랜잭션 AOP가 자동으로 처리해준다.

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

스프링에서는 데이터소스와 트랜잭션 매니저를 개발자가 직접 스프링 빈으로 등록해서 사용해야 한다. 이때까지 스프링 부트에선 직접 등록한 적이 없는데, 이는 스프링이 자동으로 데이터소스와 트랜잭션 매니저를 등록해주기 때문이다.

데이터소스 - 자동 등록

  • 스프링부트는 데이터소스를 스프링 빈에 자동으로 등록한다.
  • 자동으로 등록되는 빈 이름: dataSource

스프링 부트는 다음과 같이 application.properties에 있는 속성을 사용해서 DataSource를 생성하며, 빈에 등록한다.

[application.properties]

  • 스프링 부트가 기본으로 생성하는 데이터소스는 커넥션풀을 제공하는 HikariDataSource이다.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=

트랜잭션 매니저 - 자동 등록

  • 스프링 부트는 적절한 트랜잭션 매니저(PlatformTransactionManager)를 자동으로 스프링 빈에 등록한다.
  • 자동으로 등록되는 빈 이름: 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());
    }

예제 - 스프링 부트 자동 등록(데이터 소스, 트랜잭션 매니저)

[MemberServiceV3_4Test]

  • 이전 테스트 코드와 달리 @TestConfiguration 설정 클래스에 데이터 소스와 트랜잭션 매니저 빈들을 등록하지 않음
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
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 hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
 * 트랜잭션 - DataSource, transactionManager 자동 등록
 * 데이터 소스와 트랜잭션 매니저가 없으면,
 * 스프링 부트가 자동으로 기본 빈을 등록해준다.
 * - dataSource(데이터 소스 - 커넥션 풀을 제공하는 HikariDataSource)
 * - PlatformTransactionManager(트랜잭션 매니저)
 */
@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;

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

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

    }

    @Test
    void AopCheck() {
        log.info("memberService class = {}", memberService.getClass());
        log.info("memberRepository class = {}", memberRepository.getClass());
        assertThat(AopUtils.isAopProxy(memberService)).isTrue();
        assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
    }

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

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

        // then
        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 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: 계좌이체 로직을 실행한다.
        // memberEx 회원의 ID는 ex 이므로 중간에 예외가 발생한다.
        assertThatThrownBy(() ->
                memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

        // then: memberA의 돈이 롤백 되어야함
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberB.getMoney()).isEqualTo(10000);
    }
}

섹션 5 | 자바 예외 이해

예외 계층

  1. Object: 예외도 객체이다. 모든 객체의 최상위 부모는 Object이므로 예외의 최상위 부모도 Object이다.
  2. Throwable: 최상위 예외이다. 하위에 ExceptionError가 있다.
  3. Error: 메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구 불가능한 시스템 예외이다.
    • 상위 예외를 catch로 잡으면 하위 예외까지 함께 잡으므로 Throwable로 잡으면 안된다.
  4. Exception: 체크 예외
    • 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외
    • Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다. 단, RuntimeException은 예외로 한다.
  5. RuntimeException: 언체크 예외, 런타임 예외
    • 컴파일러가 체크하지 않는 언체크 예외이다.
    • RuntimeException과 그 자식 예외는 모두 언체크 예외이다.

image

예외 기본 규칙

예외는 잡아서 처리하거나, 처리할 수 없으면 밖으로 던져야한다.

image

예외에 대해서는 2가지 기본 규칙을 기억하자

  1. 예외는 잡아서 처리하거나 던져야 한다.
  2. 예외를 잡거나 던질 때 지정한 예외뿐만 아니라 그 예외의 자식들도 함께 처리된다.
    • 예를 들어 Exception을 catch로 잡으면 그 하위 예외들도 모두 잡을 수 있다.
    • 예를 들어 Exception을 throws로 던지면 그 하위 예외들도 모두 던질 수 있다.

예외를 처리하지 못하고 계속 던지면 어떻게 되는가?

  • 자바 main() 쓰레드의 경우 예외 로그를 출력하면서 시스템이 종료된다.
  • 웹 애플리케이션의 경우 여러 사용자의 요청을 처리하기 때문에 하나의 예외 때문에 시스템이 종료되면 안된다.
    • WAS가 해당 예외를 받아서 처리하는데, 주로 사용자에게 개발자가 지정한, 오류 페이지를 보여준다.

체크 예외 기본 이해

체크 예외는 무조건 잡아서 처리하거나, 또는 밖으로 던지도록 선언해야 한다. 그렇지 않으면 컴파일 오류가 발생한다.

예제

[CheckedTest]

package hello.jdbc.exception.basic;

import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.*;

@Slf4j
public class CheckedTest {

    @Test
    void checked_catch() {
        Service service = new Service();
        service.callCatch();
    }

    @Test
    void checked_throw() {
        Service service = new Service();
        assertThatThrownBy(() -> service.callThrow())
                .isInstanceOf(MyCheckedException.class);
    }


    /**
     * Exception을 상속받은 예외는 체크 예외가 된다.
     */
    static class MyCheckedException extends Exception {
        public MyCheckedException(String message) {
            super(message);
        }
    }

    /**
     * Checked 예외는
     * 예외를 잡아서 처리하거나, 던지거나 둘 중 하나를 필수로 선택해야 한다.
     */
    class Service {
        Repository repository = new Repository();

        /**
         * 예외를 잡아서 처리하는 코드
         */
        public void callCatch() {
            try {
                repository.call();
            } catch (MyCheckedException e) {
                // 예외 처리 로직
                log.info("예외 처리, message = {}", e.getMessage(), e);
            }
        }

        /**
         * 체크 예외를 밖으로 던지는 코드
         * 체크 예외는 예외를 잡지 않고 밖으로 던지려면 throws 예외를 메서드에 필수로 선언해야 한다.
         */
        public void callThrow() throws MyCheckedException {
            repository.call();
        }
    }

    static class Repository {
        public void call() throws MyCheckedException {
            throw new MyCheckedException("ex");
        }
    }

}

예제 실행 결과

  • checked_catch()는 예외를 잡았기 때문에 테스트 메서드까지 예외가 올라오지 않는다.
  • checked_throw()는 예외를 처리하지 않고 밖으로 던지기 때문에 예외가 테스트 메서드까지 올라온다.

체크 예외 vs 언체크 예외

  • Exception을 상속받으면 체크 예외가 된다
  • RuntimeException을 상속받으면 언체크 예외가 된다.
  • catch, throws 모두 예외를 설정한 해당 타입과 그 하위 타입을 모두 잡거나 던질 수 있다.

체크 예외의 장단점

체크 예외는 예외를 잡아서 처리할 수 없을 때, 예외를 밖으로 던지는 throws를 필수로 선언해야 한다. 그렇지 않으면 컴파일 오류가 발생한다.

  • 장점: 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아줄 수 있다.
  • 단점: 개발자가 모든 체크 예외를 반드시 잡거나 던지도록 처리해야 하기 때문에, 너무 번거로운 일이 된다.
    • 추가로 의존 관계에 따른 단점도 있다.

언체크 예외 기본 이해

언체크 예외는 잡아서 처리하거나, 또는 밖으로 던져야 한다. 예외를 던질 땐 throws 키워드를 선언하지 않아도 자동으로 예외가 상위로 던져지게 된다.

예제

[UncheckedTest]

  • RuntimeException를 상속받으면 언체크 예외가 된다.
  • 말 그대로 컴파일러가 체크하지 않기 때문에 언체크 예외이다.
package hello.jdbc.exception.basic;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Slf4j
public class UncheckedTest {

    @Test
    void checked_catch() {
        Service service = new Service();
        service.callCatch();
    }

    @Test
    void checked_throw() {
        Service service = new Service();
        assertThatThrownBy(() -> service.callThrow())
                .isInstanceOf(MyCheckedException.class);
    }


    /**
     * RuntimeException을 상속받은 예외는 언체크 예외가 된다.
     */
    static class MyCheckedException extends RuntimeException {
        public MyCheckedException(String message) {
            super(message);
        }
    }

    /**
     * Unchecked 예외는
     * 예외를 잡거나, 던지지 않아도 된다.
     * 예외를 잡지 않으면 자동으로 밖으로 던진다.
     */
    class Service {
        Repository repository = new Repository();

        /**
         * 필요한 경우 예외를 잡아서 처리하면 된다.
         */
        public void callCatch() {
            try {
                repository.call();
            } catch (MyCheckedException e) {
                // 예외 처리 로직
                log.info("예외 처리, message = {}", e.getMessage(), e);
            }
        }

        /**
         * 예외를 잡지 않아도 된다. 자연스럽게 상위로 넘어간다.
         * 체크 예외와 다르게 throws 예외 선언을 하지 않아도 된다.
         */
        public void callThrow() {
            repository.call();
        }
    }

    static class Repository {
        public void call() throws MyCheckedException {
            throw new MyCheckedException("ex");
        }
    }

}

예제 실행 결과

  • checked_catch()는 예외를 잡았기 때문에 테스트 메서드까지 예외가 올라오지 않는다.
  • checked_throw()는 예외를 처리하지 않고 밖으로 던지기 때문에 예외가 테스트 메서드까지 올라온다.

언체크 예외의 장단점

언체크 예외는 예외를 잡아서 처리할 수 없을 때, 예외를 밖으로 던지는 throws 예외를 생략할 수 있다.

이것 때문에 장점과 단점이 동시에 존재하게 된다.

  • 장점: 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다. 신경쓰고 싶지 않은 예외의 의존관계를 참조하지 않아도 되는 장점이 있다.
  • 단점: 언체크 예외는 개발자가 실수로 예외를 누락할 수 있다.

정리

체크 예외와 언체크 예외의 차이는 예외를 처리할 수 없을 때 예외를 밖으로 던지는 부분에 있다. 이 부분을 필수로 선언해야 하는가 생략할 수 있는가의 차이다.

체크 예외 활용

언제 체크 예외를 사용하고, 언제 언체크(런타임) 예외를 사용하면 좋을까?

다음 2가지의 원칙을 기억하자

  1. 기본적으로 언체크 예외를 사용하자
  2. 체크 예외는 비즈니스 로직상 의도적으로 던지는 예외에만 사용하자
    • 예외를 잡아서 반드시 처리해야 하는 문제일 경우 체크 예외를 사용해야 한다.
    • 계좌 이체처럼 비즈니스 로직 상 중요한 로직에서는 체크 예외를 통해 개발자가 놓친 예외를 인지할 수 있다.

체크 예외의 문제점

체크 예외는 컴파일러가 예외 누락을 체크해주기 때문에 개발자가 실수로 예외를 놓치는 것을 막아주어 좋을 것 같지만 다음과 같은 문제점들이 있다.

예제 코드를 통해 알아보자

[CheckedAppTest]

package hello.jdbc.exception.basic;

import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;

import java.net.ConnectException;
import java.sql.SQLException;

import static org.assertj.core.api.Assertions.*;

@Slf4j
public class CheckedAppTest {

    @Test
    void checked() {
        Controller controller = new Controller();
        assertThatThrownBy(() -> controller.request())
                .isInstanceOf(Exception.class);
    }

    static class Controller {
        Service service = new Service();

        public void request() throws SQLException, ConnectException {
            service.logic();
        }


    }

    static class Service {
        Repository repository = new Repository();
        NetworkClient networkClient = new NetworkClient();

        public void logic() throws SQLException, ConnectException {
            repository.call();
            networkClient.call();
        }
    }

    static class Repository {
        public void call() throws SQLException {
            throw new SQLException("ex");
        }
    }


    static class NetworkClient {
        public void call() throws ConnectException {
            throw new ConnectException("연결 실패");
        }
    }


}

2가지 문제점

다음 코드를 통해 2가지 문제점이 있는 것을 알 수 있다.

  • 복구 불가능한 예외
  • 의존 관계에 대한 문제

1. 복구 불가능한 예외

대부분의 예외는 복구가 불가능하다. 이런 문제들은 서비스나 컨트롤러 단에서 처리할 수 없다. 이런 문제들은 일관성 있게 공통으로 처리해야 한다. 오류 로그를 남기고 개발자가 해당 오류를 빠르게 인지하는 것이 필요하다. 서블릿 필터, 스프링 인터셉터, 스프링의 ControllerAdvice를 사용하면 이런 부분을 깔끔하게 공통으로 처리할 수 있다.

2. 의존 관계에 대한 문제

체크 예외의 또 다른 문제는 예외에 대한 의존 관계 문제이다. 체크 예외이기 때문에 예외가 메서드 시그니처에 존재하면 컨트롤러나 서비스 입장에서는 본인이 처리할 수 없어도 throws 를 통해 던지는 예외를 선언해야 한다. throws SQLException, ConnectException처럼 예외를 던지는 부분이 문제가 되는 이유는 서비스, 컨트롤러 단에서 java.sql.SQLException과 같은 특정 기술에 의존하기 때문에 문제가 된다.

서비스나 컨트롤러 입장에서는 어차피 본인이 처리할 수도 없는 예외를 의존해야 하는 큰 단점이 발생하게 된다. 결과적으로 OCP, DI를 통해 클라이언트의 코드의 변경 없이 대상 구현체를 변경할 수 있다는 장점이 체크 예외 때문에 발목을 잡게 된다.

image

정리

  • 처리할 수 있는 체크 예외라면 서비스나 컨트롤러에서 처리하겠지만, 지금처럼 데이터베이스나 네트워크 통신처럼 시스템 레벨에서 올라온 예외들은 대부분 복구가 불가능하다.
  • 이런 예외들에서 체크 예외를 사용하게 되면 올라온 복구 불가능한 예외를 서비스, 컨트롤러 같은 각각의 클래스가 모두 알고 있어야 하고, 불필요한 의존 관계의 문제를 갖게 된다.

언체크 예외 활용

이번에는 언체크 예외를 활용해보자

  • SQLException을 런타임 예외인 RuntimeSQLException으로 변환한다.
  • ConnectionException대신 RuntimeConnectionExcetpion을 사용하도록 바꾼다.
  • 런타임 예외이기 때문에 서비스, 컨트롤러는 해당 예외들을 처리할 수 없다면 별도의 선언 없이 그냥 두면 된다.

image

예제 - 런타임 예외 사용 변환

[UncheckedAppTest]

  • 런타임 예외이기 때문에 체크 예외처럼 컨트롤러나 서비스가 예외에 의존할 필요 없이 생략할 수 있다.
    • 즉, 의존 관계가 발생하지 않는다.
package hello.jdbc.exception.basic;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;

import java.sql.SQLException;

import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Slf4j
public class UncheckedAppTest {

    @Test
    void checked() {
        Controller controller = new Controller();
        assertThatThrownBy(() -> controller.request())
                .isInstanceOf(Exception.class);
    }

    @Test
    void printEx() {
        Controller controller = new Controller();
        try {
            controller.request();
        } catch (Exception e) {
            // e.printStackTrace();
            log.info("ex", e);
        }
    }

    static class Controller {
        Service service = new Service();

        public void request() {
            service.logic();
        }
    }

    static class Service {
        Repository repository = new Repository();
        NetworkClient networkClient = new NetworkClient();

        public void logic() {
            repository.call();
            networkClient.call();
        }
    }

    static class Repository {
        public void call() {
            try {
                runSQL();
            } catch (SQLException e) {
                throw new RuntimeSQLException(e);
            }
        }

        private void runSQL() throws SQLException {
            throw new SQLException("ex");
        }
    }


    static class NetworkClient {
        public void call() {
            throw new RuntimeConnectionException("연결 실패");
        }
    }

    static class RuntimeConnectionException extends RuntimeException {
        public RuntimeConnectionException(String message) {
            super(message);
        }
    }

    static class RuntimeSQLException extends RuntimeException {
        public RuntimeSQLException() {
        }

        public RuntimeSQLException(Throwable cause) {
            super(cause);
        }
    }


}

예외 전환

  • 리포지토리에서 체크 예외인 SQLException이 발생하면 런타임 예외인 RuntimeSQLException으로 전환해서 예외를 던진다.

    • 참고로 이때 기존 예외를 포함해주어야 예외 출력시 스택 트레이서에서 기존 예외도 함께 확인할 수 있다.
  • NetworkClinet는 단순히 기존 체크 예외를 RuntimeConnectionException이라는 런타임 예외가 발생하도록 코드를 변경하였다.

런타임 예외 전환 시 효과

  • 대부분 복구 불가능한 예외: 시스템에서 발생한 예외는 대부분 복구 불가능한 예외인데, 런타임 예외를 사용하면 서비스나 컨트롤러가 이런 복구 불간으한 예외를 신경쓰지 않아도 된다. 물론 이렇게 복구 불가능한 예외는 일관성 있게 공통으로 처리해야 한다.
  • 의존 관계에 대한 문제: 런타임 예외는 해당 객체가 처리할 수 없는 예외는 무시하면 된다. 따라서 체크 예외처럼 예외를 강제로 의존하지 않아도 된다.

정리

  • 런타임 예외를 사용하면 중간에 기술이 변경되어도 해당 예외를 사용하지 않는 컨트롤러, 서비스에서는 코드를 변경하지 않아도 된다.
  • 구현 기술이 변경되는 경우, 예외를 공통으로 처리하는 곳에서는 예외에 따른 다른 처리가 필요할 수 있다. 하지만 공통 처리하는 곳만 변경하면 되기 때문에 변경 영향 범위는 최소화 된다.
  • 추가로 런타임 예외는 놓칠 수 있기 때문에 문서화가 중요하다.

image

예외 포함과 스택 트레이스

예외를 전환할 때는 꼭 기존 예외를 포함해야 한다. 그렇지 않으면 스택 트레이스를 확인할 때 심각한 문제가 발생한다.

  • 로그를 출력할 때 마지막 파라미터에 예외를 넣어주면 로그에 스택 트레이스를 출력할 수 있다.
  • System.out에 스택 트레이스에 출력하려면 e.printStackTrace()를 사용한다.
    • 실무에서는 항상 로그를 사용한다고 한다.
@Test
void printEx() {
    Controller controller = new Controller();
    try {
        controller.request();
    } catch (Exception e) {
        // e.printStackTrace();
        log.info("ex", e);
    }
}

실행 결과 - 기존 예외를 포함하는 경우

예외를 포함해서 기존에 발생한 java.sql.SQLException과 스택 트레이스를 확인할 수 있다.

23:55:55.579 [Test worker] INFO  h.j.exception.basic.UncheckedAppTest --
                ex
hello.jdbc.exception.basic.UncheckedAppTest$RuntimeSQLException: java.sql.SQLException: ex
	at hello.jdbc.exception.basic.UncheckedAppTest$Repository.call(UncheckedAppTest.java:54)
	at hello.jdbc.exception.basic.UncheckedAppTest$Service.logic(UncheckedAppTest.java:44)
	at hello.jdbc.exception.basic.UncheckedAppTest$Controller.request(UncheckedAppTest.java:35)

실행 결과 - 기존 예외를 포함하지 않는 경우

예외를 포함하지 않아서 기존에 발생한 java.sql.SQLException과 스택 트레이스를 확인할 수 없다.

23:57:09.896 [Test worker] INFO  h.j.exception.basic.UncheckedAppTest --
                ex
hello.jdbc.exception.basic.UncheckedAppTest$RuntimeSQLException: null
	at hello.jdbc.exception.basic.UncheckedAppTest$Repository.call(UncheckedAppTest.java:54)
	at hello.jdbc.exception.basic.UncheckedAppTest$Service.logic(UncheckedAppTest.java:44)
	at hello.jdbc.exception.basic.UncheckedAppTest$Controller.request(UncheckedAppTest.java:35)

섹션 6 | 스프링과 문제 해결 - 예외 처리, 반복

체크 예외와 인터페이스

앞서 살펴본 바와 같이 서비스 계층은 가급적 특정 구현 기술에 의존하지 않고, 순수하게 유지하는 것이 좋다. 이렇게 하려면 예외에 대한 의존도 함께 해결해야 한다. 예외 의존을 해결하기 위해 체크 예외를 런타임 예외로 변경해야 한다.

인터페이스 도입

인터페이스 도입을 통해 구현 기술을 쉽게 변경할 수 있게 변경한다.

  • 이렇게 인터페이스를 도입하면 MemberSeriveMemberRepository 인터페이스에만 의존하면 된다.
  • 구현 기술을 변경하고 싶으면 DI를 사용해서 MemberSerivce 코드의 변경 없이 구현 기술을 변경할 수 있다.
image

코드

[MemberRepository]

  • 기존에는 체크 예외이기 때문에 인터페이스를 도입할 수 없었다.
  • 체크 예외인 경우 무조건 예외를 잡아서 처리하거나 던져야 하기 때문에 체크 예외도 인터페이스에 선언되어야 한다.
    • 따라서 차후 체크 예외를 런타임 예외로 변경할 예정이다.
  • 참고로 구현 클래스의 메서드에 선언할 수 있는 예외는 부모 타입에서 던진 예외와 같거나 하위 타입이어야 한다.
package hello.jdbc.repository;

import hello.jdbc.domain.Member;

public interface MemberRepository {

    Member save(Member save);

    Member findById(String memberId);

    void update(String memberId, int money);

    void delete(String memberId);
}

런타임 예외 적용

내용

  • 앞서 만들었던 인터페이스를 적용해 DI통해 구현체를 쉽게 변경할 수 있도록 변경
  • 런타임 예외를 정의하고 리포지토리의 의존적인 체크 예외를 런타임 예외로 변경
  • 서비스 계층을 순수한 자바 코드로 변경

예제

[MyDbException]

RuntimeException을 상속받았으므로 런타임 예외가 된다.

package hello.jdbc.repository.ex;

/**
 * RuntimeException을 상속받아기 때문에 MyDbException은 런타임 예외가 된다.
 */
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);
    }
}

[MemberRepositoryV4_1]

리포지토리의 의존적 체크 예외를 런타임 예외로 바꾸자

  • 메서드 시그니처에 체크 예외를 던지는 부분을 제거했다.
  • 대신 throw new MyDbException(e);을 통해 체크 예외를 런타임 예외로 전환했다.
    • 여기서 중요한 점은 꼭 발생한 원인이 되는 예외를 우리가 정의한 예외 객체의 생성자에 포함해야한다.
    • 기존에 발생한 예외를 포함하지 않으면, 예외를 스택 트레이스를 통해 출력했을 때 기존에 원인이 되는 부분을 확인할 수 없다.
package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.ex.MyDbException;
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;

/**
 * 예외 누수 문제 해결
 * 체크 예외를 런타임 예외로 변경
 * MemberRepository 인터페이스 사용
 * throws SQLException 제거
 */
@Slf4j
public class MemberRepositoryV4_1 implements MemberRepository {

    private final DataSource dataSource;

    public MemberRepositoryV4_1(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    private Connection getConnection() {
        Connection con = DataSourceUtils.getConnection(dataSource);
        log.info("get connection = {}, class = {}", con, con.getClass());
        return con;
    }

    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        DataSourceUtils.releaseConnection(con, dataSource);
    }

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

    @Override
    public Member findById(String memberId) {
        String sql = "select * from member where member_id = ?";

        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;


        try {
            con = 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) {
            throw new NoSuchElementException("member not found memberId = " + memberId);
        } finally {
            close(con, pstmt, rs);
        }
    }

    @Override
    public void update(String memberId, int money) {
        String sql = "update member set money = ? where member_id = ?";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setInt(1, money);
            pstmt.setString(2, memberId);
            int resultSize = pstmt.executeUpdate();
            log.info("resultSize = {}", resultSize);

        } catch (SQLException e) {
            throw new MyDbException(e);
        } finally {
            close(con, pstmt, null);

        }
    }


    @Override
    public void delete(String memberId) {
        String sql = "delete from member where member_id = ?";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);
            pstmt.executeUpdate();

        } catch (SQLException e) {
            throw new MyDbException(e);
        } finally {
            close(con, pstmt, null);
        }
    }
}

[MemberServiceV4]

서비스 계층을 비즈니스 로직만 있는 순수한 자바코드로 바꿔보자

  • MemberRepository로 인터페이스에 의존하도록 변경
  • 리포지토리에서 체크 예외를 던지지 않기 때문에 드디어 예외에 대한 의존을 벗어날 수 있다.(throws SQLException)
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepository;
import hello.jdbc.repository.MemberRepositoryV3;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;

import java.sql.SQLException;

/**
 * 예외 누수 문제 해결
 * SQLException 제거
 *
 * MemberRepository 인터페이스 의존
 */
@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("이체중 예외 발생");
        }
    }
}

[MemberServiceV4Test]

  • MemberRepository로 인터페이스 변경
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepository;
import hello.jdbc.repository.MemberRepositoryV3;
import hello.jdbc.repository.MemberRepositoryV4_1;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
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 javax.sql.DataSource;
import java.sql.SQLException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
 * 예외 누수 문제 해결
 * SQLException 제거
 *
 * MemberRepository 인터페이스 의존
 */
@Slf4j
@SpringBootTest
class MemberServiceV4Test {

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

    @Autowired
    MemberRepository memberRepository;
    @Autowired
    MemberServiceV4 memberService;

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

    @TestConfiguration
    static class TestConfig {

        private final DataSource dataSource;

        public TestConfig(DataSource dataSource) {
            this.dataSource = dataSource;
        }


        @Bean
        MemberRepository memberRepository() {
            // 단순 예외 반환
            return new MemberRepositoryV4_1(dataSource);
        }

        @Bean
        MemberServiceV4 memberServiceV4() {
            return new MemberServiceV4(memberRepository());
        }

    }

    @Test
    void AopCheck() {
        log.info("memberService class = {}", memberService.getClass());
        log.info("memberRepository class = {}", memberRepository.getClass());
        assertThat(AopUtils.isAopProxy(memberService)).isTrue();
        assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
    }

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

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

        // then
        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 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: 계좌이체 로직을 실행한다.
        // memberEx 회원의 ID는 ex 이므로 중간에 예외가 발생한다.
        assertThatThrownBy(() ->
                memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

        // then: memberA의 돈이 롤백 되어야함
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberB.getMoney()).isEqualTo(10000);
    }
}

정리

  • 체크 예외를 런타임 예외로 변환하면서 인터페이스와 서비스 계층의 순수성을 유지할 수 있게 되었다.
  • 덕분에 JDBC에서 향후 다른 기술로 변경하더라도 서비스 계층의 코드를 변경하지 않고 유지할 수 있다.

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

데이터베이스에서 특정 예외의 경우 복구를 시도할 수도 있다. 앞서 만든 예외는 런타임 예외로 처리하여 예외 의존성 문제를 해결하였지만, 예외를 구분할 수 없다는 단점이 있다. 따라서 이번에는 회원가입 시 같은 아이디가 있으면 뒤에 임의의 숫자를 붙여서 가입이 가능하도록 특정 예외를 만들어보려고 한다.

  • 데이터베이스는 에러가 발생할 때 오류 코드를 반환하고, 이 에러 코드를 받은 JDBC 드라이버는 SQLExcetpion을 던지며, 이 안에는 데이터베이스가 제공하는 errorCode라는 것이 들어있다.
  • 아래는 데이터베이스의 오류가 발생했을 때 시나리오다.
image

서비스 계층에서는 예외 복구를 위해 키 중복 오류를 확인할 수 있어야 한다. 그래야 새로운 ID를 만들어서 다시 저장을 시도할 수 있기 떄문이다. 이런 과정이 바로 예외를 확인해서 복구하는 과정이다.

H2 데이터베이스의 키 중복 오류는 23505인데 이 에러가 발생했을 때 새로운 ID를 만들어서 다시 저장하면 된다.

예제

[MyDuplicateKeyException]

SQLException은 체크 예외이기 때문에 런타임 예외로 바꿔줄 필요가 있다.

  • 기존에 사용했던 MyDbException을 상속받아 의미있는 계층을 형성한다. 이렇게하면 데이터베이스 관련 예외라는 계층을 만들 수 있다.
package hello.jdbc.repository.ex;

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

[ExTranslatorV1Test] - 아이디 중복 가입 시 예외 복구 테스트 코드

이제 실제 예제 코드를 만들어서 확인해보자

package hello.jdbc.exception.translator;

import hello.jdbc.connection.ConnectionConst;
import hello.jdbc.domain.Member;
import hello.jdbc.repository.ex.MyDbException;
import hello.jdbc.repository.ex.MyDuplicateKeyException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.support.JdbcUtils;
import org.springframework.stereotype.Repository;

import javax.sql.DataSource;
import javax.xml.crypto.Data;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Random;

import static hello.jdbc.connection.ConnectionConst.*;
import static org.springframework.jdbc.support.JdbcUtils.*;

public class ExTranslatorV1Test {

    Repository repository;
    Service service;

    @BeforeEach
    void init() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
        repository = new Repository(dataSource);
        service = new Service(repository);
    }

    @Test
    void duplicateKeySave() {
        service.create("myId");
        service.create("myId"); // 같은 ID 저장 시도
    }

    @Slf4j
    @RequiredArgsConstructor
    static class Service {

        private final Repository 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;
            }
        }

        private String generateNewId(String memberId) {
            return memberId + new Random().nextInt(10000);
        }
    }

    @RequiredArgsConstructor
    static class Repository {

        private final DataSource dataSource;

        public Member save(Member member) {
            String sql = "insert into member(member_id, money) values(?, ?)";

            Connection con = null;
            PreparedStatement pstmt = null;

            try {
                con = dataSource.getConnection();
                pstmt = con.prepareStatement(sql);
                pstmt.setString(1, member.getMemberId());
                pstmt.setInt(2, member.getMoney());
                pstmt.executeUpdate();
                return member;
            } catch (SQLException e) {
                // h2 db
                if (e.getErrorCode() == 23505) {
                    throw new MyDuplicateKeyException(e);
                }

                throw new MyDbException(e);
            } finally {
                closeStatement(pstmt);
                closeConnection(con);
            }
        }
    }
}

실행 결과

같은 아이디를 저장했지만 서비스 계층에서 예외를 잡고 복구했다.

13:03:33.882 [Test worker] INFO  h.j.e.t.ExTranslatorV1Test$Service --
                saveId = myId
13:03:33.890 [Test worker] INFO  h.j.e.t.ExTranslatorV1Test$Service --
                키 중복, 복구 시도
13:03:33.891 [Test worker] INFO  h.j.e.t.ExTranslatorV1Test$Service --
                retryId = myId1480
리포지토리
  • e.getErrorCode() == 23505: 오류 코드가 키 중복 오류인 경우 우리가 정의한 MyDuplicateKeyException을 새로 만들어서 서비스 계층에 던진다.
  • 나머지 경우 기존에 만들었던 MyDbException을 던진다.
} catch (SQLException e) {
// h2 db
if (e.getErrorCode() == 23505) {
  throw new MyDuplicateKeyException(e);
}

throw new MyDbException(e);
서비스
  • 처음엔 저장을 시도하고, 리포지토리에서 MyDuplicateKeyException 예외가 올라오면 예외를 잡아서 복구를 시도하고 다시 저장을 시도한다.
  • 만약 복구가 불가능한 에러라면(MyDbException) 로그만 남기고 예외를 던진다.
    • 만약 어차피 복구할 수 없는 예외라면 예외를 공통으로 처리하는 부분까지 전달되어서 그 곳에서 예외 로그를 남기고 처리하는 것이 좋다.
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;
}

스프링 예외 추상화 이해

위와 같은 방식으로 특정 예외에 대한 처리를 할 수 있었지만, 데이터베이스마다 에러 코드도 다르고 무수히 많은 예외 상황을 모두 작성하기는 효율적이지 않다. 데이터베이스가 바뀐다면 모두 수정해야 할 것이다.

이를 해결하기 위해 스프링에서는 데이터 접근과 관련된 예외를 추상화해서 제공한다.

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

  • 스프링은 데이터 접근 계층에 대한 수십 가지 예외를 정리해서 일관된 예외 계층을 제공한다.
  • 각각의 예외는 특정 기술에 종속적이지 않게 설계되어 있다. 따라서 서비스 계층에서도 스프링이 제공하는 예외를 사용하면 된다.
  • 예외의 최고 상위는 DataAccessException인데, 런타임 예외를 상속 받았기 떄문에 스프링이 제공하는 데이터 접근 계층의 모든 예외는 런타임 예외이다.
  • DataAccessException은 크게 두 가지로 구분한다.
    • Transient: 일시적이라는 뜻이다. Trasient 하위 예외는 동일한 SQL을 다시 시도했을 때 성공할 가능성이 있다.
      • 예를 들어 쿼리 타임아웃, 락과 관련된 오류들이다.
    • NonTransient: 일시적이지 않다는 뜻이다. 같은 SQL을 그대로 반복해서 실행하면 실패한다.
      • SQL 문법 오류, 데이터베이스 제약조건 위배 등이 있다.
image

스프링이 제공하는 예외 변환기

스프링은 데이터베이스에서 발생하는 오류 코드를 스프링이 정의한 예외로 자동으로 변환해주는 변환기를 제공한다. 예제를 통해 확인해보자

예제

[SpringExceptionTranslatorTest]

  • sqlExceptionErrorCode: 해당 테스트는 SQL ErrorCode를 직접 확인해보는 테스트이다

    • 이렇게 직접 예외를 확인하고, 스프링이 만들어준 예외(BadSqlGrammarException)로 변환하는 것은 현실성이 없다.
    • 하나하나 다 에러를 확인하고 그에 맞게 스프링이 제공하는 예외를 던져줘야한다.
  • exceptionTranslator: 해당 테스트는 스프링이 에러를 확인하고 적절한 에러로 변환시켜준다.

    • 아래처럼 단 2줄로 적절한 에러를 데이터베이스에 맞게 변환해서 던져준다.

    • DataAccessException은 최상위 부모 타입이지만, 실제 구현체는 Assertions.assertThat으로 확인해보면 BadSqlGrammarException이라는 것을 확인할 수 있다.

      SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
      DataAccessException resultEx = exTranslator.translate("select", sql, e);
package hello.jdbc.exception.translator;

import hello.jdbc.connection.ConnectionConst;
import lombok.extern.slf4j.Slf4j;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.BadSqlGrammarException;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator;

import javax.sql.DataSource;

import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;

import static hello.jdbc.connection.ConnectionConst.*;
import static org.assertj.core.api.Assertions.*;

@Slf4j
public class SpringExceptionTranslatorTest {

    DataSource dataSource;

    @BeforeEach
    void init() {
        dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
    }

    /**
     * SQL ErrorCode를 직접 확인하는 방법
     * 직접 예외를 확인하고 하나하나 스프링이 만들어준 예외로 변환하는 것은 현실성이 없다.
     */
    @Test
    void sqlExceptionErrorCode() {
        String sql = "select bad grammar";

        try {
            Connection con = dataSource.getConnection();
            PreparedStatement pstmt = con.prepareStatement(sql);
            pstmt.executeQuery();
        } catch (SQLException e) {
            assertThat(e.getErrorCode()).isEqualTo(42122);
//            throw new BadSqlGrammarException(e);
            int errorCode = e.getErrorCode();
            log.info("errorCode = {}", errorCode);
            log.info("error", e);
        }
    }


    /**
     * 스프링이 제공하는 예외 변환기
     */
    @Test
    void exceptionTranslator() {
        String sql = "select bad grammar";

        try {
            Connection con = dataSource.getConnection();
            PreparedStatement pstmt = con.prepareStatement(sql);
            pstmt.executeQuery();
        } catch (SQLException e) {
            assertThat(e.getErrorCode()).isEqualTo(42122);

            // org.springframework.jdbc.support.sql-error-codes.xml
            SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
            DataAccessException resultEx = exTranslator.translate("select", sql, e);
            log.info("resultEx", resultEx);
            assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
        }
    }
}

스프링이 적절한 예외로 변환해 줄 수 있는 비밀

org.springframework.jdbc.support.sql-error-codes.xml 해당 파일에 비밀이 숨어있다.

  • 해당 파일에는 다양한 데이터베이스에 대한 에러코드를 정의하고 있다.
  • 스프링 SQL 예외 변환기는 SQL ErrorCode를 이 파일에 대입해서 어떤 스프링 데이터 접근 예외로 전환해야 할 지 찾아낸다.

정리

  • 스프링은 데이터 접근 계층에 대한 일관된 예외 추상화를 제공한다.
  • 스프링은 예외 변환기를 통해 SQLException의 ErrorCode에 맞는 적절한 스프링 데이터 접근 예외로 변환해준다.
  • 이제 서비스, 컨틀로럴 계층에서 예외 처리가 필요하면 특정 기술에 종속적인 SQLException 같은 예외를 사용하는 것이 아니라, 스프링이 제공하는 데이터 접근 예외를 사용하면 된다.
    • 스프링의 예외 추상화 덕분에 특정 기술에 종속적이지 않게 된다.

스프링 예외 추상화 적용

이제 애플리케이션에 스프링이 제공하는 데이터 접근 예외 추상화와 SQL 예외 변환기를 적용해보자

예제

[MemberRepositoryV4_2]

  • 예외를 던지는 부분을 throw exTranslator.translate(...)으로 변경하여 특정 기술의 특정 예외를 잡을 필요 없이 스프링의 예외 추상화를 통해 특정 기술에 종속적이지 않게 되었다.
package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.ex.MyDbException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.support.JdbcUtils;
import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator;
import org.springframework.jdbc.support.SQLExceptionTranslator;

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

/**
 * SQLExceptionTranslator 추가
 * - 스프링이 제공하는 예외 변환기
 * - 데이터 접근 예외 추상화 적용
 */
@Slf4j
public class MemberRepositoryV4_2 implements MemberRepository {

    private final DataSource dataSource;
    private final SQLExceptionTranslator exTranslator;

    public MemberRepositoryV4_2(DataSource dataSource) {
        this.dataSource = dataSource;
        this.exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
    }

    private Connection getConnection() {
        Connection con = DataSourceUtils.getConnection(dataSource);
        log.info("get connection = {}, class = {}", con, con.getClass());
        return con;
    }

    private void close(Connection con, Statement stmt, ResultSet rs) {
        JdbcUtils.closeResultSet(rs);
        JdbcUtils.closeStatement(stmt);
        DataSourceUtils.releaseConnection(con, dataSource);
    }

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

    @Override
    public Member findById(String memberId) {
        String sql = "select * from member where member_id = ?";

        Connection con = null;
        PreparedStatement pstmt = null;
        ResultSet rs = null;


        try {
            con = 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) {
            throw exTranslator.translate("findById", sql, e);
        } finally {
            close(con, pstmt, rs);
        }
    }

    @Override
    public void update(String memberId, int money) {
        String sql = "update member set money = ? where member_id = ?";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setInt(1, money);
            pstmt.setString(2, memberId);
            int resultSize = pstmt.executeUpdate();
            log.info("resultSize = {}", resultSize);

        } catch (SQLException e) {
            throw exTranslator.translate("update", sql, e);
        } finally {
            close(con, pstmt, null);

        }
    }


    @Override
    public void delete(String memberId) {
        String sql = "delete from member where member_id = ?";

        Connection con = null;
        PreparedStatement pstmt = null;

        try {
            con = getConnection();
            pstmt = con.prepareStatement(sql);
            pstmt.setString(1, memberId);
            pstmt.executeUpdate();

        } catch (SQLException e) {
            throw exTranslator.translate("delete", sql, e);
        } finally {
            close(con, pstmt, null);
        }
    }
}

[MemberServiceV4Test]

  • MemberRepository 인터페이스가 제공되므로 스프링 빈에 등록할 빈만 MemberRepositoryV4_1에서 MemberRepositoryV4_2로 교체하면 리포지토리를 변경해서 테스트를 확인할 수 있다.
package hello.jdbc.service;

import hello.jdbc.domain.Member;
import hello.jdbc.repository.MemberRepository;
import hello.jdbc.repository.MemberRepositoryV3;
import hello.jdbc.repository.MemberRepositoryV4_1;
import hello.jdbc.repository.MemberRepositoryV4_2;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
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 javax.sql.DataSource;
import java.sql.SQLException;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

/**
 * 예외 누수 문제 해결
 * SQLException 제거
 *
 * MemberRepository 인터페이스 의존
 */
@Slf4j
@SpringBootTest
class MemberServiceV4Test {

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

    @Autowired
    MemberRepository memberRepository;
    @Autowired
    MemberServiceV4 memberService;

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

    @TestConfiguration
    static class TestConfig {

        private final DataSource dataSource;

        public TestConfig(DataSource dataSource) {
            this.dataSource = dataSource;
        }


        @Bean
        MemberRepository memberRepository() {
//            return new MemberRepositoryV4_1(dataSource); // 단순 예외 반환
            return new MemberRepositoryV4_2(dataSource); // 스프링 예외 변환
        }

        @Bean
        MemberServiceV4 memberServiceV4() {
            return new MemberServiceV4(memberRepository());
        }

    }

    @Test
    void AopCheck() {
        log.info("memberService class = {}", memberService.getClass());
        log.info("memberRepository class = {}", memberRepository.getClass());
        assertThat(AopUtils.isAopProxy(memberService)).isTrue();
        assertThat(AopUtils.isAopProxy(memberRepository)).isFalse();
    }

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

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

        // then
        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 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: 계좌이체 로직을 실행한다.
        // memberEx 회원의 ID는 ex 이므로 중간에 예외가 발생한다.
        assertThatThrownBy(() ->
                memberService.accountTransfer(memberA.getMemberId(), memberEx.getMemberId(), 2000))
                .isInstanceOf(IllegalStateException.class);

        // then: memberA의 돈이 롤백 되어야함
        Member findMemberA = memberRepository.findById(memberA.getMemberId());
        Member findMemberB = memberRepository.findById(memberEx.getMemberId());
        assertThat(findMemberA.getMoney()).isEqualTo(10000);
        assertThat(findMemberB.getMoney()).isEqualTo(10000);
    }
}

정리

  • 스프링이 예외를 추상화한 덕분에, 서비스 계층은 특정 리포지토리의 구현 기술과 예외에 종속적이지 않게 되었다.
    • 따라서 서비스 계층은 특정 구현 기술이 변경되어도 그대로 유지할 수 있다.
    • 다시 DI를 제대로 활용할 수 있게 된 것이다.
  • 추가로 서비스 계층에서 예외를 잡아 복구해야 하는 경우, 예외가 스프링이 제공하는 데이터 접근 예외로 변경되어서 서비스 계층에 넘어오기 때문에 필요한 경우 예외를 잡아서 복구하면 된다.

JDBC 반복 문제 해결 - JdbcTemplate

지금까지 서비스 계층의 순수함을 유지하기 위해 많은 부분을 수정했지만 리포지토리에서 JDBC를 사용하기 때문에 반복되는 문제가 있다. JdbcTemplate 통해 반복되는 문제를 해결해보자.

JDBC 반복 문제

  • 커넥션 조회, 커넥션 동기화
  • PrepareStatement 생성 및 파라미터 바인딩
  • 쿼리 실행
  • 결과 바인딩
  • 예외 발생 시 스프링 예외 변환기 실행
  • 리소스 종료

이러한 반복 문제를 해결하기 위한 효과적인 방법은 바로 템플릿 콜백 패턴이다. 스프링은 JDBC 반복 문제를 해결하기 위해 JdbcTemplate이라는 템플릿을 제공한다.

예제

JdbcTemplate을 사용하여 반복되는 코드 없애기

[MemberRepositoryV5]

package hello.jdbc.repository;

import hello.jdbc.domain.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.jdbc.support.JdbcUtils;
import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator;
import org.springframework.jdbc.support.SQLExceptionTranslator;

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

/**
 * JdbcTemplate 사용
 */
@Slf4j
public class MemberRepositoryV5 implements MemberRepository {

    private final JdbcTemplate template;

    public MemberRepositoryV5(DataSource dataSource) {
        this.template = new JdbcTemplate(dataSource);
    }

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

    private RowMapper<Member> memberRowMapper() {
        return ((rs, rowNum) -> {
            Member member = new Member();
            member.setMemberId(rs.getString("member_id"));
            member.setMoney(rs.getInt("money"));
            return member;
        });
    }

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

[MemberServiceV4Test]

JdbcTemplate 적용을 위해 빈을 변경(새로운 구현체)

@Bean
MemberRepository memberRepository() {
//            return new MemberRepositoryV4_1(dataSource); // 단순 예외 반환
//            return new MemberRepositoryV4_2(dataSource); // 스프링 예외 변환
    return new MemberRepositoryV5(dataSource); // JdbcTemplate
}

정리

JdbcTemplate은 JDBC로 개발할 때 발생하는 반복을 대부분 해결해준다.

  • 트랜잭션을 위한 커넥션 동기화
  • 예외 발생 시 스프링 예외 변환기 등록
  • 리소스 반환 등

최종 정리

  • 서비스 계층의 순수성
    • 트랜잭션 추상화 + 트랜잭션 AOP 덕분에 서비스 계층의 순수성을 최대한 유지하면서 서비스 계층에서 트랜잭션을 사용할 수 있다.
    • 스프링이 제공하는 예외 추상화와 예외 변환기 덕분에, 데이터 접근 기술이 변경되어도 서비스 계층의 순수성을 유지하면서도 예외도 사용할 수 있다.
    • 서비스 계층이 리포지토리 인터페이스에 의존한 덕분에 향후 리포지토리가 다른 기술로 변경되어도 서비스 계층을 순수하게 유지할 수 있다.
  • 리포지토리에서 JDBC를 사용하는 반복 코드가 JdbcTemplate으로 대부분 제거되었다.

Releases

No releases published

Packages

No packages published

Languages