여행 동행 참여에 대해 동시성 문제 해결하고 성능 비교 해보기

2025. 3. 16. 04:36·💻 Dev/Spring

개요

현재 여행 동행 모집 프로젝트를 진행하는 중에, 동행 참여에 대한 동시성 이슈가 발생했다.

 

1. 동시성 문제 발생 원인

여행 동행 모임 참여 로직은 다음과 같다.

  1. 멤버가 우동(동행 모임)에 대해 참여 요청을 보낸다.
  2. 참여할 수 있는지 유효성 검증을 한다. (인원 체크, 기존 참여 여부 확인 등)
  3. 우동에 대기자로 등록한다.
  4. 관리자가 승인하면 동행에 최종 참여된다.

한 우동에 대해 대기자는 최대 5명만 가능하기 때문에, 처음에는 대기자 객체를 생성할 때 대기자 수를 체크했다.

public class WaitingMember {
    public static WaitingMember of(Udong udong, Long memberId, int currentWaitingMembersCount) {
            validateWaitingCount(currentWaitingMembersCount);
            return WaitingMember.builder()
                    .udong(udong)
                    .memberId(memberId)
                    .build();
        }

        private static void validateWaitingCount(int currentWaitingMembersCount) {
            if (currentWaitingMembersCount >= MAX_WAITING_COUNT) {
                throw new InvalidParticipationException("대기 인원이 초과되었습니다.");
            }
        }
    }
}

하지만 여러 스레드에서 동시에 참여 요청을 보낼 경우, 동시성 이슈가 발생해 대기자가 5명을 초과하게 됐다.

 

문제 발생 과정

  • 기존에 대기자가 4명 있다고 가정하자.
  • 여러 사용자가 동시에 참여 요청을 보낸다.
  • waitingMemberRepository.countByUdong(udong)을 호출하면, 모든 스레드가 대기자가 4명이라고 인식한다.
  • 각각의 스레드는 현재 대기자 수가 5명을 초과하지 않는다고 판단하고, 새로운 WaitingMember 객체를 생성한다.
  • 결과적으로 여러 개의 WaitingMember가 동시에 저장되어, 대기자 수가 5명을 초과하게 된다.

이 문제를 확인하기 위해 아래와 같은 테스트 코드를 작성했다.

@Test  
void 대기자_리스트_초과_동시성_테스트() throws InterruptedException {  
    // given  
    final int REQUEST_MEMBER_COUNT = 6;  

    ExecutorService executorService = Executors.newFixedThreadPool(2);  
    CountDownLatch latch = new CountDownLatch(REQUEST_MEMBER_COUNT);  

    // when  
    long startMemberId = 5L;  
    for (int i = 0; i < REQUEST_MEMBER_COUNT; i++) {  
        long memberId = startMemberId + i;  
        executorService.submit(() -> {  
            try {  
                udongService.requestParticipation(udong.getId(), memberId);  
            } catch (Exception e) {  
                throw new RuntimeException(e);  
            } finally {  
                latch.countDown();  
            }  
        });  
    }  

    latch.await(); // 스레드 완료될 때까지 대기  
    executorService.shutdown();  

    // then  
    int waitingCount = waitingMemberRepository.countByUdong(udong);  
    assertThat(waitingCount).isGreaterThan(5);
    log.info("waitingCount={}", waitingCount);
}

 

5명이 제한인데, 6명이 대기자 리스트에 들어간 것을 확인할 수 있다.

 

2. 잘못된 해결 시도 - INSERT INTO ... WHERE

처음에는 대기자 등록 시점에서 직접 대기자 수를 체크하면 해결되지 않을까? 라는 접근을 했다.

@Modifying
@Query("INSERT INTO waiting_member (udong_id, member_id) " +
       "SELECT :udongId, :memberId " +
       "WHERE (SELECT COUNT(*) FROM waiting_member WHERE udong_id = :udongId) < 5")
int save(@Param("udongId") Long udongId, @Param("memberId") Long memberId);

