개요

먼저 스프링이 현재 Java 진영에서 백엔드 프레임워크로서 가지고 있는 영향도는 이 글을 읽고 있는 모두가 공감하고 있으리라 생각됩니다. 특히 개인적으로 스프링에서 프록시 패턴을 활용한 AOP 기술이 지금의 스프링 공화국을 만들지 않았나 생각합니다.

우리는 어노테이션을 사용하는 것만으로 너무나 쉽게 원하는 서비스 로직들을 사용할 수 있게 되죠.

다만 어노테이션을 선언하는 것이 항상 해당 기능의 올바른 동작을 보장하지는 않습니다.

그래서 이번에 준비한 아티클은 스프링에서 @Transactional 어노테이션을 사용할 때 흔히 저지를 수 있는 실수들에 대한 내용과 그 해결 방안에 대한 것입니다.

0. Transaction

DB의 상태를 변화시키기 위해 수행하는 하나의 작업 단위를 뜻합니다. ACID 로 트랜잭션의 특징을 설명합니다.

Atomicity 원자성은 트랜잭션으로 묶인 작업은 모두 반영되던가, 모두 반영되지 않아야한다는 원칙을 가집니다.

Consistency 일관성은 작업 처리 도중 원본 데이터의 변화가 있더라도 처음 작업 시점에 참조한 데이터만을 가지고 트랜잭션을 진행하는 것입니다.

Isolation 독립성은 서로 다른 트랜잭션 작업은 서로를 참조할 수 없다는 것입니다.

Durability 지속성은 트랜잭션이 성공하였을 경우 그 결과는 영구적으로 반영되어야 한다는 것입니다.

1. Self-Invocation 같은 클래스 내에서 호출시

public void registerAccount(Account acc) {
    createAccount(acc);

    notificationSrvc.sendVerificationEmail(acc);
}

@Transactional
public void createAccount(Account acc) {
    accRepo.save(acc);
    teamRepo.createPersonalTeam(acc);
}

예시 코드를 보시죠. createAccount라는 계정 생성 함수가 있습니다. 여기에 @Transactional 어노테이션이 선언됨으로써 우리는 save() 메서드와 createPersonalTeam() 메서드 중 하나라도 fail이 난다면 트랜잭션 내의 모든 작업이 반영되지 않는 원자성을 기대합니다.

하지만 예상과 다르게 같은 클래스 내에서 호출된 @Transactional 은 프록시를 적용할 수 없기 때문에 우리가 생각하는 대로 동작하지 않습니다. 이는 @Cacheable도 마찬가지 입니다.

스프링 AOP는 프록시 패턴을 확장 및 구체화한 기술인데요, 여기서 중요한 것은 흐름을 제어할 뿐 결과 값을 조작하거나 변경 시켜서는 안됩니다.

여기서 흐름의 제어란 @Transactional과 같이 DB 접근 및 커밋, 롤백 등의 메인 서비스 로직을 제외한 반복되는 코드들을 모아서 관심사를 분리하여 처리하는 것을 의미합니다.

스프링에서는 인터셉터를 통해서 적당한 시점에 요청을 가로채서 필요한 처리를 해야하는데 AOP는 프록시를 통해 들어오는 외부 메서드 호출만 인터셉트 하기 때문에 동일 클래스(this)내 접근에서는 제대로 동작하지 않는 것 입니다.

해결 방안 1. 컨테이너 Bean을 활용

내부 호출 시에도 this가 아닌 Proxy Bean을 가져와서 사용하도록 처리하면 됩니다.

@Service
@RequiredArgsConstructor
public class AccountService {
    private final AccountRepository accRepo;
    private final TeamRepository teamRepo;
    private final NotificationService notificationSrvc;
    @Lazy private final AccountService self;

    public void registerAccount(Account acc) {
        self.createAccount(acc);

        notificationSrvc.sendVerificationEmail(acc);
    }

    @Transactional
    public void createAccount(Account acc) {
        accRepo.save(acc);
        teamRepo.createPersonalTeam(acc);
    }
}

