[구름톤 유니브 3기 스터디]김영한 스프링 DB - 1 강의 정리

이재혁·2024년 9월 11일
0

프로젝트 생성

// 테스트에서 lombok 사용
    testCompileOnly 'org.projectlombok:lombok'
    testAnnotationProcessor 'org.projectlombok:lombok'
  • 테스트 코드에서 롬복(@slf4j 등) 롬복 애노테이션을 사요하려면 위 설정이 필요하다)

H2 데이터베이스 설정

rookie@192 bin % chmod 755 h2.sh
rookie@192 bin % ./h2.sh

JDBC 이해

  • 등장 이유?

  • 애플리케이션 서버와 DB - 일반적 사용법

    • 커넥션 연결
    • sql 전달
    • 결과 응답(db에서 데이터를 서버로)

JDBC와 최신 데이터 접근 기술

JDBC는 1997년에 출시된 오래된 기술, 사용도 복잡.

최근에는 JDBC를 직접 사용하기 보다는 JDBC를 편리하게 사용하는 다양한 기술이 존재.

ex) SQL Mapper, ORM

SQL Mapper

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

ORM 기술

  • orm은 객체를 관계형 데이터베이스 테이블과 매핑해주는 기술이다. 각각의 데이터베이스(mysql, oracle) 마다 다른 sql을 사용하는 문제도 중간에서 해결해준다.
  • 대표 기술: JPA, 하이버네이트, 이클립스링크
  • JPA는 자바 진영의 ORM 표준 인터페이스이고, 이것의 구현체로 하이버네이트, 이클립스링크 등이 있다

데이터베이스 연결

JDBC connection

jdbc가 제공하는 drivermanager는 라이브러리에 등록된 DB 드라이버들을 관리하고, jdbcconnection 구현체를 제공한다.

  • DriverManager 요청 흐름
    • 애플리케이션 로직이 커넥션 요청
    • DriverManager.getConnection() 커넥션 요청
    • 라이브러리에 등록된 드라이버 목록에서 해당 db가 커넥션 반환

JDBC 개발 - 등록

String sql = "insert into member (member_id, money) values (?, ?)";

위 와 같이 직접적으로 sql 을 작성해주는 것은 sql injection 공격을 당하게 되는 원인이 된다.

value를 ?로 파라미터 바인딩을 하면 단순히 데이터로 취급되기 때문에 sql injection 공격이 안되는 것이다.

class MemberRepositoryV0Test {

    MemberRepositoryV0 repository = new MemberRepositoryV0();

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

JDBC 개발 - 조회

// findById
        Member findMember = repository.findById(memberV0.getMemberId());
        log.info("findMember: {}", findMember);
        assertThat(findMember).isEqualTo(memberV0);
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);
        }
    }

JDBC 개발 - 수정, 삭제

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

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

마지막에 회원을 삭제 하기 때문에 중간엔 오류가 발생하면 삭제 로직을 수행하지 않는다. 트랜잭션을 활용하면 이 문제를 깔끔하게 해결할 수 있다.

커넥션 풀 이해

데이터베이스 커넥션을 새로 만드는 것은 과정도 복잡하고 시간도 많이 소모되는 일이다.

치명적인 문제는 고객이 애플리케이션을 사용할 때, SQL을 실행하는 시간 뿐만 아니라 커넥션을 새로 만드는 시간이 추가되기 때문에 결과적으로 응답 속도에 영향을 준다. (유저 경험이 나빠짐)

이러한 문제를 해결하는 아이디어가 바로 커넥션을 미리 생성해두고 사용하는 커넥션 풀이라는 방법이다.

커넥션 풀은 이름 그대로 커넥션을 관리하는 풀(수영장 풀)이다.

애플리케이션을 시작하는 시점에 커넥션 풀은 필요한 만큼 커넥션을 미리 확보해서 풀에 보관한다. 기본 값은 보통 10개

