728x90
반응형
SMALL
SMALL

이전 포스팅에서 스프링 트랜잭션을 공부했고 그 과정에서 @Transactional 애노테이션을 알아봤다.

이렇게 애노테이션 하나로 트랜잭션을 시작하는 것을 '선언적 트랜잭션'이라고 한다. 

 

@Transactional 애노테이션은 여러가지 옵션들이 있는데 굉장히 중요한 부분이다. 하나씩 차근차근 알아보자!

@Transactional 옵션들

이번엔 @Transactional 애노테이션이 가지고 있는 여러 속성들을 알아보자.

public @interface Transactional {
     String value() default "";
     String transactionManager() default "";
     Class<? extends Throwable>[] rollbackFor() default {};
     Class<? extends Throwable>[] noRollbackFor() default {};
     Propagation propagation() default Propagation.REQUIRED;
     Isolation isolation() default Isolation.DEFAULT;
     int timeout() default TransactionDefinition.TIMEOUT_DEFAULT;
     boolean readOnly() default false;
     String[] label() default {};
}

 

value, transactionManager

트랜잭션을 사용하려면, 먼저 스프링 빈에 등록된 어떤 트랜잭션 매니저를 사용할지 알아야 한다. @Transactional 애노테이션도 결국 트랜잭션 매니저를 사용하는거기 때문에 트랜잭션 매니저를 지정해줘야 하고 지정할 때는 value, transactionManager 둘 중 하나에 트랜잭션 매니저의 스프링 빈의 이름을 적어주면 된다. 지정하지 않으면 기본으로 등록된 트랜잭션 매니저를 사용하기 때문에 대부분 생략한다. 그러나 트랜잭션 매니저가 둘 이상이면 다음처럼 트랜잭션 매니저의 이름을 지정해서 구분하면 된다.

@Transactional("memberTxManager")
public void member() {...}

@Transactional("orderTxManager")
public void order() {...}
  • 참고로, 애노테이션에서 속성이 하나인 경우 위 예처럼 value는 생략하고 값을 바로 넣을 수 있다.

 

rollbackFor

트랜잭션 내 로직을 수행 중 예외가 발생하면 스프링 트랜잭션의 기본 정책은 다음과 같다.

  • 언체크 예외(런타임 예외라고도 함)인 RuntimeException, Error 또는 그 하위 예외가 발생하면 롤백
  • 체크 예외인 Exception 또는 그 하위 예외들은 커밋

이 옵션을 사용해서 기본 정책에 '추가적으로' 어떤 예외가 발생했을 때 롤백을 하라고 지정할 수 있다.

@Transactional(rollbackFor = Exception.class)

이렇게 하면 Exception부터 그 하위 예외들이 발생해도 롤백한다.

rollbackForClassName이라는 속성도 있는데 이는 문자로 넣는 경우이다. 그냥 rollbackFor를 사용하면 된다.

 

noRollbackFor

이는 rollbackFor와 반대 개념이다. 기본 정책에 추가로 어떤 예외가 발생하면 롤백하면 안되는지 지정할 수 있다. 마찬가지로 noRollbackForClassName도 있다. 

 

propagation

이는 트랜잭션 전파에 대한 내용이다. 매우 중요하다. 우선 가능 옵션 리스트는 다음과 같다.

전파타입 설명
REQUIRED ⭐️ 기본값으로 설정되는 전파 타입입니다. 기존에 활성화된 트랜잭션에 자식 트랜잭션이 합류하여 하나의 물리 트랜잭션으로 취급합니다. 기존 트랜잭션이 없다면 새로 트랜잭션을 만듭니다.
REQUIRES_NEW ⭐️ 기존에 활성화된 트랜잭션이 있더라도 합류하지 않고 별개의 트랜잭션으로 취급하여 수행되는 전파 타입입니다.
SUPPORTS 기존에 활성화된 트랜잭션이 있다면 합류를 하고, 활성화된 트랜잭션이 없다면 합류하지 않고 트랜잭션 없이 그대로 작업을 수행합니다. 트랜잭션이 그다지 필요없는 SELECT 쿼리에 적용하면 성능 향상을 기대할 수 있다고도 합니다.
NOT_SUPPORTED 트랜잭션을 지원하지 않는다는 의미입니다. 그래서, 기존 트랜잭션이 없으면 트랜잭션이 없이 진행하고, 기존 트랜잭션이 있어도 트랜잭션이 없이 진행합니다. 
MANDATORY 기존에 활성화된 트랜잭션이 존재할 경우 해당 트랜잭션에 합류하며, 존재하지 않을 경우 예외를 발생시킵니다. 쉽게 말해 기존 트랜잭션이 반드시 있어야 합니다.
NEVER 기존에 활성화된 트랜잭션이 존재할 경우 예외를 발생시키며, 활성화된 트랜잭션이 없을 경우 작업을 수행합니다.
NESTED 기존 트랜잭션이 없으면 새로운 트랜잭션을 만들고 기존 트랜잭션이 있으면 중첩 트랜잭션을 만듭니다. 중첩 트랜잭션은 외부(기존) 트랜잭션에 영향을 받지만, 중첩 트랜잭션은 외부 트랜잭션에 영향을 주지 않습니다. 즉, 중첩 트랜잭션이 롤백 되어도 외부 트랜잭션은 커밋할 수 있지만 외부 트랜잭션이 롤백되면 중첩 트랜잭션도 같이 롤백됩니다. (중첩 트랜잭션은 JPA에서는 사용할 수 없는 옵션입니다)

 

참고로, 트랜잭션 옵션 중 isolation, timeout, readOnly는 트랜잭션이 처음 시작될 때만 적용된다. 즉, 트랜잭션에 참여하는 경우에는 적용되지 않는다. 예를 들어, REQUIRED를 통한 트랜잭션 시작이나 REQUIRES_NEW를 통한 트랜잭션 시작 시점에만 적용되고 트랜잭션에 참여하는 트랜잭션은 기존 트랜잭션을 시작할 때 적용했던 옵션을 따라 사용된다. 

 

 

propagation - REQUIRED

기본값은 REQUIRED인데, 이 옵션은 위에 설명한 그대로 기존에 활성화된 트랜잭션이 있으면 그 트랜잭션에 합류한다. 좀 더 정확히 말하면 '물리 트랜잭션''논리 트랜잭션'으로 나뉘어지는데 다음 그림을 보자.

1. 클라이언트에서 트랜잭션을 가지는 서비스를 호출하면 트랜잭션이 시작된다. 

2. 하나의 트랜잭션에서 실행되던 로직에서 새로운 트랜잭션을 가지는 또다른 서비스를 호출하여 트랜잭션 두개가 생성된다.

3. 이 두개의 트랜잭션은 논리 트랜잭션으로 나뉘어지고 이 두개의 논리 트랜잭션을 크게 묶어 하나의 물리 트랜잭션으로 합류된다.

 

이런 흐름이 REQUIRED 옵션이다. 이렇게 하나의 물리 트랜잭션으로 합류가 된다. 그리고 이 물리 트랜잭션이 실제 데이터베이스와 통신하는 트랜잭션이고 논리 트랜잭션들은 애플리케이션 레벨에서 트랜잭션을 시작하고 종료(커밋 또는 롤백)하는 트랜잭션이다. 

 

 

이 경우, 중요한 두가지 규칙이 있다.

  • 두 개의 논리 트랜잭션이 모두 커밋되어야 물리 트랜잭션이 커밋된다.
  • 두 개의 논리 트랜잭션 중 하나라도 롤백이라면 물리 트랜잭션은 롤백된다. 

 

다음 그림을 보고 이와 같이 정하자.

최초 시작되는 트랜잭션을 외부 트랜잭션, 내부 로직에서 또다른 트랜잭션을 만들려 하는 곳을 내부 트랜잭션이라고 칭하자.

 

둘 다 논리 트랜잭션이고 이 두개의 논리 트랜잭션이 모두 커밋되어야 하나의 물리 트랜잭션(실제 데이터베이스에 커밋 또는 롤백을 날리는)이 커밋된다고 했다. 실제로 코드를 통해서 어떻게 동작하는지 눈으로 직접 확인해보자!

@Test
void inner_commit() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("outer.isNewTransaction() = {}", outer.isNewTransaction());

    log.info("내부 트랜잭션 시작");
    TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("inner.isNewTransaction() = {}", inner.isNewTransaction());

    log.info("내부 트랜잭션 커밋");
    txManager.commit(inner);

    log.info("외부 트랜잭션 커밋");
    txManager.commit(outer);
}
  • 외부 트랜잭션(outer)를 시작한다. 최초로 트랜잭션이 수행되면 신규 트랜잭션이 되고 그 값을 보관하고 있기 때문에 outer.isNewTransaction()을 호출하면 `true`를 반환한다.
  • 외부 트랜잭션이 수행중인데 내부 트랜잭션(inner)을 추가로 수행했다. 내부 트랜잭션을 시작하는 시점에는 이미 외부 트랜잭션이 진행중인 상태이다. 이 경우 내부 트랜잭션은 외부 트랜잭션에 참여한다.
  • 내부 트랜잭션이 외부 트랜잭션에 참여한다는 뜻은 내부 트랜잭션이 외부 트랜잭션을 그대로 이어 받아서 따른다는 뜻이다. 다른 관점으로 보면 외부 트랜잭션의 범위가 내부 트랜잭션까지 넓어진다는 뜻이다.
  • 내부 트랜잭션은 이미 진행중인 외부 트랜잭션에 참여하므로 신규 트랜잭션이 아니다. 그러므로 inner.isNewTransaction()을 호출하면 `false`가 된다.
  • 내부 트랜잭션을 커밋한 후 외부 트랜잭션도 커밋했다. (당연히 내부 트랜잭션을 먼저 커밋하고 외부 트랜잭션이 커밋되는게 순서가 맞다)

실행 결과

2024-12-11T16:15:04.484+09:00  INFO 75930 --- [    Test worker] cwchoiit.tx.propagation.BasicTxTest      : 외부 트랜잭션 시작
2024-12-11T16:15:04.485+09:00 DEBUG 75930 --- [    Test worker] o.s.j.d.DataSourceTransactionManager     : Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2024-12-11T16:15:04.486+09:00 DEBUG 75930 --- [    Test worker] o.s.j.d.DataSourceTransactionManager     : Acquired Connection [HikariProxyConnection@-397934204 wrapping conn0: url=jdbc:h2:mem:3b843fdd-51b1-4753-80e2-eff01972f785 user=SA] for JDBC transaction
2024-12-11T16:15:04.487+09:00 DEBUG 75930 --- [    Test worker] o.s.j.d.DataSourceTransactionManager     : Switching JDBC Connection [HikariProxyConnection@-397934204 wrapping conn0: url=jdbc:h2:mem:3b843fdd-51b1-4753-80e2-eff01972f785 user=SA] to manual commit
2024-12-11T16:15:04.487+09:00  INFO 75930 --- [    Test worker] cwchoiit.tx.propagation.BasicTxTest      : outer.isNewTransaction() = true
2024-12-11T16:15:04.487+09:00  INFO 75930 --- [    Test worker] cwchoiit.tx.propagation.BasicTxTest      : 내부 트랜잭션 시작
2024-12-11T16:15:04.487+09:00 DEBUG 75930 --- [    Test worker] o.s.j.d.DataSourceTransactionManager     : Participating in existing transaction
2024-12-11T16:15:04.487+09:00  INFO 75930 --- [    Test worker] cwchoiit.tx.propagation.BasicTxTest      : inner.isNewTransaction() = false
2024-12-11T16:15:04.487+09:00  INFO 75930 --- [    Test worker] cwchoiit.tx.propagation.BasicTxTest      : 내부 트랜잭션 커밋
2024-12-11T16:15:04.487+09:00  INFO 75930 --- [    Test worker] cwchoiit.tx.propagation.BasicTxTest      : 외부 트랜잭션 커밋
2024-12-11T16:15:04.487+09:00 DEBUG 75930 --- [    Test worker] o.s.j.d.DataSourceTransactionManager     : Initiating transaction commit
2024-12-11T16:15:04.488+09:00 DEBUG 75930 --- [    Test worker] o.s.j.d.DataSourceTransactionManager     : Committing JDBC transaction on Connection [HikariProxyConnection@-397934204 wrapping conn0: url=jdbc:h2:mem:3b843fdd-51b1-4753-80e2-eff01972f785 user=SA]
2024-12-11T16:15:04.488+09:00 DEBUG 75930 --- [    Test worker] o.s.j.d.DataSourceTransactionManager     : Releasing JDBC Connection [HikariProxyConnection@-397934204 wrapping conn0: url=jdbc:h2:mem:3b843fdd-51b1-4753-80e2-eff01972f785 user=SA] after transaction
  • 로그를 보면 꽤나 흥미로운데, 내부 트랜잭션을 시작할 때 `Participating in existing transaction` 이라는 메시지를 확인할 수 있다. 이 메시지는 내부 트랜잭션이 기존에 존재하는 외부 트랜잭션에 참여한다는 뜻이다. 근데 어떻게 알까? 아까 위에서 말한 신규 트랜잭션인지를 확인해서 신규 트랜잭션이 아닌 경우에 전파 속성의 기본값인 기존 트랜잭션에 참여하는 방식을 사용하기 때문이다.
  • 그리고 내부 트랜잭션을 커밋하고 나서 커밋 로그가 전혀 찍혀있지 않다. 그리고 외부 트랜잭션을 커밋한다는 로그가 나오고 나서 커밋 로그가 찍혀있다. 왜 그럴까? 트랜잭션을 커밋하는 순간 그 트랜잭션은 더이상 사용할 수 없다. 아예 끝난다. 그런데 외부 트랜잭션이 남아있는데 내부 트랜잭션을 커밋한다고 해서 물리 트랜잭션을 커밋해버리면 외부 트랜잭션에서는 작업했던 내용을 반영할 수 없기 때문이다. 
  • 그래서 스프링은 이렇게 여러 트랜잭션이 함께 사용되는 경우, 처음 트랜잭션을 시작한 외부 트랜잭션이 실제 물리 트랜잭션을 관리하도록 한다. 따라서 외부 트랜잭션이 커밋되어야만 실제 물리 트랜잭션을 커밋한다는 로그가 찍히는 것이다.
  • 그래서 내부 트랜잭션이 정상적으로 커밋이 되고, 외부 트랜잭션도 커밋이 되어야만 커밋을 한다는 로그가 찍히는 모습이다.

 

여기서 그럼, 둘 중 하나가 롤백을 한다면 결국 물리 트랜잭션이 롤백이 되는데 이 때 외부 트랜잭션과 내부 트랜잭션이 롤백을 할 때 처리되는 방식이 다르다. 이것을 이해해야 한다.

 

외부 트랜잭션이 최초의 트랜잭션이기 때문에 결국 이 외부 트랜잭션이 커밋 또는 롤백을 해야 물리 트랜잭션이 커밋 또는 롤백을 한다.

내부 트랜잭션은 커밋이나 롤백을 해도 물리 트랜잭션은 아무런 작업을 하지 않는다. 왜냐? 외부 트랜잭션이 아직 남아있기 때문에.

 

1.내부 트랜잭션이 커밋되고 외부 트랜잭션이 롤백이 되면 그냥 롤백 처리가 된다.
2.내부 트랜잭션이 롤백되고 외부 트랜잭션이 커밋되면 UnexpectedRollbackException 예외가 터진다.

저 부분이 중요하다. 내부 트랜잭션이 롤백을 하면 물리 트랜잭션이 롤백을 바로 하지 않지만(물리 트랜잭션이 롤백 또는 커밋이 되면 데이터베이스에 실제 커밋 또는 롤백을 날리는데 아직 트랜잭션이 끝난게 아니니까) 물리 트랜잭션에 Rollback-Only 라는 마크를 달게 된다.

 

여기서 외부 트랜잭션이 커밋이 된다면 물리 트랜잭션에 커밋을 날리는데 커밋을 할 수 없는것이다. 왜냐? 내부 트랜잭션이 롤백을 했으니까. 그러나 개발자는 외부 트랜잭션을 커밋을 했는데 커밋이 되면 안되니까 UnexpectedRollbackException이 발생하는 것이다. 즉, 스프링 입장에서는 "너가 지금 물리 트랜잭션을 커밋하려고 시도했지만 내부 트랜잭션에서 롤백을 날렸기 때문에 넌 커밋을 할 수 없어"라고 친절하게 알려주는 것. 이것을 코드로 한번 봐보자!

@Test
void inner_rollback() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());

    log.info("내부 트랜잭션 시작");
    TransactionStatus inner = txManager.getTransaction(new DefaultTransactionAttribute());

    log.info("내부 트랜잭션 롤백");
    txManager.rollback(inner);

    log.info("외부 트랜잭션 커밋");
    txManager.commit(outer);
}
  • 내부 트랜잭션은 롤백을 하고, 외부 트랜잭션은 커밋을 한다.
  • 이 경우에 전체 트랜잭션인 물리 트랜잭션은 롤백을 하고 UnexpectedRollbackException 예외를 던진다.

실행 결과

  • 실행 결과를 보면 UnexpectedRollbackException이 발생했음을 알 수 있다.
  • 그리고, 내부 트랜잭션을 롤백할 때 찍히는 로그를 보면 `marking existing transaction as rollback-only` 라는 로그가 찍혀있다. 내부 트랜잭션은 물리 트랜잭션에 커밋이나 롤백을 할 수 없기 때문에 현재 이 물리 트랜잭션에 나 롤백했어! 라고 마킹을 하는 것이다. 그래야 외부 트랜잭션에서 커밋이든 롤백이든 뭔가 하려고 할 때 확인하고 롤백을 할 수 있을테니 말이다.

이게 기본 전파 속성 'REQUIRED'의 동작 흐름이다. 

 

그래서 진짜 진짜 중요하게 이해해야 하는 부분이 있는데, 아래 그림을 보자.

 

위 그림처럼 트랜잭션에서 또 다른 트랜잭션을 가지는 내부 로직이 있고 이 세 개의 트랜잭션이 모두 REQUIRED 전파 타입인 경우가 있다고 가정하자.

 

REQUIRED 전파 속성은 모두 커밋이 되어야 물리 트랜잭션이 커밋되고 하나라도 롤백이라면 물리 트랜잭션은 롤백된다고 했다. 

근데 만약, 런타임 예외가 발생한 지점에서 예외 처리를 하지 못하고 자신을 호출한 곳으로 예외를 던졌다고 가정하자. 당연히 이 로직의 트랜잭션은 롤백을 할 것이다. 그러나 이 트랜잭션이 가장 최초에 시작된 트랜잭션이 아니라면 롤백을 바로 데이터베이스에 날리는 게 아니라 Rollback Only 마킹을 하고 끝나는데, 그럼 이 예외를 넘겨받은 바깥쪽 트랜잭션 로직에서 이 예외를 복구했다면? 즉 예외를 잡아서 처리했다면? 이 전체 물리 트랜잭션은 커밋이 성공적으로 될까? 아니다. 그렇지 않다. 왜냐하면 결국 이 REQUIRED 전파 타입은 사용중인 트랜잭션에 참여하기 때문에 내부의 논리 트랜잭션에서 하나라도 rollback only에 마킹을 했다면 무조건 물리 트랜잭션은 롤백을 한다. 근데 예외를 잡아버리니까 정상 흐름을 유지하고 트랜잭션을 커밋을 할 거라고 기대하는 경우가 종종 있을 수 있다. 충분히 그럴수 있다는 생각이든다. 하지만 아니라는 것을 꼭 염두하자.

 

코드를 통해서 직접 확인해보자!

MemberService

package cwchoiit.tx.propagation;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;
    private final LogRepository logRepository;

    @Transactional
    public void joinV1(String username) {
        Member member = new Member(username);
        Log logEntity = new Log(username);

        log.info("MemberRepository 호출 시작");
        memberRepository.save(member);
        log.info("MemberRepository 호출 종료");

        log.info("LogRepository 호출 시작");
        logRepository.save(logEntity);
        log.info("LogRepository 호출 종료");
    }

    @Transactional
    public void joinV2(String username) {
        Member member = new Member(username);
        Log logEntity = new Log(username);

        log.info("MemberRepository 호출 시작");
        memberRepository.save(member);
        log.info("MemberRepository 호출 종료");

        log.info("LogRepository 호출 시작");
        try {
            logRepository.save(logEntity);
        } catch (RuntimeException e) {
            log.info("로그 저장에 실패했습니다. logMessage = {}", logEntity.getMessage());
        }
        log.info("LogRepository 호출 종료");
    }
}
  • 간단한 Member 관련 서비스 코드이다. joinV1, joinV2 메서드가 있다.
  • 각 메서드에 @Transactional 애노테이션을 달았고, 아무런 옵션도 주지 않았으니 전파 옵션은 기본값인 REQUIRED이다.

MemberRepository

package cwchoiit.tx.propagation;

import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Slf4j
@Repository
@RequiredArgsConstructor
public class MemberRepository {

    private final EntityManager em;

    @Transactional
    public void save(Member member) {
        log.info("Saving member {}", member);
        em.persist(member);
    }

    public Optional<Member> findById(String username) {
        return em.createQuery("SELECT m FROM Member m WHERE m.username = :username", Member.class)
                .setParameter("username", username)
                .getResultList()
                .stream()
                .findFirst();
    }
}
  • MemberRepository에서 save() 메서드를 보면 이 메서드에도 @Transactional 애노테이션이 달려있다.

LogRepository

package cwchoiit.tx.propagation;

import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {

    private final EntityManager em;

    @Transactional
    public void save(Log logMessage) {
        log.info("log 저장");
        em.persist(logMessage);

        if (logMessage.getMessage().contains("로그예외")) {
            log.info("Log 저장 시 예외 발생");
            throw new RuntimeException("예외 발생");
        }
    }

    public Optional<Log> find(String message) {
        return em.createQuery("SELECT l FROM Log l WHERE l.message = :message", Log.class)
                .setParameter("message", message)
                .getResultList()
                .stream()
                .findFirst();
    }
}
  • LogRepository에도 save() 메서드에 @Transactional 애노테이션이 달려있다.