즉, "현재 대기자 수가 5명 미만이면 추가하는" 로직을 데이터베이스 레벨에서 원자적으로 실행하는 것이다.
이렇게 하면 현재 대기자 수가 5명 미만이면 추가하는 방식으로 동작할 것으로 기대했다.

하지만 동시성 제어가 되지 않았다.

 

문제점

  • SQL 자체는 원자적으로 실행되지만, 여러 개의 트랜잭션이 동시에 실행될 경우 COUNT(*) < 5 조건을 동시에 만족할 가능성이 있다.
  • 만약 여러 개의 트랜잭션이 같은 시점에 SELECT COUNT(*)을 수행하면, 동일한 대기자 수(4)를 참조한다.
  • 결과적으로 여러 개의 INSERT가 수행되어 대기자가 5명을 초과할 수 있다.

 

동시성 제어 해결법

1. synchronized

처음에는 제일 간단하게 synchronized키워드를 사용했다.

@Transactional  
public synchronized WaitingMemberResponse requestParticipation(Long udongId, Long memberId) {  
    Udong udong = findUdongById(udongId);  

    validateParticipationRequest(memberId, udong);  

    WaitingMember waitingMember = WaitingMember.of(udong, memberId, waitingMemberRepository.countByUdong(udong));  
    return WaitingMemberResponse.of(waitingMemberRepository.save(waitingMember));  
}

결과

  • 매우 간단하게 동시성 제어가 가능해졌고, 대기자가 5명을 초과하는 문제는 해결되었다.

문제점

  • synchronized 키워드는 단일 프로세스(싱글 인스턴스) 환경에서만 동작한다.
  • 멀티 인스턴스(WAS 여러 대) 환경에서는 동기화가 보장되지 않는다.
  • 한 번에 하나의 스레드만 접근할 수 있으므로 성능 저하가 발생한다.

 

2. 낙관적 락(Optimistic Lock)

낙관적 락은 실제로 Lock 을 이용하지 않고 버전(Version)을 활용하여 충돌을 감지하는 방식이다.

 

Version 추가

먼저 버저닝을 위해 우동 도메인에 대기자 수 컬럼과, @Version을 추가했다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Udong extends BaseTimeEntity {
    private static final int MAX_WAITING_COUNT = 5;

    // 생략

    @Column(nullable = false, columnDefinition = "int default 0")
    private int currentWaitingMemberCount;

    @Version
    private Long version;

    public void increaseWaitingMemberCount() {
        if (this.currentWaitingMemberCount >= MAX_WAITING_COUNT) {
            throw new InvalidParticipationException("대기 인원이 초과되었습니다.");
        }
        this.currentWaitingMemberCount++;
    }
}

 

적용(service, repository)

그리고 낙관적 락을 사용하는 service과 respository 코드를 다음과 같이 작성했다.

public interface UdongRepository extends JpaRepository<Udong, Long>, UdongRepositoryCustom {

    @Lock(LockModeType.OPTIMISTIC)
    @Query("select u from Udong u where u.id = :udongId")
    Udong findUdongByWithOptimisticLock(@Param("udongId") Long udongId);
}
public class UdongService {
    @Retryable(
        retryFor = ObjectOptimisticLockingFailureException.class,
        maxAttempts = 5,
        backoff = @Backoff(delay = 50)
)
    @Transactional
    public WaitingMemberResponse requestParticipationWithOptimisticLock(Long udongId, Long memberId) {
            Udong udong = udongRepository.findUdongByWithOptimisticLock(udongId);
            validateParticipationRequest(memberId, udong);

            WaitingMember waitingMember = waitingMemberRepository.save(WaitingMember.of(udong, memberId));

            return WaitingMemberResponse.of(waitingMemberRepository.save(waitingMember));
    }
}

 

고민했던 점

낙관적 락을 적용하는 과정에서, "정말로 동시성 제어가 잘 되는가?"에 대한 고민이 있었다.
내 구조에서는 Udong 엔티티에 대해 버전 충돌 감지를 하기 때문에