커넥션 풀에 들어 있는 커넥션은 TCP/IP로 DB와 커넥션이 연결되어 있는 상태이기 때문에 언제든지 즉시 SQL을 DB에 전달할 수 있다.

  • 커넥션 풀 사용1
    • 애플리케이션 로직에서 이제는 DB 드라이버를 통해서 새로운 커넥션을 획득하는 것이 아니다.
    • 이제는 커넥션 풀을 통해 이미 생성되어 있는 커넥션을 객체 참조로 그냥 가져다 쓰기만 하면 된다.
    • 커넥션 풀에 커넥션을 요청하면 커넥션 풀은 자신이 가지고 있는 커넥션 중에 하나를 반환한다.

image.png

  • 애플리케이션 로직은 커넥션 풀에서 받은 커넥션을 사용해서 SQL을 데이터베이스에 전달하고 그 결과를 받아서 처리한다.
  • 커넥션을 모두 사용하고 나면 이제는 커넥션을 종료하는 것이 아니라, 다음에 다시 사용할 수 있도록 해당 커넥션을 그대로 커넥션 풀에 반환하면 된다. 여기서 주의할 점은 커넥션을 종료하는 것이 아니라 커넥션이 살아있는 상태로 커넥션 풀에 반환해야 한다는 것이다.

DataSource 이해

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

  • 앞서 JDBC로 개발한 애플리케이션 처럼 DriverManager를 통해서 커넥션을 획득하다가, 커넥션 풀을 사용하는 방법으로 변경하려면 어떻게 해야할까?
    • 예를들어, 기존 DriverManager를 사용해서 커넥션을 획득하다가 HikariCP 같은 커넥션 풀을 사용하도록 변경하면 커넥션을 획득하는 애플리케이션 코드도 변경해야 한다.의존 관계가 변경되기 때문이다.

💫커넥션을 획득하는 방법을 추상화

image.png

  • 자바에서는 이런 문제를 해결하기 위해 javax.sql.DataSource라는 인터페이스르 제공한다.
  • 데이터소스는 커넥션을 획득하는 방법을 추상화하는 인터페이스이다.
  • 이 인터페이스의 핵심기능은 커넥션 조회 하나이다.

datasource 핵심 기능만 축약

public interface DataSource {
	Connection getConnection() throws SQLException;
}

스프링이 제공하는 DataSource가 적용된 DriverManager인 DriverManagerDataSource를 사용해보자.

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 {

    // 기존의 방식 jdbc 로 커넥션을 얻는 방법
    @Test
    void driverManager() throws SQLException {
        //DriverManagerDataSource - 항상 새로운 커넥션 획득
        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());
    }

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

}
  • DriverManager는 커넥션을 획득할 때 마다, 파라미터들을 계속 전달해야 한다. 반면에 DataSource를 사용하는 방식은 처음 객체를 생성할 때만 필요한 파라미터를 넘기고, 커넥션을 획득할 때는 단순히 dataSource.getConnection() 만 호출하면 된다.

→ 설정과 사용의 분리: 설정: 드라이버매니저데이터소스 객체 생성, 사용: 데이터소스 커넥션 생성

⇒ 객체를 설정하는 부분과, 사용하는 부분을 좀 더 명확하게 분리할 수 있다.

DataSource 예제2 - 커넥션 풀

@Test
    void dataSourceConnectionPool() throws SQLException, InterruptedException {
        // 커넥션 풀링
        HikariDataSource dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(URL);
        dataSource.setUsername(USERNAME);
        dataSource.setPassword(PASSWORD);
        dataSource.setMaximumPoolSize(10);
        dataSource.setPoolName("MyPool");

        useDataSource(dataSource);
        Thread.sleep(1000); // 커넥션 에서 커넥션 생성 시간 대기
    }
  • 커넥션 풀에서 커넥션을 생성하는 작업은 별도의 쓰레드에서 작동하므로 대기시간을 주어야 쓰레드 풀에 커넥션이 생성되는 로그를 확인할 수 있다.

DataSource 적용

  • 커넥션 풀 사용 시 conn0 커넥션이 재사용 된 것을 볼 수 있다.
  • 테스트는 순서대로 실행되기 때문에 커넥션을 사용하고 다시 돌려주는 것을 반복한다. 따라서 conn0만 사용된다.
  • 웹 애플리케이션에 동시에 여러 요청이 들어오면 여러 쓰레드에서 커넥션 풀의 커넥션을 다양하게 가져가는 상황을 확인할 수 있다.

