본문 바로가기

⛳️ 공동구매 서비스 총대마켓

동시성 이슈 해결 과정: 트랜잭션 격리 수준, 낙관적 잠금, 비관적 잠금

문제 상황

채팅방 목록을 조회할 때 500 예외가 반환되어 데이터가 조회되지 않고 앱에서 빈 화면이 출력되었습니다.

LoggingErrorResponse[identifier=ceedb3fa-300d-4ee4-852d-c9d83826132e, memberIdentifier={"sub":"1010","exp":1729660386}, httpMethod=GET, uri=/comments, requestBody=, statusCode=500, errorMessage=, latency=24ms, stacktrace=org.springframework.dao.IncorrectResultSizeDataAccessException: Query did not return a unique result: 2 results were returned

 

예외 발생 지점은 아래 코드였습니다.

@Transactional(readOnly = true)
public CommentRoomAllResponse getAllCommentRoom(MemberEntity member) {
    // ...
    OfferingMemberEntity offeringMember = offeringMemberRepository.findByOfferingIdAndMember(offeringId, member)
            .orElseThrow(() -> new MarketException(OfferingMemberErrorCode.NOT_FOUND));
    // ...
}

 

한건의 데이터가 Optional로 반환되어야 하는데 여러 건의 데이터가 조회되었기 때문입니다. OfferingMember는 사용자가 공모에 참여할 때 삽입되는 데이터로, 참여는 한 번만 가능합니다. 하지만 왜 여러 번의 참여가 가능했을까요?

 

 

문제 원인

동일한 API가 중복으로 호출되어 일명 `따닥` 문제로 중복 참여가 된 상황이었습니다. 두 개의 동일 API가 중복으로 호출되었다 가정하면, 첫 번째 API가 완료되기 전 두 번째 API가 호출되어 중복 검증에 걸리지 않게 되는 것이죠. 따라서 중복된 데이터가 DB 테이블에 저장되는 것입니다. 또한, 유연한 설계를 위해 테이블에 unique 조건도 걸지 않았기 때문에 데이터베이스 단에서도 검증이 불가능한 상황이었습니다.

 

아래는 중복 호출된 API의 서비스 계층 코드입니다.

@WriterDatabase
@Transactional
public Long participate(ParticipationRequest request, MemberEntity member) {
    OfferingEntity offering = offeringRepository.findById(request.offeringId())
            .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND));
    validateParticipate(offering, member);

    OfferingMemberEntity offeringMember = new OfferingMemberEntity(
            member, offering, OfferingMemberRole.PARTICIPANT);
    OfferingMemberEntity saved = offeringMemberRepository.save(offeringMember);
    offering.participate();

    eventPublisher.publishEvent(new ParticipateEvent(this, saved));
    return saved.getId();
}

 

여기서 저는 두 가지 해결 방안을 떠올렸습니다.

  1. 중복 API 호출 발생률 낮추기: 알림 로직 비동기 처리를 통해 API 속도 단축
  2. 중복 데이터 저장하지 않기: 데이터베이스 잠금(락)을 통해 동시 작업 방지

현재는 첫 번째 방법으로 위 문제를 어느 정도 해결한 상황입니다. 하지만 더욱 본질적인 원인의 뿌리를 뽑고 싶어 API가 중복 호출되어도 문제가 발생하지 않도록 두 번째 해결 방법 또한 적용하기로 하였습니다.

 

첫 번째 해결 방법을 적용한 과정은 아래 포스팅에서 소개합니다.

 

2024.11.25 - [⛳️ 공동구매 서비스 총대마켓] - FCM 푸시알림 트러블슈팅: 비동기 처리로 동시성 이슈 방지하기

 

FCM 푸시알림 트러블슈팅: 비동기 처리로 동시성 이슈 방지하기

상황 성공적으로 푸시알림 기능을 구현하였습니다. 2024.11.20 - [우아한테크코스 6기] - [프로젝트] FCM 푸시알림 도입기 | 백엔드 자바 [프로젝트] FCM 푸시알림 도입기 | 백엔드 자바상황공동구매

helenason.tistory.com

 

 

해결 과정

1. 동시성 테스트 코드 작성

확인하고 싶은 문제 상황: 동일한 데이터를 동시에 저장할 경우, 예외 없이 저장되는 비정상적인 상황

 

