
들어가며
콘서트 예약 서비스
와 같은 애플리케이션에서는 동시성 처리를 고려해야 합니다. 좌석 예매 오픈 시 여러 사용자가 동시에 예약을 시도하면, 자원이 안전하게 관리되지 않을 경우 예약이 초과되거나 데이터 일관성이 깨질 위험이 있습니다. 이번 시간에 코드 적용 및 테스트를 통해 각 동시성 처리 방식의 장단점을 검토하고, 최종적으로 비즈니스 로직에 적합한 동시성 처리 방식을 선정해보겠습니다.
동시성 이슈가 발생할 수 있는 비즈니스 로직
이 서비스에서 동시성 이슈가 발생할 수 있는 주요 로직은 아래와 같습니다.
- 좌석 예약
- 락을 걸어야 하는 자원
seat
테이블의status
concert_schedule
테이블의available_seats
- 락을 걸어야 하는 자원
- 포인트 충전 및 결제
- 락을 걸어야 하는 자원
user
테이블의balance
reservation
테이블의status
- 락을 걸어야 하는 자원
동시성 제어 방식
1. Synchronized
설명
synchronized
는 Java/Kotlin에서 특정 메서드나 블록에 대해 단일 스레드만 접근할 수 있도록 보장하는 키워드입니다. 주로 멀티스레드 환경에서 공유 자원을 보호하기 위해 사용됩니다.
예제 코드
@Component
class ConcertFacade(
/* ... */
) {
@Synchronized // 메서드에 synchronized 선언
fun createReservation(command: ReservationCommand): CreateReservationInfo {
waitingQueueService.verifyMatchingScheduleId(command.token, command.scheduleId)
userService.checkUserExists(command.userId)
concertService.checkScheduleAvailability(command.scheduleId)
return reservationService.createPendingReservation(command.userId, command.scheduleId, command.seatId)
}
}
장단점
- 장점: 구현이 매우 간단하고 사용하기 쉽습니다. JVM 레벨에서 동작하므로 외부 시스템이 필요 없습니다.
- 단점: 멀티 인스턴스 환경에서는 인스턴스 간의 동기화가 보장되지 않아 분산 환경에서는 적합하지 않습니다.
적용하지 않은 이유
멀티 인스턴스를 사용하는 시스템이므로, 인스턴스 간에 공유 자원을 안전하게 보호하지 못하기 때문에 해당 방법은 적용하지 않았습니다.
2. DB 락 - 낙관적 락 (Optimistic Lock)
설명
데이터 변경 시 충돌을 탐지하는 방식으로, 일반적으로 '버전 번호'를 사용하여 충돌 여부를 판단합니다. @Version
어노테이션을 통해 엔티티의 버전을 관리하며, 업데이트 시 충돌을 감지할 수 있습니다. 비즈니스 관점에서는 실패해도 되는 로직이라 판단될 때 사용합니다.
예제 코드
@Entity
class Seat(
val scheduleId: Long,
seatNumber: Int,
price: BigDecimal,
status: SeatStatus,
@Version
val version: Long = 0L, // 버전 번호 관리
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0L,
) : BaseEntity() { ... }
@Component
class ConcertFacade(
/* ... */
) {
fun createReservation(command: ReservationCommand): CreateReservationInfo {
waitingQueueService.verifyMatchingScheduleId(command.token, command.scheduleId)
userService.checkUserExists(command.userId)
concertService.checkScheduleAvailability(command.scheduleId)
// retry 로직을 넣는다.
while (/*...*/) {
try {
return reservationService.createPendingReservation(command.userId, command.scheduleId, command.seatId)
} catch (ex: ObjectOptimisticLockingFailureException) {
} catch (ex: Exception) {
}
}
}
}
장단점
- 장점: 락을 걸지 않기 때문에 대부분의 경우 성능이 뛰어납니다.
- 단점: 충돌이 빈번한 경우 반복적인 재시도가 발생하여 성능이 저하될 수 있습니다.
기존 코드에 적용된 부분
user
테이블의 balance
필드에 낙관적 락을 적용하여 포인트 충전 및 결제 시 동시성 문제를 방지하고 있습니다.
3. DB 락 - 비관적 락 (Pessimistic Lock)
설명
데이터를 조회할 때 락을 걸어 다른 트랜잭션이 해당 데이터를 수정하지 못하도록 하는 방식입니다. SELECT ... FOR UPDATE
구문을 사용하여 데이터에 락을 설정하며, 주로 자원에 대해 충돌이 빈번하게 발생하거나, 비즈니스 관점에서 반드시 성공해야 하는 로직이라 판단될 때 사용합니다.
예제 코드
interface SeatJpaRepository : JpaRepository<Seat, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 락
@Query("select s from Seat s where s.id = :id")
fun findByIdOrNullWithLock(id: Long): Seat?
}
@Service
class ReservationService(
/* ... */
) {
@Transactional
fun createPendingReservation(
userId: Long,
scheduleId: Long,
seatId: Long,
): CreateReservationInfo {
seatFinder.getAvailableSeatWithLock(scheduleId, seatId).reserve() // 비관적 락 적용
occupySeat(scheduleId)
val reservation = concertManager.createPendingReservation(userId, scheduleId, seatId)
return reservation.toCreateReservationInfo(success = true)
}
}
장단점
- 장점: 데이터 충돌이 많을 때 안전하게 일관성을 유지할 수 있습니다.
- 단점: 데드락 발생 가능성이 있으며, 락을 걸기 때문에 성능이 저하될 수 있습니다.
기존 코드에 적용된 부분
seat
테이블의 status
와 reservation
테이블의 status
필드에 비관적 락을 적용하여 좌석 예약과 결제 시 다른 트랜잭션이 접근하지 못하도록 해놓았습니다.
4. 분산 락 (Distributed Lock)
설명
여러 인스턴스에서 자원에 동시에 접근하지 못하도록 하여 분산 환경에서 동시성 문제를 해결합니다. Redis 같은 데이터 저장소를 활용하여 락을 설정합니다.
예제
@Component
class ConcertFacade(
private val redissonClient: RedissonClient,
/* ... */
) {
fun createReservation(command: ReservationCommand): CreateReservationInfo {
waitingQueueService.verifyMatchingScheduleId(command.token, command.scheduleId)
userService.checkUserExists(command.userId)
concertService.checkScheduleAvailability(command.scheduleId)
// redisson 분산 락 적용
val lock = redissonClient.getLock("seat:${command.seatId}") // seatId를 key로 적용
val isLocked = lock.tryLock(3, 10, TimeUnit.SECONDS)
if (!isLocked) {
throw CoreException(ErrorType.LOCK_ACQUISITION_FAILED)
}
try {
// 트랜잭션
return reservationService.createPendingReservation(command.userId, command.scheduleId, command.seatId)
} finally {
if (isLocked) {
lock.unlock()
}
}
}
}
장단점
- 장점: 분산 환경에서도 일관성을 유지할 수 있으며, 즉각적인 반영이 가능합니다.
- 단점: Redis와 같은 외부 시스템에 의존하므로 설정 및 관리가 다소 복잡하며 네트워크 지연이 발생할 수 있습니다.
5. Kafka 이벤트 처리
설명
데이터 변경 작업을 이벤트로 만들어 비동기적으로 처리하는 방식입니다. Kafka를 사용하여 이벤트를 전달하고, 이를 소비하여 동시성을 제어합니다.
장단점
- 장점: 비동기적으로 처리하므로 확장성이 뛰어나며, 대량의 요청을 처리할 수 있습니다.
- 단점: 실시간 반영이 어려우며, 예약 상태의 일관성을 유지하는 데 어려움이 있을 수 있습니다.
적용하지 않은 이유
위의 단점처럼 콘서트 예약의 비즈니스 로직의 관점에서는 즉각적인 반영이 필요하기 때문에 적용하지 않았습니다. 다만 추후에 concert_schedule
테이블의 available_seats
필드에 대해서는 사용자의 조회용 데이터 성격에 가까우므로 Eventual Consistency
를 적용하여 락에 의한 처리 부하를 줄일 계획입니다.
동시성 제어 방식에 따른 성능과 장단점을 다음 표로 정리해보았습니다.
동시성제어 방식 | 구현 복잡도 | 성능 | 효율성 | 분산 환경 지원 | 적용 가능 여부 |
Synchronized | 낮음 | 중간 | 낮음 | 불가능 | 적합하지 않음 |
DB 락 (낙관적 락) | 중간 | 높음 | 중간 | 가능 | 가능 |
DB 락 (비관적 락) | 중간 | 중간 | 높음 | 가능 | 가능 |
분산 락 (Redisson) | 중간 | 중간 | 높음 | 가능 | 가능 |
Kafka 이벤트 처리 | 높음 | 중간~높음 | 중간~높음 | 가능 | 최종 일관성에 적합 |
동시성 제어 방식의 성능 테스트 및 결과
위 동시성 제어 방식 중 DB 락(낙관적 락, 비관적 락)과 분산 락에 대해서 성능 테스트를 시도해보았습니다.
두 종류의 테스트를 준비했는데, 첫 번째는 스프링 부트 JUnit5 프레임워크를 사용한 동시성 통합 테스트로, 여러 유저가 동시에 좌석을 예약하는 시나리오에서 응답 시간을 측정했습니다. 두 번째는 Grafana k6를 사용한 동시 부하 테스트로, 해당 결과를 InfluxDB에 넣고, Grafana로 시각화했습니다.
테스트는 동일한 비즈니스 로직(좌석 예약)에서의 동시성 제어 방식만 바꿔 진행했습니다.
공통 테스트 환경
- Spring Boot는 IntelliJ를 통해 기동
- H2 Database, Redis는 네트워크를 사용하도록 독립된 Docker 컨테이너로 실행
1. JUnit5 통합 테스트
- 유저 1,000 or 5,000명이 동시에 한 좌석을 예약하는 시나리오
- 스레드풀 크기는 100으로 설정
- 각 동시성 제어 방식의 응답 속도를 비교하고, 낙관적 락의 경우 재시도 횟수에 따른 성능 차이를 측정
테스트 코드
@Test
fun `should reserve seat for only one user when multiple requests are made concurrently`() {
// Given
val seatId = 1L
val scheduleId = 1L
val userIds = (1L..1000L).toList()
/* ... */
// When
val tasks =
userIds.map { userId ->
Callable {
startLatch.await()
val startTime = System.nanoTime()
try {
concertFacade.createReservation(
ReservationCommand(
userId = userId,
scheduleId = scheduleId,
seatId = seatId,
token = "123e4567-e89b-12d3-a456-426614174000",
),
)
successCount.incrementAndGet()
} catch (e: CoreException) {
failureCount.incrementAndGet()
} catch (e: ObjectOptimisticLockingFailureException) {
failureCount.incrementAndGet()
} catch (e: Exception) {
failureCount.incrementAndGet()
} finally {
val endTime = System.nanoTime()
responseTimes.add(endTime - startTime)
endLatch.countDown()
}
}
}
tasks.forEach { executor.submit(it) }
startLatch.countDown()
endLatch.await()
executor.shutdown()
// Then
assertEquals(1, successCount.get())
assertEquals(userIds.size - 1, failureCount.get())
// 응답 시간
val responseTimeList = responseTimes.toList()
val fastestResponse = responseTimeList.minOrNull()?.let { it / 1_000_000 } ?: 0
val slowestResponse = responseTimeList.maxOrNull()?.let { it / 1_000_000 } ?: 0
val averageResponse = if (responseTimeList.isNotEmpty()) responseTimeList.average() / 1_000_000 else 0.0
println("최단 응답 시간: ${fastestResponse}ms")
println("최장 응답 시간: ${slowestResponse}ms")
println("평균 응답 시간: ${averageResponse}ms")
}
테스트 결과
1. 낙관적 락 (Optimistic Lock)
유저 수 | 재시도 횟수 | 최단 응답 시간 | 최장 응답 시간 | 평균 응답 시간 |
1,000명 | 없음 | 32ms | 1766ms | 425.774687ms |
1,000명 | 1회 | 35ms | 1541ms | 473.508084ms |
1,000명 | 2회 | 49ms | 1468ms | 441.298920ms |
5,000명 | 없음 | 22ms | 1859ms | 358.117976ms |
5,000명 | 1회 | 26ms | 1102ms | 320.788765ms |
5,000명 | 2회 | 20ms | 1486ms | 350.476428ms |
2. 비관적 락 (Pessimistic Lock)
유저 수 | 최단 응답 시간 | 최장 응답 시간 | 평균 응답 시간 |
1,000명 | 66ms | 1511ms | 511.275845ms |
5,000명 | 43ms | 2359ms | 643.524741ms |
3. 분산 락 (Distributed Lock with Redisson)
유저 수 | 최단 응답 시간 | 최장 응답 시간 | 평균 응답 시간 |
1,000명 | 22ms | 3768ms | 1141.718405ms |
5,000명 | 17ms | 4044ms | 854.586101ms |
테스트 결과 분석
- 낙관적 락과 비관적 락 비교
- 유저 1,000명 기준에서는 낙관적 락과 비관적 락의 성능 차이가 크지 않았습니다.
- 유저 수를 5,000명으로 늘리고 나니 비관적 락의 최장 응답 시간이 크게 늘어났습니다.
- 비관적 락이 자원에 대해 대기 상태의 스레드를 오래 잡아두는 것이 원인으로 보입니다.
- 분산 락의 최장 응답 시간 증가
- 분산 락의 경우, Redisson을 사용해 동시성을 제어했습니다. 하지만 분산 락은 네트워크 지연 및 Redis의 락 획득/해제 시간까지 포함되므로 락을 선점하지 못한 스레드의 대기 시간이 길어지는 경향이 나타났습니다.
2. K6 동시 부하 테스트
테스트 시나리오
- 사용자 100명이 각자 10번씩 반복하여 좌석 예약을 시도
- 시나리오의 최대 실행 시간은 30초로 설정
- 1,000개의 요청 중 1개만 성공함을 체크
import http from 'k6/http';
import { check, sleep } from 'k6';
import { SharedArray } from 'k6/data';
const BASE_URL = 'http://192.168.0.57:8080/api/concerts/reservations';
const QUEUE_TOKEN = '25f8bba1-262f-476e-8fd8-a15e0ef6b3f6';
// userId 생성 (1부터 100까지의 ID 배열)
const userIds = new SharedArray('userIds', function () {
return Array.from({ length: 100 }, (_, i) => i + 1);
});
let userIndex = 0;
export const options = {
scenarios: {
reservation_scenario: {
executor: "per-vu-iterations",
vus: 100,
iterations: 10,
maxDuration: "30s",
},
},
};
export default function () {
const userId = userIds[userIndex % userIds.length];
userIndex++;
const payload = JSON.stringify({
userId: userId,
concertId: 1,
scheduleId: 1,
seatId: 1,
});
const headers = {
'Content-Type': 'application/json',
'Queue-Token': QUEUE_TOKEN,
};
// 예약 요청
const res = http.post(BASE_URL, payload, { headers: headers });
check(res, {
'reservation success': (r) => r.json().status == 'success',
});
}
테스트 결과
각 테스트마다의 cli 결과화면과 동일 데이터를 좀 더 보기쉽도록 grafana로 시각화한 그래프를 캡쳐했습니다.
1. 낙관적 락 (Optimistic Lock)