트랜잭션 - 개념 이해

데이터를 파일에 저장해도 되는데 굳이 데이터베이스에 저장하는 이유가 무엇일까?

→ 여러 이유가 있지만, 가장 대표적인 이유는 데이터베이스가 트랜잭션이라는 개념을 지원하기 때문이다.

데이터베이스에서 트랜잭션은 하나의 거래를 안저낳게 처리하도록 보장해주는 것을 뜻한다.

데이터베이스가 제공하는 트랜잭션 기능을 사용하면 모든 작업이 성공해야 저장하고, 중간에 하나라도 실패하면 거래 전의 상태로 돌아갈 수 있다.

트랜잭션 ACID

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

트랜잭션은 원자성, 일관성, 지속성을 보장한다. 문제는 격리성인데 트랜잭션 간에 격리성을 보장하려면 트랜잭션을 거의 순서대로 실행해야 한다. 이러면 성능이 매우 나빠진다.

트랜잭션 격리 수준 - Isolation level

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

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

image.png

image.png

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

트랜잭션 사용법

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

신규 데이터 추가 후 commit

  • 세션1이 신규 데이터를 추가한 후에 commit을 호출했다.
  • commit으로 새로운 데이터가 실제 데이터베이스에 반영된다. 데이터의 상태도 임시 → 완료로 변경되었다.
  • 이제 다른 세션에서도 회원 테이블을 조회하면 신규 회원들을 확인할 수 있다.

세션1 신규 데이터 추가 후 rollback

  • 세션 1이 신규 데이터를 추가한 후에 commit 대신에 rollback을 호출
  • 세션1이 데이터베이스에 반영한 모든 데이터가 처음 상태로 복구된다.
  • 수정하거나 삭제한 데이터도 rollback 호출 시 모두 트랜잭션 시작하기 직전의 상태로 복구된다.

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

트랜잭션 - DB 예제3 - 트랜잭션 실습

트랜잭션 - DB 예제4 - 계좌이체

DB 락 - 개념 이해

세션이 트랜잭션을 시작하고 데이터를 수정하는 동안에는 커밋이나 롤백 전까지 다른 세션에서 해당 데이터를 수정할 수 없게 막아야한다

image.png

  1. 세션 1은 트랜잭션을 시작한다.
  2. 세션1은 memberA의 money를 500으로 변경을 시도한다. 이때 해당 로우의 락을 먼저 획득해야 한다. 락이 남아 있으므로 세션1은 락을 획득한다.
  3. 세션1은 락을 획득했으므로 해당 로우에 update sql을 수행한다
  4. 세션2는 트랜잭션을 시작한다.
  5. 세션2도 memberA의 money 데이터를 변경하려고 시도한다. 이때 해당 로우의 락을 먼저 획득해야 한다. 락이 없으므로 락이 돌아올 때 까지 대기한다.

참고로, 세션2가 락을 무한정 대기하는 것이 아니다. 일정 시간을 넘어가면 타임아웃이 발생. 락 대기시간은 설정할 수 있다.

  1. 세션1은 커밋을 수행한다. 커밋으로 트랜잭션이 종료됐으므로 락도 반납한다.
  2. 세션2가 락을 획득한다
  3. 세션2는 update sql을 수행한다
  4. 세션2는 커밋을 수행하고 트랜잭션이 종료되었으므로 락을 반납한다

DB 락 - 변경

DB 락 - 조회

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

조회 시점에 락이 필요한 경우는 언제일까?

  • 트랜잭션 종료 시 까지 해당 데이터를 다른 곳에서 변경하지 못하도록 강제로 막아야 할 때 사용한다.

트랜잭션 - 적용2

비즈니스 로직과 트랜잭션

  • 트랜잭션은 비즈니스 로직이 있는 서비스 계층에서 시작해야 한다. 비즈니스 로직이 잘못되면 해당 비즈니스 로직으로 문제가 되는 부분을 함게 롤백해야 하기 때문에
  • 트랜잭션 시작에는 커넥션이 필요하다.
  • 트랜잭션을 사용하는 동안 같은 커넥션을 유지해야한다. 그래야 같은 세션을 사용할 수 있다.

남은 문제