WaitingMember saved = waitingMemberRepository.save(waitingMember);

여기서 "다른 스레드가 대기자를 먼저 저장할 수 있지 않나?" 생각했다.

 

즉, 다음과 같은 시나리오가 발생할 가능성을 고민했다.

스레드 A 스레드 B
udong 조회 (버전 1) udong 조회 (버전 1)
waiting_member 저장 waiting_member 저장
udong 업데이트 (버전 2) udong 업데이트 시도 (버전 1 → 2 실패)
트랜잭션 커밋(성공) 버전 충돌 발생, 롤백(실패)

 

하지만 낙관적 락의 핵심은 트랜잭션이 커밋될 때 버전 충돌을 감지하고, 충돌이 발생하면 트랜잭션을 롤백하는 것이다.

따라서 다른 스레드가 waiting_member를 먼저 저장하더라도, 트랜잭션이 롤백되므로 문제가 없다.

💡 참고

1. JPA가 제공하는 낙관적 락 기법은 최초 커밋만 인정하고, 이후의 요청들은 ObjectOptimisticLockingFailureException이 발생하기 때문에 재시도 로직(@Retryable or 파사트 패턴)이 필요하다.

2. @Retryable를 사용한 많은 예제에서 Exception types을 지정하는 부분에 value를 많이 작성하는 걸 보았는데, 스프링 프로젝트의 spring-retry 코드를 보니 이는 Deprecated처리되었고, retryFor로 대체되었다고 한다.

 

결과

우동 엔티티에 저장할 때 where version = ? 을 체크하는 것을 볼 수 있다.

 

장단점

 

장점

  • 락을 사용하지 않아 성능이 좋다.
  • 트랜잭션 충돌이 없을 경우 빠르게 진행된다.

단점

  • 개발자가 직접 재시도 로직을 관리해야 한다.
  • 충돌이 잦을 경우 재시도가 많이 발생하여 성능이 저하될 수 있다.
  • 다중 DB 환경에서 동시성 보장이 까다롭다.
    만약 같은 DB 클러스터(Master-Slave 구조)라면 낙관적 락이 정상적으로 동작하겠지만, 분산 DB 환경이라면 같은 레코드를 다른 DB 노드에서 수정하는 경우에 Version 값 충돌을 감지하지 못할 가능성이 있다.

 

3. 비관적 락(Pessimistic Lock)

비관적 락은 트랜잭션이 시작될 때 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 락을 거는 방식이다.
Exclusive Lock(배타적 락)을 사용하며, 다른 트랜잭션은 락이 해제되기 전까지 데이터를 가져갈 수 없다.

 

적용(service, repository)

public interface UdongRepository extends JpaRepository<Udong, Long>, UdongRepositoryCustom {
    @Lock(LockModeType.PESSIMISTIC_WRITE)  
    @Query("select u from Udong u where u.id = :udongId")  
    Udong findUdongByWithPessimisticLock(@Param("udongId") Long udongId);
}
public class UdongService {
    @Transactional  
    public WaitingMemberResponse requestParticipationWithPessimisticLock(Long udongId, Long memberId) {  
        Udong udong = udongRepository.findUdongByWithPessimisticLock(udongId);  
        validateParticipationRequest(memberId, udong);  

        WaitingMember waitingMember = waitingMemberRepository.save(WaitingMember.of(udong, memberId));  
        udong.increaseWaitingMemberCount();  
        return WaitingMemberResponse.of(waitingMember);  
    }
}

 

결과


SELECT ... FOR UPDATE 구문을 사용하여 데이터에 락을 설정하여 동시성을 제어하는 걸 볼 수 있다.

 

문제점

  • SELECT 시점에서 락이 걸리고, 해당 트랜잭션이 종료될 때까지 다른 트랜잭션이 해당 레코드를 읽거나 수정할 수 없기 때문에 대기 시간이 길어진다.
  • 데드락이 발생할 수 있다.
  • 낙관적 락과 같은 이유로 분산 DB 환경에서는 동시성 문제가 여전히 발생한다.

 