문제 상황을 테스트를 통해 확인하기 위해 동시성 테스트 코드를 작성하였습니다. 멀티스레드에서의 동시 호출 환경을 구축하기 위해 ExecutorService를, 비동기로 처리되는 작업 모두가 완료되었는지를 확인하기 위해 CountDownLatch를 사용했습니다.

 

아래는 동일한 로직을 동시에 두 번 호출하는 테스트 코드입니다.

ExecutorService executorService = Executors.newFixedThreadPool(2);

int executeCount = 2; // 호출 횟수
CountDownLatch countDownLatch = new CountDownLatch(executeCount);

executorService.submit(() -> {
    try {
        // (동시에 호출할 로직)
    } finally {
        countDownLatch.countDown();
    }
});

countDownLatch.await();
executorService.shutdown();

 

[고민] ExecutorService의 execute() vs submit()

  • execute() : 반환 타입이 void로, 결과가 궁금하지 않을 때 사용
  • submit() : 반환 타입이 Future로, 결과 및 예외가 궁금할 때 사용

두 메서드 중 무엇을 사용할지 고민했는데요. 저는 결과 확인을 위해 submit()을 사용했습니다. 이때 주의할 점은, countDownLatch가 무한으로 대기하는 상황을 방지해야 합니다. 따라서 try-finally 구문을 활용하여 예외가 반환되어도 항상 countDown하도록 했습니다.

 

테스트 결과

1. 다른 스레드에서 같은 로직이 동시에 실행됨을 확인하였습니다.

 

2. 문제가 되었던 `중복 검증` 및 `데이터 저장` 로직 또한 동시에 실행되며, 비정상적으로 예외 없이 성공됨을 확인하였습니다.

중복 검증 로직
데이터 저장 로직

 

테스트를 통해, 동일한 API가 동시에 호출될 경우 중복되지 말아야 할 데이터가 중복 저장될 수 있는 문제가 있음을 확인하였습니다.

 

> 문제 상황 개선 후 통과해야 할 테스트 코드도 작성하였습니다. 아래에 첨부합니다.

더보기

개선해야 하는 상황: 중복 참여 가능 → 중복 참여 불가능

@DisplayName("같은 사용자가 같은 공모에 동시에 참여할 경우 중복 참여가 불가능하다.")
@Test
void should_failParticipate_when_givenSameMemberAndSameOffering() throws InterruptedException {
    // given
    MemberEntity proposer = memberFixture.createMember("ever");
    OfferingEntity offering = offeringFixture.createOffering(proposer);
    offeringMemberFixture.createProposer(proposer, offering);

    // when
    ParticipationRequest request = new ParticipationRequest(offering.getId());
    MemberEntity participant = memberFixture.createMember("whoever");

    ExecutorService executorService = Executors.newFixedThreadPool(5);

    int executeCount = 5;
    CountDownLatch countDownLatch = new CountDownLatch(executeCount);

    for (int i = 0; i < executeCount; i++) {
        executorService.execute(() -> {
            try {
                offeringMemberService.participate(request, participant);
            } finally {
                countDownLatch.countDown();
            }
        });
    }

    countDownLatch.await();
    executorService.shutdown();

    // then
    ParticipantResponse response = offeringMemberService.getAllParticipant(offering.getId(), proposer);
    assertThat(response.participants()).hasSize(1);
}

 

@DisplayName("다른 사용자가 같은 공모에 동시에 참여할 경우 중복 참여가 불가능하다.")
@Test
void should_failParticipate_when_givenDifferentMemberAndSameOffering() throws InterruptedException {
    // given
    MemberEntity proposer = memberFixture.createMember("ever");
    OfferingEntity offering = offeringFixture.createOffering(proposer, 2);
    offeringMemberFixture.createProposer(proposer, offering);

    // when
    ParticipationRequest request = new ParticipationRequest(offering.getId());
    MemberEntity participant1 = memberFixture.createMember("ever1");
    MemberEntity participant2 = memberFixture.createMember("ever2");

    ExecutorService executorService = Executors.newFixedThreadPool(2);

    int executeCount = 2;
    CountDownLatch countDownLatch = new CountDownLatch(executeCount);

    executorService.execute(() -> {
        try {
            offeringMemberService.participate(request, participant1);
        } finally {
            countDownLatch.countDown();
        }
    });

    executorService.execute(() -> {
        try {
            offeringMemberService.participate(request, participant2);
        } finally {
            countDownLatch.countDown();
        }
    });

    countDownLatch.await();
    executorService.shutdown();

    // then
    ParticipantResponse response = offeringMemberService.getAllParticipant(offering.getId(), proposer);
    assertThat(response.participants()).hasSize(1);
}

 