애플리케이션에서 db 트랜잭션을 적용하려면 서비스 계층이 매우 지저분해지고, 복잡한 코드를 요구한다.

커넥션을 유지하도록 코드를 변경하는 것도 쉬운 일은 아니다.

→ 스프링으로 해결

문제점들

애플리케이션 구조

가장 단순하면서 많이 사용하는 방법은 역할에 따라 3가지 계층으로 나누는 것

  • @Controller(UI 관련 처리)

    • 프레젠테이션 계층
  • @Service 비즈니스 로직

    • 서비스 계층
  • @Repository DB 접근 처리

    • 데이터 접근 계층
  • DB 서버

  • 프레젠테이션 계층

    • ui와 관련된 처리 담당
    • 웹 요청과 응답
    • 사용자 요청을 검증
    • 주 사용 기술: http, spring mvc
  • 서비스 계층

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

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

순수한 서비스 계층

  • 핵심 비즈니스 로직이 들어 있는 서비스 계층,. 비즈니스 로직은 최대한 변경 없이 유지되어야 한다.
  • 이렇게 하려면 서비스 계층을 특정 기술에 종속적이지 않게 개발해야 한다.
    • 3계층으로 나눈 이유도 서비스 계층을 최대한 순수하게 유지하기 위해서
  • 서비스 계층이 특정 기술에 종속되지 않기 때문에 비즈니스 로직을 유지보수 하기도 쉽고, 테스트 하기도 쉽다.
  • 향후 구현 기술이 변경될 때 변경의 영향 범위를 최소화 할 수 있다.

문제 정리

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

스프링은 서비스 계층을 순수하게 유지하면서, 지금까지 이야기한 문제들을 해결할 수 있는 다양한 방법과 기술들을 제공한다.

트랜잭션 추상화

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

구현 기술에 따른 트랜잭션 사용법

  • 트랜잭션은 원자적
  • 구현 기술 별 트랜잭션 사용법
    • JDBC: con.setAutoCommit(false)
    • JPA: transaction.begin()
public interface TxManager {
		begin();
		commit();
		rollback();
}

트랜잭션은 사실 단순하다. 트랜잭션을 시작하고, 비즈니스 로직의 수행이 끝나면 커밋하거나 롤백하면 된다.

그리고 다음과 같이 TxManager 인터페이스를 기반으로 각각의 기술에 맞는 구현체를 만들면 된다.
JdbcTxManager : JDBC 트랜잭션 기능을 제공하는 구현체
JpaTxManager : JPA 트랜잭션 기능을 제공하는 구현체

⇒ 인테페이스에 의존하고 DI를 사용한 덕분에 OCP 원칙을 지키게 되었다.

→ 서비스 코드를 수정하지 않고 트랜잭션 기술을 마음껏 변경할 수 있다.

스프링의 트랜잭션 추상화

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

트랜잭션 동기화

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

  • 트랜잭션 추상화
  • 리소스 동기화
    • 트랜잭션을 유지하려면 트랜잭션의 시작부터 끝까지 같은 데이터베이스 커넥션을 유지해야한다.

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

  • 스프링은 트랜잭션 동기화 매니저를 제공한다.
  • 트랜잭션 동기화 매니저는 쓰레드 로컬을 사용하기 때문에 멀티쓰레드 환경에서 안전하게 커넥션을 동기화 할 수 있다.

image.png