이런 상태에서 MemberService.joinV1(), joinV2() 호출하면, MemberRepository.save()도 호출하고, LogRepository.save()도 호출한다. 그리고 joinV1, joinV2@Transactional이 붙어있기 때문에 트랜잭션을 최초로 시작하는 지점은 이 곳이고, MemberRepository.save()LogRepository.save()에 붙어있는 @Transactional은 전파 옵션이 REQUIRED이므로 기존 트랜잭션에 참여한다. 

 

즉, 외부 트랜잭션, 내부 트랜잭션, 내부 트랜잭션 이렇게 총 세개의 논리 트랜잭션이 있게 되고, 모두가 커밋해야 물리 트랜잭션이 커밋이 되고 하나라도 롤백이면 물리 트랜잭션은 롤백이다. 이게 REQUIRED 옵션이니까. 그런데 이 코드는 사용자가 회원가입을 할때 호출되는 코드인데 회원가입 시 비즈니스 규칙으로 Log라는 엔티티에 해당 유저를 저장하는 부가 기능이 있을뿐이다. LogRepository.save()에서 어떤 예외가 발생했다고 해서 사용자 회원가입까지 못시키게 해버리면 사용자 입장에서는 원인도 모르고 그냥 회원가입이 안되는 상황을 마주하게 된다. 그게 싫어서 MemberService.joinV2()에서 LogRepository.save()의 예외가 올라오면 그 예외를 잡아버린다고 코드를 작성한 것이다. 이런다고 롤백이 안되는게 아니다! 이미 LogRepository.save()에서 실행된 논리 트랜잭션은 Rollback-only 마크를 찍었다. 그럼 외부 트랜잭션에서 정상흐름으로 커밋을 하더라도 절대 물리 트랜잭션은 커밋되지 않는다. 테스트 코드로 테스트를 해보자.

/**
 * memberService     @Transactional: ON
 * memberRepository  @Transactional: ON
 * logRepository     @Transactional: ON Exception
 */
@Test
void recover_exception_fail() {
    String username = "로그예외_recover_exception_fail";

    assertThatThrownBy(() -> memberService.joinV2(username)).isInstanceOf(UnexpectedRollbackException.class);

    assertThat(memberRepository.findById(username)).isEmpty();
    assertThat(logRepository.find(username)).isEmpty();
}
  • joinV2를 호출하는데 이 메서드는 분명히 UnexpectedRollbackException을 던질 것이다.
  • 그리고 데이터베이스에 Log 엔티티 뿐 아니라, Member 엔티티 역시 저장되지 않았을 것이다. 물리 트랜잭션은 롤백이 되기 때문에.

실행 결과

실행 결과를 보면 테스트는 통과하고 로그는 커밋을 시도했는데 트랜잭션에 rollback-only 마크가 찍혀있기 때문에 롤백을 한다라는 로그가 뚜렷히 보인다. 이 경우, 어떻게 해결할 수 있을까? 가장 간단한 건 바로 다음에 소개할 REQUIRES_NEW 전파 옵션을 사용하면 된다.

 

또다른 방법으로는 MemberRepository.save(), LogRepository.save() 이 두 개가 각각의 트랜잭션을 사용하면 된다. 아 물론 REQUIRES_NEW도 각각의 트랜잭션인데 MemberRepository.save()는 기존 트랜잭션에 참여하는 상태이고(왜냐하면, MemberService.joinV2()@Transactional이 있기 때문에 여기가 최초의 트랜잭션 시작점이라 그렇다) LogRepository.save()는 새로운 트랜잭션을 열어버리는게 REQUIRES_NEW인데, 아예 각각의 트랜잭션이 둘 다 물리 트랜잭션이 되게끔 MemberService.joinV2()에 있는 @Transactional을 없애버리면, 각각 트랜잭션이 둘 다 물리 트랜잭션이 되는 그런 경우로도 해결할 수 있다.

 

근데 이 방법의 문제는, 트랜잭션의 가장 중요한 특성인 원자성을 해친다는 점이다. 결국은 멤버를 저장하고, 로그를 저장하는 작업은 MemberService.joinV2()안에서 모두 일어나는 건데 이 joinV2()가 트랜잭션을 시작해야 이 안에서 일어난 모든 작업이 성공하거나 모든 작업이 실패하게 할 수 있다. 그런데 여기에 트랜잭션을 거는게 아니라, 각각의 save() 메서드에 걸어버리면 어떤건 성공하고 어떤건 실패하는 경우가 되는 것이다. 이게 만약 출금과 입금 작업이라면 큰 문제가 된다. 그러나 지금같은 비즈니스 규칙이 생겨서 로그를 저장하는데 실패하더라도 멤버는 회원가입 시키게 하고 싶다라는 룰이 프로젝트에 만들어졌으면 이런 방식으로도 해결을 할 수 있다는 말이다. 그래서 정답이 딱 있는게 아니라는 말을 하고 싶었다. 

 

 

propagation - REQUIRES_NEW

REQUIRES_NEW는 위에서 설명한 REQUIRED와 다르게 새 트랜잭션을 사용하는 내부 로직이 있으면, 이전 트랜잭션과 완전히 분리된 새로운 트랜잭션에서 동작하는 방식이다. 즉, 또다른 커넥션이 사용된다.

 

기존 트랜잭션(A)이 있는 경우 해당 트랜잭션을 잠시 보류한 뒤 새로운 트랜잭션(B)을 만들어서 그 트랜잭션(B)을 사용하고 내부 로직이 모두 종료되는 시점에 트랜잭션(B)을 커밋 또는 롤백한 후 기존 트랜잭션(A)이 다시 사용된다.

 

그림으로 보면 다음으로 이해할 수 있다.

즉, 완전히 분리된 물리 트랜잭션을 가지며 어느 한쪽이 다른 한쪽에게 영향을 주지 않는다. 다시 말해 기존 물리 트랜잭션이 커밋이나 롤백을 하던, 새로운 물리 트랜잭션이 커밋이나 롤백을 하던 아무런 영향을 서로에게 주지 않는다. 자기 할 것을 한다. 이것도 코드로 한번 봐보자.

@Test
void required_new() {
    log.info("외부 트랜잭션 시작");
    TransactionStatus outer = txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("outer.isNewTransaction() = {}", outer.isNewTransaction());

    log.info("내부 트랜잭션 시작");
    DefaultTransactionAttribute definition = new DefaultTransactionAttribute();
    definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); // 새 트랜잭션을 만듦.
    TransactionStatus inner = txManager.getTransaction(definition);
    log.info("inner.isNewTransaction() = {}", inner.isNewTransaction());

    log.info("내부 트랜잭션 롤백");
    txManager.rollback(inner);

    log.info("외부 트랜잭션 커밋");
    txManager.commit(outer);
}
  • 내부 트랜잭션(inner)을 시작할 때 전파 옵션인 propagationBehaviorPROPAGATION_REQUIRES_NEW 옵션을 주었다.
  • 이렇게 하면 내부 트랜잭션을 시작할 때 기존 트랜잭션에 참여하는 게 아니라 새로운 물리 트랜잭션을 만들어서 시작하게 된다. 
  • 당연히, 각각의 커밋과 롤백이 따로 수행된다.

실행 결과

  • 실행 결과를 보면 외부 트랜잭션, 내부 트랜잭션의 각각 커넥션이 conn0, conn1로 다른 것을 알 수 있고, 내부 트랜잭션을 롤백할때 바로 트랜잭션 롤백 로그가 남는 것을 확인할 수 있다. 
  • 외부 트랜잭션을 커밋할때도 커밋 로그가 따로 남는 것을 확인할 수 있다.

주의

REQUIRES_NEW를 사용할 때 주의할 점은 커넥션이 쉽게 고갈될 수 있다. 만약 고객이 10명 접속했다고 하면 이 전파 옵션을 사용할 경우 쉽게 말해 20개의 커넥션을 사용할 수 있다. 내부 트랜잭션을 모두 이 옵션으로 사용한 경우에 해당하는 말이지만. 그래서, 이 옵션은 주의하면서 사용해야 한다.

 

문제 해결

아까 위에서 겪었던 MemberService, MemberRepository, LogRepository 이 문제를 REQUIRES_NEW로 해결해보자! 건드릴 부분은 딱 한 부분이다. LogRepositorysave() 메서드에 붙어있는 @Transactional에 전파 옵션을 REQUIRES_NEW로 변경만 해주면 된다. 아래 코드를 보자.

package cwchoiit.tx.propagation;

import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.util.Optional;

@Slf4j
@Repository
@RequiredArgsConstructor
public class LogRepository {

    private final EntityManager em;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void save(Log logMessage) {
        log.info("log 저장");
        em.persist(logMessage);

        if (logMessage.getMessage().contains("로그예외")) {
            log.info("Log 저장 시 예외 발생");
            throw new RuntimeException("예외 발생");
        }
    }
	
    ...
}
  • @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용하므로써, 이 메서드가 호출되면 아예 새로운 커넥션을 만든다. 즉, MemberService.joinV2()에서 만든 커넥션과는 별개의 커넥션으로 동작하므로, 이 LogRepository.save()에서 롤백을 하더라도 MemberService.joinV2()에는 아무런 영향을 끼치지 않는다. 
  • 테스트 코드로 실행해보자.
/**
 * memberService     @Transactional: ON
 * memberRepository  @Transactional: ON
 * logRepository     @Transactional: ON (REQUIRES_NEW)
 */
@Test
void recover_exception_success() {
    String username = "로그예외_recover_exception_success";

    memberService.joinV2(username);

    assertThat(memberRepository.findById(username)).isNotNull();
    assertThat(logRepository.find(username)).isEmpty();
}
  • joinV2()는 이제 어떠한 에러도 던지지 않을 것이다. 별개의 트랜잭션을 사용하기 때문에 LogRepository.save()가 롤백을 하더라도 아무런 영향을 받지 않는다.
  • 그렇게 되면, 멤버는 잘 저장이 되고 로그는 남지 않은 그런 상태가 된다. 

실행 결과

 

isolation

트랜잭션 격리 수준을 지정할 수 있다. 기본값은 데이터베이스에서 설정한 트랜잭션 격리 수준을 사용하는 'DEFAULT'이다. 대부분 데이터베이스에서 설정한 기준을 따른다. 애플리케이션 개발자가 트랜잭션 격리 수준을 직접 지정하는 경우는 드물다. 

  • DEFAULT: 데이터베이스에서 설정한 격리 수준을 따른다.
  • READ_UNCOMMITTED: 커밋되지 않은 읽기 (특정 레코드를 변경 후 커밋하지 않았을 시점에서도 변경된 값을 적용해서 데이터베이스를 통해 읽어올 수 있음을 의미)
  • READ_COMMITED: 커밋된 읽기 (특정 레코드를 변경 후 커밋하지 않으면 변경된 값이 아닌 직전 커밋 데이터를 읽음)
  • REPEATABLE_READ: 반복 가능한 읽기
  • SERIALIZABLE: 직렬화 가능

timeout

트랜잭션 수행 시간에 대한 타임아웃을 초 단위로 지정. 기본값은 트랜잭션 시스템의 타임아웃을 사용한다. 운영 환경에 따라 동작하는 경우도 있고 그렇지 않은 경우도 있기 때문에 꼭 확인하고 사용해야 한다.

 

label

트랜잭션 애노테이션에 있는 값을 직접 읽어서 어떤 동작을 하고 싶을 때 사용할 수 있다. 일반적으로 사용하지 않는다.

 

readOnly

트랜잭션은 기본적으로 읽기 쓰기가 모두 가능한 트랜잭션이 생성된다. `readOnly=true` 옵션을 사용하면 읽기 전용 트랜잭션이 생성된다. 이 경우 등록, 수정, 삭제가 안되고 읽기 기능만 동작한다. (드라이버나 데이터베이스에 따라 정상 동작하지 않는 경우도 있다) 그리고 이 옵션을 사용하면 읽기에서 다양한 성능 최적화가 발생할 수 있다.

 

readOnly 옵션은 크게 3가지에 적용된다.

  • 프레임워크
    • JdbcTemplate은 읽기 전용 트랜잭션 안에서 변경 기능을 실행하면 예외를 던진다.
    • JPA는 읽기 전용 트랜잭션의 경우 커밋 시점에 `flush()`를 호출하지 않는다. 읽기 전용이니 변경에 사용되는 플러시를 호출할 필요가 없다. 추가로 변경이 필요 없으니 변경 감지를 위한 스냅샷 객체도 생성하지 않는다. 이렇게 JPA에서는 다양한 최적화가 발생
  • JDBC 드라이버 (DB와 드라이버 버전에 따라 다르게 동작하기 때문에 사전 확인 필요)
    • 읽기 전용 트랜잭션에서 변경 쿼리가 발생하면 예외를 던진다.
    • 읽기, 쓰기 데이터베이스를 구분해서 요청한다. 읽기 전용 트랜잭션의 경우 읽기 데이터베이스의 커넥션을 획득해서 사용한다. 읽기 전용 트랜잭션의 경우 읽기 데이터베이스의 커넥션을 획득해서 사용한다.
  • 데이터베이스
    • 데이터베이스에 따라 읽기 전용 트랜잭션의 경우 읽기만 하면 되므로, 내부에서 성능 최적화가 발생한다.

 

 

체크 예외 / 언체크 예외(런타임 예외) 발생 시 커밋 또는 롤백

위 속성을 설명하면서 스프링 트랜잭션의 기본 설정으로 체크 예외가 발생하면 커밋, 언체크 예외가 발생하면 롤백한다고 했다.

실제 그렇게 동작하는지 확인해보자.

 

package com.example.springtx.exception;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;

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

@Slf4j
@SpringBootTest
public class RollbackTest {

    @Autowired RollbackService rollbackService;

    @Test
    void runtimeException() {
        assertThatThrownBy(rollbackService::runtimeException)
                .isInstanceOf(RuntimeException.class);
    }

    @Test
    void checkedException() {
        assertThatThrownBy(rollbackService::checkedException)
                .isInstanceOf(MyException.class);
    }

    @Test
    void checkedRollbackFor() {
        assertThatThrownBy(rollbackService::rollbackFor)
                .isInstanceOf(MyException.class);
    }

    @TestConfiguration
    static class RollbackTestConfig {

        @Bean
        RollbackService rollbackService() {
            return new RollbackService();
        }
    }

    static class RollbackService {

        /**
         * 런타임 예외(언체크 예외)시 롤백 확인
         * */
        @Transactional
        public void runtimeException() {
            log.info("call runtimeException");
            throw new RuntimeException();
        }

        /**
         * 체크 예외 발생 시 커밋 확인
         * */
        @Transactional
        public void checkedException() throws MyException {
            log.info("call checkedException");
            throw new MyException();
        }

        /**
         * 체크 예외 rollbackFor 지정 시 롤백 확인
         * */
        @Transactional(rollbackFor = MyException.class)
        public void rollbackFor() throws MyException {
            log.info("call checkedException rollbackFor");
            throw new MyException();
        }
    }

    static class MyException extends Exception {

    }
}

 

RollbackService 클래스에는 세 개의 메소드가 있다. runtimeException(), checkedException(), rollbackFor().

 

runtimeException() 메소드를 실행하면 언체크 예외를 발생시키고 이는 롤백을 하게 된다.

그 결과를 확인하는 테스트 코드는 RollbackTest.runtimeException()이다. 

 

그리고 실제 커밋을 하는지 롤백을 하는지 로그로 확인해보기 위해서는 다음 설정이 application.yml 파일에 필요하다.

logging:
  level:
    org.springframework.transaction.interceptor: TRACE
    org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUG
    org.springframework.orm.jpa.JpaTransactionManager: DEBUG
    org.hibernate.resource.transaction: DEBUG

 

이제 실행해보자.

RollbackTest.runtimeException() 결과

로그를 살펴보면 rollback이 실행됐음을 알 수 있다.

 

 

그 다음, 체크 예외를 발생시키면 커밋을 하는 것을 확인하는 테스트 코드는 RollbackTest.checkedException()이다.

RollbackTest.checkedException() 결과

커밋 로그가 찍힌다.

 

이제 체크 예외를 발생시켰지만 rollbackFor로 해당 체크예외를 지정해 놓으면 커밋이 아닌 롤백을 하는지 확인해보는 테스트 코드는 RollbackTest.checkedRollbackFor()이다.

 

RollbackTest.checkedRollbackFor()결과

롤백 로그가 찍힌다.

그럼 왜 체크 예외는 커밋이고 언체크 예외는 롤백일까?

 

예외와 트랜잭션 커밋, 롤백

스프링은 왜 체크 예외는 커밋, 언체크 예외는 롤백할까?

스프링은 기본적으로 체크 예외는 비즈니스 의미가 있을 때 사용하고, 런타임 예외는 복구가 불가능한 예외로 가정한다.

체크 예외 자체를 개발자가 이 예외가 터지는 것 자체가 비즈니스적으로 의미가 있다라고 판단한 예외라고 스프링은 가정하는 것.

 

비즈니스적으로 의미가 있다라고 판단한 예외란 또 어떤걸까? 아래와 같은 비즈니스 요구사항이 있다고 가정해보자.

비즈니스 요구사항

  • 정상 처리: 주문시 결제를 성공하면 주문 데이터를 저장하고 결제 상태를 '완료'로 처리한다.
  • 시스템 예외: 주문시 내부 복구 불가능한 예외(SQL 에러, 네트워크 에러, 시스템 장애 등)가 발생하면 전체 데이터를 롤백한다.
  • 비즈니스 예외: 주문시 결제 잔고가 부족하면 주문 데이터를 저장하고 결제 상태를 '대기'로 처리한다. 이 경우, "고객에게 잔고 부족을 알리고 별도의 계좌로 입금하도록 안내한다."

이 때, 결제 잔고가 부족하면 NotEnoughMoneyException 이라는 체크 예외가 발생한다고 가정해보면, 이 예외는 시스템에 문제가 있어서 발생하는 예외가 아니다. 시스템은 정상 동작해도 비즈니스 상황적으로 문제가 되기 때문에 발생한 예외이다. 이런 예외를 비즈니스 예외라고 한다. 위 요구사항에서 결제 상태를 대기로 처리하는 것 자체가 롤백될 이유가 없다는 소리다.

 

중요한 건 예외는 시스템 예외비즈니스 예외로 항상 분기되고 비즈니스 예외는 시스템적으로 문제가 생긴게 아닌것임을 인지하는것이 중요하다.

 

728x90
반응형
LIST
728x90
반응형
SMALL
SMALL

스프링과 데이터베이스를 사용할 때 @Transactional 애노테이션을 사용해서 트랜잭션을 시작하고 종료하는데, 이 @Transactional 애노테이션을 사용하는 것을 '선언적 트랜잭션'이라고 한다. 근데 이 선언적 트랜잭션을 사용할 때 주의할 점이 있다. 

 

트랜잭션 프록시 객체

우선 @Transactional이 달려있는 클래스 또는 메소드를 스프링이 실행할 때 쭉 돌면서 찾는다. 찾으면 빈으로 등록하는데 이때, 등록되는 것은 실제 해당 객체가 아닌 '프록시'이다. 아래 그림을 보자.

 

위 그림처럼 실제 Service@Transactional 애노테이션이 클래스 단위로 붙든, 특정 메소드에 붙든 있기만 하면 해당 클래스를 프록시로 만든다. 그리고 그 프록시는 실제 Service상속받는다. 여기서 이 프록시가 스프링 컨테이너에 올라가기 때문에 애플리케이션이 실행하고 특정 지점에서 (컨트롤러가 됐던, 다른 서비스가 됐던) 이 Service를 주입받으면 그 주입받은 녀석은 프록시가 된다.

 

근데 해당 클래스에서 2개의 메소드 중 하나는 @Transactional이 붙고 하나는 붙지 않았다면 어떻게 동작할까?

그리고 붙지 않은 메소드가 붙은 메소드를 내부에서 호출한다면 트랜잭션이 동작할까?

 

선언적 트랜잭션 내부 호출 주의

다음 코드를 보자.

package com.example.springtx.apply;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Slf4j
@SpringBootTest
public class InternalCallV1Test {

    @Autowired
    CallService callService;

    @Test
    void printProxy() {
        log.info("callService class={}", callService.getClass());
    }

    @Test
    void internalCall() {
        callService.internal();
    }

    @Test
    void externalCall() {
        callService.external();
    }

    @TestConfiguration
    static class InternalCallV1TestConfig {

        @Bean
        CallService callService() {
            return new CallService();
        }
    }

    static class CallService {

        public void external() {
            log.info("call external");
            printTxInfo();
            internal();
        }

        @Transactional
        public void internal() {
            log.info("call internal");
            printTxInfo();
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active = {}", txActive);
            boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
            log.info("tx readOnly = {}", readOnly);
        }
    }
}

 

이 코드에서 CallService 클래스를 유심히 봐야한다.

static class CallService {

    public void external() {
        log.info("call external");
        printTxInfo();
        internal();
    }

    @Transactional
    public void internal() {
        log.info("call internal");
        printTxInfo();
    }

    private void printTxInfo() {
        boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
        log.info("tx active = {}", txActive);
        boolean readOnly = TransactionSynchronizationManager.isCurrentTransactionReadOnly();
        log.info("tx readOnly = {}", readOnly);
    }
}

 

`external()` 메소드와 `internal()` 메소드가 있을 때, `internal()` 메소드에만 @Transactional 애노테이션이 달려있다. 그리고 `external()` 메소드에서 `internal()` 메소드를 호출한다. 이 경우, 트랜잭션이 발동하지 않는다.

 

왜 그럴까? 순차적인 흐름으로 살펴보자.

1. 최초 `external()` 메소드가 호출될 때, @Transactional이 달려있지 않지만, `internal()` 메소드가 @Transactional이 달려있으므로 스프링은 프록시 객체 CallService$$CGLIB를 만든다.

2. 외부에서 이 CallService를 주입받으면 프록시 객체가 주입된다.

3. 외부에서 이 주입받은 CallService`external()` 메소드를 호출하면 프록시 객체의 `external()`이 호출된다.

4. 실제 객체에서 @Transactional이 붙지 않았기 때문에 트랜잭션은 동작하지 않는다.

5. 트랜잭션이 붙지 않은 상태에서 `external()` 메소드를 호출하는데 `external()` 메소드를 호출할 때 프록시는 자기가 참조하고 있는 실제 객체의 `external()` 메소드를 호출한다.