2. 해결 방법 탐색

총 네 가지 방법으로 해결을 시도했습니다. 그중 두 가지 방법으로 해결이 가능했습니다. 그 해결 과정을 공유하겠습니다.

 

A) 트랜잭션 격리 수준 조정 [실패]

트랜잭션의 격리 수준을 높게 정의한다면 각 트랜잭션별 간섭이 적어지기 때문에 동시성 이슈가 없지 않을까? 하는 생각에서 비롯된 시도였습니다. 하지만 저의 생각은 잘못되었습니다.

 

구현

Isolation level을 가장 높은 격리 수준인 SERIALIZABLE로 설정해 주었습니다.

@WriterDatabase
@Transactional(isolation = Isolation.SERIALIZABLE)
public Long participate(ParticipationRequest request, MemberEntity member) {
    // ...
}

 

결과

결과는 처참했습니다. 데드락이 발생했습니다.

 

트랜잭션 격리 수준을 SERIALIZABLE로 설정하면 순수 SELECT 작업을 포함한 모든 작업에 락을 걸게 됩니다. 이 과정에서 무한 대기가 발생합니다. 아래 코드는 문제가 발생했던 코드입니다.

@WriterDatabase
@Transactional(isolation = Isolation.SERIALIZABLE)
public Long participate(ParticipationRequest request, MemberEntity member) {
    OfferingEntity offering = offeringRepository.findById(request.offeringId())
            .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND)); // s-lock
    // ...
    offering.participate(); // x-lock
    // ...
}

 

s-lock과 x-lock을 획득하는 과정에서 무한 대기 상태에 빠지게 되는데요. 그림으로 설명하면 아래와 같습니다.

 

동시에 두 트랜잭션이 s-lock 획득에 성공합니다. s-lock 간 획득은 자유롭기 때문이죠. 트랜잭션이 커밋되지 않았기 때문에 여전히 s-lock이 걸려있는 상황에서, x-lock 획득을 시도합니다. 하지만 두 트랜잭션 모두에 s-lock이 걸려있기 때문에 x-lock 획득이 불가능하므로 s-lock이 해제될 때까지 대기합니다. 이 대기가 무한으로 길어지면서 데드락이 발생합니다.

 

B) 낙관적 잠금 (낙관적 락) [성공]

문제가 되는 아래 로직 중, Offering 테이블의 값을 갱신하는 로직이 있습니다. 따라서 갱신을 시도하는 레코드에 대해 애플리케이션 단 잠금을 걸어 동시 수정이 불가능하도록 하였습니다.

@WriterDatabase
@Transactional
public Long participate(ParticipationRequest request, MemberEntity member) {
    OfferingEntity offering = offeringRepository.findByIdWithLock(request.offeringId())
            .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND));
    validateParticipate(offering, member);

    OfferingMemberEntity offeringMember = new OfferingMemberEntity(
            member, offering, OfferingMemberRole.PARTICIPANT);
    OfferingMemberEntity saved = offeringMemberRepository.save(offeringMember);
    offering.participate(); // 테이블 갱신 로직

    eventPublisher.publishEvent(new ParticipateEvent(this, saved));
    return saved.getId();
}

 

구현

Offering 엔티티 클래스에 아래 버저닝 필드를 추가합니다.

@Version
private Long version;

 

이로 인해 테이블에 수정 쿼리가 발생할 때마다 아래와 같이 버전을 확인 및 갱신하여 동일 레코드를 동시에 수정할 수 없도록 합니다.

update
    offering 
set
    current_count=?,
    description=?,
    discount_rate=?,
    is_deleted=?,
    meeting_address=?,
    meeting_address_detail=?,
    meeting_address_dong=?,
    meeting_date=?,
    member_id=?,
    offering_status=?,
    origin_price=?,
    product_url=?,
    room_status=?,
    thumbnail_url=?,
    title=?,
    total_count=?,
    total_price=?,
    updated_at=?,
    version=2 
