개발지식 먹는 하마 님의 블로그
[내일배움캠프 63일차] nullnullTicket 동시성 제어 분산락 구현 본문
고민에 따른 수정사항
우선 튜터님께서 회차별 좌석의 정보(상태, 가격, 등급 등)를 나타내는 엔티티의 이름이 모호하다는 피드백을 받아서
회차별 좌석 정보라는 뜻으로 seat_schedule_info로 변경했다.
1) 가격 등급 표기 방법
가격은 좌석과 회차별 좌석 정보가 함께 가지고 있고
초기에 자동으로 회차별 정보가 생성될 때, 좌석 엔티티에 설정되어있는 등급과 가격으로 초기 설정되도록 하였다.
2) 좌석 상태 표기
좌석 상태의 유효시간을 일부 다르게 설정해줘야 했기 때문에
SELECTED, HOLD 등 다양한 상태를 다루기로 하였다.
Redis의 TTL을 사용하여 각 상태에 따른 만료 시간을 다르게 설정해주었다.
트러블 슈팅) 복잡하게 꼬인 생각
좌석 선택과 좌석 상태 조회에 대한 동시성 제어를 동시에 고려하다보니
Redis 활용에 대한 생각이 꼬여서 엉뚱한 방향으로 분산락에 대해 생각하고 있었다.
튜터님께 이 고민을 털어놓아 복잡하게 꼬인 생각을 다시
좌석 선택을 위한 분산락과 좌석 상태 조회를 위한 캐싱을 별도로 명쾌하게 생각하게 되었다.
Redisson 분산락 + AOP 구현
https://helloworld.kurly.com/blog/distributed-redisson-lock/
풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법 - Spring Redisson
어노테이션 기반으로 분산락을 사용하는 방법에 대해 소개합니다.
helloworld.kurly.com
분산락은 구현 방법이 자세히 나와있는 글들이 너무 많아서 큰 어려움은 없었다.
이 부분이 동시성 제어의 핵심적인 부분이락 생각한다.
@Component
public class AopForTransaction {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
return joinPoint.proceed();
}
}
위의 코드는 분산락 어노테이션을 사용하는 메서드는 기존의 트랜잭션이 존재하면 이를 중단하고
새로운 트랜잭션을 독립적으로 수행하겠다는 부분을 나타내는 것이다.
트랜잭션을 독립적으로 관리해야 하는 이유
데이터 정합성을 위하여
분산락 시스템은 다음과 같이 흘러간다.
락을 획득한 유저가 로직을 수행한 후 변경사항을 커밋한다.
이 때, 트랜잭션 커밋 요청을 날렸지만 이것이 실제로 커밋되기 전에 락이 해제되어
다음 락을 획득한 사람이 동일한 자원에 접근할 때 데이터 정합성을 보장할 수 없다.
따라서, 별도의 트랜잭션을 사용하여데이터 정합성을 위해 트랜잭션 커밋이 완료된 후 락이 해제된다는 것을 보장해주어야 한다.
트러블 슈팅) REQUIRES_NEW는 별도의 트랜잭션을 독립적으로 관리한다면서?
n개의 데이터에 대해 좌석 선택에 성공하는 케이스와 실패하는 케이스의 개수를 카운트하는 테스트 코드를 작성하였다.
@Test
@DisplayName("동일 좌석 동시 요청: 1명만 성공하고 나머지는 선점 메시지")
void sameSeatConcurrentAccessTest() throws InterruptedException {
// given
SeatScheduleInfo seatScheduleInfo = seatScheduleInfoRepository.save(new SeatScheduleInfo(seat, schedule, SeatStatus.AVAILABLE, seat.getDefaultGrade(), seat.getDefaultPrice()));
Long seatScheduleInfoId = seatScheduleInfo.getId();
int totalThreads = 1000;
ExecutorService executor = Executors.newFixedThreadPool(100);
CountDownLatch latch = new CountDownLatch(totalThreads);
List<String> resultMessages = Collections.synchronizedList(new ArrayList<>());
// when
IntStream.range(0, totalThreads).forEach(i -> {
executor.submit(() -> {
try {
seatScheduleInfoService.selectSeat((long) i + 1, schedule.getId(), seatScheduleInfoId);
resultMessages.add("SUCCESS");
} catch (ResponseStatusException e) {
resultMessages.add(e.getReason());
} finally {
latch.countDown();
}
});
});
latch.await();
// then
long successCount = resultMessages.stream().filter("SUCCESS"::equals).count();
long conflictCount = resultMessages.stream().filter("이미 선점된 좌석입니다."::equals).count();
System.out.println("\n성공 요청 수: " + successCount);
System.out.println("실패 요청 수: " + conflictCount);
assertEquals(1, successCount);
assertEquals(totalThreads - 1, conflictCount);
}
이를 실행했을 때, 예상과는 다르게 분산락이 제대로 적용되지 않아 성공 개수가 1보다 많이 나왔다.
원인을 확인해본 결과 테스트 클래스 상단에 @Transaction 어노테이션이 사용되어있었기 때문에 발생한 문제였다.
@SpringBootTest
@Transactional
class SeatSelectionIntegrationTest {
...
}
그런데 Requires_New 전파 속성이 새로운 트랜잭션을 만들어서 별도로 관리를 한다면,
분산락을 적용한 메서드가 사용되는 플로우 상위에 트랙잭션을 사용해도 괜찮은거 아닌가?
A : 아니다.
실제로 동작을 할 때, 별도의 새로운 트랜잭션을 생성하더라도 이것을 관리하는 쓰레드는 동일하기 때문이라고 한다.
아래의 블로그 내용이 이해하는데 도움이 되었다.
https://woodcock.tistory.com/40
Transactional REQUIRES_NEW에 대한 오해
서론예전에 함께 스터디를 했던 스터디원이 트랜잭션에 관한 블로그 글을 공유하면서, 흥미로운 내용이라고 소개했다.해당 글에서는 기존에 내가 알고있던 사실이 틀리다라고 얘기하는 내용이
woodcock.tistory.com
나도 다시 한 번 트랜잭션에 대한 내용을 심도있게 찾아봐야겠다고 생각하게 되었다.
테스트 코드 상단에 실수로 들어간 @Transaction을 제거한 후, 테스트 코드를 실행한 결과는 아래와 같다.
좌석 선택 테스트 코드 실행 시, 동시성 제어가 잘 되고 있는 결과를 확인할 수 있었다
'내일배움캠프 (CS25)' 카테고리의 다른 글
[내일배움캠프 68일차] 좌석 상태 관리 - 트러블 슈팅 (0) | 2025.05.27 |
---|---|
[내일배움캠프 65일차] Github CI 트러블 슈팅 (0) | 2025.05.22 |
[내일배움캠프 62일차] 동시성 제어 티켓팅 프로젝트 설계 (0) | 2025.05.16 |
[내일배움캠프 61일차] Spring 심화 프로젝트 회고 (0) | 2025.05.15 |
[내일배움캠프 51일차] 배달앱 아웃소싱 프로젝트 회고 (0) | 2025.04.29 |