6. 실제 객체의 `external()` 메소드에서 `internal()` 메소드를 호출해봐야 프록시에서 트랜잭션을 실행하지 않은 상태이므로 여전히 트랜잭션은 실행되지 않는다. 

 

즉, 아래 그림이 위 흐름이라고 보면 된다.

 

결론은, `internal()`@Transactional 애노테이션이 있지만, 같은 클래스의 `external()`@Transactional이 없고, 최초에 호출된 건 `external()` 이기 때문에 트랜잭션을 적용하지 않은 상태에서 프록시 객체는 그저 실제 객체에게 `external()`을 위임한다. 그럼 실제 객체의 `external()`이 내부적으로 `internal()`을 호출해봤자 트랜잭션은 적용되지 않는것이다. 

 

그럼 이를 해결하려면 어떻게 할까? 클래스를 분리하는게 가장 간단한 방법이다.

 

클래스를 분리하여 내부 호출을 피하기

문제의 원인은 `external()`, `internal()` 메소드가 같은 클래스인데 `external()`@Transactional이 붙지 않고 `internal()`은 붙어있기에 스프링은 해당 클래스를 프록시 객체로 만들어 스프링 컨테이너에 올리고 실제로 클라이언트가 주입받는 것 역시 이 프록시 객체이다.

 

그래서 이 프록시 객체는 `external()`@Transactional이 붙지 않았기에 트랜잭션을 실행하지 않은 상태에서 실제 객체에게 위임한다. 실제 객체는 그대로 본인의 `internal()` 메소드를 호출하기 때문에 트랜잭션은 실행되지 않는다. 그럼 `external()``internal()`의 클래스를 분리하자.

 

@RequiredArgsConstructor
static class CallService {

    private final InternalService internalService;

    public void external() {
        log.info("call external");
        printTxInfo();
        internalService.internal();
    }
}

static class InternalService {
    @Transactional
    public void internal() {
        log.info("call internal");
        printTxInfo();
    }
}

 

자, 이제 이렇게 분리된 두 클래스는 어떤 차이를 보일까? `external()``internalService.internal()`을 호출하지만, `internalService.internal()`은 다른 클래스의 메소드이다. 사용하기 위해선 주입받아야 하고 주입받는건? InternalService$$CGLIB이다.

 

그럼 이 프록시 객체는 `internal()`@Transactional이 있는지 확인한 후 있기 때문에 트랜잭션을 실행한 상태에서 실제 객체에게 위임한다. 이렇게 문제를 해결할 수 있다.

 

그림으로 보면 좀 더 명확하다.

 

 

결론

앞으로 트랜잭션을 달았는데도 트랜잭션이 먹지 않았다면, 이런 현상이지 않을까를 고민해보자.

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

스프링 DB 2편 - 데이터 접근 활용 기술 강의 | 김영한 - 인프런

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

www.inflearn.com

스프링 트랜잭션 추상화

각각의 데이터 접근 기술들은 트랜잭션을 처리하는 방식에 차이가 있다. 예를 들어 JDBC 기술과 JPA 기술은 트랜잭션을 사용하는 코드 자체가 다르다. 

 

JDBC 트랜잭션 코드 예시

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

 

JPA 트랜잭션 코드 예시

public static void main(String[] args) {
    //엔티티 매니저 팩토리 생성
    EntityManagerFactory emf = Persistence.createEntityManagerFactory("jpabook");
    EntityManager em = emf.createEntityManager(); //엔티티 매니저 생성 
    EntityTransaction tx = em.getTransaction(); //트랜잭션 기능 획득
    try {
        tx.begin(); //트랜잭션 시작 logic(em); //비즈니스 로직 tx.commit();//트랜잭션 커밋
    } catch (Exception e) { 
        tx.rollback(); //트랜잭션 롤백
    } finally {
        em.close(); //엔티티 매니저 종료
    }
        emf.close(); //엔티티 매니저 팩토리 종료 
}

 

JDBC 기술을 사용하다가 JPA 기술로 변경하게 되면 트랜잭션을 사용하는 코드도 모두 함께 변경해야 한다. 스프링은 이런 문제를 해결하기 위해 트랜잭션 추상화를 제공한다. 트랜잭션을 사용하는 입장에서는 스프링 트랜잭션 추상화를 통해 둘을 동일한 방식으로 사용할 수 있게 되는 것이다. 스프링은 PlatformTransactionManager라는 인터페이스를 통해 트랜잭션을 추상화한다.

 

  • 스프링은 트랜잭션을 추상화해서 제공할 뿐만 아니라, 실무에서 주로 사용하는 데이터 접근 기술에 대한 트랜잭션 매니저의 구현체도 제공한다. 우리는 필요한 구현체를 스프링 빈으로 등록하고 주입 받아서 사용하기만 하면 된다.
  • 그런데, 그럴 필요도 없다. 스프링 부트는 어떤 데이터 접근 기술을 사용하는지를 자동으로 인식해서 적절한 트랜잭션 매니저를 선택해서 스프링 빈으로 등록해주기 때문에 트랜잭션 매니저를 선택하고 등록하는 과정도 생략할 수 있다. 예를 들어서 JdbcTemplate, Mybatis를 사용하면 DataSourceTransactionManager를 스프링 빈으로 등록하고, JPA를 사용하면 JpaTransactionManager를 스프링 빈으로 등록해준다.

 

스프링 트랜잭션 사용 방식

PlatformTransactionManager를 사용하는 방법은 크게 2가지가 있다. 

  • 선언적 트랜잭션 관리
    • @Transactional 애노테이션 하나만 선언해서 매우 편리하게 트랜잭션을 적용하는 것을 선언적 트랜잭션 관리라 한다.
  • 프로그래밍 방식의 트랜잭션 관리
    • 트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용해서 트랜잭션 관련 코드를 직접 작성하는 것을 프로그래밍 방식의 트랜잭션 관리라 한다.
    • 이 방식의 문제는 애플리케이션 코드가 트랜잭션이라는 기술 코드와 강하게 결합된다는 점이다. 쉽게 말해 서비스 코드 안에 트랜잭션 처리 코드가 같이 있다는 말이다.

 

선언적 트랜잭션과 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);
}

 

트랜잭션을 처리하기 위한 프록시를 적용하면 트랜잭션을 처리하는 객체와 비즈니스 로직을 처리하는 서비스 객체를 명확하게 분리할 수 있다. 그래서 아래와 같은 그림과 코드가 된다.

public void logic() {
    //트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음 
    bizLogic(fromId, toId, money);
}
  • 위의 프로그래밍 방식의 트랜잭션 관리랑 비교해보면 정말 순수하고 깔끔한 코드이다. 
  • 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져간 상태이다. 그리고 트랜잭션을 시작한 후에 실제 서비스를 대신 호출해준다. 

 

프록시 도입 후 전체 과정

  • 트랜잭션은 커넥션에 con.setAutoCommit(false)를 지정하면서 시작한다.
  • 같은 트랜잭션을 유지하려면 같은 데이터베이스 커넥션을 사용해야 한다. 이것을 위해 스프링 내부에서는 트랜잭션 동기화 매니저가 사용된다.
  • JdbcTemplate을 포함한 대부분의 데이터 접근 기술들은 트랜잭션을 유지하기 위해 내부에서 트랜잭션 동기화 매니저를 통해 리소스(커넥션)를 동기화한다.
  • 스프링의 트랜잭션은 매우 중요한 기능이고, 전세계 누구나 사용하는 기능이다. 스프링은 트랜잭션 AOP를 처리하기 위한 모든 기능을 제공한다. 스프링 부트를 사용하면 트랜잭션 AOP를 처리하기 위해 필요한 스프링 빈들도 자동으로 등록해준다.
  • 개발자는 트랜잭션 처리가 필요한 곳에 @Transactional 애노테이션만 붙여주면 된다. 스프링의 트랜잭션 AOP는 이 애노테이션을 인식해서 트랜잭션을 처리하는 프록시를 적용해준다.

 

트랜잭션 적용 확인

실제로 저렇게 @Transactional 애노테이션만 붙여주면 정말 트랜잭션이 적용되는 걸까? 눈으로 봐야만 확신할 수 있는 우리는 직접 확인해봐야겠다. 그러기 위해 다음과 같은 테스트 코드를 준비해보자.

package cwchoiit.tx.apply;

import lombok.extern.slf4j.Slf4j;
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.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

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

@Slf4j
@SpringBootTest
public class TxBasicTest {

    @Autowired
    BasicService basicService;

    @Test
    void proxyCheck() {
        log.info("aop class = {}", basicService.getClass());
        assertThat(AopUtils.isAopProxy(basicService)).isTrue();
    }

    @Test
    void txTest() {
        basicService.tx();
        basicService.nonTx();
    }

    @TestConfiguration
    static class TxApplyBasicConfig {
        @Bean
        BasicService basicService() {
            return new BasicService();
        }
    }

    static class BasicService {

        @Transactional
        public void tx() {
            log.info("call Tx");
            log.info("tx active = {}", TransactionSynchronizationManager.isActualTransactionActive());
        }

        public void nonTx() {
            log.info("call Non Tx");
            log.info("tx active = {}", TransactionSynchronizationManager.isActualTransactionActive());
        }
    }
}

 

proxyCheck() 실행

  • AopUtils.isAopProxy()는 선언적 트랜잭션 방식에서 스프링 트랜잭션은 AOP를 기반으로 동작한다. @Transactional을 메서드나 클래스에 붙이면 해당 객체는 트랜잭션 AOP 적용 대상이 되고, 결과적으로 실제 객체 대신에 트랜잭션을 처리해주는 프록시 객체가 스프링 빈에 등록된다. 그리고 주입을 받을 때도 실제 객체 대신에 프록시 객체가 주입된다.
  • 클래스 이름을 출력해보면, 아래와 같은 이름의 프록시 클래스가 출력된다.
aop class = class cwchoiit.tx.apply.TxBasicTest$BasicService$$SpringCGLIB$$0
  • 근데 어떻게 프록시가 주입될까? 

스프링 컨테이너에 트랜잭션 프록시 등록

  • @Transactional 애노테이션이 특정 클래스나 메서드에 하나라도 있으면 트랜잭션 AOP는 프록시를 만들어서 스프링 컨테이너에 등록한다. 그리고 실제 basicService 객체 대신에 프록시인 BasicService$$SpringCGLIB를 스프링 빈에 등록한다. 그리고 프록시는 내부에 실제 BasicService를 참조하게 된다. 여기서 핵심은 실제 객체 대신에 프록시가 스프링 컨테이너에 등록되었다는 점이다.
  • 클라이언트인 txBasicTest는 스프링 컨테이너에 BasicService로 의존관계 주입을 요청하는데 스프링 컨테이너에는 실제 객체 대신에 프록시가 스프링 빈으로 등록되어 있기 때문에 프록시를 주입한다. 타입은 BasicService이고 인스턴스가 프록시일 뿐이다.
  • 왜냐하면 프록시는 BasicService를 상속해서 만들어지기 때문에 다형성을 활용할 수 있기 때문이다. 따라서 BasicService 대신에 프록시인 BasicService$$SpringCGLIB 주입받을 수 있다.

txTest() 실행

TransactionSynchronizationManager.isActualTransactionActive()는 현재 쓰레드에 트랜잭션이 적용되어 있는지 확인할 수 있는 기능이다. 결과가 true면 트랜잭션이 적용되어 있는 것이다. 트랜잭션의 적용 여부를 가장 확실하게 확인할 수 있다. 

2024-12-08T18:22:49.688+09:00  INFO 18734 --- [    Test worker] cwchoiit.tx.apply.TxBasicTest            : call Tx
2024-12-08T18:22:49.688+09:00  INFO 18734 --- [    Test worker] cwchoiit.tx.apply.TxBasicTest            : tx active = true
2024-12-08T18:22:49.690+09:00  INFO 18734 --- [    Test worker] cwchoiit.tx.apply.TxBasicTest            : call Non Tx
2024-12-08T18:22:49.690+09:00  INFO 18734 --- [    Test worker] cwchoiit.tx.apply.TxBasicTest            : tx active = false

 

트랜잭션 적용 위치

이번에는 @Transactional 애노테이션의 적용 위치에 따른 우선순위를 확인해보자. 스프링에서 우선순위는 항상 더 디테일하고 자세한 것이 높은 우선순위를 가진다. 이것만 기억하면 스프링에서 발생하는 대부분의 우선순위를 쉽게 기억할 수 있다. 그리고 더 구체적인 것이 더 높은 우선순위를 가지는 것은 상식적으로 자연스럽다. 예를 들어, 메서드와 클래스에 애노테이션을 붙일 수 있다면, 더 구체적인 메서드가 더 높은 우선순위를 가진다. 인터페이스와 해당 인터페이스를 구현한 클래스에 애노테이션을 붙일 수 있다면 더 구체적인 클래스가 더 높은 우선순위를 가진다. 

 

package cwchoiit.tx.apply;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Slf4j
@SpringBootTest
public class TxLevelTest {

    @Autowired LevelService levelService;

    @Test
    void orderTest() {
        levelService.write();
        levelService.read();
    }

    @TestConfiguration
    static class TestConfig {
        @Bean
        public LevelService levelService() {
            return new LevelService();
        }
    }

    @Transactional(readOnly = true)
    static class LevelService {

        @Transactional
        public void write() {
            log.info("call write");
            printTxInfo();
        }

        public void read() {
            log.info("call read");
            printTxInfo();
        }

        private void printTxInfo() {
            boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("tx active: {}", txActive);
        }
    }
}
  • 스프링의 @Transactional 애노테이션은 다음 두 가지 규칙이 있다.
    • 우선순위 규칙
    • 클래스에 적용하면 메서드는 자동 적용

우선순위

트랜잭션을 사용할 때는 다양한 옵션을 사용할 수 있다. 그런데 어떤 경우에는 옵션을 주고, 어떤 경우에는 옵션을 주지 않으면 어떤 것이 선택될까? 예를 들어 읽기 전용 트랜잭션 옵션을 사용하는 경우와 아닌 경우를 비교해보자. 위 코드의 경우 클래스 레벨에는 읽기 전용 옵션`@Transactional(readOnly = true)`이 붙어 있다. 그런데 write() 메서드에는 `@Transactional`만 붙어있다. 이러면 당연히 메서드가 더 구체적이기 때문에 메서드에 있는 쓰기도 가능한 트랜잭션이 적용된다.

 

클래스에 적용하면 메서드는 자동 적용

위 코드에서 read()의 경우 메서드에 @Transactional 애노테이션이 붙지 않았다. 이런 경우 더 상위 개념인 클래스를 확인한다. 클래스에 @Transactional(readOnly = true)가 붙어 있다. 따라서 트랜잭션이 적용되고 읽기 전용 모드로 진행된다. 참고로 기본 옵션이 readOnlyfalse이기 때문에 위 코드처럼 생략한다. 

 

인터페이스에 @Transactional 적용

인터페이스에도 @Transactional 애노테이션을 적용할 수는 있다. 이 경우 다음 순서로 적용된다. 구체적인 것이 더 높은 우선순위를 가진다고 생각하면 바로 이해가 될 것이다.

  1. 클래스의 메서드 (우선순위가 가장 높다)
  2. 클래스의 타입
  3. 인터페이스의 메서드
  4. 인터페이스의 타입 (우선순위가 가장 낮다)

클래스의 메서드를 찾고, 만약 없으면 클래스의 타입을 찾고, 만약 없으면 인터페이스의 메서드를 찾고, 그래도 없으면 인터페이스의 타입을 찾는다. 그런데 인터페이스에 @Transactional 애노테이션을 사용하는 것은 스프링 공식 매뉴얼에도 권장하지 않는 방법이다. 그러니까 이렇게 코드는 작성 하지 않았으면 한다. 가급적 구체 클래스에 @Transactional을 사용하자. 왜 권장하지 않냐면, 스프링이 프록시를 만들어내는 방식 중 하나인 CGLIB는 구체 클래스를 상속받아 만들어낸다. 그래서 인터페이스까지 올라가지 않고 구체클래스를 상속받은 프록시를 만들기 때문에 AOP가 적용되지 않는 사례가 있기 때문이다. 지금은 인식문제가 없다. 과거, 그러니까 스프링 5.0 이전에는 이런 인식 문제가 발생했는데 지금은 그 부분을 많이 개선했지만 그래도 불구하고 구체 클래스에 @Transactional을 사용하자!

 

@Transactionalprivate 메서드에는 적용되지 않는다.

이건 CGLIB를 통해 프록시를 만들어내는 스프링의 방식에서 비롯된건데 CGLIB로 프록시를 만들땐 실제 객체를 상속받아 프록시를 만든다. 상속받아 프록시를 만들고 그 프록시 안에 실제 객체를 참조하기까지 한다. 참조하고 있어야 실제 객체의 메서드를 호출할테니. 그런데 상속받아 만들어 기존 객체의 메서드를 재정의하여 만약 @Transactional 애노테이션이 붙은 메서드라면 트랜잭션 기능을 적용한 재정의 메서드가 만들어진다. `private` 접근 제어자는 상속받는다고 해도 재정의할 수 없다. 아예 자식 객체에서 호출조차 불가능하다. 이러한 이유 때문에 private 메서드는 @Transactional 애노테이션이 적용되지 않는 것이다. 이 말은, 스프링이 지원하는 애노테이션을 붙였을때 프록시로 만들어주는 모든 애노테이션이 다 동일하게 이렇게 동작한다는 말이다. 여기까지는 기술적인 이유이다.

 

그럼, 논리적인 이유는 뭘까? `private` 접근 제어자는 트랜잭션이 필요할까? `private` 이라는 키워드는 나 자신만이 이 메서드를 사용하고 싶다, 외부로 노출하고 싶지 않다는 강력한 의지 표현이다. 그럼 외부에 노출하고 싶지 않은데 트랜잭션과 상호작용할 필요가 있을까? 사실 논리적으로 생각해도 이건 말이 맞지 않기도 하다. 

 

결론적으로, @Transactional 애노테이션은 `private`에는 적용되지 않는다.

 

참고로, 스프링 부트 3.0 이전에는 protected, default 접근 제어자 역시도 @Transactional 애노테이션을 달아도 트랜잭션이 적용되지 않았다. 오로지 `public`만이 가능했는데 3.0 이후부터는 `private`을 제외하고 다 가능하다.

 

트랜잭션 AOP 주의 사항 - 초기화 시점

스프링 초기화 시점에는 트랜잭션 AOP가 적용되지 않을 수 있다. 타이밍 이슈 때문인데 코드를 보자.

package cwchoiit.tx.apply;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.event.EventListener;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronizationManager;

@Slf4j
@SpringBootTest
public class InitTxTest {

    @Test
    void go() {

    }

    @TestConfiguration
    static class InitTxTestConfiguration {
        @Bean
        public Hello hello() {
            return new Hello();
        }
    }

    static class Hello {

        @PostConstruct
        @Transactional
        public void initV1() {
            boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
            log.info("initV1 isActive: {}", isActive);
        }
    }
}
  • 여기서 주의깊게 볼 부분은 다음 부분이다.
@PostConstruct
@Transactional
public void initV1() {
    boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
    log.info("initV1 isActive: {}", isActive);
}
  • @PostConstruct 애노테이션은 해당 클래스가 스프링 컨테이너에 빈으로 등록된 직후에 이 애노테이션이 달려있으면 해당 메서드를 호출해준다. 
  • 그런데 이 메서드에 @Transactional 애노테이션이 붙어있다. 이 애노테이션이 붙어있으면, 해당 객체를 프록시로 만들어 빈으로 등록한다. 벌써부터 그럼 뭐가 먼저지? 이런 고민거리가 생긴다. 이렇게 애매모호한 경우에는 뭐든간에 사용하면 안되는게 맞다.
  • 결론부터 말하면 스프링 컨테이너에 빈으로 등록해서 @PostConstruct 애노테이션이 먼저 사용되고 그 다음 AOP로 프록시로 만들어 빈을 갈아끼운다. 따라서 @PostConstruct는 트랜잭션이 적용되지가 않는다.
  • 순서가 @PostConstruct → @Transactional 이렇기 때문에 트랜잭션이 적용되지가 않는다.

실행결과

 

 

그래서, 이 경우 아래와 같은 해결책이 있다.

@EventListener(ApplicationReadyEvent.class)
@Transactional
public void initV2() {
    boolean isActive = TransactionSynchronizationManager.isActualTransactionActive();
    log.info("initV2 isActive: {}", isActive);
}
  • @EventListener(ApplicationReadyEvent.class) 애노테이션은 이 스프링이 완전히 준비가 다 끝난 후 호출되는 이벤트고 그 이벤트를 캐치하는 리스너 애노테이션이다. 따라서 AOP가 모두 적용된 상태 이후에 호출되므로 정상적으로 트랜잭션이 적용된다. 

 

정리를 하자면

모든 데이터베이스와의 연동은 트랜잭션안에서 이루어져야 한다. 그래서 JDBC를 직접 사용하든 JPA, Mybatis 등 어떤 기술을 사용하더라도 트랜잭션이 사용된다. 그런데 문제는 각 기술마다 트랜잭션을 사용하는 방법이 다르다는 것이다. 그래서 스프링은 이 트랜잭션을 사용하는 방법을 추상화했다. 그게 PlatformTransactionManager이다. 이 표준을 각 기술별로 구현한 구현체를 가져다가 사용만 하면 되는데 이 구현체 역시도 스프링이 만들어놨기 때문에 아주 편리하게 사용할 기술만 라이브러리로 내려받으면 된다. 

 

그런데, 여기서 한 발 더 나아가서 스프링이 이 PlatformTransactionManager를 주입받아 사용하는 것조차 귀찮고, 트랜잭션 적용 코드와 비즈니스 로직이 들어있는 서비스 코드가 합쳐져 순수한 서비스 코드를 망치게 하니 스프링은 @Transactional 애노테이션을 만들어 두었다. 이 애노테이션 하나만 있으면 AOP, 프록시 기술을 사용하여 순수한 서비스 코드를 작성할 수 있다. 

 

지금까지 이 @Transactional 애노테이션의 기본 사용 방법과 주의할 사항들을 알아보았다. 그럼 다음 포스팅에서는 이 @Transactional 애노테이션이 가지고 있는 여러 속성들을 알아보고 거기서 중요한 부분이 굉장히 많으니 그 부분에 대해서 심도있게 알아보자!