where
    id=? 
    and version=1

 

결과

아래와 같이 첫 번째 요청의 이후 요청에 대해 낙관적 잠금 관련 예외가 발생함으로써 중복 참여를 방지합니다.

 

C) 비관적 쓰기 잠금 [성공]

데이터베이스 단에서 잠금을 거는 비관적 잠금을 시도했습니다. 쓰기 잠금(x-lock)과 읽기 잠금(s-lock)을 모두 시도해 보았는데요, 쓰기 잠금 적용 과정부터 공유합니다.

 

구현

아래와 같이 조회 쿼리에 @Lock 어노테이션을 달아주고 LockModeType을 통해 쓰기 잠금을 걸어줍니다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT o FROM OfferingEntity o WHERE o.id = :id")
Optional<OfferingEntity> findByIdWithLock(Long id);

 

이로 인해 아래와 같이 조회 시 `SELECT ~ FOR UPDATE` 쿼리로 쓰기 잠금을 획득합니다.

select
    oe1_0.id,
    oe1_0.created_at,
    oe1_0.current_count,
    oe1_0.description,
    oe1_0.discount_rate,
    oe1_0.is_deleted,
    oe1_0.meeting_address,
    oe1_0.meeting_address_detail,
    oe1_0.meeting_address_dong,
    oe1_0.meeting_date,
    oe1_0.member_id,
    oe1_0.offering_status,
    oe1_0.origin_price,
    oe1_0.product_url,
    oe1_0.room_status,
    oe1_0.thumbnail_url,
    oe1_0.title,
    oe1_0.total_count,
    oe1_0.total_price,
    oe1_0.updated_at 
from
    offering oe1_0 
where
    (
        oe1_0.is_deleted = false
    ) 
    and oe1_0.id=? for update

 

결과

중복 참여 여부를 검증하는 코드에서 첫 번째 요청의 이후 요청에서 정상적으로 예외가 발생합니다.

 

동작 방식은 아래와 같습니다. Offering 레코드를 조회할 때 쓰기 잠금을 획득하여, 다른 트랜잭션의 읽기 및 쓰기 잠금을 막습니다. 따라서 두 번째 트랜잭션은 메서드 초입 부분에서 잠금 획득에 실패하기 때문에 첫 번째 트랜잭션이 커밋된 후 잠금을 획득합니다. 첫번째 트랜잭션 커밋 후 두번째 트랜잭션은 첫번째 트랜잭션에 의해 중복 참여로 판단되어 참여가 불가능해지는 것이죠. 코드는 아래와 같습니다.

@WriterDatabase
@Transactional
public Long participate(ParticipationRequest request, MemberEntity member) {
    OfferingEntity offering = offeringRepository.findByIdWithLock(request.offeringId())
            .orElseThrow(() -> new MarketException(OfferingErrorCode.NOT_FOUND)); // x-lock
    validateParticipate(offering, member); // exception

    OfferingMemberEntity offeringMember = new OfferingMemberEntity(
            member, offering, OfferingMemberRole.PARTICIPANT);
    OfferingMemberEntity saved = offeringMemberRepository.save(offeringMember);
    offering.participate();

    eventPublisher.publishEvent(new ParticipateEvent(this, saved));
    return saved.getId();
} // commit

 

그림으로 소개하면 아래와 같습니다. 트랜잭션 B의 경우 메서드 초입부터 막히기 때문에 트랜잭션 A가 커밋될 때까지 대기해야 하죠. 이로 인해 성능은 저하될 수 있지만, 그만큼 동시성으로부터 안전해집니다.

 

D) 비관적 읽기 잠금 [실패]

그렇다면 읽기 잠금은 어떨까요? 읽기 잠금의 경우 현재 로컬 DB 환경인 H2에서 지원하지 않았습니다. 따라서 MySQL DB 환경을 로컬에 별도로 구축하여 비관적 읽기 잠금을 걸어보았습니다.

 

구현

아래와 같이 LockModeType만 PESSIMISTIC_READ로 변경해 주었습니다.

