🎯 들어가며
이벤트 티켓 예약, 쿠폰 차감, 재고 관리... 실무에서 마주치는 가장 까다로운 문제 중 하나가 바로 동시성 이슈입니다.
여러 사용자가 동시에 같은 자원에 접근할 때 발생하는 Lost Update 문제를 Redis 분산락과 JPA 비관적 락으로 해결하는 과정을 상세히 공유해보겠습니다.
💥 문제 상황: Lost Update의 공포
시나리오: 이벤트 티켓 예약 시스템
@Transactional
public Event reserveTicket(Long eventId) {
// 1. DB에서 이벤트 조회
Event event = eventRepository.findById(eventId)
.orElseThrow(() -> new IllegalArgumentException("이벤트를 찾을 수 없습니다"));
// 2. 티켓 차감
event.reserveTicket(); // availableTickets--
// 3. 저장
return eventRepository.save(event);
}
10명이 동시에 예약하면 어떻게 될까요?
😱 실제 테스트 결과
- 예상: 100장 → 90장 (10장 차감)
- 실제: 100장 → 99장 (1장만 차감!)
- 9장의 예약이 중복!
모든 요청이 동일한 초기값(100)을 읽고 → 99로 저장 → 마지막 저장만 남음
🔧 해결책 1: Redis 분산락
분산락이란?
분산 환경에서 여러 인스턴스가 공유 자원에 동시 접근하는 것을 방지하는 락입니다.
1. 의존성 추가
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.24.3</version>
</dependency>
2. Redis 설정
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host:localhost}")
private String redisHost;
@Value("${spring.data.redis.port:6379}")
private int redisPort;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + redisHost + ":" + redisPort)
.setRetryAttempts(3)
.setRetryInterval(1500)
.setTimeout(3000)
.setConnectTimeout(10000);
return Redisson.create(config);
}
}
3. 분산락 어노테이션 구현
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key();
long waitTime() default 3000L;
long leaseTime() default 5000L;
}
4. AOP 구현
@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAspect {
private final RedissonClient redissonClient;
@Around("@annotation(distributedLock)")
public Object around(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
String lockKey = distributedLock.key();
long waitTime = distributedLock.waitTime();
long leaseTime = distributedLock.leaseTime();
log.info("분산락 획득 시도 중... key: {}", lockKey);
RLock lock = redissonClient.getLock(lockKey);
boolean isLocked = false;
long startTime = System.currentTimeMillis();
try {
isLocked = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS);
if (!isLocked) {
log.warn("락 획득 실패! key: {}", lockKey);
throw new RuntimeException("분산락 획득에 실패했습니다");
}
long waitedTime = System.currentTimeMillis() - startTime;
log.info("락 획득 성공! key: {}, 대기시간: {}ms", lockKey, waitedTime);
return joinPoint.proceed();
} finally {
if (isLocked && lock.isHeldByCurrentThread()) {
long totalTime = System.currentTimeMillis() - startTime;
lock.unlock();
log.info("락 해제 완료! key: {}, 총 소요시간: {}ms", lockKey, totalTime);
}
}
}
}
5. 서비스에 적용
@Service
@RequiredArgsConstructor
@Slf4j
public class EventService {
private final EventRepository eventRepository;
@DistributedLock(key = "'event:' + #eventId", waitTime = 3000L, leaseTime = 5000L)
@Transactional
public Event reserveTicketWithDistributedLock(Long eventId) {
log.info("이벤트 {}번에 대한 티켓 예약을 시도합니다 (분산락 사용)", eventId);
Event event = eventRepository.findById(eventId)
.orElseThrow(() -> new IllegalArgumentException("이벤트를 찾을 수 없습니다"));
event.reserveTicket();
Event savedEvent = eventRepository.saveAndFlush(event); // 🔥 핵심!
log.info("이벤트 {}번 티켓 예약 완료, 남은 티켓: {}장", eventId, savedEvent.getAvailableTickets());
return savedEvent;
}
}
🔥 핵심 포인트: saveAndFlush() 사용 이유
// ❌ 문제 상황
@DistributedLock
@Transactional
public Event reserve(Long eventId) {
// 🔒 분산락 획득
Event event = repository.findById(eventId);
event.reserveTicket();
repository.save(event); // 트랜잭션 커밋 시 실행
// 🔓 분산락 해제 (UPDATE 전에 해제!)
}
// ✅ 올바른 해결
@DistributedLock
@Transactional
public Event reserve(Long eventId) {
// 🔒 분산락 획득
Event event = repository.findById(eventId);
event.reserveTicket();
repository.saveAndFlush(event); // 즉시 DB 반영
// 🔓 분산락 해제 (UPDATE 후 해제)
}
분산락 범위 안에서 모든 DB 작업이 완료되어야 합니다!
🔧 해결책 2: JPA 비관적 락
1. Repository에 비관적 락 추가
@Repository
public interface EventRepository extends JpaRepository<Event, Long> {
// JPA 어노테이션 방식
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM Event e WHERE e.id = :eventId")
Optional<Event> findByIdWithLock(@Param("eventId") Long eventId);
// Native Query 방식 (더 확실함)
@Query(value = "SELECT * FROM events WHERE id = :eventId FOR UPDATE", nativeQuery = true)
Optional<Event> findByIdWithNativeLock(@Param("eventId") Long eventId);
}
2. 서비스에 적용
@Transactional
public Event reserveTicketWithPessimisticLock(Long eventId) {
log.info("이벤트 {}번에 대한 티켓 예약을 시도합니다 (비관적 락 사용)", eventId);
// SELECT ... FOR UPDATE로 락 획득
Event event = eventRepository.findByIdWithNativeLock(eventId)
.orElseThrow(() -> new IllegalArgumentException("이벤트를 찾을 수 없습니다"));
event.reserveTicket();
Event savedEvent = eventRepository.save(event); // save()만으로 충분!
log.info("이벤트 {}번 티켓 예약 완료 (비관적 락), 남은 티켓: {}장", eventId, savedEvent.getAvailableTickets());
return savedEvent;
}
🤔 비관적 락은 왜 save()만으로 충분할까?
@Transactional
public Event reserve(Long eventId) {
// SELECT ... FOR UPDATE (DB 레벨 락 획득)
Event event = repository.findByIdWithLock(eventId);
// 메모리 수정 (락 유지 중)
event.reserveTicket();
// save() → 더티 체킹으로 UPDATE 준비
repository.save(event);
// 트랜잭션 커밋 시: UPDATE 실행 + 락 해제
}
DB 트랜잭션이 전체 과정을 보호하므로 save()만으로 충분합니다.
📊 성능 테스트 결과
테스트 환경
- 15개 동시 요청
- Docker: Redis + H2 Database
- Spring Boot 3.2.0
결과 비교
| 방식 | 요청 수 | 예상 차감 | 실제 차감 | Lost Update | 평균 응답시간 |
|---|---|---|---|---|---|
| 분산락 | 15개 | 15장 | ✅ 15장 | 0개 | ~400ms |
| 비관적 락 | 15개 | 15장 | ✅ 15장 | 0개 | ~400ms |
| 락 없음 | 15개 | 15장 | ❌ 1장 | 14개 | ~300ms |
🏭 실무 적용 가이드
언제 분산락을 사용할까?
✅ 분산락이 적합한 경우
- 마이크로서비스 환경
- 여러 인스턴스에서 동일한 자원 접근
- Redis 인프라가 구축되어 있음
- 네트워크 지연 허용 가능
✅ 비관적 락이 적합한 경우
- 단일 데이터베이스 환경
- 높은 경합이 예상되는 상황
- 데이터 정합성이 최우선
- 빠른 응답 속도 필요
실무에서 자주 발생하는 실수들
1. 트랜잭션 범위 설정 실수
// ❌ 잘못된 예시
@DistributedLock
@Transactional
public void badExample() {
// 락 해제 후 트랜잭션 커밋 발생
}
// ✅ 올바른 예시
@DistributedLock
public void goodExample() {
transactionTemplate.execute(status -> {
// 락 범위 안에서 트랜잭션 완료
});
}
2. 예외 처리 미흡
// ❌ 락 획득 실패 시 적절한 처리 없음
if (!lock.tryLock()) {
// 아무 처리 없음
}
// ✅ 사용자 친화적 예외 처리
if (!lock.tryLock()) {
throw new ConcurrencyException("현재 많은 사용자가 접속해있습니다. 잠시 후 다시 시도해주세요.");
}
🚀 마무리
핵심 포인트 정리
- 동시성 이슈는 실무에서 필수로 고려해야 할 사항
- 분산락은 실시간 반영을 위해 saveAndFlush() 사용
- 환경에 따라 적절한 락 방식 선택
- 완벽한 테스트로 동작 검증
다음 단계
- 대용량 트래픽 환경에서의 성능 최적화
- 락 타임아웃 전략 수립
- 모니터링 및 알람 체계 구축
실무에서 마주치는 동시성 문제를 체계적으로 해결하는 방법을 공유했습니다.
혹시 궁금한 점이나 추가로 다뤄보고 싶은 내용이 있다면 댓글로 남겨주세요! 🙌
📎 첨부 자료
GitHub 저장소
https://github.com/eet43/concurrency-test
핵심 파일들
DistributedLockAspect.java- 분산락 AOP 구현EventService.java- 비즈니스 로직EventRepository.java- 비관적 락 쿼리docker-compose.yml- Redis + H2 환경 설정
테스트 스크립트
# 분산락 테스트
for i in {1..15}; do
curl -X POST http://localhost:8080/api/events/reserve/1/distributed-lock &
done; wait
# 비관적 락 테스트
for i in {1..15}; do
curl -X POST http://localhost:8080/api/events/reserve/2/pessimistic-lock &
done; wait
# 락 없음 테스트 (Lost Update 확인)
for i in {1..15}; do
curl -X POST http://localhost:8080/api/events/reserve/3/no-lock &
done; wait
#SpringBoot #분산락 #동시성 #Redis #JPA #실무개발
'개인 공부 > Spring' 카테고리의 다른 글
| [Spring] Redis Cache 사용하기 (0) | 2023.03.10 |
|---|---|
| [Spring] AWS S3 Bucket 에 이미지 등록하기 (0) | 2023.02.28 |
| [Spring] Bean Scope (0) | 2022.11.22 |
| [Spring] Multi Thread (2) | 2022.11.21 |
| [Spring] HashMap 으로 Cache 구현하기 (0) | 2022.11.14 |