@Transactional 어노테이션에 대한 이해

2022. 6. 7. 19:18토이프로젝트/팁 모음집

트랜잭션이란???

데이터베이스 트랜잭션은 데이터베이스 관리 시스템 또는 유사한 시스템에서 상태를 변경시키기 위해 수행하는 작업 단위이다. (데이터베이스의 상태를 변경시킨다는 이야기는, select, update, insert, delete와 같은 행동을 뜻한다.)

여기서 트랜잭션을 조금 더 자세히 풀어 쓰자면, 쪼갤 수 없는 업무 처리의 최소 단위라고 할 수 있다.

 

트랜잭션에는 4가지의 특징이 존재한다.

1. 원자성

2. 일관성

3. 독립성

4. 지속성

 

원자성

원자성은 트랜잭션이 DB에 모두 반영되거나, 전혀 반영되지 않거나를 뜻한다. All or Nothing의 의미이다.

 

일관성

일관성은 트랜잭션 작업 처리의 결과가 항상 일관되어야 한다를 뜻한다.

즉, 데이터 타입이 반환 후와 전이 항상 동일해야 한다.

 

독립성

독립성은 하나의 트랜잭션은 다른 트랜잭션에 끼어들 수 없고, 마찬가지로 독립적임을 의미한다.

즉, 각각의 트랜잭션은 독립적이라 서로 간섭이 불가능하다.

 

지속성

지속성은 트랜잭션이 성공적으로 완료되면 영구적으로 결과에 반영되어야 함을 의미한다.

보통 commit이 된다면 지속성은 만족할 수 있다.

 

 

기본 예시

예시를 들어보자. 만약 내가 쇼핑 앱을 켜서 상품을 구매하려고 한다.

그런데 내가 결제를 하는 짧은 시간 사이에 아래와 같은 일이 벌어지면 어떨까?

  • 해당 판매자가 상품의 가격을 바꿔버려서, 잘못된 금액이 결제됨
  • 같은 상품을 다른 사람도 구매해서, 상품 재고는 1개인데 2명에게 결제됨
  • 결제가 완료되기 직전에 네트워크가 끊겨서, 돈은 나갔지만 구매완료는 되지 않음

아무래도 황당할 수밖에는 없다. 

위의 예외적 상황을 막기 위해서, 다음과 같은 조치가 필요할 것이다.

  • 내가 결제중일 때에는 해당 상품의 정보를 바꿀 수 없게 함
  • 내가 결제중일 때에는 해당 상품을 다른 사람이 결제하지 못하게 함
  • 내 구매가 오류로 완료되지 않았다면, 결제된 금액을 환불 처리함

위의 조치사항을 좀 더 간략하게 정리하면, 아래와 같이 정리할 수 있다.

 

"결제는 다른 사람과 독립적으로 이루어지며, 과정 중에 다른 연산이 끼어들 수 없다.

오류가 생긴 경우 연산을 취소하고 원래대로 되돌린다. 성공할 경우 결과를 반영한다."

 

여기서 결제는 트랜잭션의 예시로 든 것이다. 트랜잭션 역시, 위의 원칙을 바탕으로 한다.

그래서 어떤 연산에 트랜잭션이 보장된다면, DB에서 의도치 않은 값이 저장되거나 조회되는 것을 막을 수 있다.

 

 

@Transactional 어노테이션

@Transactional은 클래스나 메서드에 붙여줄 경우, 해당 범위 내 메서드가 트랜잭션이 되도록 보장해준다.

선언적 트랜잭션이라고도 하는데, 직접 객체를 만들 필요 없이 선언만으로도 관리를 용이하게 해 주기 때문이다.

특히나 스프링부트에서는 선언적 트랜잭션에 필요한 여러 설정이 이미 되어있어 더 쉽게 사용할 수 있다.

 

@RequiredArgsConstructor
@Service
public class BookService {
    private final BookRepository bookRepository;