@Service
@RequiredArgsConstructor
public class AccountService {
    private final AccountRepository accRepo;
    private final TeamRepository teamRepo;
    private final NotificationService notificationSrvc;
    private ApplicationContext context;

    public void registerAccount(Account acc) {
        this.getSpringProxy().createAccount(acc);

        notificationSrvc.sendVerificationEmail(acc);
    }

    @Transactional
    public void createAccount(Account acc) {
        accRepo.save(acc);
        teamRepo.createPersonalTeam(acc);
    }
    
    private AccountService.getSpringProxy() {
    return context.getBean(AccountService.class);
}

해결 방안 2. AspectJ Weaving 사용

위빙은 AOP 관심사 분리 코드를 핵심 코드에 적용되는 일련의 과정을 의미합니다. Spring AOP에서는 프록시 기반의 런타임 위빙 방식을 따릅니다.

AspectJ 는 프록시 패턴이 아닌 바이트 코드를 기반으로 위빙을 적용하기 때문에 Self Invocation 문제와 성능 문제를 해결할 수 있습니다. (AspectJ는 Spring2.0부터 지원)

2. 모든 예외를 처리하지 않습니다.

기본적으로 롤백은 런타임에서만 작동합니다.
그 외 예외 상황에 대한 롤백은 코드 설정(rollbackFor 옵션)을 통해 가능합니다.

@Transactional(rollbackFor = StripeException.class)
public void createBillingAccount(Account acc) throws StripeException {
    accSrvc.createAccount(acc);

    stripeHelper.createFreeTrial(acc);
}

3. 트랜잭션 격리 수준 및 전파

3-1. 트랜잭션 격리 수준 (Isolation Level)

격리 수준이란, 하나의 트랜잭션이 다른 트랜잭션에서 변경한 데이터를 어느 수준까지 볼 수 있도록 허용하는지 정도를 나타냅니다.

종종 개발자들은 어떤 격리 수준을 달성하려는지 생각하지 않고 어노테이션을 추가합니다.

하지만 격리 수준을 이해하는 것은 데이터 문제를 파악하는데 있어 아주 중요합니다.

DEFAULT

기본적으로 코드상에서 따로 격리 수준을 지정하지 않는다면 DB의 격리 수준을 따릅니다.

oracle = READ_COMMITED, mysql = REPEATABLE_READ

READ_UNCOMMITED (Level0)

커밋되지 않은 데이터에 대한 읽기 허용

*Dirty Read 문제 발생

트랜잭션A가 수정 중인 데이터를 커밋하기 전에 트랜잭션B가 읽을 수 있습니다. 이 때, 트랜잭션A의 작업이 롤백되면, 트랜잭션B가 읽었던 데이터는 잘못된 데이터가 됩니다. (데이터 정합성 문제 발생)

READ_COMMITED (Level1)

커밋된 데이터에 대해 읽기 허용

*Dirty Read 문제 해결

커밋된 데이터만 읽으므로 데이터 정합성 문제를 해결합니다.

*Non-repeatable Read 문제 발생

하지만 트랜잭션A가 조회 중인 데이터를 트랜잭션B가 수정하고 커밋한다면, 트랜잭션A가 다시 그 데이터를 조회했을 때는 이미 수정된 데이터가 조회되고 이전에 조회된 데이터는 얻을 수 없습니다.
이처럼 반복해서 같은 데이터를 읽을 수 없는 문제가 발생합니다.

REPEATEABLE_READ (Level2)

동일 필드에 대해 다중 접근시 모두 동일한 결과를 보장

*Non-repeatable Read 문제 해결

트랜잭션이 완료될 때까지 모든 조회문의 데이터는 shared lock이 걸리므로 다른 트랜잭션은 데이터 수정이 불가능합니다. 따라서 선행 트랜잭션의 작업이 완료될 때까지 몇 번을 조회하더라도 일관성 있는 조회 결과를 리턴합니다.

*UPDATE 부정합 문제 발생

START TRANSACTION; -- transaction id : 1
SELECT * FROM Member WHERE name='soo';