728x90
반응형
LIST
728x90
반응형
SMALL

실용적인 구조구조적 안정성 간의 고민

지금까지, JdbcTemplate, Mybatis, Spring Data JPA, QueryDsl을 쭉 공부해봤다. 그리고 각각의 장단점도 배워봤고 이 기술들을 사용해서 서비스 레이어에서 데이터를 접근해봤다. 그런데 기존의 서비스 구조를 다시 한번 상기해보자.

 

ItemService

package hello.itemservice.service;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;

import java.util.List;
import java.util.Optional;

public interface ItemService {

    Item save(Item item);

    void update(Long itemId, ItemUpdateDto updateParam);

    Optional<Item> findById(Long id);

    List<Item> findItems(ItemSearchCond itemSearch);
}
  • ItemService 인터페이스이다. 물론, 서비스는 인터페이스를 따로 만들지 않아도 된다고 생각한다. 이것도 고민거리 중 하나인데 생각해보면 서비스는 최대한 순수한 자바 코드를 사용해서 구현하면 할수록 좋다. 그러려고 노력하고 있고. 그렇기 때문에 서비스는 특정 기술에 의존하는 게 비교적 적다. 그럼 기술을 갈아끼우는 작업이 없는데 인터페이스가 굳이 필요할까?에 대한 고민이 첫번째.
  • 그럼에도 불구하고 인터페이스를 만들고 그 구현체를 만들었긴 했으니 일단 계속 진행하자.

ItemServiceV1

package hello.itemservice.service;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class ItemServiceV1 implements ItemService {

    private final ItemRepository itemRepository;

    @Override
    public Item save(Item item) {
        return itemRepository.save(item);
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        itemRepository.update(itemId, updateParam);
    }

    @Override
    public Optional<Item> findById(Long id) {
        return itemRepository.findById(id);
    }

    @Override
    public List<Item> findItems(ItemSearchCond cond) {
        return itemRepository.findAll(cond);
    }
}
  • ItemService를 구현한 구현체이다. 그리고 이 서비스는 지금 코드로만 보면 단순히 ItemRepository에 위임하고 있는 수준이다.
  • 그런데 ItemRepository는 인터페이스고 구현체가 다양하다. JdbcTemplate, Mybatis, Spring Data JPA, QueryDsl을 사용한 여러 구현체가 있는데 여기서 나의 고민이 발생한다.

 

구현 기술이 바뀌더라도 사용하는 클라이언트 코드에는 변경이 없도록 의존관계를 주입받아 사용하여 OCP 원칙을 지키는 설계를 반드시 지켜야 하는가?

아주 좋은 설계이고 유지보수에 굉장히 도움이 되는 설계이다. 그런데 이 원칙을 고수하고자 하면 이러한 모양이 그려진다.

  • 생각해보면 Spring Data JPA를 사용하면, 그냥 JpaRepository를 상속받은 인터페이스를 하나 만들면 Spring Data JPA가 알아서 구현체를 만들어주기 때문에 서비스는 그냥 JpaRepository를 상속받은 인터페이스만 주입받으면 된다.
  • 그런데 서비스는 ItemRepository를 주입받기 때문에 이 ItemRepository를 구현하는 구현체를 만들고 그 구현체가 Spring Data JPA를 사용하도록 중간 다리 하나가 더 필요해진다.

만약, 아래와 같은 그림으로 설계하면 어떨까?

  • 서비스는 바로 Spring Data JPA를 사용하기 위해 만들어진 Repository를 주입받는 것이다. 
  • 이러면 중간 다리도 필요없고 복잡한 구조도 사라지고 그림이 매우 간단해진다. 가장 좋은건 개발자가 단순하게 바로 JpaRepository를 상속받은 인터페이스를 직접 주입받아 사용하면 되니까 개발 속도가 빨라진다는 점이다.

 

트레이드 오프

이 부분이 트레이드 오프가 된다.

  • 1 → DI, OCP를 지키기 위해 중간에 어댑터를 도입하고 더 많은 코드를 유지하거나
  • 2 → 어댑터를 제거하고 구조를 단순하게 가져가지만, DI와 OCP를 포기하고 ItemService 코드를 직접 변경한다.

1번의 장점

자, DI와 OCP를 지킨다면 추후 데이터 접근 기술이 바뀌어도 서비스 코드는 변경할 필요가 전혀 없다. 이 서비스가 의존하는건 인터페이스지 구현체가 아니기 때문에 어떤 기술을 사용하던지 그 인터페이스를 구현한 구현체만 만들어 빈으로 등록하면 된다. 즉, 구조의 안정성이 생긴다. 

 

1번의 단점

개발 속도와 개발의 편리성이 떨어지고 코드가 많아진다. 쉽게 말해, 안해도 되는 작업을 구조의 안정성을 위해 해야하는 공수가 생긴다.

 

→ 정답은 없다.

내가 내린 결론은 정답은 없다이다. 만약, 프로젝트 규모가 굉장히 크고 추후 기술의 변경점이 반드시 생길 것 같다고 판단되면 구조의 안정성을 가져가는게 좋은 판단이라고 생각된다. 그러나, 빠른 개발이 가장 우선이고 추후 기술의 변경점이 많지 않을 것 같다고 판단되면 실용적인 구조가 더 좋은 판단이라고 생각된다. 쉽게 말해, 현재 상황에 맞게 적절한 선택을 해야 한다는 말이다

 

 

 

다양한 데이터 접근 기술 조합

또 다른 주제로, 이렇게 여러 기술을 배워봤는데 그래서 어떤 기술을 선택하면 좋을까? 이 부분 역시 하나의 정답이 있는건 아니라고 생각한다. JdbcTemplate이나 Mybatis와 같은 기술들은 SQL을 직접 작성해야 하는 단점은 있지만 기술이 단순하기 때문에 SQL에 익숙한 개발자라면 러닝 커브가 굉장히 낮다. 

 

JPA, 스프링 데이터 JPA, QueryDsl과 같은 기술들은 개발 생산성을 혁신할 수 있지만, 학습 곡선이 높아 이런 부분을 감안해야 한다. 그리고 매우 복잡한 통계 쿼리를 주로 작성하는 경우에는 잘 맞지 않는다

 

그래서, 메인 시나리오는 이렇다.

스프링 데이터 JPA, QueryDsl을 기본으로 사용하고 복잡하고 통계형 쿼리를 사용해야 하는 경우에 JdbcTemplate이나 MyBatis를 함께 사용하는 것. 

 

거의 대부분의 경우 스프링 데이터 JPAQueryDsl만으로 문제가 해결이 되는데 가끔 네이티브 SQL을 사용해야 하는 경우가 생기긴 한다. 그런 경우에 JdbcTemplate, Mybatis를 사용하면 된다. 

 

트랜잭션 매니저 선택

JPA, Spring Data JPA, QueryDsl은 모두 JPA 기술을 사용하는 것이기 때문에 트랜잭션 매니저로 JpaTransactionManager를 사용한다. 위 기술들을 사용하면 스프링 부트는 자동으로 JpaTransactionManager를 스프링 빈으로 등록한다. 그런데 JdbcTemplate, Mybatis와 같은 기술들은 DataSourceTransactionManager를 사용한다. 따라서 JPAJdbcTemplate 두 기술을 함께 사용하면 트랜잭션 매니저가 달라진다. 결국 트랜잭션을 하나로 묶을 수 없는 문제가 발생한다. 어떻게 하면 되지? 트랜잭션을 하나로 묶을 수 없다면 데이터베이스 커넥션이 달라진다는 얘기고 커넥션이 달라진다는 것은 두 작업간의 동시적으로 동기화를 할 수 없다는 얘긴데 말이다. 동기화가 안된다는 것은 롤백과 커밋이 불가능하다는 얘기다. 

JpaTransactionManager의 지원

위 걱정은 할 필요가 없다. JpaTransactionManager는 놀랍게도 DataSourceTransactionManager가 제공하는 기능도 대부분 제공한다. JPA라는 기술도 결국 내부에는 다 DataSourceJDBC 커넥션을 사용할 수 밖에 없기 때문이다. 따라서 JdbcTemplate, Mybatis와 함께 사용할 수 있다. 결과적으로 JpaTransactionManager만 스프링 빈에 등록하면 JPA, JdbcTemplate, Mybatis 모두를 하나의 트랜잭션으로 묶어 사용할 수 있다. 그 말은 커밋도 롤백도 다 가능해진다는 의미다.

 

주의할 점은, JPAJdbcTemplate을 함께 사용할 경우 JPA 플러시 타이밍을 고려해야 한다. JPA는 데이터를 변경할 때 변경 사항을 즉시 데이터베이스에 반영하는 게 아니라 커밋 시점에 변경 사항을 데이터베이스에 반영한다. 쓰기 지연이라는 기술을 내부적으로 사용하기 때문에 그렇다. 그런데 이런 경우에 하나의 트랜잭션 안에서 JPA를 통해 데이터를 변경한 다음 JdbcTemplate을 호출해 사용하는 경우 JdbcTemplate에서는 JPA가 변경한 데이터를 읽지 못하는 문제가 발생한다. 같은 커넥션이라고 해도 이건 커넥션이나 트랜잭션의 문제가 아니라 기술이 다르기 때문에 JPA가 처리하는 방식이 커밋 시점에 한번에 쓰기에 대한 쿼리를 날리기 때문에 그렇다. 그래서 이렇게 한 트랜잭션에서 JPA로 데이터를 변경한 다음 JdbcTemplate을 호출하는 경우가 있다면 JdbcTemplate을 호출하기 전에 JPA가 제공하는 플러시를 사용해서 JPA의 변경 내역을 바로 데이터베이스에 적용해줘야 한다. 

 

 

728x90
반응형
LIST

'Spring + Database' 카테고리의 다른 글

[Renewal] @Transactional 내부 호출 주의  (2) 2024.12.11
[Renewal] 스프링 트랜잭션 이해  (0) 2024.12.08
[Renewal] Spring Data JPA  (4) 2024.12.07
[Renewal] MyBatis  (4) 2024.12.06
[Renewal] JdbcTemplate  (8) 2024.12.06
728x90
반응형
SMALL

Spring Data JPAJPA랑 다른건가? Spring Data JPA가 무엇이고, 왜 탄생했는지를 시작해서 어떻게 사용하면 좋을지에 대한 포스팅을 작성하기로 했다. 

 

최악의 EJB

아주 예전에 EJB(Enterprise Java Beans)라는 기술이 있었다. 자바 표준으로 제공하는 기술이었는데 이 기술을 사용하려면 정말 너무너무 어렵고 복잡하고 코드도 지저분하고 말도 아니었다. 그래서 사용자들은 피로감을 느꼈고, 오죽하면 순수한 자바로 다시 돌아가자라는 의미를 가진 POJO(Plan Old Java Object)라는 단어도 탄생할 지경이었다. 

 

그래서 EJB의 지옥에서 탈출하기 위해 두명의 영웅이 탄생한다. 스프링의 창시자라고도 불리는 로드 존슨과 하이버네이트의 창시자 개빈 킹. 개빈 킹이라는 사람은 EJB의 엔티티빈을 사용하면서 데이터 접근 기술을 사용하려다가 "도저히 못해먹겠다. 이렇게 할 바에 내가 만드는 게 더 나을것 같다"라는 생각을 가지고 만든 하이버네이트가 곧 JPA라는 표준을 만들게 된다. 그러니까 역사적으로도 JPA라는 표준 기술보다 하이버네이트가 먼저 나왔다. 사람들이 하이버네이트에 열광을 하니 자바 진영에서 개빈 킹이라는 사람을 불러다가 이 기술을 표준화해서 자바 표준으로 하나 만들고 싶다라는 청을 했고 그렇게 만들어진게 JPA다. 

 

그렇게 JPA라는 표준과 그 표준을 구현한 구현체 중 하나인 Hibernate가 있게 된다. 오픈소스를 기반으로 표준을 만들어 냈기 때문에 실용적일뿐더러 딱딱하고 고리타분한 표준이 아니라 사용자들이 훨씬 더 사용하기 쉽게 표준이 만들어졌다.

 

Spring Data JPA의 탄생

그래서 이렇게 스프링과 JPA가 한 묶음이 되어 사람들이 편리하게 개발을 하던 중 이런 고민이 생기게 된다. MongoDB, Redis, 관계형 데 이터베이스 등 데이터 접근 기술이 하도 방대하고 방식도 다른데 결국 컨셉은 데이터를 어딘가에 저장하고 그 데이터를 관리, 보관, 사용에 목적이 있지 않나? 그럼 이 데이터 접근 기술의 방식은 다를지언정 결국 CRUD라는 큰 컨셉이 동일하다 보니 이 또한 표준으로 만들어볼까? 라는 생각으로 만들어진 것이 바로 Spring Data 라는 표준이다. 그리고 그 표준을 따라 구현한 하나의 구현체가 Spring Data JPA가 있는 것이다. Spring Data Redis, Spring Data Mongo 등 여러가지 구현체가 있지만 결국 Spring Data라는 큰 표준을 두고 그 표준에 맞춰 구현 기술을 구현하되 각각의 특징에 맞게 구현된 것이다. 

 

그리고 우리는 여기서 Spring Data JPA를 배울것이고 이는 JPA를 사용한 방식인 것이다.

 

Spring Data 표준은 결국 이런 것이다.

  • CRUD + 쿼리
  • 동일한 인터페이스
  • 페이징 처리
  • 메서드 이름으로 쿼리 생성
  • 스프링 MVC에서 ID값만 넘겨도 도메인 클래스로 바인딩

이게 다 결국 데이터 접근 기술들이 가지고 있는 특징들 아닌가? 그 안에 방식이 살짝 다를뿐 결국 목적은 여기에 있다. 그러다보니 이런 표준을 만들고 구현은 너네 입맛에 맞게 구현해라라고 표준이 만들어졌다. 

 

 

Spring Data JPA 주요 기능

스프링 데이터 JPA는 JPA를 편리하게 사용할 수 있도록 도와주는 라이브러리이다. 수많은 편리한 기능을 제공하지만, 가장 대표적인 기능은 다음과 같다.

  • 공통 인터페이스 기능
  • 쿼리 메서드 기능

공통 인터페이스 기능

  • JpaRepository 인터페이스를 통해서 기본적인 공통 CRUD 기능을 제공한다.
  • 공통화 가능한 기능이 거의 모두 포함되어 있다. 
  • CrudRepository에서 지금은 findOne() → findById()로 변경되었다.

JpaRepository를 사용하는 방법은 정말 매우 간단한데, 그냥 아래와 같이 인터페이스를 상속만 받으면 된다.

 public interface ItemRepository extends JpaRepository<Item, Long> {}
  • JpaRepository 인터페이스를 상속 받고, 제네릭에 관리할 <엔티티, 엔티티ID 타입>을 주면 된다. 
  • 그러면 JpaRepository가 제공하는 기본 CRUD 기능을 모두 사용할 수 있다.
  • 어떻게 인터페이스만 상속받으면 위 기능을 다 사용할 수 있냐? 당연히 스프링 데이터 JPA 라이브러리에서 구현 클래스를 대신 만들어준다. 스프링을 사용하다보면 느끼겠지만 이런 유사한 기능이 정말 많다. 

  • JpaRepository 인터페이스만 상속받으면, 스프링 데이터 JPA가 프록시 기술을 사용해서 구현 클래스를 만들어준다.
  • 그리고 만든 구현 클래스의 인스턴스를 만들어서 스프링 빈으로 등록한다.
  • 따라서 개발자는 구현 클래스 없이 인터페이스만 만들면 기본 CRUD 기능을 사용할 수 있다.

 

쿼리 메서드 기능

스프링 데이터 JPA는 인터페이스에 메서드만 적어두면, 메서드 이름을 분석해서 쿼리를 자동으로 만들어주고 실행해주는 기능을 제공한다. 다음 코드를 보자.

 

순수한 JPA 레포지토리

public List<Member> findByUsernameAndAgeGreaterThan(String username, int age) {
    return em.createQuery("select m from Member m where m.username = :username and m.age > :age")
         .setParameter("username", username)
         .setParameter("age", age).getResultList();
}
  • 순수하게 JPA만 사용할 경우에, 개발자는 이러한 JPQL을 작성해서 메서드로 만들어 두어야 한다. 

스프링 데이터 JPA

 public interface MemberRepository extends JpaRepository<Member, Long> {
     List<Member> findByUsernameAndAgeGreaterThan(String username, int age);
}
  • 이렇게 메서드 시그니처만 만들어두면, 스프링 데이터 JPA는 메서드 이름을 분석해서 필요한 JPQL을 만들고 실행해준다. 이게 바로 위에 코드랑 완전히 동일하게 만들어준다. 
  • 물론, JPQL은 JPA가 SQL로 번역해서 실행된다. 이건 당연한 이야기다.

위와 같이 메서드 시그니처만 만들어두면 되는데 이것도 규칙이라는 게 있다.

  • 조회: findBy, readBy, queryBy, getBy 와 같은 키워드를 사용해야 한다. 
  • COUNT: countBy
  • EXISTS: existsBy
  • 삭제: deleteBy, removeBy

이보다도 내용이 더 많은데 이 부분은 공식 문서를 참고해서 더 찾아볼 수 있다.

 

JPA Query Methods :: Spring Data JPA

By default, Spring Data JPA uses position-based parameter binding, as described in all the preceding examples. This makes query methods a little error-prone when refactoring regarding the parameter position. To solve this issue, you can use @Param annotati

docs.spring.io

 

JPQL 직접 사용하기

물론 위와 같이 메서드 시그니처만 만들어도 알아서 뚝딱 뚝딱 만들어주는데 가끔은 그게 불편할때가 있다. 조건이 너무 많아서 메서드 이름이 너무 길어지는 경우가 딱 이 경우이다. 그럴땐 JPQL을 직접 작성할 수 있도록 해준다.

public interface SpringDataJpaItemRepository extends JpaRepository<Item, Long> {
     //쿼리 메서드 기능
     List<Item> findByItemNameLike(String itemName);
     
     //쿼리 직접 실행
     @Query("select i from Item i where i.itemName like :itemName and i.price <= :price")
     List<Item> findItems(@Param("itemName") String itemName, @Param("price") Integer price);
}
  • 위 코드와 같이 @Query 애노테이션을 사용해서 직접 JPQL을 작성하면 이 메서드를 실행하면 작성한 JPQL대로 쿼리를 날려준다.
  • 참고로, 스프링 데이터 JPA는 JPQL 뿐만 아니라, JPA의 네이티브 쿼리도 지원해준다. 그러니까 JPQL말고 SQL을 직접 작성해서 실행하게도 해준다.

 

스프링 데이터 JPA 적용

라이브러리를 먼저 추가하자.

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

 

SpringDataJpaItemRepository

package hello.itemservice.repository.jpa;

import hello.itemservice.domain.Item;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface SpringDataJpaItemRepository extends JpaRepository<Item, Long> {

    List<Item> findByItemNameLike(String itemName);

    List<Item> findByPriceLessThanEqual(Integer price);

    @Query("SELECT i FROM Item i WHERE i.itemName LIKE :itemName AND i.price <= :price")
    List<Item> findItems(@Param("itemName") String itemName, @Param("price") Integer price);
}
  • 스프링 데이터 JPA가 제공하는 JpaRepository 인터페이스를 상속받으면 기본적인 CRUD 기능을 사용할 수 있다.
  • 그런데 이름으로 검색하거나, 가격으로 검색하는 기능은 공통으로 제공할 수 있는 기능이 아니다. 따라서 쿼리 메서드 기능을 사용하거나 @Query 애노테이션을 사용해서 직접 쿼리를 실행하면 된다.
  • 아쉽게도 스프링 데이터 JPA는 동적 쿼리에 약하다. 마치 JdbcTemplate처럼. 그래서 이후에 QueryDsl을 사용하여 이 부분을 깔끔하게 해결해 볼 생각이다.

스프링 데이터 JPA의 스프링 데이터 접근 예외 추상화 지원

참고로, 이렇게 스프링 데이터 JPA가 제공하는 JpaRepository 인터페이스를 상속받으면 구현체도 프록시로 자동으로 만들어 준다고 했는데 그때, 스프링 예외 추상화를 지원한다. 즉, 스프링 데이터 JPA가 만들어주는 프록시에서 이미 예외 변환을 처리해서 구현체로 빈을 등록해주기 때문에 @Repository 애노테이션이 굳이 없어도 예외가 발생 시 스프링 데이터 접근 예외가 반환된다.

 

스프링 데이터 접근 예외 추상화는 스프링에서 제공해주는 기능이다. 결국 JPA를 사용하던, JDBC 기술을 직접 사용하던, 결국은 내부에서 JDBC 기술을 사용해서 데이터베이스와 연동한다. 그 과정에서 에러가 발생하면 SQLException이 발생하는데, 이 예외를 스프링은 추상화하여 예외 계층을 지원해준다. 그 부분에 대한 얘기인데, 이 내용은 다음 포스팅에 자세히 작성해 두었다. 

 

[Renewal] 예외2, 스프링의 데이터접근 예외 추상화

https://cwchoiit.tistory.com/68 예외자바에서 예외는 크게 두 가지(체크 예외, 언체크 예외)로 나뉘어진다. 체크 예외 컴파일러가 체크하는 예외이다. 체크 예외는 잡아서 처리하거나 또는 밖으로 던지

cwchoiit.tistory.com

 

728x90
반응형
LIST
728x90
반응형
SMALL

이번에는 MyBatis라는 기술을 이용해서 Database와 통신하는 법을 알아보자.

 

이 역시 나는 사용하지 않을것 같다. 왜냐하면 이 MyBatis를 사용하기엔 QuerydslSpring Data JPA가 너무 강력하기 때문이다.

그래도 공부를 한 이유는 왜 이 MyBatis가 게임체인저가 되지 못하고 JPA, Querydsl이 나왔을까?에 초점을 두었다.

 

SMALL

 

이전 포스팅인 JdbcTemplate은 다 좋은데 동적 쿼리를 만들어내기가 쉽지만은 않았다.

 

[Renewal] JdbcTemplate