@Lock(LockModeType.PESSIMISTIC_READ)
@Query("SELECT o FROM OfferingEntity o WHERE o.id = :id")
Optional<OfferingEntity> findByIdWithLock(Long id);

 

이로 인해 아래와 같이 조회할 때 `SELECT ~ FOR SHARE` 쿼리로 읽기 잠금을 획득합니다.

select
    oe1_0.id,
    oe1_0.created_at,
    oe1_0.current_count,
    oe1_0.description,
    oe1_0.discount_rate,
    oe1_0.is_deleted,
    oe1_0.meeting_address,
    oe1_0.meeting_address_detail,
    oe1_0.meeting_address_dong,
    oe1_0.meeting_date,
    oe1_0.member_id,
    oe1_0.offering_status,
    oe1_0.origin_price,
    oe1_0.product_url,
    oe1_0.room_status,
    oe1_0.thumbnail_url,
    oe1_0.title,
    oe1_0.total_count,
    oe1_0.total_price,
    oe1_0.updated_at 
from
    offering oe1_0 
where
    (
        oe1_0.is_deleted = 0
    ) 
    and oe1_0.id=? for share

 

결과

데드락이 발생했습니다. 트랜잭션 격리 수준을 SERIALIZABLE로 설정했을 때 데드락이 발생한 이유와 동일합니다. 읽기 잠금을 획득한 상황에서 각 트랜잭션이 동시에 쓰기 잠금 획득을 시도하기 때문에 무한대기 상태에 빠지는 것이죠.

 

따라서, 비관적 잠금을 걸 때는 읽기 잠금이 아닌 쓰기 잠금을 걸어 하나의 트랜잭션이 커밋될 때까지 대기하도록 해야 했습니다.

 

 

최종 해결 방안

적용 가능한 두 가지 방법 낙관적 잠금과 비관적 쓰기 잠금 중 저의 선택은 비관적 쓰기 잠금이었습니다. 각 방법의 장단점을 검토한 결과, 안정성 측면에서 비관적 쓰기 잠금이 더 적합하다고 판단했습니다.

낙관적 잠금의 단점

낙관적 잠금을 적용하면 불필요한 쿼리가 발생할 가능성이 있습니다. 낙관적 잠금에서는 트랜잭션 커밋 시점에 UPDATE 쿼리가 실행되며, 예외는 이 시점에 발생합니다. 따라서 롤백이 보장된 상황에서도 메서드의 모든 로직이 실행되어야 하며, 불필요한 쿼리가 추가적으로 발생합니다. 그럼에도 콘서트 티켓팅이나 이벤트 응모처럼 트래픽이 몰리는 상황은 아니기 때문에 치명적인 단점은 아니라고 보았습니다.

비관적 쓰기 잠금의 단점

비관적 쓰기 잠금을 적용하면 하나의 트랜잭션이 실행되는 동안 다른 트랜잭션은 대기 상태로 전환되어 성능 저하가 있을 수 있습니다. 하지만 문제가 되었던 API는 동시성 발생률이 매우 낮기 때문에 이 단점도 치명적이지는 않다고 판단했습니다. 동시에, 비관적 쓰기 잠금은 안정성 측면에서 더욱 확실한 이점을 제공하기도 합니다.

성능 테스트

총대마켓의 참여하기 API는 동시성 발생률이 낮기 때문에 낙관적 잠금과 비관적 쓰기 잠금 모두 성능에 미치는 영향이 크지 않을 것이라 생각했습니다. 이를 확인하기 위해 아래 상황에 대해 테스트를 진행했습니다.

  • 두 개의 중복 요청이 동시에 발생하는 상황
  • 1000개의 요청이 동시에 발생하는 상황

테스트 결과, 두 방법 간 속도 차이가 거의 없어 안정성이 더 높은 비관적 쓰기 잠금을 선택하였습니다.

 

참고

동시성 테스트 코드 관련

https://www.baeldung.com/java-executor-service-tutorial

https://www.baeldung.com/java-execute-vs-submit-executor-service

 

트랜잭션 격리 수준 관련

https://alexander96.tistory.com/55

https://mangkyu.tistory.com/299

 

낙관적 락 관련

https://www.baeldung.com/jpa-optimistic-locking

 

비관적 락 관련

https://www.baeldung.com/jpa-pessimistic-locking