[BEInternship] @Transactional 이란?

junghan·2023년 8월 14일
0

BE 인턴십

목록 보기
4/9
post-thumbnail

Transaction이란?

모든 작업들이 성공적으로 완료되어야 작업 묶음의 결과를 적용하고, 어떤 작업에서 오류가 발생했을 때는 이전에 있던 모든 작업들이 성공적이었더라도 없었던 일처럼 완전히 되돌리는 것이 트랜잭션의 개념입니다.

데이터베이스를 다룰 때 트랜잭션을 적용하면 데이터 추가, 갱신, 삭제 등으로 이루어진 작업을 처리하던 중 오류가 발생했을 때 모든 작업들을 원상태로 되돌릴 수 있습니다. 모든 작업들이 성공해야만 최종적으로 데이터베이스에 반영하도록 합니다.

💡 즉, 원자성 보장!


## @Transactional이란?

비즈니스로직이 트랜잭션 처리를 필요로할 때 트랜잭션 처리 코드가 비즈니스 로직과 공존한다면 코드 중복이 발생하고 비지니스 로직에 집중 또한 힘들어질 수 있습니다.

@Transactional은 이러한 문제를 해결해주는 Spring이 제공하는 어노테이션으로 @Transactional을 메서드 또는 클래스에 명시하게 되면 특정 메서드 또는 클래스가 제공하는 모든 메서드에 대해 내부적으로 AOP를 통해 트랜잭션 처리코드가 전 후 로 수행됩니다.

@Transactional 은 어떤 계층에 속할까?

@Transactional은 Service 계층의 경계를 정의해야할 책임을 가지고 있기 때문에, 서비스 계층에 속합니다.

웹 계층(Presentation layer)에서는 사용하지 말아야 합니다. 데이터베이스 트랜잭션 응답 시간이 증가하고 주어진 데이터베이스 트랜잭션 오류(예: 일관성, 교착 상태, 잠금 획득, 낙관적 잠금)에 대해 올바른 오류 메시지를 제공하기가 더 어려워질 수 있기 때문입니다.

DAO(Data Access Object) 또는 Repository 계층은 응용 프로그램 수준의 트랜잭션이 필요하지만 이 트랜잭션은 서비스 계층에서 전파되어야 합니다.



AOP(Aspect Oriented Programming)이란?

AOP는 핵심기능 코드에 존재하는 공통된 부가기능 코드를 독립적으로 분리해주는 기술입니다.

부가기능을 어드바이스(Advice)라 부르고 부가기능이 부여될 타깃을 선정하는 룰을 포인트컷(Point Cut)이라 부릅니다.

Spring 진영에서는 어드바이스, 포인트컷을 통틀어 어드바이저라 부르며 어드바이저는 아주 단순한 형태의 에스펙트(Aspect)라 부를 수 있습니다.

에스펙트란 핵심기능에 부가되는 특별한 모듈을 뜻하며 이 에스펙트를 통해 애플리케이션을 설계하여 부가기능을 분리하며 개발하는 방법을 관점 지향 프로그래밍(AOP) 이라 부릅니다.

💡 즉, OOP를 돕는 보조적인 기술입니다.

AOP는 일반적으로 두가지 방식이 있습니다.

  • JDK Dynamic Proxy 방식
  • CGLib 방식

JDK Dynamic Proxy 방식이란?

왼쪽은 @Transactional을 적용하기전 상태이며, 오른쪽은 @Trnasactional이 적용되고 JDK Dynamic Proxy 방식 AOP로 동작했을 때의 모습입니다.

트랜잭션 처리를 다이나믹 프록시 객체에 대신 위임합니다.

다이나믹 프록시 객체는 타깃이 상속하고있는 인터페이스를 상속 후 추상메서드를 구현하며 내부적으로 타깃 메서드 호출 전 후로 트랜잭션 처리를 수행합니다. Controller는 타깃의 메서드를 호출하는것으로 생각하지만 실제로는 프록시의 메서드를 호출하게 됩니다.

트랜잭션 처리 이외에도 로깅 처리를 비즈니스 로직을 가진 타깃에 부여하고싶으면 위의 그림과 같이 제공하면 될 것 입니다.

트랜잭션과 로깅 처리는 부가기능일 뿐이며 이러한 부가기능을 독립적으로 추출하고 핵심 기능을 가진 타깃에 부여하는 기술이 앞서 말했던 AOP입니다.