    START TRANSACTION; -- transaction id : 2
    SELECT * FROM Member WHERE name = 'soo';
    UPDATE Member SET name = 'seongsoo' WHERE name = 'soo';
    COMMIT;

UPDATE Member SET name = 'parkseongsoo' WHERE name = 'soo'; -- 0 row(s) affected
COMMIT;

위 최종 수행 결과 name은 seongsoo 입니다.
왜 parkseongsoo가 아닐까요?
1. 2번 트랜잭션에서 name = seongsoo 로 변경 및 커밋
2. 이전 데이터인 name = soo 의 내용을 언두 로그에 남김 (그래야 1번 트랜잭션에서 일관된 데이터 조회가 가능하다.)
3. 1번 트랜잭션의 UPDATE 문이 수행되지만, name=soo 의 경우 레코드가 아닌 언두영역의 데이터이다. 따라서 쓰기 잠금을 할 수 없고 name = parkseongsoo 로의 업데이트는 이루어지지 않는다.

*Phantom Read 문제 발생

또한 REPEATEABLE_READ 격리 수준에서는 INSERT에 대한 정합성 보장은 하지 않기 때문에 Read 트랜잭션 작업 중간 다른 Write 트랜잭션이 데이터를 추가한다면 갑자기 데이터가 생기는 문제가 생깁니다.

SERIALIZABLE (Level3)

가장 높은 격리 수준으로 SELECT 작업 및 INSERT 작업에도 shared lock을 겁니다.
이는 동시 처리 능력이 떨어지고 성능 저하 문제가 발생합니다.

*Phantom Read 문제 해결

Insert 문에도 shared lock을 걸기 때문에 Phantom Read 문제도 해결합니다.

3-2. 트랜잭션 전파 (Propagation)

트랜잭션 전파란, 비즈니스 로직에서 아래 코드와 같이 하나의 트랜잭션이 동작중인 과정에서 다른 트랜잭션을 실행할 경우 어떻게 처리하는가에 대한 개념입니다.

@Transactional(rollbackFor=Exception.class)
public void insertEmployeeAll() { //부모
	commonDao.insert("employee.insertEmployee", new EmployeeVO("조던"));
	insertEmployee(new EmployeeVO("타이슨"));
    	commonDao.insert("employee.insertEmployee", new EmployeeVO("우즈"));
}

@Transactional(propagation=Propagation.REQUIRED, rollbackFor=Exception.class)
public void insertEmployee(EmployeeVO employeeVO) { //자식
	try {
    	commonDao.insert("employee.insertEmployee", employeeVO);
        throw new RuntimeException("자식 문제");
	} catch (Exception e) {
    		TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
        }
}

REQUIRED (Default)

선행 트랜잭션의 속성을 따르고 만약 진행중인 트랜잭션이 없다면 새로운 트랜잭션을 생성한다.

부모, 자식 중 하나라도 Fail 발생시 전체 롤백 수행

REQUIRES_NEW

항상 새로운 트랜잭션을 생성한다. 이미 선행중인 트랜잭션이 있다면 보류한채 해당 트랜잭션 작업을 먼저 수행한다.

부모, 자식 중 하나라도 Fail 발생시 문제가 생긴 코드만 롤백 수행

SUPPORT

선행 트랜잭션의 속성을 따르고 만약 진행중인 트랜잭션이 없다면 트랜잭션을 설정하지 않는다.

SUPPORT 같은 경우 트랜잭션이 별로 필요 없는 조회 메서드에 많이 사용한다. (propagation="SUPPORT", read-only="true") 옵션으로 트랜잭셕을 태우는게 좋다.

NOT_SUPPORT

선행 트랜잭션이 있다면 보류하고, 트랜잭션 없이 작업을 수행한다.

MANDATORY

선행 트랜잭션이 있어야만 작업을 수행한다. 트랜잭션이 없다면 Exception을 발생시킨다.

NEVER

트랜잭션이 진행중이지 않을 때 작업을 수행한다. 트랜잭션이 있다면 Exception을 발생시킨다.

NESTED

진행중인 트랜잭션이 있다면 중첩된 트랜잭션이 실행되며, 트랜잭션이 없다면 REQUIRED와 동일하게 수행된다.

4. 트랜잭션은 데이터를 잠그지 않는다.

@Transactional
public List<Message> getAndUpdateStatuses(Status oldStatus, Status newStatus, int batchSize) {
    List<Message> messages = messageRepo.findAllByStatus(oldStatus, PageRequest.of(0, batchSize));
    
    messages.forEach(msg -> msg.setStatus(newStatus));

    return messageRepo.saveAll(messages);
}

트랜잭션은 원자성을 보장하므로 단일 요청으로 실행된다.
문제는 다른 인스턴스가 findAllByStatus()를 동시에 호출하는 것을 막을 수 없다는 것입니다.
따라서 메서드는 두 인스턴스 모두에서 동일한 데이터를 반환받고 setStatus() 데이터는 2번 처리될 것입니다.

해결 방법