Spring과 데이터베이스를 연동할 때 사용되는 기술 중 여전히 잘 사용되는 기술인 JdbcTemplate을 사용하는 법을 정리하고자 한다.우선, JdbcTemplate은 기존 JDBC 기술을 직접 사용할 때 겪는 문제들을 해

cwchoiit.tistory.com

MyBatis는 동적 쿼리를 훨씬 간단하게 작성할 수 있다. 그리고 한가지 좋은 점은 눈으로 SQL문을 보기가 좀 더 간결하고 직관적이다. 왜냐하면 xml 파일로 SQL문을 작성하기 때문이다.

 

 

라이브러리 다운로드

우선 MyBatis를 다운받자.

build.gradle

//MyBatis
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0'

 

JdbcTemplate과 한가지 다른 점은 MyBatis는 스프링 부트가 공식적으로 지원하는 라이브러리가 아니다. 그래서 현재 사용중인 스프링의 버전과 가장 적합한 버전을 찾아주지 않기 때문에 버전 명시가 필요하다.

 

이렇게 라이브러리를 다운받으면 다음과 같이 외부 라이브러리 리스트에 Mybatis가 노출된다. 

  • 근데 한가지 눈이 가는 부분이 있다. 빨간 표시해둔 `ibatis`? 이건 뭐지?
  • 예전 이름이 ibatis고 지금은 mybatis로 변경된 것이다.
  • 그 이름이 그대로 남아있을 뿐이고 그냥 mybatis라고 생각하면 된다.
  • 그리고 라이브러리 보면, spring-boot-starter라는 이름과 autoconfigure 라는 이름의 라이브러리도 들어와 있는데 이거 친숙하다. 스프링 부트가 라이브러리 만들어낼 때 이런 이름을 사용하는데 mybatis는 스프링 부트에서 공식적으로 지원하는 라이브러리는 아니다. 근데 이름이 왜 이럴까? mybatis에서 직접 만든 라이브러리다. 스프링 부트와 사용할 때 사용자들에게 편리하게 설정을 해주도록 mybatis에서 직접 만들어서 넣어준 라이브러리다. 

 

application.yaml

mybatis:
  type-aliases-package: hello.itemservice.domain
  configuration:
    map-underscore-to-camel-case: true
logging:
  level:
    hello.itemservice.repository.mybatis: trace
  • mybatis를 사용하려면 간단한 설정을 해줘야 한다. 이건 필수는 아니고 설정하면 편하다. 
  • 우선, type-aliases-packagemybatis를 사용할 때 SELECT절로 가져오는 데이터를 객체로 바인딩할 때 어디에 있는 객체를 나타내는지 등을 작성하는 속성이다. 진행하면서 한번 더 이 부분에 대해 설명하겠다.
  • configuration.map-underscore-to-camel-case는 데이터베이스에서는 관례가 (_)를 사용하다 보니, 객체와 매핑할 때 alias를 사용해야 하는 불편함이 있다. 객체는 item_name 이 아니라 itemName으로 표기하니까. 그래서 SELECT절에서도 이렇게 사용해야 했다.
`select item_name as itemName`
  • 그리고 객체로 매핑할 때 해당 필드를 찾아서 값을 넣어주는 방식으로 진행됐는데, 이렇게 configuration.map-underscore-to-camel-case 속성을 true로 설정해주면 자동으로 (_)camelCase로 변환해준다.

Mapper

package hello.itemservice.repository.mybatis;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;
import java.util.Optional;

@Mapper
public interface ItemMapper {

    void save(Item item);

    void update(@Param("id") Long id, @Param("updateParam") ItemUpdateDto updateDto);

    List<Item> findAll(ItemSearchCond itemSearchCond);

    Optional<Item> findById(Long id);
}
  • MyBatis @Mapper라는 애노테이션이 필요한데, 이를 붙여야만 Mybatis 스프링 연동 모듈(아까 위에서 봤던 autoconfigure 라이브러리)이 스프링이 시작할 때 이 @Mapper가 있는 곳을 다 찾아서 인터페이스의 구현체를 내가 만든 xml 파일을 바라보며 직접 만들어주기 때문이다. 
  • 그리고 어떤건 @Param이 있고 어떤건 없는데, 이는 두 개 이상인 경우 @Param을 붙여야한다.

 

ItemMapper.xml

이제 실제 위 인터페이스의 구현체를 만들어야 하는데 그건 자바 파일로 만드는게 아니고 xml 파일로 만들어야 한다.

그리고 이 xml 파일의 경로는 임의로 정할수 있는게 아니고 반드시 인터페이스와 같은 패키지 경로와 일치해야한다. (물론 변경할 순 있다)

그래서 위 ItemMapper 인터페이스의 패키지 경로인 hello.itemservice.repository.mybatissrc/main/resources 하위에 만들고 ItemMapper.xml 파일을 만들자.

 