3. 분산 락(Distributed Lock)

앞서 살펴본 낙관적 락과 비관적 락은 데이터베이스 단위에서 동시성을 제어하는 방법이기 때문에, 분산 DB 환경에서는 동시성 문제가 해결되지 않을 가능성이 있다.

 

때문에 공통의 락 저장소가 필요하며 이때 Redis 같은 데이터 저장소를 활용하여 동시성을 제어할 수 있다.

 

대표적인 Spin Lock방식의 Lettuce, Pub-Sub 방식의 Redisson 2가지 방법이 있다.

1. Lettuce (Spin Lock 방식)

  • 락을 획득하려고 반복적으로 시도하는 방식이다.
  • 재시도 로직을 직접 구현해야 하며, 락을 해제하지 못할 경우 데드락이 발생할 위험이 있다.

 

2. Redisson (Pub-Sub 기반 방식)

  • 락 획득 여부를 Pub-Sub 방식으로 관리하여 락 대기 중인 트랜잭션의 부하를 줄일 수 있다.
  • Lettuce보다 안정적이므로 보통 분산 락에서는 Redisson을 선호한다.

 

현재 우동 참여 기능에 대한 트래픽이 많지 않다고 가정했기 때문에, 분산 락은 학습만 진행했고 실제 적용은 하지 않았다.

 

4. Kfaka 메시지 큐

앞서 살펴본 방법들은 각 환경에서 동시성을 보장할 수 있다.
하지만 선착순이라는 요구사항이 들어간다면? 위의 방식들은 모두 선착순을 보장하면서 동시성을 제어할 수 없다.

 

1. 낙관적 락

충돌 발생 시, 누가 먼저 처리될지는 트랜잭션 재시도 타이밍에 따라 달라지므로 요청 순서를 보장할 방법이 없다

 

2. 비관적 락

트랜잭션은 락을 획득하기 위해 DB 커넥션 풀에서 대기할 것이다. 만약 선착순 요청이 먼저 도착했더라도, 트랜잭션이 DB 커넥션을 확보하지 못하면 후순위 요청이 먼저 실행될 수 있다.
즉, 락을 기다리는 시간이 길어지면 후순위 요청이 먼저 커넥션을 확보하고 트랜잭션을 실행할 가능성이 생긴다.

 

또한 락을 대기하는 동안 일정 시간이 지나면 락 타임아웃(lock timeout)이 발생하여 트랜잭션이 실패할 수 있다.

즉, 락을 먼저 요청한 트랜잭션이 락을 획득하지 못한 채 실패하고 후순위 요청이 성공하는 경우가 발생할 수 있다.

= 락을 먼저 요청했다고 해서 반드시 먼저 처리되는 것이 아니다.

 

3. 분산 락

Redis 락은 보통 TTL(Time-To-Live, 만료 시간)을 설정하여 일정 시간이 지나면 자동으로 락이 해제되도록 한다.

하지만 락이 해제되었을 때, 누가 먼저 다시 락을 획득할지는 보장되지 않는다.


예를 들어 요청 A가 락을 획득하지 못하고 재시도 대기 중인데, 요청 B가 먼저 락을 시도해서 획득하면 FIFO 순서가 깨지게 된다.
(하지만 타임아웃 관련 설정을 해서 선착순을 보장하는 방법이 있다고도 한다.)

 

그래서 "선착순"을 보장하면서 동시성을 제어하려면 Kafka와 같은 메시지 큐(FIFO Queue)를 적용할 수 있다.
즉, 요청이 들어온 순서대로 하나의 소비자(Consumer)가 메시지를 처리하면, 선착순을 보장할 수 있다.

 

 

성능 비교 테스트&모니터링

낙관적 락과 비관적 락 둘 다 동시성 제어는 됐지만, 어떤 방법이 더 적절한지 비교하기 위해 성능 테스트를 수행했다.
이를 위해 도커 컨테이너에 프로젝트를 올리고, K6으로 부하를 주어 성능을 비교해 보았다.