  • 비관적 잠금(Select for update)
    JPA 사용시 LockModeType 으로 구현할 수 있고 해당 어노테이션 사용시 "SELECT FOR UPDATE" 쿼리가 나갑니다.

    SELECT FOR UPDATE 는 동시성 제어를 위해 특정 row에 배타적 LOCK을 거는 행위로써, "데이터를 수정하려고 조회한 것이니 다른 놈들은 건들지 마" 라는 의미입니다.

public interface HomeRepository extends JpaRepository<Home, Long> {

    Home findByName(String name);

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select h from Home h where h.name = :name")
    Home findWithNameForUpdate(@Param("name") String name);
}

  • 낙관적 잠금(엔티티 버전)
    엔티티 열에 version을 추가하여 DB의 엔티티 버전이 응용 프로그램의 버전과 일치하는 경우에만 데이터를 수정할 수 있게 하는 것입니다.
    JPA를 사용하는 경우 @Version을 사용할 수 있습니다.

5. 두개의 서로 다른 데이터 원본

@Transactional
public void saveAccount(Account acc) {
    dataSource1Repo.save(acc);
    dataSource2Repo.save(acc);
}

이 경우 기본값으로 간주되는 트랜잭션 관리자에서 하나의 save 트랜잭셕 방식으로 처리됩니다.

5-1. ChainedTransactionManager

1st TX Platform: begin
  2nd TX Platform: begin
    3rd Tx Platform: begin
    3rd Tx Platform: commit
  2nd TX Platform: commit <-- fail
  2nd TX Platform: rollback  
1st TX Platform: rollback

여러 트랜잭션 처리 중 Fail이 발생할 경우 예외의 역순으로 롤백이 수행 됩니다. 따라서 위의 경우 두 번째 커밋 중 Fail이 발생 했으므로 1st, 2nd만 롤백이 되고 3rd는 커밋이 그대로 수행 될 것 입니다.

5-2. JtaTransactionManager

위와 같이 원자성을 잃을 수 있는 상황이라면 분산 트랜잭션을 보장하기 위해서 2단계 커밋이 필요하다.
1. 첫번째는 단계에서는 각 트랜잭션의 수행을 처리한다.
2. 두번째 단계에서는 모든 트랜잭션에게 커밋 가능한 상태인지 확인 후, 일제히 커밋 또는 롤백을 수행한다.

JtaTransactionManager는 2단계 커밋을 기반으로 완전히 지원되는 분산 트랜잭션을 사용할 수 있습니다.

느낀점

이번에 글을 정리하면서 느낀점은 너무나 많은 신기술과 프레임워크들이 쏟아지는 시점에서 공부해야 할 내용이 너무나 많지만 그럼에도 가장 중요한 것은 그것들의 근본이 되는 지식은 변하지 않는다는 것입니다.

따라서 오늘 아티클처럼 내가 잘 안다고 생각했지만 실은 중요한 것을 놓치고 있지 않았나 또 너무나 당연해서 소홀히 해왔던 부분들이 있지 않았나 되돌아 볼 수 있었습니다.

출처> Spring @Transactional mistakes everyone did

Transaction Isolation 정보

profile
Java 백엔드 개발자입니다. 제가 생각하는 개발자로서 가져야하는 업무적인 기본 소양과 현업에서 가지는 고민들을 같이 공유하고 소통하려고 합니다.

0개의 댓글