동작 방식

  1. 트랜잭션이 시작하려면 커넥션이 필요하다. 트랜잭션 매니저는 데이터소스를 통해 커넥션을 만들고 트랜잭션을 시작한다.
  2. 트랜잭션 매니저는 트랜잭션이 시작된 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  3. 리포지토리는 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다.
  4. 트랜잭션이 종료되면 트랜잭션 매니저는 트랜잭션 동기화 매니저에 보관된 커넥션을 통해 트랜잭션을 종료, 커넥션도 닫는다.

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

  1. 서비스 계층에서 요청으로 transactionManager.getTransaction()을 호출해서 트랜잭션을 시작한다.
  2. 트랜잭션을 시작하려면 먼저 데이터베이스 커넥션이 필요하다. 트랜잭션 매니저는 내부 데이터소스를 사용해서 커넥션을 생성한다.
  3. 커넥션을 수동 커밋 모드로 변경해서 실제 데이터베이스 트랜잭션을 시작한다.
  4. 커넥션을 트랜잭션 동기화 매니저에 보관한다.
  5. 트랜잭션 동기화 매니저는 쓰레드 로컬에 커넥션을 보관한다. 따라서 안전하게 커넥션을 보관할 수 있다.
  6. 서비스는 비즈니스 로직을 실행하면서 리포지토리의 메서드들을 호출한다. 이때 커넥션을 파라미터로 전달하지 않는다.
  7. 리포지토리 메서드들은 트랜잭션이 시작된 커넥션이 필요하다. 리포지토리는 DataSourceUtils.getConnection() 을 사용해서 트랜잭션 동기화 매니저에 보관된 커넥션을 꺼내서 사용한다.
  8. 획득한 커넥션을 사용해서 sql을 데이터베이스로 전달해서 실행한다.
  9. 비즈니스 로직이 끝나고 트랜잭션을 종료한다. 트랜잭션은 커밋하거나 롤백하면 종료된다.
  10. 트랜잭션을 종료하려면 동기화된 커넥션이 필요하다. 트랜잭션 동기화 매니저를 통해 동기화된 커넥션을 획득한다
  11. 획득한 커넥션을 통해 데이터베이스에 트랜잭션을 커밋하거나 롤백한다.
  12. 전체 리소스를 정리한다.

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

트랜잭션 사용 코드

try {
		// 비즈니스 로직
		bizLogic(fromId, toId, money);
		transactionManager.commit(status); // 성공 시 커밋
} catch (Exception e) {
		transactionManager.rollback(status); // 실패 시 롤백
		throw new ~;
}

달라지는 부분은 비즈니스 로직 뿐이다.
이럴 때 템플릿 콜백 패턴을 활용하면 이런 반복 문제를 깔끔하게 해결할 수 있다.

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

TransactionTemplate

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

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

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

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

프록시를 통한 문제 해결

프록시 도입 전

클라이언트 → 서비스(트랜잭션시작~트랜잭션종료)-비즈니스 로직 → 리포지토리-데이터 접근 로직

서비스 계층의 트랜잭션 사용 코드 예시

//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());

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

프록시 도입 후

image.png

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

트랜잭션 프록시 코드 예시

public class TransactionProxy {
		private MemberService target;
		
		public void logic() {
				//트랜잭션 시작
				TransactionStatus status = transactionManager.getTransaction(..);
				try {
						// 실제 대상 호출
						target.logic();
						transactionManager.commit(status); // 성공 시 커밋
					} catch (Exception e) {
						transactionManager.rollback(status); // 실패 시 콜백
						throw new IllegalStateException(e);
					}
			}
}

트랜잭션 프록시 적용 후 서비스 코드 예시

public class Service {

		public void logic() {
				//트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
				bizLogic(fromId, toId, money);
		}
}
  • 프록시 도입 전: 서비스에 비즈니스 로직 + 트랜잭션 처리 로직이 함께 섞여있다.
  • 프록시 도입 후: 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져간다. 그리고 트랜잭션을 시작한 후에 실제 서비스를 대신 호출한다. 트랜잭션 프록시 덕분에 서비스 계층에는 순수한 비즈니스 로직만 남길 수 있다.

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

  • 스프링이 제공하는 AOP 기능을 사용하면 프록시를 매우 편리하게 적용할 수 있다.
    • @Aspect, @Advice, @Pointcut
  • 물론 스프링 AOP를 직접 사용해서 트랜잭션을 처리해도 되지만, 트랜잭션은 매우 중요한 기능이고, 누구나 사용하는 기능이다.
  • 개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션만 붙여주면 된다. 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션 프록시를 적용해준다.

@Transactional

org.springframework.transaction.annotation.Transactional

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

트랜잭션 AOP를 사용하는 새로운 서비스 클래스를 만들자.

  • @Transactional 애노테이션은 메서드에 붙여도 되고, 클래스에 붙여도 된다. 클래스에 붙이면 외부에서 호출 가능한 public 메서드가 AOP 적용 대상이 된다.

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