src/main/resources/hello/itemservice/repository/mybatis/ItemMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "- //mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="hello.itemservice.repository.mybatis.ItemMapper">
    <insert id="save" useGeneratedKeys="true" keyProperty="id">
        insert into item (item_name, price, quantity)
        values (#{itemName}, #{price}, #{quantity})
    </insert>
    
    <update id="update">
        update item
        set item_name=#{updateParam.itemName},
            price=#{updateParam.price},
            quantity=#{updateParam.quantity}
        where id = #{id}
    </update>
    
    <select id="findById" resultType="Item">
        select id, item_name, price, quantity
        from item
        where id = #{id}
    </select>

    <select id="findAll" resultType="Item">
        select id, item_name, price, quantity
        from item
        <where>
            <if test="itemName != null and itemName != ''">
                and item_name like concat('%', #{itemName}, '%')
            </if>
            <if test="maxPrice != null">
                and price &lt;= #{maxPrice}
            </if>
        </where>
    </select>
</mapper>
  • 우선 직관적으로 어떤 태그가 SELECT문, UPDATE문, INSERT문인지 알 것 같다. 태그 이름이 그렇게 알려주니까.
  • 그리고 속성으로 id를 작성하는데 이 id에 작성하는 값은 실제 내가 작성한 ItemMapper 인터페이스의 메소드 명으로 설정하면 된다.

하나하나 뜯어보자.

<insert id="save" useGeneratedKeys="true" keyProperty="id">
    insert into item (item_name, price, quantity)
    values (#{itemName}, #{price}, #{quantity})
</insert>

 

  • save() 메소드와 매핑될 SQL문이다. useGeneratedKeys 속성은 데이터베이스가 키를 자동으로 생성해주는 IDENTITY 전략일 때 사용한다. 이 값을 true로 했으면 keyProperty를 설정해야 한다. keyProperty는 기본키의 컬럼명이다.
  • 이제 파라미터를 넘겨받을 땐 `#{}` 문법을 사용한다. 이 안에 던질 데이터를 넣어주면 된다. save(Item item) 메소드의 이 item이 가지는 필드명이다. save(Item item) 메소드는 파라미터가 하나이기 때문에 바로 그 안에 필드에 접근할 수 있다.
    그래서 #{item.itemName}이 아니고 #{itemName}으로 작성하면 된다. 

 

<update id="update">
    update item
    set item_name=#{updateParam.itemName},
        price=#{updateParam.price},
        quantity=#{updateParam.quantity}
    where id = #{id}
</update>

 

  • 진짜 보기 편하게 생겼다. SQL문이 바로바로 보인다. 자 이제 update()를 보면 간단하다. 그냥 UPDATE 쿼리문을 작성하면 된다. 그리고 이 update() 메소드는 파라미터가 두개였기 때문에 @Param() 애노테이션에 괄호안에 작성한 값으로 파라미터를 바인딩해야 한다.
  • @Param("id")id@Param("updateParam")updateParam.xxx로. 

 

<select id="findById" resultType="Item">
    select id, item_name, price, quantity
    from item
    where id = #{id}
</select>
  • 이제 SELECT문이다. 이 또한 보기가 너무 좋긴 하다. 더 말할게 없다. resultType은 이 SELECT문이 반환하는 객체 타입을 작성해주면 된다. "Item"이다. 원래는 이것도 풀 패키지 명을 작성해줘야 하는데 위에서 작성한 application.yaml 파일을 기억하는가? type-alias-package를 작성해줬기 때문에 그 하위에 있는 Item을 찾게 된다.

 

 

이제 JdbcTemplate에서 느꼈던 단점인 동적 쿼리를 이 MyBatis가 어떻게 해결해주는지 확인해보자.

<select id="findAll" resultType="Item">
    select id, item_name, price, quantity
    from item
    <where>
        <if test="itemName != null and itemName != ''">
            and item_name like concat('%', #{itemName}, '%')
        </if>
        <if test="maxPrice != null">
            and price &lt;= #{maxPrice}
        </if>
    </where>
</select>

 

  • 너무 간단하다. 작성하는 코드도 몇 줄 안된다. 다른건 볼 것 없고 <where></where> 여기만 보면 되는데, 일단 <where> 태그가 마법같은 일들을 3개나 해준다.
    • 안에 <if> 태그의 조건이 만족하는 게 없으면 WHERE절을 무시해버린다. 
    • 안에 <if> 태그의 조건이 하나라도 만족하면 WHERE절을 만들어준다.
    • WHERE절의 첫번째로 들어오는 문장에 "AND"가 있으면 지워준다.
  • 이 세 조건을 딱 보고 <where> 태그를 보면 모든게 다 이해가 될 것인데 다만 한가지 아쉬운 점은 xml파일이기 때문에 <, > 기호가 그대로 태그로 인식이 된다. 이 문제를 해결하려면 이 기호를 &lt;로 변환해줘야 한다. 

 

ItemRepository 구현체

package hello.itemservice.repository.mybatis;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
@RequiredArgsConstructor
public class MyBatisItemRepository implements ItemRepository {

    private final ItemMapper itemMapper;

    @Override
    public Item save(Item item) {
        itemMapper.save(item);
        return item;
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        itemMapper.update(itemId, updateParam);
    }

    @Override
    public Optional<Item> findById(Long id) {
        return itemMapper.findById(id);
    }

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        return itemMapper.findAll(cond);
    }
}

 

  • 여기서는 ItemRepository 구현체는 ItemMapper의 메소드를 가지고 위임만 해준다.

 

DTO

ItemSearchCond.java

package hello.itemservice.repository;

import lombok.Data;

@Data
public class ItemSearchCond {

    private String itemName;
    private Integer maxPrice;

    public ItemSearchCond() {
    }

    public ItemSearchCond(String itemName, Integer maxPrice) {
        this.itemName = itemName;
        this.maxPrice = maxPrice;
    }
}

 

ItemUpdateDto.java

package hello.itemservice.repository;

import lombok.Data;

@Data
public class ItemUpdateDto {
    private String itemName;
    private Integer price;
    private Integer quantity;

    public ItemUpdateDto() {
    }

    public ItemUpdateDto(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

 

Configuration

package hello.itemservice.config;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.jdbctemplate.JdbcTemplateItemRepositoryV3;
import hello.itemservice.repository.mybatis.ItemMapper;
import hello.itemservice.repository.mybatis.MyBatisItemRepository;
import hello.itemservice.service.ItemService;
import hello.itemservice.service.ItemServiceV1;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration
@RequiredArgsConstructor
public class MyBatisConfig {

    private final ItemMapper itemMapper;

    @Bean
    public ItemService itemService() {
        return new ItemServiceV1(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository() {
        return new MyBatisItemRepository(itemMapper);
    }
}

 

  • Configuration에서 한가지 짚고 넘어갈 건, DataSource 관련 코드가 한 줄도 없다. 이는 application.yml 파일에 정의한 DataSource 설정값들을 스프링이 알아서 ItemMapper랑 매핑해준다. 그래서 정의할 필요가 없다.

 

SpringBootApplication

package hello.itemservice;

import hello.itemservice.config.*;
import hello.itemservice.repository.ItemRepository;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Profile;


@Import(MyBatisConfig.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}
}

 

  • 여기서 MyBatisConfig를 컴포넌트 스캔을 통해 스프링이 알아서 등록하게 해줄 수 있지만, 이 예제에서는 컴포넌트 스캔 대상에 제외했기 떄문에 Config 파일을 임포트해서 스프링이 띄워질 때 빈으로 등록해줘야 한다.

 

 

테스트 코드

이제 실행해보자. 😊

package hello.itemservice.domain;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import hello.itemservice.repository.memory.MemoryItemRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.DefaultTransactionAttribute;

import java.util.List;

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

@Transactional
@SpringBootTest
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    @Test
    void save() {
        //given
        Item item = new Item("itemA", 10000, 10);

        //when
        Item savedItem = itemRepository.save(item);

        //then
        Item findItem = itemRepository.findById(item.getId()).get();
        assertThat(findItem).isEqualTo(savedItem);
    }

    @Test
    void updateItem() {
        //given
        Item item = new Item("item1", 10000, 10);
        Item savedItem = itemRepository.save(item);
        Long itemId = savedItem.getId();

        //when
        ItemUpdateDto updateParam = new ItemUpdateDto("item2", 20000, 30);
        itemRepository.update(itemId, updateParam);

        //then
        Item findItem = itemRepository.findById(itemId).get();
        assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
        assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
        assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
    }

    @Test
    void findItems() {
        //given
        Item item1 = new Item("itemA-1", 10000, 10);
        Item item2 = new Item("itemA-2", 20000, 20);
        Item item3 = new Item("itemB-1", 30000, 30);

        itemRepository.save(item1);
        itemRepository.save(item2);
        itemRepository.save(item3);

        //둘 다 없음 검증
        test(null, null, item1, item2, item3);
        test("", null, item1, item2, item3);

        //itemName 검증
        test("itemA", null, item1, item2);
        test("temA", null, item1, item2);
        test("itemB", null, item3);

        //maxPrice 검증
        test(null, 10000, item1);

        //둘 다 있음 검증
        test("itemA", 10000, item1);
    }

    void test(String itemName, Integer maxPrice, Item... items) {
        List<Item> result = itemRepository.findAll(new ItemSearchCond(itemName, maxPrice));
        assertThat(result).containsExactly(items); // containsExactly는 순서도 다 맞아야한다
    }
}

 

 

MyBatis 분석

사용법은 굉장히 간단했고, 궁금한 부분은 Mapper 인터페이스만 만들었을 뿐인데 구현체를 어떻게 만들었을까?에 대한 고민을 할 차례다.

ItemMapper 

package hello.itemservice.repository.mybatis;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;
import java.util.Optional;

@Mapper
public interface ItemMapper {

    void save(Item item);

    void update(@Param("id") Long id, @Param("updateParam") ItemUpdateDto updateParam);

    Optional<Item> findById(Long id);

    List<Item> findAll(ItemSearchCond itemSearchCond);
}

 

아까 위에서도 살짝 얘기했지만, Mybatis 스프링 연동 모듈에서 저 @Mapper 애노테이션이 달려있는 것들을 다 찾아서, 프록시를 만들고 그 프록시를 스프링 컨테이너에 빈으로 등록해준다. 이 과정에서 정말 여러가지 기술이 사용된다. 

  • 애노테이션 프로세싱
  • 리플렉션
  • JDK 동적 프록시 
  • 등등

이 세가지 내용 모두 내 블로그에서 다룬 내용들이다. 궁금하면 참고하면 좋을듯하다. 여튼 그림으로 보면 다음과 같다.

  • 애플리케이션 로딩 시점에, Mybatis 스프링 연동 모듈은 @Mapper가 붙어있는 인터페이스를 조사한다.
  • 해당 인터페이스가 발견되면 JDK 동적 프록시 기술을 사용해서 ItemMapper 인터페이스의 구현체를 만든다.
  • 생성된 구현체를 스프링 빈으로 등록한다.

이렇게 만들어진 Mapper의 구현체(프록시)는 심지어 예외 변환까지 스프링 데이터 접근 예외 추상화인 DataAccessException에 맞게 변환해서 반환해준다. JdbcTemplate이 제공하는 예외 변환 기능을 여기서도 제공한다고 이해하면 된다.

 

 

728x90
반응형
LIST
728x90
반응형
SMALL
SMALL

Spring과 데이터베이스를 연동할 때 사용되는 기술 중 여전히 잘 사용되는 기술인 JdbcTemplate을 사용하는 법을 정리하고자 한다.

우선, JdbcTemplate은 기존 JDBC 기술을 직접 사용할 때 겪는 문제들을 해결해 준다. 예를 들면 트랜잭션을 시작하고 종료하는 코드 작성이나 반복적인 커넥션 후처리와 같은 것들.

 

나는 개인적으로는 JdbcTemplate을 사용하지 않고 Spring Data JPAQuerydsl을 같이 사용하는 방식을 선호한다. 그러나, 이 JdbcTemplate은 알아둘 만한 가치가 있다고 생각해서 기록하고자 한다. 

 

우선, JDBC 기술을 직접 사용하면서 코드를 작성해보았다. 다음 포스팅에서 말이다.

 

[Renewal] JDBC 이해

참고자료 스프링 DB 1편 - 데이터 접근 핵심 원리 강의 | 김영한 - 인프런김영한 | 백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원

cwchoiit.tistory.com

 

 

[Renewal] 커넥션풀과 DataSource 이해

참고자료 스프링 DB 1편 - 데이터 접근 핵심 원리 강의 | 김영한 - 인프런김영한 | 백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원

cwchoiit.tistory.com

 

[Renewal] 트랜잭션, DB 락 이해

참고자료: 스프링 DB 1편 - 데이터 접근 핵심 원리 강의 | 김영한 - 인프런김영한 | 백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원

cwchoiit.tistory.com

 

[Renewal] 스프링의 트랜잭션

참고자료 스프링 DB 1편 - 데이터 접근 핵심 원리 강의 | 김영한 - 인프런김영한 | 백엔드 개발에 필요한 DB 데이터 접근 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 DB 접근 기술의 원

cwchoiit.tistory.com

 

이 과정을 거치면서 JDBC를 직접 사용하면 비효율적인 부분이 같은 코드의 중복(try - catch, 커넥션 획득, 자원 반납 등)을 피할 수 없었다. 그래서 그 부분을 깔끔하게 해결해주는 스프링이 제공하는 JdbcTemplate을 사용해서 해결해보자.

 

라이브러리 다운로드

우선, JdbcTemplate 라이브러리를 받아야 한다. 

 

build.gradle

//JdbcTemplate
implementation 'org.springframework.boot:spring-boot-starter-jdbc'

 

build.gradle 파일에서 dependencies 추가하는 부분에 위 한 줄을 넣어주고 빌드를 다시 해주면 라이브러리를 내려받는다.

버전 명시는 따로 할 필요 없다. 스프링 부트는 현재 사용중인 스프링의 버전과 가장 호환이 잘 되는 버전을 알아서 선택해서 내려받아준다.

 

 

인터페이스 구현

이제 JdbcTemplate을 이용해서 DB와의 커뮤니케이션을 위한 인터페이스를 만들어야 한다.

이렇게 인터페이스와 구현체를 분리해서 추상화하면 추후 DB 접근 기술에 변경이 생겨도 비즈니스 로직에서의 코드 변경을 최소화할 수 있고 유지보수에 유리해진다.

 

ItemRepository.java

package hello.itemservice.repository;

import hello.itemservice.domain.Item;

import java.util.List;
import java.util.Optional;

public interface ItemRepository {

    Item save(Item item);

    void update(Long itemId, ItemUpdateDto updateParam);

    Optional<Item> findById(Long id);

    List<Item> findAll(ItemSearchCond cond);

}

 

우선 구현할 메소드는 4개이다. save(), update(), findById(), findAll().

여기서 짚고 넘어가야 할 건 update(Long itemId, ItemUpdateDto updateParam), findAll(ItemSearchCond cond) 메소드의 파라미터인 DTO 클래스들이다. 이 DTO 클래스의 위치를 두고 고민을 할 때가 있는데 DTO 클래스는 어떤 패키지에 있어야 할까?

딱 이것만 기억하기로 했다. 저 DTO 클래스의 사용하는 마지막 레벨이 어디인가?

만약, 저 DTO 클래스를 사용하는 마지막 레벨이 리포지토리 레벨이면 리포지토리 패키지에 클래스를 만들면 된다. 그게 아니라 만약 서비스 레벨이면 서비스 패키지에 클래스를 만들면 된다.

 

즉, 의존성 주입에 Circular dependency injection이 일어나지 않으면 된다. 만약 리포지토리에서 사용하는 DTO를 서비스 패키지에 만들어 두었다면 순환 의존성 주입 문제가 발생한다. 왜냐하면 컨트롤러 -> 서비스 -> 리포지토리 레벨로 호출이 되는데 서비스가 리포지토리를 호출하면서 의존 관계가 생기는데 리포지토리는 다시 서비스에게 의존해야 하는 (서비스 레벨에 DTO 클래스가 있으므로) 의존 관계 문제가 생긴다. 

 

그러니까 결국 DTO는 마지막으로 사용하는 레벨이 어디인가를 고려해서 패키지 위치를 결정하면 된다. 그게 아니라면, 여기저기서 다 사용되니 아예 dto를 위한 패키지를 따로 빼서 사용해도 좋다.

 

DTO

ItemUpdateDto.java

package hello.itemservice.repository;

import lombok.Data;

@Data
public class ItemUpdateDto {
    private String itemName;
    private Integer price;
    private Integer quantity;

    public ItemUpdateDto() {
    }

    public ItemUpdateDto(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

ItemSearchCond.java

package hello.itemservice.repository;

import lombok.Data;

@Data
public class ItemSearchCond {

    private String itemName;
    private Integer maxPrice;

    public ItemSearchCond() {
    }

    public ItemSearchCond(String itemName, Integer maxPrice) {
        this.itemName = itemName;
        this.maxPrice = maxPrice;
    }
}

 

ItemRepository 구현체 - JdbcTemplate 사용

package hello.itemservice.repository.jdbctemplate;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import org.springframework.util.StringUtils;

import javax.sql.DataSource;
import java.util.List;
import java.util.Map;
import java.util.Optional;


@Slf4j
public class JdbcTemplateItemRepository implements ItemRepository {

    private final NamedParameterJdbcTemplate template;
    private final SimpleJdbcInsert jdbcInsert;

    public JdbcTemplateItemRepositoryV3(DataSource dataSource) {
        this.template = new NamedParameterJdbcTemplate(dataSource);
        this.jdbcInsert = new SimpleJdbcInsert(dataSource)
                .withTableName("item")
                .usingGeneratedKeyColumns("id");
    }

    @Override
    public Item save(Item item) {
        BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(item);
        Number key = jdbcInsert.executeAndReturnKey(param);

        item.setId(key.longValue());
        return item;
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        String sql = "UPDATE item SET item_name = :itemName, price = :price, quantity = :quantity WHERE id = :id";

        SqlParameterSource param = new MapSqlParameterSource()
                .addValue("itemName", updateParam.getItemName())
                .addValue("price", updateParam.getPrice())
                .addValue("quantity", updateParam.getQuantity())
                .addValue("id", itemId);

        template.update(sql, param);
    }

    @Override
    public Optional<Item> findById(Long id) {
        String sql = "SELECT id, item_name, price, quantity FROM item WHERE id = :id";
        try {
            Map<String, Object> param = Map.of("id", id);
            Item item = template.queryForObject(sql, param, itemRowMapper());
            return Optional.of(item);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }

    private RowMapper<Item> itemRowMapper() {
        return BeanPropertyRowMapper.newInstance(Item.class); // snake_case를 camelCase로 변환해주는 작업도 해줌
    }

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();

        BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(cond);

        String sql = "SELECT id, item_name, price, quantity FROM item";

        // 동적 쿼리
        if (StringUtils.hasText(itemName) || maxPrice != null) {
            sql += " WHERE";
        }

        boolean andFlag = false;

        if (StringUtils.hasText(itemName)) {
            sql += " item_name LIKE concat('%', :itemName, '%')";
            andFlag = true;
        }

        if (maxPrice != null) {
            if (andFlag) {
                sql += " AND";
            }
            sql += " price <= :maxPrice";
        }

        log.info("sql={}", sql);

        return template.query(sql, param, itemRowMapper());
    }
}

 

위 코드는 구현체의 전체 코드이다. 하나씩 뜯어보자.

private final NamedParameterJdbcTemplate template;
private final SimpleJdbcInsert jdbcInsert;

 

NamedParameterJdbcTemplate 클래스는 JdbcTemplate을 사용하지만, 파라미터를 순서에 맞게 작성해야하는 불편함을 해결하기 위해 이름에 따른 파라미터 전달을 가능하게 해주는 NamedParameterJdbcTemplate를 사용했다.

 

SimpleJdbcInsertINSERT 쿼리를 좀 더 간단하게 사용할 수 있게 도와주는 클래스라고 보면 된다. 그러니까 JdbcTemplate을 사용할 때 INSERT 쿼리 작성을 하지 않아도 되고, PK를 auto generated key로 설정한 경우 생성한 새로운 레코드의 키를 KeyHolder에 담고 돌려주고 하는 번거로운 작업을 대신해준다.

 

public JdbcTemplateItemRepositoryV3(DataSource dataSource) {
    this.template = new NamedParameterJdbcTemplate(dataSource);
    this.jdbcInsert = new SimpleJdbcInsert(dataSource)
            .withTableName("item")
            .usingGeneratedKeyColumns("id");
}

 

생성자 부분을 보자. 우선 DataSource를 파라미터로 받아 NamedParameterJdbcTemplate()SimpleJdbcInsert()에 각각 넣어준다. SimpleJdbcInsert는 오로지 INSERT만을 위한 편의성을 제공해주는 녀석이다. 그래서 어떤 테이블에 INSERT를 할지 알려주어야 한다. 그래서 withTableName()"item"이라는 테이블을 넣어주었고, usingGeneratedKeyColums()에는 "id"를 넣어주었다. 이건 기본키 자동 생성 옵션으로 테이블을 만들었다면 그 키 이름을 알려주어야 하기 때문에 작성했다.

 

@Override
public Item save(Item item) {
    BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(item);
    Number key = jdbcInsert.executeAndReturnKey(param);

    item.setId(key.longValue());
    return item;
}

 

save(Item item) 메소드를 확인해 보자. 이 메소드는 새로운 Item 레코드 하나를 추가할 때 사용된다. 여기서 위에서 말한 SimpleJdbcInsert가 사용될 거고 전달해 주는 파라미터는 Item을 생성할 때 필요한 파라미터 (itemName, price, quantity)가 전달된다. 근데 그때 사용되는 파라미터는 BeanPropertySqlParameterSource라는 클래스인데 이 클래스에 item 객체를 넘기면 이 item 객체가 가지고 있는 필드값을 그대로 파라미터에 필드 이름을 기준으로 알아서 넣어준다. 즉, 사실 저 두 줄은 다음과 같다.

String sql = "INSERT INTO item(item_name, price, quantity) values (:itemName, :price, :quantity)";

 

INSERT SQL문에 :itemName, :price, :quantity에 각각 필드값이 들어간다. (item에 들어있는)

그리고 SimpleJdbcInsert.executeAndReturnKey() 메소드의 반환값은 INSERT문으로 생성된 새로운 레코드의 키를 반환한다.

 

@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
    String sql = "UPDATE item SET item_name = :itemName, price = :price, quantity = :quantity WHERE id = :id";

    SqlParameterSource param = new MapSqlParameterSource()
            .addValue("itemName", updateParam.getItemName())
            .addValue("price", updateParam.getPrice())
            .addValue("quantity", updateParam.getQuantity())
            .addValue("id", itemId);

    template.update(sql, param);
}

 

update() 메소드를 보자. UPDATE SQL문을 작성하고 그 작성한 SQL에 필요한 파라미터를 MapSqlParameterSource를 이용해서 넘겨준다. 보기만 해도 딱 간단하고 명료하다. addValue()key, value 값을 차례대로 넣어주면 된다. 위에서는 BeanPropertySqlParameterSource를 사용했는데 여기서는 MapSqlParameterSource를 사용했다. 이렇게도 사용할 수 있다는 것을 보여주기 위해 사용한 것 뿐이다.

 

@Override
public Optional<Item> findById(Long id) {
    String sql = "SELECT id, item_name, price, quantity FROM item WHERE id = :id";
    try {
        Map<String, Object> param = Map.of("id", id);
        Item item = template.queryForObject(sql, param, itemRowMapper());
        return Optional.of(item);
    } catch (EmptyResultDataAccessException e) {
        return Optional.empty();
    }
}

 

이번엔 findById() 메소드이다. 여기서도 마찬가지로 SQL문을 작성해 주고 그 SQL에 필요한 파라미터를 넘겨주면 되는데, 여기서는 다른 방법을 사용해 봤다. 물론 위에서 사용한 MapSqlParameterSource를 사용해도 된다. 

딱 한 개의 파라미터가 필요하니까 HashMap을 만들 필요 없이 바로 Map.of("id", id)로 파라미터를 만들어주고 넘겨주면 된다.

 

이때, itemRowMapper()를 호출하는데 이는 SELECT SQL문을 날려서 반환되는 레코드를 Item 객체로 변환해 주는 메서드이다.

private RowMapper<Item> itemRowMapper() {
    return BeanPropertyRowMapper.newInstance(Item.class); // snake_case를 camelCase로 변환해주는 작업도 해줌
}

 

아주 간단하다. BeanPropertyRowMapper로 변환하고자 하는 클래스를 넘겨주면 된다. 이 녀석이 반환된 ResultSet의 값을 이용해 item 객체를 만들어준다. 그리고 이 한 줄의 코드는 아래 코드를 축약했다고 보면 된다.

private RowMapper<Item> itemRowMapper() {
    return ((rs, rowNum) -> {
        Item item = new Item();
        item.setId(rs.getLong("id"));
        item.setItemName(rs.getString("item_name"));
        item.setPrice(rs.getInt("price"));
        item.setQuantity(rs.getInt("quantity"));
        return item;
    });
}

 

@Override
public List<Item> findAll(ItemSearchCond cond) {
    String itemName = cond.getItemName();
    Integer maxPrice = cond.getMaxPrice();

    BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(cond);

    String sql = "SELECT id, item_name, price, quantity FROM item";

    // 동적 쿼리
    if (StringUtils.hasText(itemName) || maxPrice != null) {
        sql += " WHERE";
    }

    boolean andFlag = false;

    if (StringUtils.hasText(itemName)) {
        sql += " item_name LIKE concat('%', :itemName, '%')";
        andFlag = true;
    }

    if (maxPrice != null) {
        if (andFlag) {
            sql += " AND";
        }
        sql += " price <= :maxPrice";
    }

    log.info("sql={}", sql);

    return template.query(sql, param, itemRowMapper());
}

 

이제 findAll() 메소드를 보자. 이 부분이 JdbcTemplate의 단점이라고 생각하면 된다. 즉, 동적 쿼리를 만들어내기 쉽지 않다는 것.

우선, BeanPropertySqlParameterSource를 생성해 파라미터 바인딩을 해준다. ItemSearchCond를 넘겼을 때 이 객체가 가지고 있는 필드 이름을 통해 파라미터 바인딩을 해줄 것이다. 

if (StringUtils.hasText(itemName) || maxPrice != null) {
    sql += " WHERE";
}

 

이 부분에서 itemName이나 maxPrice가 있다면 위에 만들어놓은 SQL문에 WHERE 절을 붙인다.

 

boolean andFlag = false;

if (StringUtils.hasText(itemName)) {
    sql += " item_name LIKE concat('%', :itemName, '%')";
    andFlag = true;
}

if (maxPrice != null) {
    if (andFlag) {
        sql += " AND";
    }
    sql += " price <= :maxPrice";
}

 

이 부분에서 itemNamemaxPrice를 각각 구분하여 WHERE절에 조건을 넣어주는데, 이제 SQL문에 각각 조건을 추가하면 된다. 근데 이게 여간 귀찮은 게 아니다. 문자열마다 공백도 신경 써야 하고, 있는지 없는지 판단해야 하는 조건문이나 AND를 추가하고 말고까지 다 생각해야 하니 이런 부분에서 JdbcTemplate의 단점이 드러난다고 볼 수 있다. 

 

JdbcTemplateConfig

package hello.itemservice.config;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.jdbctemplate.JdbcTemplateItemRepository;
import hello.itemservice.service.ItemService;
import hello.itemservice.service.ItemServiceV1;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration
@RequiredArgsConstructor
public class JdbcTemplateV3Config {

    private final DataSource dataSource;

    @Bean
    public ItemService itemService() {
        return new ItemServiceV1(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository() {
        return new JdbcTemplateItemRepository(dataSource);
    }
}

 

물론, 이 Config 파일을 만들어 직접 빈으로 등록하는 게 아니라 컴포넌트 스캔을 사용해도 무방하다.

 

SpringBootApplication

package hello.itemservice;

import hello.itemservice.config.*;
import hello.itemservice.repository.ItemRepository;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Profile;


@Import(JdbcTemplateV3Config .class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}
}

 

@Import 애노테이션으로 Config 파일을 Import 한 이유는 컴포넌트 스캔의 패키지가 해당 파일을 포함하지 않기 때문이다. 위 코드에서 보다시피 scanBasePackagesJdbcTemplateV3Config 파일이 포함된 패키지를 포함하지 않는다. 이건 크게 중요하지 않다. 아까 말했듯, 그냥 컴포넌트 스캔으로 해도 된다. 이런 방법도 있다라는 것을 보여주는 것 뿐이다.

 

ItemService

package hello.itemservice.service;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;

import java.util.List;
import java.util.Optional;

public interface ItemService {

    Item save(Item item);

    void update(Long itemId, ItemUpdateDto updateParam);

    Optional<Item> findById(Long id);

    List<Item> findItems(ItemSearchCond itemSearch);
}

ItemService 구현체

package hello.itemservice.service;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class ItemServiceV1 implements ItemService {

    private final ItemRepository itemRepository;

    @Override
    public Item save(Item item) {
        return itemRepository.save(item);
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        itemRepository.update(itemId, updateParam);
    }

    @Override
    public Optional<Item> findById(Long id) {
        return itemRepository.findById(id);
    }

    @Override
    public List<Item> findItems(ItemSearchCond cond) {
        return itemRepository.findAll(cond);
    }
}

 

구현체는 리포지토리의 위임만 하고 있다. 사실상 이렇게 위임만 하는 경우 서비스가 필요 없을 수 있다 프로젝트에 따라. 그러나 구조를 좀 체계적으로 만들기 위해 서비스까지 작성했다. 이제 실제로 이 코드를 수행해 보자. 그리고 보통은, 서비스는 인터페이스를 굳이 만들지 않아도 된다. 그 이유는, 음.. 서비스 코드는 순수한 비즈니스 로직이 담겨있는 코드이다. 여기는 최대한 어떤 특정 기술에 종속적이게 작성하지 말고 순수한 자바 코드로 작성해야 한다. 물론 상황에 따라 다르겠지만. 그래서 사용하는 기술을 갈아끼우는게 아니라 순수한 서비스 코드를 만든다면 구현체를 갈아끼울 이유가 없다. 사실. 

 

 

테스트 코드

package hello.itemservice.domain;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import hello.itemservice.repository.memory.MemoryItemRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.DefaultTransactionAttribute;

import java.util.List;

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

@Transactional
@SpringBootTest
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    @Test
    void save() {
        //given
        Item item = new Item("itemA", 10000, 10);

        //when
        Item savedItem = itemRepository.save(item);

        //then
        Item findItem = itemRepository.findById(item.getId()).get();
        assertThat(findItem).isEqualTo(savedItem);
    }

    @Test
    void updateItem() {
        //given
        Item item = new Item("item1", 10000, 10);
        Item savedItem = itemRepository.save(item);
        Long itemId = savedItem.getId();

        //when
        ItemUpdateDto updateParam = new ItemUpdateDto("item2", 20000, 30);
        itemRepository.update(itemId, updateParam);

        //then
        Item findItem = itemRepository.findById(itemId).get();
        assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
        assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
        assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
    }

    @Test
    void findItems() {
        //given
        Item item1 = new Item("itemA-1", 10000, 10);
        Item item2 = new Item("itemA-2", 20000, 20);
        Item item3 = new Item("itemB-1", 30000, 30);

        itemRepository.save(item1);
        itemRepository.save(item2);
        itemRepository.save(item3);

        //둘 다 없음 검증
        test(null, null, item1, item2, item3);
        test("", null, item1, item2, item3);

        //itemName 검증
        test("itemA", null, item1, item2);
        test("temA", null, item1, item2);
        test("itemB", null, item3);

        //maxPrice 검증
        test(null, 10000, item1);

        //둘 다 있음 검증
        test("itemA", 10000, item1);
    }

    void test(String itemName, Integer maxPrice, Item... items) {
        List<Item> result = itemRepository.findAll(new ItemSearchCond(itemName, maxPrice));
        assertThat(result).containsExactly(items); // containsExactly는 순서도 다 맞아야한다
    }
}

 

 

728x90
반응형
LIST

'Spring + Database' 카테고리의 다른 글

[Renewal] Spring Data JPA  (4) 2024.12.07
[Renewal] MyBatis  (4) 2024.12.06
[Renewal] 테스트 시 데이터베이스 연동  (0) 2024.12.05
[Renewal] 예외2, 스프링의 데이터접근 예외 추상화  (3) 2024.12.05
[Renewal] 예외  (0) 2024.12.05
728x90
반응형
SMALL

참고자료

 

스프링 DB 2편 - 데이터 접근 활용 기술 강의 | 김영한 - 인프런

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

www.inflearn.com

 

스프링 프로젝트에서 데이터베이스에 연동하는 테스트에 대해 알아보는 포스팅이다. 데이터 접근 기술은 실제 데이터베이스에 접근해서 데이터를 잘 저장하고 조회할 수 있는지 확인하는 것이 필요하다. 지금부터 테스트를 실행할 때 실제 데이터베이스를 연동해서 진행해보자. 

 

우선, main 하위에 있는 application.yamltest 하위에 있는 application.yaml이 나뉘어져 있는데, 그 이유는 테스트 케이스는 src/test 하위에 있기 때문에 실행하면 src/test에 있는 application.yaml 파일이 우선순위를 가지고 실행된다. 그래서 일단 이 파일을 다음과 같이 수정해보자. 

spring:
  profiles:
    active: test

  datasource:
    url: jdbc:h2:tcp://localhost/~/h2/db/springdb
    username: sa
    password:
  • 이렇게 설정을 하면 데이터베이스는 H2 데이터베이스를 사용하는 것이고, 로컬 DB에 있는 데이터베이스로 테스트를 하겠다는 의미가 된다.

테스트 실행

@SpringBootTest
class ItemRepositoryTest {...}
  • 가장 먼저 이 @SpringBootTest 애노테이션이 달려있음을 확인할 수 있는데 이 애노테이션은 @SpringBootApplication을 찾아서 설정으로 사용한다.
  • 그러니까 쉽게 말해서, 우리의 main 하위에 있는 스프링 애플리케이션을 사용한다고 보면 된다. 실제로 스프링 컨테이너도 띄워지고 우리가 작업했던 설정 데이터들을 사용해 빈으로 등록하고 한다.

ItemRepositoryTest - 전체 소스

package hello.itemservice.domain;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import hello.itemservice.repository.memory.MemoryItemRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

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

@SpringBootTest
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    @AfterEach
    void afterEach() {
        //MemoryItemRepository 의 경우 제한적으로 사용
        if (itemRepository instanceof MemoryItemRepository) {
            ((MemoryItemRepository) itemRepository).clearStore();
        }
    }

    @Test
    void save() {
        //given
        Item item = new Item("itemA", 10000, 10);

        //when
        Item savedItem = itemRepository.save(item);

        //then
        Item findItem = itemRepository.findById(item.getId()).get();
        assertThat(findItem).isEqualTo(savedItem);
    }

    @Test
    void updateItem() {
        //given
        Item item = new Item("item1", 10000, 10);
        Item savedItem = itemRepository.save(item);
        Long itemId = savedItem.getId();

        //when
        ItemUpdateDto updateParam = new ItemUpdateDto("item2", 20000, 30);
        itemRepository.update(itemId, updateParam);

        //then
        Item findItem = itemRepository.findById(itemId).get();
        assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
        assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
        assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
    }

    @Test
    void findItems() {
        //given
        Item item1 = new Item("itemA-1", 10000, 10);
        Item item2 = new Item("itemA-2", 20000, 20);
        Item item3 = new Item("itemB-1", 30000, 30);

        itemRepository.save(item1);
        itemRepository.save(item2);
        itemRepository.save(item3);

        //둘 다 없음 검증
        test(null, null, item1, item2, item3);
        test("", null, item1, item2, item3);

        //itemName 검증
        test("itemA", null, item1, item2);
        test("temA", null, item1, item2);
        test("itemB", null, item3);

        //maxPrice 검증
        test(null, 10000, item1);

        //둘 다 있음 검증
        test("itemA", 10000, item1);
    }

    void test(String itemName, Integer maxPrice, Item... items) {
        List<Item> result = itemRepository.findAll(new ItemSearchCond(itemName, maxPrice));
        assertThat(result).containsExactly(items);
    }
}
  • 이 테스트 케이스 3개를 실행해보면 다음과 같은 결과를 얻을 것이다.

실행 결과

  • updateItem() - 성공
  • save() - 성공
  • findItems() - 실패

findItems()는 실패했을까? 이유는 간단하다. updateItem(), save()를 실행하면서 생긴 데이터를 데이터베이스가 그대로 가지고 있기 때문에 findItems() 테스트를 실행할 때 예상하는 데이터 개수가 다르기 때문이다. 그러니까 결론적으로 테스트 시 데이터베이스는 운영이나 로컬 데이터베이스와는 철저히 분리를 해야 하며, 분리한 데이터베이스로 테스트를 할때 테스트 후 데이터를 남겨두면 안된다!

 

테스트 - 데이터베이스 분리

가장 원초적인 방법으로는, 테스트용 데이터베이스를 새로 만드는 것이다. 따라서 H2 데이터베이스를 하나 더 만들면 된다.

그래서 나는 다음과 같이 application.yaml 파일을 수정했다.

spring:
  profiles:
    active: test

  datasource:
    url: jdbc:h2:tcp://localhost/~/h2/db/springdbtest
    username: sa
    password:
  • 로컬 데이터베이스는 `springdb` 였다면, 테스트 데이터베이스는 `springdbtest`이다.
  • 그리고 아래와 같이 테스트에 필요한 item 테이블 하나를 만든다.
 create table item
 (
     id        bigint generated by default as identity,
     item_name varchar(10),
     price     integer,
     quantity  integer,
     primary key (id)
 );

 

이렇게 변경하고 테스트를 전체 실행해보자. 과연 잘될까? 물론 아니다.

 

당연하게도 로컬 데이터베이스와 테스트 데이터베이스는 분리해야 하는건 맞지만, 분리한 데이터베이스인 테스트용 데이터베이스 역시 여러 테스트가 진행된다면 데이터가 남아있게 된다. 즉, 테스트에서 매우 중요한 원칙은 다음과 같다.

  • 테스트는 다른 테스트와 격리해야 한다.
  • 테스트는 반복해서 실행할 수 있어야 한다.

그런데 지금은 findItems()라는 테스트는 updateItem(), save() 둘 다와 격리성이 없다. 왜냐하면 같은 데이터베이스를 사용하는데다가 테스트를 실행하고 생긴 데이터가 그대로 남아있으니 말이다. 물론, 테스트가 끝날때마다 DELETE 쿼리를 날리면 해결될 수도 있는데 이건 근본적인 해결책이 아니다. 만약 테스트 과정에서 이 쿼리에 대한 오류가 생기면 테스트가 끝나고 이 쿼리가 제대로 수행되지 않을 수 있기 때문이다. 그래서 이 문제를 해결하는 방법은 데이터 롤백을 하는것이다.

 

테스트 - 데이터 롤백

테스트가 끝나고 나서 트랜잭션을 강제로 롤백해버리면 데이터가 깔끔하게 제거된다. 테스트를 하면서 데이터를 이미 저장했는데, 중간에 테스트가 실패해서 롤백을 호출하지 못해도 괜찮다. 트랜잭션을 커밋하지 않았기 때문에 데이터베이스에 해당 데이터가 반영되지 않는다. 이렇게 트랜잭션을 활용하면 테스트가 끝나고 나서 데이터를 깔끔하게 원래 상태로 되돌릴 수 있다.

 

그리고 테스트는 각각의 테스트 실행 전 후로 동작하는 @BeforeEach, @AfterEach 라는 편리한 기능을 제공한다.

 

ItemRepositoryTest - 롤백 코드 추가

@SpringBootTest
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    @Autowired
    PlatformTransactionManager transactionManager;
    TransactionStatus transaction;

    @BeforeEach
    void beforeEach() {
         transaction = transactionManager.getTransaction(new DefaultTransactionDefinition());
    }

    @AfterEach
    void afterEach() {
        //MemoryItemRepository 의 경우 제한적으로 사용
        if (itemRepository instanceof MemoryItemRepository) {
            ((MemoryItemRepository) itemRepository).clearStore();
        }

        transactionManager.rollback(transaction);
    }
}
  • 위 코드처럼 @BeforeEach 애노테이션이 달리면, 각 테스트 마다마다 테스트 실행 전에, 작성한 코드를 수행해주는데 그게 트랜잭션 시작을 하는 코드이다.
  • 위 코드처럼 @AfterEach 애노테이션이 달리면, 각 테스트 마다마다 테스트 실행 후에, 작성한 코드를 수행해주는데 그게 바로 롤백이다. 
  • 이렇게 @BeforeEach, @AfterEach를 사용해서, 테스트 실행 전 후로 트랜잭션을 시작하고 롤백을 해주면 데이터가 테스트 후에 남지 않게 된다.

 

이 상태로 테스트를 실행하면 계속 실행해도 테스트에 통과하게 된다. 그런데 여전히 불편한 건 뭐냐면 테스트 코드 파일 마다 이걸 이렇게 귀찮게 작성해줘야 할까?에 대한 고민이다. 스프링은 우리에게 이런 불편함을 그대로 떠안도록 내버려 두지 않는다. 

 

테스트 - @Transactional

스프링은 테스트 데이터 초기화를 위해 트랜잭션을 적용하고 롤백하는 방식을 @Transactional 애노테이션 하나로 깔끔하게 해결해준다. 위에서 사용했던 직접 트랜잭션을 시작하고 롤백하는 코드를 다 날려도 된다.

@SpringBootTest
@Transactional
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    @AfterEach
    void afterEach() {
        //MemoryItemRepository 의 경우 제한적으로 사용
        if (itemRepository instanceof MemoryItemRepository) {
            ((MemoryItemRepository) itemRepository).clearStore();
        }
    }
    
    ...
}
  • 이렇게 클래스 레벨에 @Transactional 애노테이션 하나만 붙여주고 아까 위에서 작성했던 직접 트랜잭션을 시작하고 롤백하는 코드는 다 날려버렸다.
  • 참고로 org.springframework.transaction.annotation.Transactional 패키지를 사용해야 한다.
  • 이렇게 하기만 하면 끝이다. 이대로 테스트를 계속 실행해도 계속 통과한다. 자동으로 롤백이 된다.

 

원래, 메인 코드에서 @Transactional 애노테이션이 달리면 정상적으로 끝나면 커밋이 맞다. 근데 테스트 코드에서는 기본값이 롤백이다. 스프링이 우릴 위해 편리하게 사용할 수 있도록 이렇게 설계한 것이다.

 

정리하자면

  • @Transactional은 테스트가 끝난 후 개발자가 직접 데이터를 삭제하지 않아도 되는 편리함을 제공한다.
  • 테스트 실행 중에 데이터를 등록하고 중간에 테스트가 강제로 종료되어도 걱정이 없다. 이 경우, 트랜잭션 커밋을 하지 않기 때문에 데이터는 자동으로 롤백된다. (보통 데이터베이스 커넥션이 끊어지면 자동으로 롤백되어 버린다)
  • 트랜잭션 범위 안에서 테스트를 진행하기 때문에 동시에 다른 테스트가 진행되어도 서로 영향을 주지 않는 장점이 있다. 
  • @Transactional 애노테이션 덕분에 아주 편리하게 다음 원칙을 지킬 수 있게 되었다.
    • 테스트는 다른 테스트와 격리해야 한다.
    • 테스트는 반복해서 실행할 수 있어야 한다. 

 

테스트 시 강제로 커밋 - @Commit

@Transactional을 테스트에서 사용하면 테스트가 끝나면 바로 롤백되기 때문에 테스트 과정에서 저장한 모든 데이터가 사라진다. 당연히 이렇게 되는게 맞다. 그런데 가끔은 데이터베이스에 데이터가 잘 보관되었는지 최종 결과를 눈으로 확인하고 싶을때도 있다. 이럴때는 다음과 같이 @Commit 애노테이션을 클래스 또는 메서드에 붙이면 테스트 종료 후 롤백 대신 커밋이 호출된다.

@Test
@Commit
void save() {
    //given
    Item item = new Item("itemA", 10000, 10);

    //when
    Item savedItem = itemRepository.save(item);

    //then
    Item findItem = itemRepository.findById(item.getId()).get();
    assertThat(findItem).isEqualTo(savedItem);
}

 

테스트 - 임베디드 모드 DB

테스트 케이스를 실행하기 위해서 별도의 데이터베이스를 설치하고, 운영하는 것은 상당히 번잡한 작업이다. 단순히 테스트를 검증할 용도로만 사용하기 때문에 테스트가 끝나면 데이터베이스와 데이터를 모두 삭제해도 된다. 더 나아가서 테스트가 끝나면 데이터베이스 자체를 제거해도 된다. 

 

임베디드 모드

H2 데이터베이스는 자바로 개발되어 있고, JVM안에서 메모리 모드로 동작하는 특별한 기능을 제공한다. 그래서 애플리케이션을 실행할때 H2 데이터베이스도 해당 JVM 메모리에 포함해서 함께 실행할 수 있다. DB를 애플리케이션에 내장해서 함께 실행한다고 해서 임베디드 모드라 한다. 물론 애플리케이션이 종료되면 임베디드 모드로 동작하는 H2 데이터베이스도 함께 종료되고, 데이터도 모두 사라진다. 쉽게 이야기해서 애플리케이션에서 자바 메모리를 함께 사용하는 라이브러리처럼 동작하는 것이다. 

 

이제 H2 데이터베이스를 임베디드 모드로 사용해보자.

 

임베디드 모드 직접 사용

임베디드 모드를 직접 사용하는 방법은 다음과 같다.

ItemServiceApplication

package hello.itemservice;

import hello.itemservice.config.*;
import hello.itemservice.repository.ItemRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Profile;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

import javax.sql.DataSource;


@Slf4j
// @Import(MemoryConfig.class)
// @Import(JdbcTemplateV1Config.class)
// @Import(JdbcTemplateV2Config.class)
@Import(JdbcTemplateV3Config.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}

	@Bean
	@Profile("local")
	public TestDataInit testDataInit(ItemRepository itemRepository) {
		return new TestDataInit(itemRepository);
	}

	@Bean
	@Profile("test")
	public DataSource dataSource() {
		log.info("메모리 데이터베이스 초기화");
		DriverManagerDataSource dataSource = new DriverManagerDataSource();
		dataSource.setDriverClassName("org.h2.Driver");
		dataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1");
		dataSource.setUsername("sa");
		dataSource.setPassword("");
		return dataSource;
	}
}
  • 이 부분에서 주의깊게 볼 부분은 다음 부분이다.
@Bean
@Profile("test")
public DataSource dataSource() {
    log.info("메모리 데이터베이스 초기화");
    DriverManagerDataSource dataSource = new DriverManagerDataSource();
    dataSource.setDriverClassName("org.h2.Driver");
    dataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1");
    dataSource.setUsername("sa");
    dataSource.setPassword("");
    return dataSource;
}
  • @Profile("test") 애노테이션으로 현재 프로파일이 `test`인 경우에만 이 빈은 등록된다. 테스트 케이스에서만 이 데이터소스를 스프링 빈으로 등록해서 사용하겠다는 뜻이 된다.
  • 이때 DataSource를 빈으로 등록하는데 URL 부분을 자세히 보면 `jdbc:h2:mem:...` 으로 되어 있다. memmemory를 의미한다. 즉, 메모리에 H2 데이터베이스를 띄우겠다는 의미이다. DB_CLOSE_DELAY=-1이 의미하는건, 임베디드 모드에서는 데이터베이스 커넥션 연결이 모두 끊어지면 데이터베이스도 종료되는데 그것을 방지하는 설정이다.

이제 테스트 케이스를 실행해보면 된다. 근데! 그 전에, 애플리케이션이 뜨는 시점에 메모리에 H2 데이터베이스를 띄우는데 우리가 원하는 테이블이 만들어질까? 아니다. 메모리에 띄운 H2 데이터베이스에서 사용할 테이블도 정의를 해줘야 한다. 그것은 `src/test/resources` 하위에 다음과 같은 파일로 정의할 수 있다.

schema.sql

drop table if exists item CASCADE;
create table item
(
    id        bigint generated by default as identity,
    item_name varchar(10),
    price     integer,
    quantity  integer,
    primary key (id)
);
  • 내가 원하는 테이블은 딱 이렇게 생긴 item 테이블 하나이다.
  • 이렇게 파일도 만들었으면 이제 테스트를 마음껏 실행할 수 있다.

테스트는 실행해보면 결과를 확인할 수 있을 것이고, 그건 그렇고, 이 작업 과연 개발자가 직접 해줘야할까?

테스트 - 스프링 부트와 임베디드 모드

스프링 부트는 개발자에게 정말 많은 편리함을 제공하는데, 임베디드 데이터베이스에 대한 설정도 기본으로 제공한다. 그래서 스프링 부트는 데이터베이스에 대한 별다른 설정이 없으면 기본으로 임베디드 데이터베이스를 사용한다. 이건 테스트나 메인 소스나 동일하다. 

 

그래서 위에서 작업했던 이 부분 주석처리해보자.

ItemServiceApplication  일부분

/*@Bean
@Profile("test")
public DataSource dataSource() {
    log.info("메모리 데이터베이스 초기화");
    DriverManagerDataSource dataSource = new DriverManagerDataSource();
    dataSource.setDriverClassName("org.h2.Driver");
    dataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1");
    dataSource.setUsername("sa");
    dataSource.setPassword("");
    return dataSource;
}*/

 

그리고 테스트 하위에 있는 application.yaml 파일에서도 datasource 관련 내용을 다 주석처리하자.

src/test/resources/application.yaml 일부분

#  datasource:
#    url: jdbc:h2:tcp://localhost/~/h2/db/springdbtest
#    username: sa
#    password:

 

이렇게 까지 하면, 데이터베이스에 대한 아무런 내용도 지금 없는 상태이다. 그러면 스프링 부트는 기본으로 임베디드 데이터베이스를 사용한다. (당연히, 테이블 정의에 대한 DDL 스크립트, 즉, schema.sql은 살려두어야 한다)

 

이 상태로 테스트 실행해보자. 정상적으로 잘 진행될 것이다.

 

결론

결국, 결론은 그냥 테스트할 때 데이터베이스와 연동이 필요하면 그냥 스프링 부트가 제공하는 임베디드 데이터베이스를 사용하면 된다. 데이터베이스를 별도로 만들 필요도 없고, 그저 테스트를 위한 것이기 때문에 완전히 안성맞춤이다. 그리고 테이블 정의에 대한 작업만 해주면 되는데 이 부분도 나중에 ORM을 사용하면 그마저도 필요없다. ORM으로 테이블 매핑을 다 해놓으면 그냥 그대로 동작하기 때문에. 

 

728x90
반응형
LIST

'Spring + Database' 카테고리의 다른 글

[Renewal] MyBatis  (4) 2024.12.06
[Renewal] JdbcTemplate  (8) 2024.12.06
[Renewal] 예외2, 스프링의 데이터접근 예외 추상화  (3) 2024.12.05
[Renewal] 예외  (0) 2024.12.05
[Renewal] 스프링의 트랜잭션  (0) 2024.11.24
728x90
반응형
SMALL

https://cwchoiit.tistory.com/68

 

예외

자바에서 예외는 크게 두 가지(체크 예외, 언체크 예외)로 나뉘어진다. 체크 예외 컴파일러가 체크하는 예외이다. 체크 예외는 잡아서 처리하거나 또는 밖으로 던지도록 선언해야한다. 그렇지

cwchoiit.tistory.com

이전 포스팅인 1편에서 기본적인 예외의 내용을 알아보았고 이제는 스프링과 관련된 내용을 작성하고자 한다.

 

만약, 데이터 접근 시 에러가 발생했을 때 그 에러를 복구하거나 플랜B로 처리하고 싶다면 어떻게 할까?

예를 들어, 새로운 데이터를 데이터베이스에 생성하고자 하는데 새로운 데이터의 기본키가 이미 존재한다면 어떻게 하면 좋을까?

 

프로젝트나 비즈니스마다 다르겠지만 이런 경우도 있을것이다.

새로운 값으로 또는 다른 값으로 대체하여 추가하자.


이런 경우를 알아보자.

우선, DB마다 에러가 발생하면 에러 코드를 반환하는데 그 에러 코드가 DB별로 상이하다. 일단 이 부분이 굉장히 큰 문제거리가 되지만 우선 H2 데이터베이스를 사용한다고 했을 때, 중복 PK 에러 코드는 '23505'이다.

 

그 경우에는 새로운 PK값으로 재시도를 할 것이다. 그러기 위해선 SQLException은 체크 예외이기 때문에 런타임 예외로 커스텀을 해야한다. 그렇지 않으면 의존 관계 문제가 발생하면서 동시에 SQLException 하위 에러 모두 이 에러로 반환될 수 있다.

 

반응형
SMALL

MyDuplicateKeyException

package com.example.jdbc.exception;

public class MyDuplicateKeyException extends RuntimeException {

    public MyDuplicateKeyException() {
    }

    public MyDuplicateKeyException(String message) {
        super(message);
    }

    public MyDuplicateKeyException(String message, Throwable cause) {
        super(message, cause);
    }

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

 

이제 중복키 관련 에러가 발생하면 런타임 예외를 상속받은 MyDuplicateKeyException을 반환할 것이다.

아래 코드는 DB에 INSERT 쿼리를 날렸을 때 SQLException이 발생하는 에러를 catch에서 잡아준다. 이 때 이 에러의 에러 코드가 23505(H2의 중복키 에러코드)일 경우 위에서 새로 만든 MyDuplicateKeyException을 던진다.

@RequiredArgsConstructor
static class Repository {
    private final DataSource dataSource;

    public Member save(Member member) {
        String sql = "INSERT INTO member(member_id, money) values(?, ?)";
        Connection connection = null;
        PreparedStatement pstmt = null;

        try {
            connection = dataSource.getConnection();
            pstmt = connection.prepareStatement(sql);
            pstmt.setString(1, member.getMemberId());
            pstmt.setInt(2, member.getMoney());
            pstmt.executeUpdate();
            return member;
        } catch (SQLException e) {
            // 데이터 베이스의 오류 중 특정 예외는 복구하고 싶을 수 있는 경우가 있다. 그럴 때 이렇게 우리만의 에러를 만들어서 리턴해주고 받는 쪽은
            // 그 에러인지 확인해서 그 에러라면 복구 로직을 작성하면 된다.
            if (e.getErrorCode() == 23505) {
                throw new MyDuplicateKeyException(e);
            }
            throw new MyDbException(e);
        } finally {
            JdbcUtils.closeStatement(pstmt);
            JdbcUtils.closeConnection(connection);
        }
    }
}

 

그럼 이제 서비스는 이 리포지토리를 호출한다. 

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

 

서비스에서 리포지토리가 가진 create() 메소드를 호출할 때 catch에서 MyDuplicateKeyException을 받는 경우엔 새로운 키를 생성하는 메소드 generateNewId()를 호출한다. 해당 메소드로부터 돌려받는 키로 다시 새로운 멤버를 만드는 save() 메소드를 호출하여 재실행한다.

 

이제 테스트 코드로 실행해보자.

@Slf4j
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 저장 시도
    }
    
    ...
}

 

@SpringBootTest가 아니기 때문에 Test를 실행하기 전 DataSource를 직접 만들어 repository에게 전달해줘야 한다. 그 부분이 @BeforeEach. 그리고 @Test 어노테이션이 붙은 메소드를 실행해보자. 같은 아이디를 사용해서 create() 메소드를 두번 호출하기 때문에 두번째 생성 시 MyDuplicateKeyException이 발생한다. 그러나 해당 예외를 처리하면서 다른 ID로 재시도를 하기 때문에 myId를 가진 멤버 한명과 새로운 아이디를 가진 멤버 한명 이렇게 두 명의 멤버가 정상적으로 만들어진다.

 

결과

 

전체 코드

package com.example.jdbc.exception.translator;

import com.example.jdbc.connection.ConnectionConst;
import com.example.jdbc.domain.Member;
import com.example.jdbc.exception.MyDbException;
import com.example.jdbc.exception.MyDuplicateKeyException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.support.JdbcUtils;

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

import static com.example.jdbc.connection.ConnectionConst.*;

@Slf4j
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 저장 시도
    }

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

        public 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 connection = null;
            PreparedStatement pstmt = null;

            try {
                connection = dataSource.getConnection();
                pstmt = connection.prepareStatement(sql);
                pstmt.setString(1, member.getMemberId());
                pstmt.setInt(2, member.getMoney());
                pstmt.executeUpdate();
                return member;
            } catch (SQLException e) {
                if (e.getErrorCode() == 23505) {
                    throw new MyDuplicateKeyException(e);
                }
                throw new MyDbException(e);
            } finally {
                JdbcUtils.closeStatement(pstmt);
                JdbcUtils.closeConnection(connection);
            }
        }
    }
}

 

 