import http from "k6/http";
import { check } from "k6";

export const options = {
	vus: 1000, // 1000명의 동시 사용자
	iterations: 1000, // 각 VU당 1회 요청
	duration: "2m", // 테스트 지속 시간
	};
	
	const udongId = 1; // 참여할 우동 ID
	
	export default function () {
	const memberId = 5 + (__VU - 1);
	
	const url = `http://localhost:8080/api/udongs/${udongId}/participate/${memberId}`;
	
	const response = http.post(url);
	
	check(response, {
	"status is 200 or 400": (r) => r.status === 200 || r.status === 400,
	"response time < 2000ms": (r) => r.timings.duration < 2000,
	});
}

k6 스크립트

1. 낙관적 락 - 100명

k6 로그&분석

 

지표 값
총 요청 수 100회
성공 수/요청 수 1/100
평균 응답 시간 470.84ms
최단 응답 시간 22.43ms
최장 응답 시간 684.46ms
실패율 99% (99/100)
대기 시간 평균 468.36ms

 

2. 낙관적 락 -1000명

k6 로그&분석

 

지표 값
총 요청 수 1000회
성공 수/요청 수 1/1000
평균 응답 시간 2310ms
최단 응답 시간 29.45ms
최장 응답 시간 4650ms
실패율 99.9% (999/1000)
대기 시간 평균 2310ms

 

요청이 많아지자 충돌+재시도 포함 시간 때문에 평균 응답 시간, 최장 응답 시간, 대기 시간이 꽤 길어졌다.

 

spring 모니터링

db 모니터링

 

 

3. 비관적 락 - 100명

k6 로그&분석

지표 값
총 요청 수 100회
성공 수/요청 수 1/100
평균 응답 시간 687ms
최단 응답 시간 130ms
최장 응답 시간 1030ms
실패율 99% (99/100)
대기 시간 평균 677ms

낙관적 락과 비교하면 평균 응답 시간, 최장 응답 시간은 더 느려졌지만 크~게 차이는 없다.

하지만 최단 응답 시간이 29.45ms -> 130ms로 4배 이상 느려졌다. 😯

 

4. 비관적 락 - 1000명

k6 로그&분석

지표 값
총 요청 수 1000회
성공 수/요청 수 1/1000
평균 응답 시간 3100ms
최단 응답 시간 79ms
최장 응답 시간 5370ms
실패율 99.9% (999/1000)
대기 시간 평균 3090ms

충돌이 많은 상황에서 낙관적 락보다 빠른 응답 시간을 기대했는데, 모든 수치에서 응답 시간이 길어졌다.

 

spring 모니터링

 

db 모니터링

 

결과

테스트 구분 유저 수 성공 수/요청 수 최단 응답 시간(ms) 최장 응답 시간(ms) 평균 응답 시간(ms)
낙관적 락 / 적은 충돌 100 1/100 22 684 471
낙관적 락 / 많은 충돌 1000 1/1000 29 4650 2310
비관적 락 / 적은 충돌 100 1/100 130 1030 687
비관적 락 / 많은 충돌 1000 1/1000 61 7920 4930

보다시피 적은 충돌, 많은 충돌 2가지 다 낙관적 락에서 응답 시간이 빠른 것을 볼 수 있다.

 

그리고 흥미로운 점이 2가지 있었다. 🧐

  1. 많은 충돌에서도 낙관적 락이 성능이 높은 점
  2. 비관적 락에서 유저 수가 더 적음에도 최단 응답 시간이 2배 이상 긴 점

 

1. 많은 충돌에서도 낙관적 락이 성능이 높은 이유

이번에 동시성 관련해서 공부하면서 많은 충돌에서 낙관적 락보다 대체로 응답 시간이 빠르다고 봤다.
왜냐면 일반적으로 충돌이 잦으면 낙관적 락은 재시도 빈도가 높아지고, 심지어는 한 트랜잭션이 완료될 때까지 다른 트랜잭션이 실패하고 재시도하는 악순환이 발생하기 때문이다.

 