    @Transactional(readOnly = true)
    public List<BookResponseServiceDto> getBooks() {
        return bookRepository.findAll()
                .stream()
                .map(BookResponseServiceDto::new)
                .collect(Collectors.toList());
    }

    @Transactional(readOnly = true)
    public BookResponseServiceDto getBook(Long bookId) {
        final Book book = bookRepository.findById(bookId)
                .orElseThrow(() -> new BookNotFoundException(bookId));

        return new BookResponseServiceDto(book);
    }
}

@Transactional 어노테이션을 getBooks()와 getBook() 메서드에 작성해 주었다.

그 결과로, 만약 getBook() 메서드가 실행될 경우 해당 메서드는 아래의 속성을 가진다.

  • 연산이 고립되어, 다른 연산과의 혼선으로 인해 잘못된 값을 가져오는 경우가 방지된다.
  • 연산의 원자성이 보장되어, 연산이 도중에 실패할 경우 변경사항이 Commit되지 않는다.

위의 속성이 보장되기 때문에, 해당 메서드를 실행하는 도중 메서드 값을 수정/삭제하려는 시도가 들어와도 값의 신뢰성이 보장된다. 또한, 연산 도중 오류가 발생해도 rollback해서 DB에 해당 결과가 반영되지 않도록 할 수 있다. (위의 코드가 조회 코드가 와닿지 않을 수 있지만, 생성/수정/삭제 도중 오류가 발생한다고 생각해보자!!)

 

 

@Transactional 작동 원리와 흐름

그렇다면 @Transactional이 붙은 메서드를 호출할 경우, 우리 코드에는 어떤 일이 벌어질까?

 

@Transactional이 클래스 내지 메서드게 붙을 때, Spring은 해당 메서드에 대한 프록시를 만든다.

프록시 패턴은 디자인 패턴 중 하나로, 어떤 코드를 감싸면서 추가적인 연산을 수행하도록 강제하는 방법이다.

 

트랜잭션의 경우, 트랜잭션의 시작과 연산 종료시의 커밋 과정이 필요하므로, 프록시를 생성해 해당 메서드의 앞뒤에 트랜잭션의 시작과 끝을 추가하는 것이다.

이러한 로직은 AOP에 바탕을 두고 설계되었기 때문에, 이후 설명에서 해당 프록시는 트랜잭션 AOP로 명칭하겠다.

 

더보기

유의사항 : 호출의 문제

 

스프링이 만들어주는 proxy는 동적으로 생성되며, 프록시를 통해 들어오는 외부 메서드 호출은 차단된다.

하지만, 내가 자체적으로 다른 메서드를 호출하는 경우, 해당 메서드에는 트랜젝션이 적용되지 않을수도 있다.

또한, 스프링 컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다.

서비스 클래스에서 @Transactional을 사용할 경우, 해당 코드 내의 메서드를 호출할 때 영속성 컨텍스트가 생긴다는 뜻이다. 영속성 컨텍스트는 트랜잭션 AOP가 트랜잭션을 시작할 때 생겨나고, 메서드가 종료되어 트랜잭션 AOP가 트랜잭션을 커밋할 경우 영속성 컨텍스트가 flush되면서 해당 내용이 반영된다. 이후 영속성 컨텍스트 역시 종료되는 것이다.

 

김영한님의 [자바 ORM 표준 JPA 프로그래밍] 579p 인용

이러한 방식으로 영속성 컨텍스트를 관리해 주기 때문에, @Transactional을 쓸 경우 트랜잭션의 원칙을 정확히 지킬 수 있다.

또한, 아래의 원칙 역시 유의해야 한다.

  • 만약 같은 트랜잭션 내에서 여러 EntityManager를 쓰더라도, 이는 같은 영속성 컨텍스트를 사용한다.
  • 같은 EntityManager를 쓰더라도, 트랜잭션이 다르면 다른 영속성 컨텍스트를 사용한다.