즉 AOP는 프록시 패턴으로 Controller가 타깃 메서드에 대한 접근을 컨트롤하고 부가기능을 입맛에 맞게 데코레이션 할 수 있는 데코레이터 패턴을 적용했다고 볼 수 있습니다.

그런데 왜 프록시 앞에 다이나믹이란 단어가 붙을까?

부가 기능을 핵심 기능과 독립적으로 분리하는것 까지는 좋지만 부가 기능을 가진 프록시 객체를 개발자가 일일이 생성한다면 비효율적일것입니다.

이러한 프록시 객체를 개발자 대신 런타임 시점에 동적으로 만들어주기 때문에 프록시 앞에 다이나믹이란 단어가 붙습니다.

JDK Dynamic Proxy 방식은 Java의 리플렉션 패키지에 존재하는 Proxy 클래스 통해 동적으로 다이나믹 프록시 객체를 생성합니다.


CGLib 방식이란?

CGLib는 Java 리플랙션 대신 바이트 코드 생성 프레임워크를 사용하여 런타임 시점에 프록시 객체를 만드는 방식입니다.

타깃오브젝트가 인터페이스를 상속하고 있지 않는 다면 CGLib를 사용하여 인터페이스 대신 타깃오브젝트를 상속하는 프록시 객체를 만듭니다.



@Transaction의 롤백처리

Java에는 체크 예외(Checked Exception)와 언체크 예외(Unchecked Exception)가 있습니다. 두 가지 예외 종류를 구분하는 것이 중요한 이유는 트랜잭션 롤백 범위가 다르기 때문입니다. 체크 예외란 Exception 클래스 하위 클래스이며, 언체크 예외란 Exception 하위의 RuntimException 하위의 예외입니다. (Java 예외 종류에 대해 모르면 이 글을 참고해주세요)

스프링의 선언적 트랜잭션(@Transactional) 안에서 예외가 발생했을 때, 해당 예외가 언체크 예외(런타임 예외)라면 자동적으로 롤백이 발생합니다. 하지만 체크 예외라면 롤백이 되지 않습니다. 체크 예외를 롤백시키기 위해서는 @Transactional의 rollbackFor 속성으로 해당 체크 예외를 적어주어야 합니다.

스프링의 트랜잭션이 언체크 예외(런타임 예외)나 에러(Error) 만을 롤백 대상으로 보는 이유는 해당 예외들이 복구 가능성이 없는 예외들이므로 별도의 try-catch나 throw를 통해 처리를 강제하지 않기 때문입니다. 스프링의 트랜잭션은 내부적으로 언체크 예외(런타임 예외)이거나 에러(Error) 인지 검사한 후에 맞으면 롤백 여부를 결정하는 rollback-only를 True로 변경하는 로직이 있습니다. (정확히는 TransactionInfo의 transactionAttribute의 rollbackOn에 의해 검사됩니다)

@Override
public boolean rollbackOn(Throwable ex) {
    return (ex instanceof RuntimeException || ex instanceof Error);
}

그리고 만약 언체크 예외라면 rollback 처리를 진행합니다.

protected void completeTransactionAfterThrowing(@Nullable TransactionInfo txInfo, Throwable ex) {
    if (logger.isTraceEnabled()) {
        logger.trace("Completing transaction for [" + txInfo.getJoinpointIdentification() + "] after exception: " + ex);
    }
    if (txInfo.transactionAttribute != null && txInfo.transactionAttribute.rollbackOn(ex)) {
        try {
            txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
        }
    ...
    
}
 

@Transactional의 대체 정책(Fallback Policy)

만약 모든 메소드에 @Transactional이 붙어있으면 메소드가 상당히 더러워집니다. 그래서 스프링은 메소드 외에도 클래스와 인터페이스에 어노테이션을 붙일 수 있도록 하고 있습니다. 그리고 트랜잭션 어노테이션을 적용할 때 타깃 메소드, 타깃 클래스, 선언 메소드, 선언 타입(클래스 or 인터페이스) 순으로 @Transactional이 적용되었는지 차례로 확인하고, 가장 먼저 발견되는 속성 정보를 사용합니다. 이를 4단계의 대체 정책(fallback policy)라고 부르며, 이를 통해 어노테이션을 최소화하는 동시에 세밀한 제어를 해줄 수 있습니다.



Spring에서 트랜잭션의 사용법

1. 비지니스 로직과의 결합

트랜잭션을 중구난방으로 적용하는 것은 좋지 않습니다. 대신 특정 계층의 경계를 트랜잭션 경계와 일치시키는 것이 좋은데, 일반적으로 비지니스 로직을 담고 있는 서비스 계층의 메소드와 결합시키는 것이 좋습니다. 왜냐하면 데이터 저장 계층으로부터 읽어온 데이터를 사용하고 변경하는 등의 작업을 하는 곳이 서비스 계층이기 때문입니다. 위와 같이 클래스 레벨에 트랜잭션 어노테이션을 붙여주면 메소드까지 적용이 됩니다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder passwordEncoder;

    public List<User> getUserList() {
        return userRepository.findAll();
    }

}

