개요
현재 여행 동행 모집 프로젝트를 진행하는 중에, 동행 참여에 대한 동시성 이슈가 발생했다.
1. 동시성 문제 발생 원인
여행 동행 모임 참여 로직은 다음과 같다.
- 멤버가 우동(동행 모임)에 대해 참여 요청을 보낸다.
- 참여할 수 있는지 유효성 검증을 한다. (인원 체크, 기존 참여 여부 확인 등)
- 우동에 대기자로 등록한다.
- 관리자가 승인하면 동행에 최종 참여된다.
한 우동에 대해 대기자는 최대 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가지 있었다. 🧐
- 많은 충돌에서도 낙관적 락이 성능이 높은 점
- 비관적 락에서 유저 수가 더 적음에도 최단 응답 시간이 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);
}
}
이유를 추측해 보자면 내 로직은 아래 순서로 동작한다.
- 우동 데이터 조회 (비관적 락 걸림)
- 참여 요청 검증
- 대기열에 사용자 추가
- 응답 객체 생성
즉, 나의 경우에는 "트랜잭션이 시작하자마자 락이 걸리기 때문에 남아있는 로직들이 많으니까 처리 시간이 긴 것 아닐까?"라고 추측했다. 🤔
만약에 3번부터 락이 걸린다면(예시로) 1, 2번 로직은 트랜잭션들이 동시에 실행하기 때문에 남아있는 로직은 4번밖에 없으니 응답 시간이 줄어들 것이다.
2. 비관적 락에서 적은 충돌이 많은 충돌보다 최단 응답 시간이 2배 이상 긴 점
적은 충돌일 때는, 100명 중 1명만 성공하는 구조면, 대부분 락을 건드리지 않고 그냥 성공하거나 대기자 초과로 바로 컷된다.
즉, 최단 응답 시간은 DB 락 대기 없이 "비즈니스 로직 수행 시간 + DB 조회 시간"으로 결정되는 것이다.
많은 충돌일 때는 1000명이 동시 참여 요청을 보내면, 같은 우동 엔티티에 대해 1000개 트랜잭션이 한 번에 몰린다.
DB는 하나의 트랜잭션만 락을 획득하고, 나머지 999개는 바로 락 대기 상태로 진입한다.
이때 DB 커넥션 풀이 빠르게 소진되고, 일부 요청은 즉시 타임아웃되면서 최단 응답 시간이 짧아진 게 아닐까?라고 추측해 보았다. 🤔
어떻게 적용했나?
결과적으로 낙관적 락과 비관적 락 중에 어떤 것을 적용할지에 대한 고민이 많았다.
단순히 성능 수치만 보면 낙관적 락이 적합해 보였다.
우동 참여 요청 기능이 선착순을 보장해야 하는 것도 아니었고, 충돌이 자주 발생하는 기능도 아니었기 때문이다.
근데 계~속 찜찜한 부분이 있었다.
1. 낙관적 락 적용을 위한 구조 변경
내가 공부했던 낙관적 락의 동작 방식은 아래와 같다.
- 데이터에
version
필드를 추가 - 데이터를
SELECT
한 후, 트랜잭션이 종료될 때version
을 비교하여 업데이트 version
이 변경되지 않았을 때만UPDATE
를 수행하고, 충돌이 발생하면 재시도
하지만 내 기존 구조는 낙관적 락을 적용하기에 적합하지 않았다.
- 멤버가 우동에 참여 요청을 보냄
waiting_member
테이블에서udong_id
기준으로COUNT(*)
를 가져옴- 대기자 수가 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 엔티티를 업데이트해야 하는 부담이 생기기 때문이다.
그래서 비즈니스 상황을 고려했을 때
- 우동이 엄청 많은가?
- 우동에 대기자가 많이 발생하는가?
를 고민해 봤는데 둘 다 그렇게 많지 않을 가능성이 높았기 때문에 "굳이 카운팅 컬럼 추가하는것 보다는 카운팅 컬럼 없이 비관적 락을 쓰는 것이 더 합리적일 수 있지 않을까?"라는 생각 들었다.
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 |