용로그
article thumbnail

들어가며


지난 글에서 JPA가 알아서 동시성 이슈를 해결해주는 모습을 보였지만, 어디까지나 운이라는 결론을 내렸습니다. 이어서 어드민 페이지 동시성 이슈를 직접 해결해보겠습니다.

 

두 번의 갱신 분실 문제


JPA가 동시성 문제를 파악하고 예외를 던져주는 행위가 운이라고 했는데, 이는 영속성 컨텍스트를 계속 동기화 시켜주는 부분을 제거하면 곧 바로 동시성 문제가 다시 발생하기 때문입니다.

 

여기서 말하는 동시성 문제란 두 번의 갱신 분실 문제를 뜻하는데, 아래와 같이 3가지 상황이 존재합니다.

  1. 최초 커밋만 인정하기 : 첫 번째로 커밋한 트랜잭션의 변경 사항만 반영하고, 마지막 트랜잭션의 변경 사항은 무시한다.
  2. 마지막 커밋만 인정하기 : 마지막으로 커밋한 트랜잭션의 변경 사항만 반영하고, 첫 번째 트랜잭션의 변경 사항은 무시한다.
  3. 충돌하는 내용 병합하기 : 최초 커밋과 마지막 커밋을 병합한다.

트랜잭션의 격리 수준으로는 마지막 커밋만 인정하기 외의 정책은 구현할 수 없습니다. 따라서 데이터 정합성을 위해 최초의 변경 사항만 인정하려면 별도의 처리를 해주어야 합니다.

 

두번의 갱신 분실 문제

 

지금 상황에서는 테스트 코드를 실행하면, 어떨때는 사료의 이름이 연어로 만들어진 사료로 바뀌고, 어떨 때는 소고기로 만들어진 사료로 바뀝니다. 결괏값을 예측할 수 없기 때문에 사용자에게 혼동을 줄 가능성이 높습니다.

 

자바가 제공하는 synchronized 살펴보기


자바에서는 멀티 스레드 환경에서 동시성을 관련 문제를 해결하기 위해 synchronized라는 키워드를 제공합니다. synchronized는 여러 개의 스레드가 하나의 자원을 사용하고자 할 때 현재 데이터를 사용하고 있는 해당 스레드를 제외하고 나머지 스레드들을 대기시킵니다.

 

Wrapped(StaleObjectStateException)

synchronized를 사용하면 자원을 사용하고 있는 스레드의 작업이 끝날 때 까지 기다렸다가 다음 스레드가 실행되어 정상적으로 동작해야하는데, 여전히 메서드가 동시에 실행되어 StaleObjectStateException 예외가 발생하는 모습입니다. 분명 synchronized를 사용하면 하나의 스레드만 공유 자원을 점유할텐데 어떻게 이런 결과가 나올 수 있었던 걸까요?

 

synchronized가 동시성 이슈를 해결하지 못하는 이유

synchronized를 사용했음에도 동시성 이슈를 해결하지 못하는 이유는 @Transactional에 숨어있습니다. @Transactional은 AOP로 구현되어 있는데, AOP는 원본 객체의 프록시 객체를 만들어 사용하지만 synchronized는 프록시 객체에 상속되지 않습니다.

 

proxy 객체로 실행하는 모습

그럼 synchronized로는 동시성을 해결할 수 없을까요? 해결할 수 있는 방법이 존재하긴 합니다. 당연하게도 프록시 객체를 사용하지 않도록 @Transactional을 사용하지 않으면 됩니다. 하지만 @Transactional을 제거하게 된다면 3가지 문제가 생깁니다.

  1. 커밋과 롤백을 직접 구현해야 한다.
  2. 지연 로딩을 사용하는 객체가 있다면 상황에 따라 세션이 만료될 수 있다.
  3. 분산 환경에서는 정상적으로 동작하지 못한다.

이렇게 근본적으로 synchronized가 동시성을 해결할 수 없는 이유들이 있습니다. 그렇다면 이를 대체하기 위한 방법들을 사용해야하는데, JPA에서는 이를 위해 여러가지 방법을 제시합니다.

Lock으로 해결하기


JPA는 데이터베이스에 대한 동시 접근으로부터 엔터티에 대한 정합성을 유지할 수 있게 해주는 여러가지 락(Lock)을 지원합니다. 이 락(Lock)에는 낙관적 락비관적 락이 존재합니다.

 

낙관적 락(Optimistic Lock)