image.png

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

  • 선언적 트랜잭션 관리

    • @Transactional 애노테이션 하나만 선언해서 매우 편리하게 트랜잭션을 적용하는 것을 선언적 트랜잭션 관리라 한다.
    • 이름 그대로 해당 로직에 적용하겠다라고 어딘가에 선언하기만 하면 트랜잭션이 적용되는 방식이다.
  • 프로그래밍 방식 트랜잭션 관리

    • 트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용해서 트랜잭션 관련 코드를 직접 작성하는 것을 프로그래밍 방식의 트랜잭션 관리라 한다.
  • 선언적 트랜잭션 관리가 프로그래밍 방식에 비해 훨씬 간단하고 실욪적이기 때문에 실무에서는 대부분 선언적 트랜잭션 관리를 사용한다

  • 프로그래밍 방식의 트랜잭션 관리는 스프링 컨테이너나 AOP 기술 없이 간단히 사용할 수 있지만 실무에서는 대부분 스프링 컨테이너와 스프링 AOP를 사용하기 때문에 거의 사용되지 않는다.

  • 그나마 프로그래밍 방식 트랜잭션 관리는 테스트 시에 가끔 사용될 때는 있다.

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

기존에는 데이터소스와 트랜잭션 매니저를 직접 스프링 빈으로 등록해야 했다. 그런데 스프링 부트가 나오면서 많은 부분이 자동화되었다.

  • 스프링 부트는 데이터소스(DataSource)를 스프링 빈에 자동으로 등록한다.

  • 참고로 개발자가 직접 데이터소스를 빈으로 등록하면 스프링 부트는 데이터소스를 자동으로 등록하지 않는다.

  • 스프링부트가 기본으로 생성하는 데이터소스는 커넥션풀을 제공하는 HikariDataSource이다. 커넥션풀과 관련된 설정도 설정파일에서 지정할 수 있다.

  • spring.database.url 속성이 없으면 내장 데이터베이스(메모리 DB)를 생성하려고 시도한다.

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

  • 스프링 부트는 적절한 트랜잭션 매니저를 자동으로 스프링 빈에 등록한다.
  • 자동으로 등록되는 스프링 빈 이름: transactionManager
  • 참고로 개발자가 직접 트랜잭션 매니저를 빈으로 등록하면 스프링 부트는 트랜잭션 매니저를 자동으로 등록하지 않는다.

예외 계층

image.png

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

예외 기본 규칙

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

image.png

image.png

예외에 대한 2가지 기본 규칙을 기억하자

  • 예외는 잡아서 처리하거나 뱎으로 던져야 한다.
  • 예외를 잡거나 던질 때 지정한 예외뿐만 아니라 그 예외의 자식들도 함께 처리된다.
💡

예외를 처리하지 못하고 계속 던지면 어떻게 될까?

  • 자바 main() 쓰레드의 경우 예외 로그를 출력하면서 시스템이 종료된다
  • 웹 애플리케이션의 경우 여러 사용자의 요청을 처리하기 때문에 하나의 예외 때문에 시스템이 종료되면 안된다.

체크 예외 기본 이해

  • Exception 과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외이다. 단 RuntimeException은 예외로 한다.
  • 체크 예외는 잡아서 처리하거나, 또는 밖으로 던지도록 선언해야한다. 그렇지 않으면 컴파일 오류가 발생한다.

체크 예외의 장단점

체크 예외는 예외를 잡아서 처리할 수 없을 때, 예외를 밖으로 던지는 throws 예외를 필수로 선언해야 한다. 그렇지 않으면 컴파일 오류가 발생한다. 이것 때문에 장점과 단점이 동시에 존재한다.

  • 장점: 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아주는 훌륭한 안전장치이다.
  • 단점: 하지만 실제로는 개발자가 모든 체크 예외를 반드시 잡거나 던지도록 처리해야 하기 때문에, 너무 번거로운 일이 된다. 크게 신경쓰고 싶지 않은 예외까지 모두 챙겨야 한다. 추가로 의존 관계에 따른 단점도 존재

언체크 예외 기본 이해

  • RuntimeException과 그 하위 예외는 언체크 예외로 분류된다
  • 언체크 예외는 말 그대로 컴파일러가 예외를 체크하지 않는다는 뜻이다.
  • 언체크 예외는 체크 예외와 기본적으로 동일하다. 차이가 있다면 예외를 던지는 throws를 선언하지 않고, 생략할 수 있다. 이 경우 자동으로 예외를 던진다.