근데 난 요청이 잦아도 낙관적 락의 응답 시간이 빨랐다.

public class UdongService {
    @Transactional  
    public WaitingMemberResponse requestParticipationWithPessimisticLock(Long udongId, Long memberId) {  
        Udong udong = udongRepository.findUdongByWithPessimisticLock(udongId);  
        validateParticipationRequest(memberId, udong);  

        WaitingMember waitingMember = waitingMemberRepository.save(WaitingMember.of(udong, memberId));  
        udong.increaseWaitingMemberCount();  
        return WaitingMemberResponse.of(waitingMember);  
    }
}

이유를 추측해 보자면 내 로직은 아래 순서로 동작한다.

  1. 우동 데이터 조회 (비관적 락 걸림)
  2. 참여 요청 검증
  3. 대기열에 사용자 추가
  4. 응답 객체 생성

즉, 나의 경우에는 "트랜잭션이 시작하자마자 락이 걸리기 때문에 남아있는 로직들이 많으니까 처리 시간이 긴 것 아닐까?"라고 추측했다. 🤔
만약에 3번부터 락이 걸린다면(예시로) 1, 2번 로직은 트랜잭션들이 동시에 실행하기 때문에 남아있는 로직은 4번밖에 없으니 응답 시간이 줄어들 것이다.

 

2. 비관적 락에서 적은 충돌이 많은 충돌보다 최단 응답 시간이 2배 이상 긴 점

적은 충돌일 때는, 100명 중 1명만 성공하는 구조면, 대부분 락을 건드리지 않고 그냥 성공하거나 대기자 초과로 바로 컷된다.
즉, 최단 응답 시간은 DB 락 대기 없이 "비즈니스 로직 수행 시간 + DB 조회 시간"으로 결정되는 것이다.

 

많은 충돌일 때는 1000명이 동시 참여 요청을 보내면, 같은 우동 엔티티에 대해 1000개 트랜잭션이 한 번에 몰린다.
DB는 하나의 트랜잭션만 락을 획득하고, 나머지 999개는 바로 락 대기 상태로 진입한다.
이때 DB 커넥션 풀이 빠르게 소진되고, 일부 요청은 즉시 타임아웃되면서 최단 응답 시간이 짧아진 게 아닐까?라고 추측해 보았다. 🤔

 

 

어떻게 적용했나?

결과적으로 낙관적 락과 비관적 락 중에 어떤 것을 적용할지에 대한 고민이 많았다.
단순히 성능 수치만 보면 낙관적 락이 적합해 보였다.
우동 참여 요청 기능이 선착순을 보장해야 하는 것도 아니었고, 충돌이 자주 발생하는 기능도 아니었기 때문이다.

 

근데 계~속 찜찜한 부분이 있었다.

 

1. 낙관적 락 적용을 위한 구조 변경

내가 공부했던 낙관적 락의 동작 방식은 아래와 같다.

  1. 데이터에 version 필드를 추가
  2. 데이터를 SELECT한 후, 트랜잭션이 종료될 때 version을 비교하여 업데이트
  3. version이 변경되지 않았을 때만 UPDATE를 수행하고, 충돌이 발생하면 재시도

 

하지만 내 기존 구조는 낙관적 락을 적용하기에 적합하지 않았다.

  1. 멤버가 우동에 참여 요청을 보냄
  2. waiting_member 테이블에서 udong_id 기준으로 COUNT(*)를 가져옴
  3. 대기자 수가 5명을 초과하지 않으면 waiting_member에 새로운 멤버를 INSERT함

즉, "우동(Udong)" 엔티티 자체가 아니라, waiting_member 테이블을 기반으로 동작하는 구조였던 것이다.


낙관적 락을 적용하려면 @Version 필드를 둬야 하는데, "충돌을 감지할 대상 엔티티"가 존재하지 않았다.


waiting_member 테이블에는 @Version을 추가할 엔티티가 없었기 때문에, 우동 엔티티에 대기자 수 컬럼(currentWaitingMemberCount)을 추가하는 방식으로 변경해야 했다.

 