대부분의 트랜잭션에 충돌이 발생하지 않을 것이라고 낙관적으로 가정하는 방법입니다. 따라서 데이터베이스가 제공하는 락 기능을 사용하지 않고, 애플리케이션에서 엔터티의 버전(Version)을 통해 동시성을 제어합니다.

 

엔터티의 버전은 JPA 내부에서 지원하는 @Version 어노테이션을 통해 관리할 수 있습니다. @Version 적용이 가능한 타입은 Long, Integer, Short, Timestamp 등이 있으며, 보통 별도의 버전 관리용 필트를 만들어 적용합니다.

 

@Entity
@Getter
@Builder
@AllArgsConstructor
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class PetFood extends BaseTimeEntity {

    @Id
    @Include
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Version
    private Integer version;
}

 

@Version 어노테이션을 적용했다면, PetFood 엔터티의 데이터가 변경될 때 마다 version 필드가 자동으로 1씩 증가합니다. 그리고 엔터티를 수정할 때 엔터티를 조회한 시점의 버전과 수정한 시점의 버전이 일치하지 않으면 ObjectOptimisticLockingFailureException이 발생합니다.

 

최초 커밋만 인정하기

 

이렇게 엔터티의 version을 통해 최초 커밋만 인정하기라는 정책을 구현할 수 있습니다. 참고로 JPA가 엔터티를 수정하고 트랜잭션을 커밋하는 시점에 변경 사항과 version이 1 증가합니다.

 

낙관적 락 예외

이런 예외가 발생했다면 정상적으로 적용이 되었다는 의미입니다.

주의 사항

엔터티에 연관관계 필드가 존재하는 경우, 연관관계의 주인 필드를 변경할 때만 버전이 증가합니다. 따라서 연관관계를 동시에 변경하는 상황이 존재한다면, 연관관계 테이블에도 version 필드를 추가해야 합니다.

 

재시도하기

만약 트랜잭션의 충돌이 일어나 예외가 발생한다면 하나의 요청은 무시되었다는 뜻입니다. 지금과 같은 상황에서는 마지막 요청이 무시될것입니다. 하지만, 애플리케이션에서 처리하는 락이기 때문에, 마지막 요청이 버전 충돌으로 인한 예외로 요청이 실패할 경우 실패한 지점부터 재시도해야 합니다.

 

AOP로 @Retry 구현하기

이렇게 전/후처리가 필요하거나 반복적으로 처리해줘야하는 작업의 경우 AOP로 간편하게 처리할 수 있습니다.

 

@Target(METHOD)
@Retention(RUNTIME)
public @interface Retry {

}

 

다음은 재시도 메서드를 구현해야 합니다. 주의사항은 아래와 같이 @Pointcut("@annotation(Retry)")를 사용하는 경우 @interface Retry 클래스와 RetryAspect 클래스가 같은 패키지에 존재해야 합니다.

 

@Aspect
@Component
@Order(LOWEST_PRECEDENCE - 1)
public class RetryAspect {

    private static final int MAX_RETRIES = 1000;
    private static final int RETRY_DELAY_MS = 100;

    @Pointcut("@annotation(Retry)")
    public void retry() {
    }

    @Around("retry()")
    public Object retryOptimisticLock(ProceedingJoinPoint joinPoint) throws Throwable {
        Exception exceptionHolder = null;
        for (int retryCount = 0; retryCount < MAX_RETRIES; retryCount++) {
            try {
                return joinPoint.proceed();
            } catch (OptimisticLockException | ObjectOptimisticLockingFailureException | StaleObjectStateException e) {
                exceptionHolder = e;
                Thread.sleep(RETRY_DELAY_MS);
            }
        }
        throw exceptionHolder;
    }
}

 

@Transactional은 클래스 레벨에 있습니다.

이렇게 @Retry 어노테이션을 붙여주면 아래과 같이 동시 요청을 정상적으로 처리할 수 있게 됩니다.

 

정상적으로 처리되는 모습

 

하지만 지금 상황과 같이 이름이 변경되거나 어떠한 값이 다른 값으로 대체되는 상황이라면 Retry는 적합하지 않을 수도 있습니다. 첫 번째 요청을 적용하고, 두 번째 요청은 처리하지 않고 관리자에게 충돌 관련 메시지를 전달하는게 더 적절할 수 있죠.

 

또한 현재 상황은 낙관적 락으로 충분히 해결할 수 있는 문제이므로 비관적 락을 고려하지는 않았습니다. 비관적 락은 낙관적 락 보다 비교적 더 무거운 락이기 때문에 성능 저하와 데드락을 고려해야합니다.

profile

용로그

@용로그

벨덩보단 용덩 github.com/wonyongChoi05