장점: 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다. 체크 예외의 경우 처리할 수 없는 예외를 밖으로 던지려
면 항상 throws 예외 를 선언해야 하지만, 언체크 예외는 이 부분을 생략할 수 있다. 이후에 설명하겠지만, 신경
쓰고 싶지 않은 예외의 의존관계를 참조하지 않아도 되는 장점이 있다.
단점: 언체크 예외는 개발자가 실수로 예외를 누락할 수 있다. 반면에 체크 예외는 컴파일러를 통해 예외 누락을
잡아준다.

체크 예외 활용

기본 원칙은 다음 2가지를 기억

  • 기본적으로 언체크(런타임) 예외를 사용하자
  • 체크 예외는 비즈니스 로직상 의도적으로 던지는 예외에만 사용하자
    • 해당 예외를 잡아서 반드시 처리해야 할 경우 체크 예외를 사용해야 한다.
      • 예)
        • 계좌 이체 실패
        • 결제 시 포인트 부족
        • 로그인 ID, PW 불일치 예외

체크 예외의 문제점

체크 예외는 컴파일러가 예외 누락을 체크하기 때문에 개발자가 실수로 놓친 예외를 잡아준다. 그래서 항상 명시적으로 예외를 잡아서 처리하거나, 처리할 수 없을 때는 예외를 던지도록 throws 예외를 선언한다.

image.png

  • 리포지토리는 DB에 접근해서 데이터를 저장하고 관리한다. 여기서는 SQLException 체크 예외를 던진다.
  • NetworkClient는 외부 네트워크에 접속해서 어떤 기능을 처리하는 객체이다. ConnectException 체크 예외를 던진다.
  • 서비스는 위 둘을 모두 호출한다
    • 따라서 두 곳에서 올라오는 체크 예외를 처리해야 한다
    • 근데 이 두 에러는 심각한 문제로 애플리케이션 로직에서 처리할 방법이 없다
  • 서비스는 SQLException과 ConnectException를 처리할 수 없으므로 둘 다 밖으로 던진다
  • 컨트롤러도 마찬가지로 둘을 처리할 수 없다
  • 웹 애플리케이션이라면 서블릿의 오류 페이지나, 스프링 MVC가 제공하는 ControllerAdvice에서 이런 예외를 공통으로 처리한다
    • 이런 문제는 보통 사용자에게 어떤 문제가 발생했는지 자세히 설명하기 어렵다. 그래서 일반적인 메시지를 보여준다
    • api는 500에러를 내려준다
    • 이렇게 해결이 불가능한 공통 예외는 별도의 오류 로그를 남기고, 개발자가 오류를 빨리 인지할 수 있도록 메일, 알림(문자, 슬랙)등을 통해서 전달 받아야 한다.

언체크 예외 활용

image.png

SQLException 을 런타임 예외인 RuntimeSQLException 으로 변환했다.

ConnectException 대신에 RuntimeConnectException 을 사용하도록 바꾸었다.
런타임 예외이기 때문에 서비스, 컨트롤러는 해당 예외들을 처리할 수 없다면 별도의 선언 없이 그냥 두면 된다.

JPA 기술도 런타임 예외를 사용한다. 스프링도 대부분 런타임 예외를 제공한다.

런타임 예외도 필요하면 잡을 수 있기 때문에 필요한 경우에는 잡아서 처리하고, 그렇지 않으면 자연스럽게 던지도록 둔 다. 그리고 예외를 공통으로 처리하는 부분을 앞에 만들어서 처리하면 된다.

추가로 런타임 예외는 놓칠 수 있기 때문에 문서화가 중요하다.

  • 런타임 예외는 문서화
    런타임 예외는 문서화를 잘해야 한다.
    또는 코드에 throws 런타임예외 을 남겨서 중요한 예외를 인지할 수 있게 해준다.

스프링과 문제 해결 - 예외 처리, 반복
todo..

profile
서비스기업 가고 싶은 대학생

0개의 댓글

관련 채용 정보