2. 대기자 수 컬럼을 추가하는 것이 맞을까?

처음에는 waiting_member의 개수를 조회(select count~)해서 처리하는 방식이었는데, 낙관적 락을 적용하기 위해 대기자 수 컬럼을 강제로 추가한 느낌이 들었다.

컬럼 설명
udong_id 우동 ID
waiting_count 현재 대기자 수

 

그렇다고 이렇게 우동에 대한 대기자 수를 따로 관리하는 테이블을 생성하자니 좀 오바같았다.

 

사실 currentWaitingMemberCount = WaitingMember.size()이기 때문에 불필요한 관리 포인트가 늘어난 것이다.

대기자가 등록될 때마다 Udong 엔티티를 업데이트해야 하는 부담이 생기기 때문이다.

 

그래서 비즈니스 상황을 고려했을 때

  1. 우동이 엄청 많은가?
  2. 우동에 대기자가 많이 발생하는가?

를 고민해 봤는데 둘 다 그렇게 많지 않을 가능성이 높았기 때문에 "굳이 카운팅 컬럼 추가하는것 보다는 카운팅 컬럼 없이 비관적 락을 쓰는 것이 더 합리적일 수 있지 않을까?"라는 생각 들었다.

 

3. 카운팅 컬럼의 장점

계속 고민하다가 문득 카카오 오픈채팅의 "하트(좋아요) 기능"에 비정규화에 대한 사례가 떠올랐다.

컬럼 설명
member_id 멤버 ID
chat_id 채팅방 ID

(간략한 예시)

 

원래는 좋아요 데이터를 이렇게 로그식 테이블로 관리했었는데, 오픈채팅방 목록을 조회할 때마다 COUNT를 구하기 위해 이 테이블을 매번 찔러야 하다 보니 성능 문제가 발생해서, 아예 "좋아요 개수를 채팅방 테이블에 직접 저장"하는 방식으로 변경해서 성능을 최적화했다고 들었다.

 

이런 관점에서 우동이 엄~청 많아지고, 우동 목록에서 대기자 수가 자주 필요해진다면?
차라리 currentWaitingMemberCount 컬럼을 유지하는 것이 더 합리적인 선택이 될 수도 있다고 생각했다.

 

4. 낙관적 락 vs 비관적 락 최종 선택

결과적으로 두 가지 옵션을 비교했다.

 

낙관적 락

  • 성능상 비관적 락보다 응답 시간이 빠름
  • 대기자 추가/삭제할 때 currentWaitingMemberCount 업데이트 비용 발생
  • waiting_member.size()를 매번 COUNT(*)로 계산하는 것보다 성능상 이점이 있을 가능성이 있음

 

비관적 락

  • 충돌이 적은 환경에서는 오버헤드가 있을 수 있음
  • 대기자 수 컬럼 없이 waiting_member 테이블을 그대로 조회하는 방식

 

결론적으로? 낙관적 락을 선택했다.
대기자 추가/삭제 시 currentWaitingMemberCount를 업데이트하는 오버헤드를 감수하기로 했다.
"대기자 수가 필요해지는 상황"을 대비하면, 비정규화를 통한 성능 최적화가 더 합리적일 수 있다고 판단했기 때문이다.

 

5. 재시도 간격(backoff) 결정

낙관적 락을 적용하면서 재시도 간격을 몇 ms로 설정할지 테스트를 진행했다.

재시도 간격 유저 수 최단 응답 시간(ms) 최장 응답 시간(ms) 평균 응답 시간(ms) 총 처리 시간(ms)
50ms 100 291 1370 921 약 92,100
100ms 100 1170 2470 1930 약 193,000
1000ms 100 1020 2300 1750 약 175,000

 

재시도 간격이 짧을수록 응답 속도가 빨라지고, 총 처리 시간이 줄어든다.

하지만 너무 짧으면 CPU와 DB 부하가 증가할 가능성이 있다.