그런데, 이렇게 막상 코드를 짜보니 이걸 일일이 다 할 수 있을까? 라는 의문이 생긴다. 그도 그럴것이 데이터베이스에서 반환되는 에러코드만 따져도 수십개 수백개는 될 것같은데 이게 데이터베이스 별로 다르다는 건 또 다른 문제가 된다. 이 문제를 스프링이 해결해준다.

 

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

 

스프링이 만들어준 데이터 접근 예외 계층 구조도이다. 그림에서 볼 수 있듯 가장 상위 예외는 RuntimeException이다. 즉, 스프링이 제공하는 데이터 접근 예외들 모두가 언체크 예외라는 의미이다. 그 바로 하위에는 DataAccessException이 있다. 그리고 여기서 두 분류로 분개를 하는데 하나는 NonTransient 나머지 하나는 Transient이다. 

 

Transient는 일시적이라는 의미이다. 즉, 이 에러부터 하위 에러는 다시 시도했을 때 성공할 가능성이 있는 경우이다. 예를 들면 쿼리 타임아웃이나 락과 관련된 에러이다. 데이터베이스의 부하가 줄어들면 쿼리 타임아웃에 걸리지 않거나 락이 풀린 상태에서 접근했을 땐 문제 없이 성공할 수 있는 그런 경우들을 말한다.

 

NonTransient는 일시적이지 않다는 의미이다. 같은 SQL을 그대로 반복해서 실행하면 실패한다. SQL문법 오류인 경우 같은 쿼리를 백날 다시 날려도 똑같이 에러가 날 것이다. 데이터베이스 제약 조건에 위배되는 경우 언제나 위배가 될 것이다 제약 조건이 변경되기 전까지.

 

 

이 스프링이 제공하는 데이터 접근 예외를 사용해서 일일이 커스텀할 필요 없이 가져다가 사용해보자.

package com.example.jdbc.exception.translator;

import lombok.extern.slf4j.Slf4j;
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 com.example.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);
    }

    @Test
    void springSqlException() {
        String sql = "bad sql grammar";

        try {
            Connection connection = dataSource.getConnection();
            PreparedStatement preparedStatement = connection.prepareStatement(sql);
            preparedStatement.executeQuery();
        } catch (SQLException e) {
            int errorCode = e.getErrorCode();
            assertThat(errorCode).isEqualTo(42122);

            SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
            DataAccessException resultEx = exTranslator.translate("selectQuery", sql, e);
            log.info("resultEx", resultEx);

            assert resultEx != null;
            assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
        }
    }
}

 

위 코드에서 주의깊게 볼 부분은 이 부분이다.

SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("selectQuery", sql, e);
log.info("resultEx", resultEx);

 

SQLException이 터지는 경우 catch에서 해당 에러를 다루는데, 그 에러의 종류를 스프링이 위에서 봤듯이 만들어 놨기 때문에 가져다가 사용하면 된다. 그 에러를 가져오는 방법은 SQLErrorCodeSQLExceptionTranslator를 사용하면 된다. 

 

새로운 SQLErrorCodeSQLExceptionTranslator객체를 만들어야 하는데 객체를 만들 때 DataSource 객체를 파라미터로 넘겨야한다. 그렇게 만들어진 SQLErrorCodeSQLExceptionTranslator객체를 이용해 실제 SQLException을 SQL문과 같이 translate()에 넘겨주면 스프링이 알아서 변환해준다. 그 객체의 타입은 DataAccessException이고 이 객체는 위 구조도에서 봤듯 가장 상위 에러 객체이다. 

 

그리고 실제 이 변환된 에러의 클래스는 BadSqlGrammarException이다. 그것을 확인하는 코드가 다음 한 줄이다.

assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);

 

이렇게 우리 대신 스프링이 알아서 다 만들어준다. 근데 그럼 스프링은 각기 다른 DB의 에러코드를 어떻게 알고 이런 에러를 반환해주는걸까? 스프링은 이미 다 정의를 해놨다. 그 정의 파일은 다음 파일이다. sql-error-codes.xml

 

이 파일을 열어보면 다음 사진처럼 생겼다.

 

이렇게 모든 코드가 Database별로 정의가 되어 있고, 해당 코드이면 어떤 에러를 뱉을지 모두 다 정의가 되어있다. 이렇게 우리 대신 정의된 스프링이 만들어준 것을 가져다가 사용하면 일일이 커스텀하여 에러를 만들 필요는 없어지고 그렇기에 실용적인 코드를 작성할 수 있다.

 

그러나, 물론 스프링이 만든 데이터 접근 예외를 가져다가 사용을 한다면 의존 관계에서 독립적이지 못하게 되는 서비스 코드가 만들어진다. 즉, 다음 패키지가 서비스 또는 리포지토리에 자리잡게 된다.

import org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.BadSqlGrammarException;

 