서비스 계층을 트랜잭션의 시작과 종료 경계로 정했다면, 테스트와 같은 특별한 이유가 아니고는 다른 계층이나 모듈에서 DAO에 직접 접근하는 것은 차단해야 합니다. 트랜잭션은 보통 서비스 계층의 메소드 조합을 통해 만들어지기 때문에 DAO가 제공하는 주요 기능은 서비스 계층에 위임 메소드를 만들어둘 필요가 있습니다. 그리고 가능하면 다른 모듈의 DAO에 접근할 때는 서비스 계층을 거치도록 하는 것이 바람직합니다.

2. 읽기 전용 트랜잭션의 공통화

클래스 레벨에는 공통적으로 적용되는 읽기전용 트랜잭션 어노테이션을 선언하고, 추가나 삭제 또는 수정이 있는 작업에는 쓰기가 가능하도록 별도로 @Transacional 어노테이션을 메소드에 선언하는 것이 좋습니다. 이를 체감하기는 힘들겠지만 약간의 성능적인 이점을 얻을 수 있습니다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {

    private final UserRepository userRepository;
    private final BCryptPasswordEncoder passwordEncoder;

    public List<User> getUserList() {
        return userRepository.findAll();
    }

    @Transactional
    public User signUp(final SignUpDTO signUpDTO) {
        final User user = User.builder()
                .email(signUpDTO.getEmail())
                .pw(passwordEncoder.encode(signUpDTO.getPw()))
                .role(UserRole.ROLE_USER)
                .build();

        return userRepository.save(user);
    }

}
 

3. 테스트의 롤백

트랜잭션 어노테이션을 테스트에 붙이면 테스트의 DB 커밋을 롤백해주는 기능이 있습니다.

DB와 연동되는 테스트를 할 때에는 DB의 상태와 데이터가 상당히 중요합니다. 하지만 문제는 테스트에서 DB에 쓰기 작업을 하면 DB의 데이터가 바뀌는 것인데, 트랜잭션 어노테이션을 테스트에 활용하면 테스트를 진행하는 동안에 조작한 데이터를 모두 롤백하고 테스트를 진행하기 전의 상태로 만들어줍니다. 어떠한 경우에도 커밋을 하지 않기 때문에 테스트가 성공하거나 실패해도 상관이 없으며 심지어 예외가 발생해도 어떠한 문제가 발생하지 않습니다. 강제로 롤백시키도록 설정되어 있기 때문입니다.

@Transactional
@ExtendWith(SpringExtension.class)
@DataJpaTest
class UserRepositoryTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void findByEmailAndPw() {
        final User user = User.builder()
                .email("email")
                .pw("pw")
                .role(UserRole.ROLE_USER).build();
        userRepository.save(user);

        assertThat(userRepository.findAll().size()).isEqualTo(1);
    }

}
 

하지만 테스트 메소드 안에서 진행되는 작업을 하나의 트랜잭션으로 묶고는 싶지만 강제 롤백을 원하지 않을 수 있습니다. 테스트의 작업을 그대로 DB에 반영하고 싶다면 @Rollback(false)를 이용해주면 됩니다. @Rollback은 메소드에만 적용가능하므로, 클래스 레벨에 부여하기를 원한다면 @TransactionConfiguration(defaultRollback=false) 를 이용하고, 롤백을 원하는 메소드에 @Rollback(true)를 이용하면 됩니다.

물론 여기서 auto_increment나 sequence 등에 의해 증가된 값은 롤백이 되지 않습니다. 그렇기 때문에 테스트를 위해서는 별도의 데이터베이스로 연결을 하거나 또는 H2와 같은 휘발성(인메모리) 데이터베이스를 사용하는 것이 좋습니다.



https://hwannny.tistory.com/98
https://velog.io/@gongel/Spring-Transactional-annotation-%ED%99%9C%EC%9A%A9
https://mangkyu.tistory.com/170

profile
42seoul, blockchain, web 3.0

0개의 댓글