그래서 성능 비교를 진행했고, 재시도 간격을 `50ms`로 설정했다.

@Retryable(
    retryFor = ObjectOptimisticLockingFailureException.class,
    maxAttempts = 5,
    backoff = @Backoff(delay = 50) // 재시도 간격 50ms로 설정
)

 

 

이번 동시성 관련 이슈를 해결하면서 다양한 방법들이 있지만 서버와 DB 등 인프라 환경과 선착순을 보장하는지도 고려해야 한다는 것을 깨달았다... 😱 (관련 PR은 여기서 확인할 수 있습니다.)

 

 

 

 

 

참고
동시성 문제 해결하기 V1 - 낙관적 락(Optimistic Lock) feat.데드락 첫 만남
동시성 문제 해결하기 V2 - 비관적 락(Pessimistic Lock)
Java에서 동시성 문제를 해결하는 다양한 기법과 성능 평가
선착순 쿠폰 동시성 문제 해결하기 (메세지큐 적용)
동시성 제어하기
PostgreSQL 트랜잭션 격리 수준 (Transaction isolation level)
동시성 처리 (낙관적 락, 비관적 락)
낙관적 락 동시성 제어 이슈와 해결 과정
콘서트 예약 서비스에서 Lock 성능 비교해보기 (feat. 낙관적 락, 비관적 락, 분산 락)
spring-retry-Retryable.java

 

저작자표시 비영리 (새창열림)

'💻 Dev > Spring' 카테고리의 다른 글

스프링MVC는 왜 스레드를 최대 200개까지 사용할까?  (0) 2024.12.28
[Spring Boot] gradle 프로젝트 불러오기  (0) 2020.09.18
[Spring] 1.스프링 프레임워크란?, IoC(스프링 컨테이너)  (0) 2020.09.02
'💻 Dev/Spring' 카테고리의 다른 글
  • 스프링MVC는 왜 스레드를 최대 200개까지 사용할까?
  • [Spring Boot] gradle 프로젝트 불러오기
  • [Spring] 1.스프링 프레임워크란?, IoC(스프링 컨테이너)
현주먹
현주먹
끄적끄적 개발.log
  • 현주먹
    현주먹의 개발로그
    현주먹
  • 전체
    오늘
    어제
    • 전체글 (162)
      • 👶🏻 CS (15)
        • Operating System (8)
        • Database (4)
        • Data Structure (2)
        • Software Engineering (1)
      • 💻 Dev (54)
        • Java & OOP (24)
        • Spring (4)
        • JPA (5)
        • Test Code (1)
        • Database (1)
        • JSP & Servlet (13)
        • Etc (6)
      • 💡 Algorithm (25)
        • 인프런 (9)
        • 백준 (16)
      • 🛠 DevOps & Tool (11)
        • Linux (4)
        • AWS (1)
        • Git (2)
        • Etc (4)
      • 📝 끄적끄적 (57)
        • 후기 및 회고 (5)
        • TDD, 클린 코드 with Java 17기 (3)
        • F-Lab (23)
        • 🖥️ 자바의 정석 (11)
        • 📖 Clean Code (3)
        • 항해99 코테 스터디 (11)
  • 블로그 메뉴

    • 🐈‍⬛ GitHub
    • TIL
  • 인기 글

  • 태그

    개발자취업
    f-lab 후기
    에프랩
    로또 미션
    99클럽
    F-Lab
    에프랩 후기
    인프런 단어뒤집기
    NextSTEP
    자바의정석
    오블완
    ==와 equals()
    JPA
    C
    til
    개발자멘토링
    코딩테스트준비
    TDD 클린 코드 with Java
    jsp
    자바의신절판
    티스토리챌린지
    백준
    데브클럽
    오라클
    코테스터디
    인프런 특정문자뒤집기
    백준10250
    PostGreSQL함수
    객체지향
    항해99
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
현주먹
여행 동행 참여에 대해 동시성 문제 해결하고 성능 비교 해보기
상단으로

티스토리툴바