2. 비관적 락 (Pessimistic Lock)


3. 분산 락 (Distributed Lock with Redisson)


테스트 결과
- 모든 락에서, 1,000개의 요청 중 1개만 성공하고 999개가 실패하는 것은 비즈니스 로직에서 정상적인 결과입니다. 한 좌석에 대해 여러 사용자가 동시에 접근하는 경우, 한 명만 성공하고 나머지는 모두 실패하도록 설계해놓은대로 동작함을 확인할 수 있었습니다.
- 낙관적 락의 평균 응답 시간은 약 31.04ms로 나타났으며, 최장 응답 시간은 189.97ms로 측정되었습니다. 낙관적 락이 재시도 없이 빠르게 실패를 처리하고, 성공적인 요청에 대해서만 자원을 업데이트하기 때문에 3개의 동시성 처리 중 가장 빠른 평균 응답 시간을 유지할 수 있었습니다.
최종 적용 방식
1. 좌석 예약 - 낙관적 락 (Optimistic Lock)
선정 이유
콘서트 예약의 경우, 이미 예약된 좌석을 다시 예약하려는 시도는 드물기 때문에 추후에 경합이 줄어들게 됩니다. 동시 경합 시에는 '이미 선점된 좌석'에 대해 예약 실패한 사용자에게 빠른 피드백을 제공하여 즉시 다른 좌석을 예약하도록 유도하는 것이 비즈니스적으로 중요하다고 판단됩니다. 경합이 대개 초기에 집중되며, 이후에는 자원의 상태가 안정화되기 때문에 낙관적 락이 최적의 선택이라고 생각되었습니다.
2. 포인트 충전 및 결제 - 낙관적 락 (Optimistic Lock)
선정 이유
포인트는 사용자에게 민감한 정보이므로, 충돌 발생 시 성공 또는 실패 여부를 사용자에게 빠르게 알리는 것이 중요합니다. 낙관적 락을 사용하여 빠르게 피드백을 제공함으로써 사용자 경험을 개선할 수 있으며, 예외 처리를 통해 일관성을 유지할 수 있으므로 낙관적 락이 적합하다고 생각했습니다.
마치며
좌석 예약과 포인트 결제는 실시간적인 데이터 일관성이 중요한 비즈니스 로직입니다. 이번 시간을 통해 다양한 동시성 제어 방식을 검토하고, 최종적으로 콘서트 예약 서비스에는 낙관적 락을 비즈니스 상황에 맞는 동시성 처리 방식으로 선정했습니다. 앞으로도 시스템, 비즈니스 로직에 맞는 기술을 선정하는데에 있어 지금과 같은 많은 고민이 필요해 보입니다.
'💻 개발 > 🎸 ETC' 카테고리의 다른 글
부하 테스트: 유저 행동 기반 시나리오로 시스템 한계 측정과 SLA, SLO 검증 (1) | 2024.11.29 |
---|---|
E-Book을 PDF로 추출하는 프로그램 만들기 (23.09.06. 실행파일 추가) (51) | 2023.01.29 |
JMeter Dashboard를 활용하여 변수값 변화 그래프 만들기 (1) | 2022.07.08 |