Post

트랜잭션 범위를 넘어선 동시 수정 문제

들어가며

어느 날 A 관리자가 관리하고 있는 감자빵 상품의 이름을 “감자빵”에서 “가장 맛있는 감자빵”으로 수정하고 운영환경에 반영된 모습까지 확인한 뒤 업무를 마무리했다. 하지만 그날 저녁 감자빵 판매자가 요구했던 “가장 맛있는 감자빵” 으로 상품 이름이 노출되기를 바랬는데 반영되지 않고 “감자빵”으로 노출되고 있다고 문의가 들어왔다. 관리자 A 는 다른 누군가 상품의 이름을 다시 “감자빵”으로 변경했는지 수소문했지만 찾지 못했다.

관리자 A 는 분명 상품을 관리하는 어드민 페이지에서 상품의 이름을 “가장 맛있는 감자빵”으로 수정했는데 왜 이런 일이 발생한 것일까?

문제의 원인

관리자 A 가 상품 이름을 수정한 흐름을 알아보자.

먼저 , 관리자 A 는 해당 상품을 수정할 수 있는 수정 페이지에 접속한 뒤 “가장 맛있는 감자빵”으로 수정한 뒤 수정 버튼을 클릭했을 것이다.

이때 서버의 흐름을 간단히 살펴보면 다음과 같다.

  1. 상품 수정 페이지에 접속했을 때 해당 상품의 정보를 데이터베이스에서 가져와 페이지에 응답할 것이다.
  2. 수정 버튼을 클릭했을 때 가장 맛있는 감자빵으로 해당 상품의 이름을 데이터베이스에 업데이트 요청을 할 것이다.

만약 관리자 A 가 상품을 수정하는 과정에 있을 때 관리자 B 가 해당 상품을 수정하러 상품 수정 페이지에 접속하면 어떻게 될까?

관리자 B 가 해당 상품의 이미지를 변경하려고 상품 수정 페이지에 접속했는데 그때가 하필 관리자 A 가 “가장 맛있는 감자빵”으로 수정하고 있는 과정에 있었다고 가정해 보자.

이 경우에 서버의 흐름을 간단히 살펴보면 다음과 같다.

  1. 관리자 A 가 상품 수정 페이지에 접속했을 때 해당 상품의 정보를 데이터베이스에서 가져와 페이지에 응답할 것이다. 이때 상품 이름은 “감자빵”이다.
  2. 관리자 B 가 상품 수정 페이지에 접속했을 때는 아직 관리자 A 가 수정 요청을 수행하기 전이므로 관리자 A 가 보는 데이터와 같은 응답을 받을 것이다. 즉 , 상품 이름이 “감자빵” 인 것이다.
  3. 관리자 A 가 상품 이름을 “가장 맛있는 감자빵” 으로 변경한 뒤 서버에 요청을 전달 후 데이터베이스에도 반영했다.
  4. 관리자 B 가 상품 이미지를 수정하고 버튼을 클릭해 변경된 내용으로 데이터베이스에 수정 요청을 전달할 것이다.

하지만 이때 관리자 B 요청에서 상품 이름은 “감자빵”이다. 상품의 수정은 PUT 메서드를 이용한 upsert 방식이었기 때문에 데이터베이스에는 다시 관리자 B 요청에 의해 상품 이름이 “감자빵” 으로 변경되었던 것이다.

문제 해결

이러한 문제는 트랜잭션에서도 자주 발생하는 문제로 읽은 다음 업데이트 하는 트랜잭션이라면 읽는 단계서부터 해당 레코드에 베타락을 걸어 동시성 문제를 해결하고는 한다. 하지만 이번 문제는 트랜잭션 수준에서 걸릴 수 있는 문제이면서 동시에 트랜잭션 범위를 넘어서서도 발생할 수 있는 문제이다.

문제를 해결하기 앞서 이 문제를 어떻게 해결해야 할 것인지를 결정해야 한다.

  1. 관리자 A 가 먼저 수정했으므로 관리자 B 가 수정할 때 실패한다.
  2. 관리자 B 가 수정한 것을 반영하고 관리자 A 에게 직접 이 사실을 알린다.
  3. 오류 상황으로 보지 않고 관리자 B 가 수정한 사항을 반영하도록 한다.

개인적으로 가장 이상적인 해결방법은 관리자 A, 관리자 B 가 의도한 대로 동작하는지 바로 피드백을 받을 수 있어야 한다고 생각한다.

만약 1번으로 한다면 관리자 A 는 의도한 대로 수정했으니 문제없다. 관리자 B 는 수정했지만 실패했다는 피드백을 받았기에 재시도하는 방법으로 목적을 달성할 수 있다.

1번 방법으로 상품 동시 수정 문제를 해결하기 위한 방법 중 하나로 낙관적락을 사용할 수 있다. 낙관적 락의 컨셉은 내가 읽은 시점의 상품의 버전을 알고 있고, 커밋했을 때에도 내가 읽은 버전하고 같은지 비교하여 같으면 커밋하고 다르면 롤백하는 방식이다.

JPA 를 사용하는 경우 다음과 같이 @Version 컬럼을 추가해 주는 것만으로 낙관적 락 기능을 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
@Entity
class Product(

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "product_id")
    val id: Long? = null

    @Version
    var version: Long = 0L,
)

이 경우 트랜잭션 내 해당 상품을 읽었을 때 버전과 커밋할 때 버전을 체크하고 다르면 OptimisticLockingFailureException 예외를 던진다.

하지만 위 사례는 트랜잭션 범위를 벗어난 경우이기에 이 방법만으로는 해결하기 어렵다.

결국 읽은 시점의 버전과 커밋 시점의 버전이 같아야만 반영되면 되기에 상품 수정 페이지에 진입시 버전도 같이 응답해 주고 , 상품 수정 요청 시 해당 버전으로 요청하면 트랜잭션 범위를 벗어난 경우에도 동시성 문제를 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun doEdit(
        productId: Long,
        ...
        version: Long
    ): Product {
        val product: Product = productRepository.findByIdOrThrow(productId)

        product.checkVersion(version) // 요청의 버전과 현재 읽은 상품의 버전이 같은지 확인한다.

        return product.apply { update(...) }
    }

마무리하며

정리하자면 두 번의 버전체크를 통해 이 케이스에 대한 동시성 이슈를 해결한다.

  1. 트랜잭션 내 읽은 버전과 요청으로 온 버전
  2. 트랜잭션 내 읽은 버전과 커밋할 때 버전
This post is licensed under CC BY 4.0 by the author.