그럼 이러한 의존 관계에 대한 독립성과 실용적인 코드작성 사이에 Trade-off가 생기는 것인데, 이는 프로젝트 별 어떤 선택을 하냐에 달려있다. 사실 모든 데이터베이스가 보내는 에러 코드를 다 커스텀한 에러로 변환하는 것은 거의 불가능에 가깝고 너무나 비효율적이기 때문에 이런 경우 trade-off를 가져가는 것이 방안이 될 수 있다. 순수함에 취해 효율과 실용성을 잃는다면 그것또한 다시 고려해 봐야 하지 않을까?

 

그럼에도 DataAccessException은 스프링이 제공하는 데이터 접근 예외라 어떤 특정 기술(JDBC, JPA,...)에 종속적이지 않고 기술을 변경하더라도 그대로 사용할 수 있다. 

 

728x90
반응형
LIST

'Spring + Database' 카테고리의 다른 글

[Renewal] JdbcTemplate  (8) 2024.12.06
[Renewal] 테스트 시 데이터베이스 연동  (0) 2024.12.05
[Renewal] 예외  (0) 2024.12.05
[Renewal] 스프링의 트랜잭션  (0) 2024.11.24
[Renewal] 트랜잭션, DB 락 이해  (0) 2024.11.22
728x90
반응형
SMALL

자바에서 예외는 크게 두 가지(체크 예외, 언체크 예외)로 나뉘어진다. 

반응형
SMALL

체크 예외

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

언체크 예외

컴파일러가 체크하지 않는 예외이며, 런타임 예외라고도 불린다. 언체크 예외와 체크 예외의 차이가 있다면 예외를 던지는 throws를 선언하지 않고 생략할 수 있다. 이 경우에 자동으로 예외를 던진다. 

 

예외 계층

 

예외 계층 구조를 그림으로 보자.

 

예외 역시 객체이므로 최상위 부모인 Object가 예외 객체의 부모가 된다. 

Throwable은 예외의 최상위 객체이다. 그리고 이 Throwable 객체의 하위에는 ExceptionError가 있다.

 

Error: 메모리 부족이나 심각한 시스템 오류와 같이 애플리케이션에서 복구가 불가능한 시스템 예외이자 언체크 예외이다. 애플리케이션 개발자는 이 예외를 잡으려고 해서는 안된다. 당연하게도 잡는다고 코드적으로 해결되는 문제가 아니기 때문이다. 따라서 애플리케이션 로직에서는 Throwable 예외도 잡으면 안되는데, 그 이유는 ErrorThrowable의 하위 예외이기 때문이다. 이러한 이유로 애플리케이션 로직은 Exception부터 필요한 예외로 생각하고 잡으면 된다.

 

Exception체크 예외로, 애플리케이션 로직에서 사용할 수 있는 실질적인 최상위 예외이다. Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외지만 RuntimeException만은 아니다. RuntimeException은 언체크 예외이다. 

 

RuntimeException: 언체크 예외이며 런타임 예외라고도 자주 불린다. 컴파일러가 체크하지 않는 언체크 예외이고 이 자식 예외들은 모두 언체크 예외이다.

 

예외 기본 규칙

예외는 폭탄 돌리기와 같다. 1. 잡아서 처리하거나  2. 처리할 수 없으면 밖으로 던진다. 여기서 밖으로라는 말은 자신을 호출한 곳을 의미한다.

 

설명: 위 그림처럼 Repository에서 예외가 발생했고 그 예외를 Repository에서는 처리하지 못하여 자신을 호출한 Service로 예외를 던졌다. 예외를 받은 Service는 이 곳에서 Repository가 던질 수 있는 가능성이 있는 예외를 처리한 후 정상 흐름을 자신을 호출한 Controller에게 돌려준다.

 

예외를 잡거나 던질 때 지정한 예외뿐만 아니라 그 예외의 자식들도 함께 처리된다. 

그래서 Exceptioncatch로 잡으면 그 하위 예외들도 모두 잡을 수 있다. 또는 Exceptionthrows로 던지면 그 하위 예외들도 모두 던질 수 있다.

 

설명: 예외를 처리하지 못하면 호출한 곳으로 계속 예외를 던진다.

그래서? 예외를 처리하지 못하고 계속 던지면 어떻게 될까? 자바 main() 쓰레드의 경우 결국 어디서도 처리하지 못하고 예외가 최초 시작 지점인 main()까지 도달하면 예외 로그를 출력하면서 시스템이 종료된다. 그러나 웹 애플리케이션의 경우 시스템이 종료되는 현상은 일어나면 안되기 때문에 WAS까지 해당 예외가 올라오면 그 예외를 받아 처리하는데 주로 사용자에게 개발자가 지정한 오류 페이지를 보여준다. (그런 페이지가 없다면 WAS에서 기본으로 제공하는 에러 페이지나 에러 API를 던진다.)

 

 

체크 예외 예시 코드

체크 예외는 반드시 던지거나 처리하거나 둘 중 하나는 해야한다. 그렇지 않으면 컴파일러가 컴파일 오류를 뱉어내기 때문에. 이게 체크 예외의 기본 규칙이다.

package com.example.jdbc.exception.basic;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class CheckedTest {

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

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

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

        /**
         * 체크 예외를 던지는 코드
         * @throws MyCheckedException
         * */
        public void callThrow() throws MyCheckedException {
            repository.call();
        }
    }

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

}

 

위 코드를 보면, MyCheckedException 이라는 체크 예외를 만들었다. Exception을 상속받으면 그 예외는 체크 예외가 된다. 그리고 Service에서 Repository를 사용하는데 Repositorycall() 메소드는 체크 예외(MyCheckedException)를 던진다. 이럴 때 이 call() 메소드를 호출한 서비스 쪽에서는 두가지 행위를 취할 수 있게 된다. 던지거나 잡거나. callCatch() 메소드는 catch에서 해당 에러를 잡는다. callThrow() 메소드는 해당 에러를 던진다. 이것이 체크 예외를 다루는 기본 방식이다. 그럼 이 서비스를 호출하는 쪽은 어떻게 될까?

 

package com.example.jdbc.exception.basic;

import org.junit.jupiter.api.Test;

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


class CheckedTestTest {

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

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

 

서비스를 호출하는 쪽 역시 마찬가지로 체크 예외이기 때문에 던져진 예외는 잡거나 던져야한다. 여기서 checked_catch() 메소드는 서비스의 callCatch() 메소드를 호출하고 이는 메소드 내에서 예외를 잡았기 때문에 어떠한 행위도 할 필요가 없다. 그러나, checked_throw() 메소드는 callThrow()를 호출하기 때문에 던져진 예외를 받는다. 그렇기 때문에 그 던져진 예외를 검증하는 테스트 코드가 들어가 있다.

 

체크 예외의 장단점

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

 

언체크 예외 예시 코드

언체크 예외는 역시 체크 예외와 같이 잡거나 던지거나하면 되는데, 잡지도 던지지도 않아도 컴파일러는 이에 대해 오류를 뱉어내지 않는다.

그리고 던질 땐 throws를 생략해도 무방하다. 자동으로 던져준다.

package com.example.jdbc.exception.advance;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class UncheckedTest {

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

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

        public void callCatch() {
            try {
                repository.call();
            } catch (MyUncheckedException e) {
                log.info("예외 처리 message = {}, ", e.getMessage(), e);
            }
        }

        public void callThrows() {
            repository.call();
        }
    }

    /**
     * 언체크 예외는 에러를 던질 때 throws가 생략 가능하다.
     * */
    static class Repository {
        public void call() {
            throw new MyUncheckedException("ex");
        }
    }
}

 

설명: MyUncheckedExceptionRuntimeException을 상속받는다. 이 RuntimeException을 상속받으면 언체크 예외가 된다. 그리고 RepositoryService를 만들고 Repositorycall() 메소드는 언체크 예외를 던진다. 이 call() 메소드를 호출하는 Service는 해당 예외를 잡을수도 던질수도 있으며 잡는 코드는 callCatch() 메소드이며 던지는 코드는 callThrows() 메소드이다. 마찬가지로 던질땐 throws를 생략할 수 있기 때문에 callThrows()에도 따로 throws를 작성하지 않아도 된다. 

 

이를 호출하는 테스트 코드를 작성해보자.

package com.example.jdbc.exception.advance;

import org.junit.jupiter.api.Test;

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

class UncheckedTestTest {
    @Test
    void unchecked_catch() {
        UncheckedTest.Service service = new UncheckedTest.Service();
        service.callCatch();
    }

    @Test
    void unchecked_throws() {
        UncheckedTest.Service service = new UncheckedTest.Service();


        assertThatThrownBy(service::callThrows)
                .isInstanceOf(UncheckedTest.MyUncheckedException.class);
    }
}

 

unchecked_catch() 메소드는 서비스가 가지는 callCatch() 메소드를 호출한다. 이 callCatch() 메소드는 내부에서 언체크 예외를 잡아서 정상 흐름을 반환하고 그대로 테스트는 통과한다. 그러나, unchecked_throws() 메소드는 서비스가 가지는 callThrows() 메소드를 호출한다. 이 callThrows() 메소드는 내부에서 언체크 예외를 잡지 않고 던지기 때문에 이를 호출한 unchecked_throws() 메소드 역시 잡거나 던지거나 둘 중 하나를 해야한다. 그래서 언체크예외가 던져지는 것을 확인하는 Assertions을 작성한다.

 

 

언체크 예외 장단점

  • 장점: 신경쓰고 싶지 않은 언체크 예외를 무시할 수 있다. 체크 예외의 경우 처리할 수 없는 예외를 밖으로 던지려면 항상 throws를 선언해야 하지만 언체크 예외는 이 부분을 생략할 수 있다. 
  • 단점: 언체크 예외는 개발자가 실수로 예외를 누락할 수 있다. 그리고 실제 서비스가 실행되는 시점에 언체크 예외가 터지면 애플리케이션에서 오류를 뱉어낸다. 런타임 시에 발생하는 에러, 다시 말해 사용자가 수행한 행위가 에러로직을 발생할 때 일어나는 에러가 가장 최악의 에러인데 이 최악의 에러를 발생시킬 수 있다.

 

체크 예외와 언체크 예외를 언제 사용할까?

기본적으로 언체크 예외를 사용하자.

 

이것이 대원칙이다. 대원칙은 기본으로는 언체크 예외를 사용하고, 비즈니스 로직 상 반드시 처리해야 하는 문제일 때만 체크 예외를 사용하자. 예를 들면 다음과 같다

  • 계좌 이체 실패 예외
  • 결제시 포인트 부족 예외
  • 로그인 ID, PW 불일치 예외

그렇다고 저 예시들도 반드시 체크 예외를 사용해야 하는게 아니라, 정말 정말 처리를 반드시 해줘야하는 경우에 체크 예외를 사용하면 된다는 것이다. 그것은 비즈니스 요구사항과 프로젝트 별로 달라지기 때문에 프로젝트마다 적절히 판단하는 것이 중요하다.

 

그런데 얼핏 말만 들어보면 체크 예외는 컴파일 시 오류도 잡아주고 발생할 수 있는 예외를 미리 알려주기까지 하는데 왜 체크 예외를 기본으로 사용하는게 아니고 언체크 예외일까?

 

체크 예외의 문제점

 

위 그림을 보자. 서비스에서 Repository, NetworkClient를 호출하는데 각각 체크 예외 (SQLException, ConnectException)을 던진다고 해보자. 그럼 두 곳에서 올라오는 체크 예외를 서비스가 처리할 수 있을까? 만약, DB가 터져서 SQLException을 서비스가 받으면 서비스가 그 에러를 처리할 수 있을까? 못한다. 아무것도 할 수 있는게 없다. 또 다른 예로 만약 네트워크가 일시적 문제가 생겨서 ConnectException을 서비스가 받으면 그 체크 예외를 처리할 수 있을까? 못한다. 아무것도 역시 할 수 있는게 없다. 이럴 땐 서비스 역시 저 두개의 에러가 체크 예외이기 때문에 던져야한다. 던지면 컨트롤러가 받는다. 컨트롤러라고 뭘 할 수 있을까? 못한다. 할 수 있는게 없다.

 

그럼 이 처리할 수 없는 체크 예외 때문에 컨트롤러가 100개면 100개 모두 throws 선언을 해야하는거고 서비스가 200개면 200개 모두 throws를 선언해야 하는 불필요한 공수가 들어간다. 또한 DB가 문제가 생겨서 아무것도 할 수 없는데 throws만 선언한다고 능사가 아니다. 

 

이런 네트워크 에러나 DB관련 문제는 보통 사용자에게 어떤 문제가 발생했는지 설명하기가 어렵다. 가령, 사용자에게 "DB가 터졌습니다. 😢" 이런 에러를 보여줄 수 있을까? 어떤 사이드 이펙트가 생길지 알고 저런 에러 메시지를 보낼까? 안된다. 그래서 이렇게 해결이 불가능한 공통 예외는 별도의 오류 로그를 남기고 개발자가 최대한 빠르게 인지할 수 있도록 하여야 하는 것이지 체크 예외라고 능사가 아니다.

 

체크 예외의 또다른 심각한 문제는 의존 관계 문제이다. 위처럼 복구가 불가능한 예외(SQLException, ConnectException)가 체크 예외가 된다면 서비스 입장에서는 잡거나 던져야하는데 처리할 수 없으니 던질 수 밖에 없다. 그럼 그 던져진 예외를 받는 컨트롤러역시 잡거나 던져야한다. 이 컨트롤러에서도 역시 처리할 수 없으니 던지게 되는데 이 때가 의존 관계 문제가 생기는 지점이다. 즉, 컨트롤러는 java.sql.SQLException을 의존하게 된다. 이게 왜 문제가 될까? 만약 향후에 JDBC 기술이 아닌 다른 기술로 변경한다면, 예를 들어 JDBC에서 JPA로 변경한다면 컨트롤러의 throws 코드를 모두 JPAException으로 변경해야 한다. 

 

어? 그럼 그냥 throws Exception으로 하면 되는거 아닌가요?

 

이 방법은 의존 관계에 상관없이 모든 체크 예외를 처리해줄 수 있기 때문에 위에서 말한 의존 관계 문제 자체는 해결해줄 수 있을지 모른다. 그러나, 이는 또 다른 심각한 문제를 야기한다. 이 Exception은 모든 체크 예외의 부모이기 때문에 정말 확실하게 체크하고 넘어가야 하는 체크 예외를 놓칠 수 있게 된다. 그래서 throws Exception은 절대 절대 사용하지 말자. 이건 매우 좋지 않은 안티 에러 패턴이다.

 

체크 예외를 다루는 예시 코드를 한번 보자.

체크 예외 예시 코드

package com.example.jdbc.exception.advance;

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

public class CheckedAppTest {

    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 NetworkClient {
        public void call() throws ConnectException {
            throw new ConnectException("연결 실패");
        }
    }

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

 

ServiceRepositoryNetworkClient의 메소드를 모두 호출해야 한다. Repositorycall() 메소드가 있고 이 메소드는 SQLExceptionthrows로 선언했다. 즉, 체크 예외를 던진다는 의미이다. 그리고 NetworkClientcall() 메소드가 있고 이 메소드는 ConnectExceptionthrows로 선언했다. 마찬가지로, 체크 예외를 던진다는 의미이다. 

 

이 때, Service에서 logic() 메소드는 RepositoryNetworkClient가 던지는 SQLExceptionConnectException을 처리할 능력이 없다. 사실상 처리할 수 있는 뚜렷한 방법도 없다. 그렇기 때문에 서비스 역시 이 에러들을 던진다. 그럼 Controller는 서비스를 호출하고 서비스가 던진 체크 예외를 받는다. 이 때 컨트롤러 역시 해당 에러들을 처리할 수 있는 능력은 없다. 그렇기에 또 던진다. 여기서 서비스와 컨트롤러에 의존 관계 문제가 생긴다. 즉, 컨트롤러와 서비스 각각이 java.sql.SQLException 의존 관계가 생기는 것. 그리고 모든 메소드마다 throws 선언을 해야하는 번거로움은 덤이다.

 

이런 체크 예외에 대한 문제를 해결하기 위해 대원칙으로 언체크 예외를 기본으로 사용하자고 했다. 언체크 예외를 사용하는 코드를 예시로 보자.

언체크 예외 예시 코드

package com.example.jdbc.exception.advance;

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

public class UnCheckedAppTest {

    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 NetworkClient {
        public void call() {
            throw new RuntimeConnectException("연결 실패");
        }
    }

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

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

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

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

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

 

설명: 이제 SQLExceptionConnectException을 언체크 예외로 변경하기 위해 RuntimeException을 상속받는 RuntimeSQLException, RuntimeConnectException 객체를 만들었다. 그리고 RepositoryrunSQL() 메소드에서 SQLException을 던지는데 이 runSQL() 메소드를 호출하는 call() 메소드에서 이 던져진 SQLExceptionRuntimeSQLException으로 치환하여 던진다. 이 때 RuntimeSQLException은 언체크 예외이기 때문에 throws를 선언하지 않아도 아무런 문제가 없다. (물론 선언하여 IDE의 도움을 받아 좀 더 명확하게 어떤 에러를 던질 수 있는지 확인하면 좋은 방법이 될 수 있다.) NetworkClient 객체가 가지고 있는 call() 메소드는 이제 RuntimeConnectException을 던진다. 이 또한 언체크 예외이기 때문에 throws를 선언하지 않아도 된다. Service에서는 이 두 객체의 각각의 call() 메소드를 호출한다. 저 둘이 던질 수 있는 언체크 예외가 있어도 따로 다루지 않아도 된다. 그렇기에 의존 관계 문제도 사라진다. 그렇기에 throws를 일일이 선언할 필요도 없다. 이 서비스를 호출하는 Controller 역시 마찬가지다. 

 

이제 런타임 에러로 변환하여 DB, 네트워크 에러처럼 발생하면 어떤 방법으로도 처리를 할 수 없는 경우에 적절히 에러 로그를 남긴 후 그냥 에러를 사용자에게 적절하게 알리고 개발자가 그 에러를 빠르게 대응하는 방식으로 운영할 수 있다. 그렇게 해야만 하고 그렇게밖에 할 수 없다. 그렇지만 의존 관계 문제라던가 발생 가능한 모든 예외를 처리해야 하는 체크 예외의 번거로움은 사라졌다.

 

그리고 이 언체크 예외를 공통으로 처리하는 부분이 따로 있으면 된다.

Database에서 생긴 에러라던가, Network 에러는 어차피 에러를 잡아도 복구할 수 없다. 이런것들을 체크 예외로 만들면 호출하는 쪽에서만 고생할 뿐이다. 체크 예외는 잡거나 던지지 않으면 컴파일 에러가 나니까 무조건 건드려줘야 하는 부분이라 그렇다. 그러니까 언체크 예외로 발생 가능성이 있는 에러를 정의하고 사용하는 쪽에서 에러가 나도 처리할 필요 없다. 처리할 수도 없을 뿐더러. 

 

그리고 그 에러를 공통으로 처리하는 부분이 있으면 되는것이다. 가령, 사용자에겐 "서버에 문제가 발생했다"는 안내 문구를 보여주고 개발자는 발생한 로그를 통해 빠르게 에러를 수정하는 방향으로 구현하는게 가장 좋은 방법이다.

 

그러나, 위 코드로 변경할 때 (체크 예외에서 언체크 예외로 예외 변경) 주의할 점이 있다.

주의할 점

기존 예외를 반드시 스택 트레이스에 추가를 해줘야한다. 그렇지 않으면 실제로 에러가 난 원인을 알 수가 없다.

 

아래 코드를 보자.

package com.example.jdbc.exception.advance;

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

public class UnCheckedAppTest {

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

        public void request() {
            try {
                service.logic();
            } catch (Exception e) {
                log.error("error: ", e);
            }
        }
    }

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

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

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

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

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

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

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

        public RuntimeSQLException(String message) {
            super(message);
        }

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

 

여기서 중요한 부분은 바로 이부분이다.

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

 

이 부분이 체크 예외를 언체크 예외로 변경하는 부분이다. 실제 체크 예외인 SQLExceptionRuntimeSQLException으로 언체크 예외로 변경한다. 변경 자체에는 아무런 문제가 없지만 변경하면서 실제 에러에 대한 내용을 언체크 예외에 포함하지 않아버렸다. 이 때 에러가 발생하면 실제 SQLException 예외가 어떤식으로 왜 발생했는지 알 턱이 없다. 실행해보자.

 

실행 코드

package com.example.jdbc.exception.advance;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;


class UnCheckedAppTestTest {

    @Test
    void unchecked() {
        UnCheckedAppTest.Controller controller = new UnCheckedAppTest.Controller();
        
        controller.request();
        //Assertions.assertThatThrownBy(controller::request).isInstanceOf(RuntimeException.class);
    }

}

 

결과

 

결과에는 RuntimeSQLException에 대한 내용만 나오지 실제 에러의 원인인 SQLException에 대한 내용이 스택 트레이스에 빠져있다. 

이러면 이 에러가 발생해도 뭐때문에 에러가 났는지 알 수 없다. 그러니 반드시 반드시 스택 트레이스에 원래 에러를 포함시켜야한다.

 

포함하는 방법은 다음처럼 생성자에 에러를 추가해주면 된다.

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

 

RuntimeSQLException의 생성자는 여러가지가 있지만 Throwable 객체를 받는 생성자가 있다. 그 생성자를 이용하면 스택 트레이스에 생성자로부터 받은 에러를 포함시킬 수 있다. 

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

    public RuntimeSQLException(String message) {
        super(message);
    }

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

 

 

스택 트레이스에 실제 원인을 가지는 에러를 포함시킨 후 다시 실행해보자.

스택 트레이스에 에러를 포함한 결과

 

스택 트레이스에 SQLException의 에러 내용이 포함되어 있음을 확인할 수 있다. 이렇게 반드시 체크 예외를 언체크 예외로 변경 시에는 기존 에러를 스택 트레이스에 넣어줘야 한다.

참고로, 에러를 찍을때 e.printStackTrace() 이런거 쓰지말자. 이게 System.out으로 찍히는 것인데 그렇게 찍으면 안되고 로그로 찍어야한다. 운영상에 System.out은 절대 사용하지 않아야 한다.

 

728x90
반응형
LIST

+ Recent posts