Notion에서 작성 된 글입니다. 템플릿이 깨진다면 Notion을 확인해주세요.
동시성을 잠금으로 안전하게 처리해보자 | Notion
들어가기앞서…
hail-buttercup-c86.notion.site
들어가기앞서…
이제 본격적으로 공부한 내용을 프로젝트에 적용해보고자한다.
필자가 맡고있는 이벤트 기능의 주요 서비스는 여러명의 사용자가 포인트를 지불하고 이벤트에 참여하면 이벤트에 누적 금액이 쌓이게되고, 목표 금액에 달성한 사용자만이 티켓을 얻게되는 이벤트이다.
때문에 동시에 여러명이 요청할 가능성이 있는 이벤트 응모 요청은 동시성 처리가 필수이다.
실습 전 읽어보면 도움되는 문서화 노트
[ CS ] Lock
Notion에서 작성 된 글입니다. 템플릿이 깨진다면 Notion을 확인해주세요. Lock | Notion들어가기앞서…hail-buttercup-c86.notion.site 들어가기앞서…필자는 이번 프로젝트에서 이벤트 기능을 맡게되었다.이
dev-haen.tistory.com
동시성을 잠금으로 안전하게 처리해보자.
동시성 처리를 안하면 어떤 문제가 발생할까?
@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
@Sql(scripts = "classpath:sql/cleanup.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "classpath:sql/schema.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "classpath:sql/data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
class EventServiceImplTest{
@DisplayName("티켓 이벤트에 여러 사용자가 동시에 응모할 수 있다.")
@Test
void applyTicketEventWithMultipleMembers() throws InterruptedException {
// given
int memberCount = 30;
final long eventId = 6L;
final int perPrice = 10000; // 이벤트 per price
int eventAmount = 60000;
Event event = eventRepository.findById(eventId).orElseThrow();
ExecutorService executorService = Executors.newFixedThreadPool(memberCount);
CountDownLatch latch = new CountDownLatch(memberCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// 각 멤버에게 포인트 적립(2,000)
for (long id = 1; id <= memberCount; id++) {
Member m = memberRepository.findById(id).orElseThrow();
m.addPoint(perPrice);
memberRepository.save(m);
}
// when
for (long id = 1; id <= memberCount; id++) {
final long memberId = id;
executorService.submit(() -> {
try {
// 스레드 별 로그인 컨텍스트 세팅
var authorities = List.of(new SimpleGrantedAuthority("MEMBER")); // 필요시 "ROLE_MEMBER"로
var principal = new CustomUserDetails(
memberId,
"user" + memberId + "@test.com",
"pw" + memberId, // 더미 비밀번호
"유저" + memberId, // 더미 닉네임
authorities
);
var authentication =
new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities());
TestSecurityContextHolder.setAuthentication(authentication);
eventService.applyTicketEvent(eventId);
successCount.incrementAndGet();
} catch (Exception e) {
// 실패 사유 로그
failCount.incrementAndGet();
log.error("apply failed for member {}: {}", memberId, new String[]{e.getMessage()}, e);
} finally {
latch.countDown();
}
});
}
latch.await();
// then
log.info("successCount = " + successCount.get());
log.info("failCount = " + failCount.get());
Event updated = eventRepository.findById(event.getId()).orElseThrow();
assertThat(updated.getAccrued()).isEqualTo(eventAmount);
}
}
- 코드 분석
- ExecutorService → 스레드 풀을 이용해서 동시에 여러 요청 실행.
- CountDownLatch → 모든 스레드 작업이 끝날 때까지 기다리기.
- AtomicInteger → 멀티스레드 환경에서 안전하게 성공/실패 카운트 집계.
for (long id = 1; id <= memberCount; id++) { final long memberId = id; executorService.submit(() -> { try { // 스레드 별 로그인 컨텍스트 세팅 var authorities = List.of(new SimpleGrantedAuthority("MEMBER")); var principal = new CustomUserDetails( memberId, "user" + memberId + "@test.com", "pw" + memberId, "유저" + memberId, authorities ); var authentication = new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities()); TestSecurityContextHolder.setAuthentication(authentication); eventService.applyTicketEvent(eventId); // 실제 서비스 호출 successCount.incrementAndGet(); } catch (Exception e) { failCount.incrementAndGet(); log.error("apply failed for member {}: {}", memberId, new String[]{e.getMessage()}, e); } finally { latch.countDown(); } }); } latch.await();- 각 스레드가 서로 다른 멤버 ID로 로그인한 것처럼 SecurityContext 세팅.
- eventService.applyTicketEvent(eventId) 호출 → 실제 이벤트 응모 로직 실행.
- 성공하면 successCount++, 실패하면 failCount++.
- 마지막에 latch.countDown()으로 “작업 1개 끝!” 표시.
- latch.await() → 모든 스레드가 끝날 때까지 메인 스레드 대기.
- ExecutorService executorService = Executors.newFixedThreadPool(memberCount); CountDownLatch latch = new CountDownLatch(memberCount); AtomicInteger successCount = new AtomicInteger(); AtomicInteger failCount = new AtomicInteger();
위와 같이 스레드 풀을 생성하고 여러 스레드가 동시에 요청을 보내도록 해보자.

JPQL 벌크 원자 업데이트로 동시성 해결해보기
두 벌크 UPDATE가 DB에서 원자적으로 실행하고, 행 단위 락과 조건절로 레이스를 막아보자.
이론 설명을 하자
@DisplayName("티켓 이벤트에 여러 사용자가 동시에 응모할 수 있다.")
@Test
void applyTicketEventWithMultipleMembers() throws InterruptedException {
// given
int memberCount = 30;
final long eventId = 6L;
final int perPrice = 10000; // 이벤트 per price
int eventAmount = 60000;
Event event = eventRepository.findById(eventId).orElseThrow();
ExecutorService pool = Executors.newFixedThreadPool(memberCount);
CountDownLatch startGate = new CountDownLatch(1);
CountDownLatch doneGate = new CountDownLatch(memberCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
// 각 멤버에게 포인트 적립(2,000)
for (long id = 1; id <= memberCount; id++) {
Member m = memberRepository.findById(id).orElseThrow();
m.addPoint(perPrice);
memberRepository.save(m);
}
// when
for (long id = 1; id <= memberCount; id++) {
final long memberId = id;
pool.submit(() -> {
try {
// 스레드 준비 완료 → 출발 신호 기다림
startGate.await();
// 스레드 별 로그인 컨텍스트 세팅
var authorities = List.of(new SimpleGrantedAuthority("MEMBER")); // 필요시 "ROLE_MEMBER"로
var principal = new CustomUserDetails(
memberId,
"user" + memberId + "@test.com",
"pw" + memberId, // 더미 비밀번호
"유저" + memberId, // 더미 닉네임
authorities
);
var authentication =
new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities());
TestSecurityContextHolder.setAuthentication(authentication);
eventService.applyTicketEvent(eventId);
successCount.incrementAndGet();
} catch (Exception e) {
// 실패 사유 로그
failCount.incrementAndGet();
log.error("apply failed for member {}: {}", memberId, new String[]{e.getMessage()}, e);
} finally {
doneGate.countDown();
TestSecurityContextHolder.clearContext();
}
});
}
// 모든 작업자 준비 후 동시에 출발
startGate.countDown();
// 모두 끝날 때까지 대기
doneGate.await();
pool.shutdown();
// then
log.info("successCount = " + successCount.get());
log.info("failCount = " + failCount.get());
Event updated = eventRepository.findById(event.getId()).orElseThrow();
assertThat(updated.getAccrued()).isEqualTo(eventAmount);
}
@Override
@Transactional
public TicketApplyResponseDto applyTicketEvent(Long eventId) {
Event event = getEventOrThrow(eventId);
Member member = getMemberOrThrow();
Point point = member.deductPoint(event.getPerPrice(), eventTarget);
pointRepository.save(point);
eventRepository.addAccrued(eventId, event.getPerPrice());
Event fresh = eventRepository.findById(eventId).orElseThrow();
boolean win = fresh.getAccrued() >= fresh.getGoalPrice();
if (win) {
Status inProgress = statusRepository.findById(4L).orElseThrow();
Status completed = statusRepository.findById(6L).orElseThrow();
// 4) 상태 전이도 원자적으로 한 번만!
int rows = eventRepository.markCompletedIfInProgress(eventId, inProgress, completed);
if (rows == 1) {
// ★ 전이 "성공"한 스레드만 예약 생성
Seat seat = getSeatOrThrow(fresh.getSeat().getId());
Status paid = statusProvider.provide(StatusIds.Reservation.PAID);
Reservation r = Reservation.create(member, seat.getPerformance(), paid, fresh.getAccrued());
r.assignSeat(seat);
Status reserved = statusProvider.provide(StatusIds.Seat.RESERVED);
seat.completeReservation(member, reserved, null);
reservationRepository.save(r);
} else {
// 다른 스레드가 이미 완료 처리함 → 그냥 패스
}
}
return TicketApplyResponseDto.from(eventId, member.getId(), win);
package com.profect.tickle.domain.event.repository;
import com.profect.tickle.domain.event.entity.Event;
import com.profect.tickle.global.status.Status;
import org.apache.ibatis.annotations.Param;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
@Repository
public interface EventRepository extends JpaRepository<Event, Long> {
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("""
update Event e
set e.accrued = e.accrued + :delta,
e.updatedAt = CURRENT_TIMESTAMP
where e.id = :eventId
""")
int addAccrued(@Param("eventId") Long eventId, @Param("delta") int delta);
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("""
update Event e
set e.status = :completed
where e.id = :eventId
and e.status = :inProgress
""")
int markCompletedIfInProgress(@Param("eventId") Long eventId,
@Param("inProgress") Status inProgress,
@Param("completed") Status completed);
}

JPQL 벌크 원자 업데이트
짧게 말하면, 저 두 쿼리는 DB를 락·공유자원으로 써서 원자적(atomic) 업데이트와 조건부 상태 전이(compare-and-set) 를 하는 방식의 동시성 제어이다. 자바에서 락을 잡지 않아도, 하나의 SQL UPDATE가 트랜잭션 안에서 단일 작업으로 수행되기 때문에 경합 중에도 값이 꼬이지 않는다.
낙관적 락 사용해보기
낙관적 락이란?
낙관적 락이란, 충돌이 잘 안 날 거라고 낙관적으로 가정하는 방식이다. 즉 동시성이 잦지 않은 경우 사용하면 좋다. 이후에 실습할 비관적 락보다 성능이 더 좋다.
데이터를 읽을 때는 아무 제약 없이 읽지만, 쓸 때는(업데이트할 때는) 내가 읽을 당시의 상태와 DB의 현재 상태가 같은지 검사하여 다르면 OptimisticLockException을 던지기때문에 개발자가 재시도 로직을 통해 해결한다.
낙관적 락 동작 방식 (@Version 활용)
낙관적 락을 사용하는 방법은 여러가지가 있지만 대표적으로는 @Version 애노테이션을 사용하는 방법이 있다.
@Version은 낙관적 락 값으로 사용되는 엔터티 클래스의 버전 필드 또는 속성을 지정한다. 이 버전은 병합 작업을 수행할 때 무결성을 보장하고 낙관적 동시성 제어를 위해 사용 된다.
@Entity
public class Event {
@Id
private Long id;
private int accrued;
@Version
private Long version;
}
만약에 version의 값이 1이고 accrued가 0일때 A와 B가 동시에 이벤트에 응모하여, 2000원의 포인트를 지불해 accrued에 +2000원을 한다고 생각해보자.
- A가 + 2000 → accrued = 2000
- B가 + 2000 → accrued = 2000
둘다 로컬에서는 2000을 가지지만, 커밋 시점으로 돌아가면
A의 Update
UPDATE event
SET accrued = 2000,
version = 2
WHERE id = 1
AND version = 1;
DB의 반영에 성공하여 DB값은 2000, version은 2가 된다.
B의 Update
UPDATE event
SET accrued = 2000,
version = 2
WHERE id = 1
AND version = 1;
하지만 DB의 version은 이미 2이므로, Where절의 version불 일치로 업데이트가 0건이 되고, OptimisticLockException 발생한다.
특징
이런 낙관적 락의 장점과 단점은 뚜렷하다.
- 장점
- DB에서 물리적 락을 걸지 않아도 되므로, 동시성을 보장하면서도 성능이 좋다.
- 충돌이 드문 상황에 적합하다. (단점 1과 이어진다)
- 단점
- 충돌이 자주 발생하면 예외가 자꾸 발생해서 재시도 비용이 크다.
- 재시도 로직을 반드시 개발자가 구현해야한다.
프로젝트에 낙관적 락을 적용해보자.
서비스코드
@Service
@RequiredArgsConstructor
public class EventServiceImpl implements EventService {
...
@Override
public TicketApplyResponseDto applyTicketEvent(Long eventId) {
int maxTry = 20;
for (int i = 0; i < maxTry; i++) {
try {
return executor.applyTicketEventOnce(eventId);
} catch (ObjectOptimisticLockingFailureException | OptimisticLockException e) {
if (i == maxTry - 1) throw e;
// 재시도 대기: 지수 백오프 + 지터
try {
long base = 5L; // 기본 단위
long backoff = (long) (base * Math.pow(2, i)); // 지수적으로 증가
long jitter = ThreadLocalRandom.current().nextLong(0, 5); // 약간 랜덤 섞기
Thread.sleep(Math.min(200L, backoff + jitter)); // 너무 길지 않게 상한 200ms
} catch (InterruptedException ignored) {}
}
}
throw new IllegalStateException("unreachable");
}
...
}
서비스코드
@Service
@RequiredArgsConstructor
public class EventApplyExecutor {
private final PointTarget eventTarget = PointTarget.EVENT;
private final SeatRepository seatRepository;
private final EventRepository eventRepository;
private final MemberRepository memberRepository;
private final ReservationRepository reservationRepository;
private final PointRepository pointRepository;
private final PerformanceRepository performanceRepository;
private final StatusProvider statusProvider;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public TicketApplyResponseDto applyTicketEventOnce(Long eventId) {
Event event = getEventOrThrow(eventId); // @Version 필드 포함
Member member = getMemberOrThrow();
// 실제 로직이 진행되기 전에 진행 상태 검사
if (!StatusIds.Event.IN_PROGRESS.equals(event.getStatus().getId())) {
throw new BusinessException(ErrorCode.EVENT_NOT_IN_PROGRESS);
}
Point point = member.deductPoint(event.getPerPrice(), eventTarget);
pointRepository.save(point);
// 가능하면 아래 한 줄로(방법 B) 대체 권장. 엔티티 변경을 고수한다면 여기서 예외 날 수 있음.
event.accumulate(event.getPerPrice());
boolean isWinner = (event.getAccrued() >= event.getGoalPrice());
if (isWinner) {
Seat seat = getSeatOrThrow(event.getSeat().getId());
event.updateStatus(statusProvider.provide(StatusIds.Event.COMPLETED));
Status paidStatus = statusProvider.provide(StatusIds.Reservation.PAID);
Reservation reservation = Reservation.create(member, seat.getPerformance(), paidStatus, event.getAccrued());
reservation.assignSeat(seat);
Status reservedStatus = statusProvider.provide(StatusIds.Seat.RESERVED);
seat.completeReservation(member, reservedStatus, null);
reservationRepository.save(reservation);
}
return TicketApplyResponseDto.from(eventId, member.getId(), isWinner);
}
...
}
Entity
@Getter
@Entity
@Table(name = "event")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "event_id")
private Long id;
@Version
@Column(name = "event_version") // 컬럼명은 임의, 스키마에 추가 필요
private Long version;
...
}
테스트코드
@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)
@Sql(scripts = "classpath:sql/cleanup.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "classpath:sql/schema.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "classpath:sql/data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
class EventServiceImplTest{
...
@DisplayName("티켓 이벤트에 여러 사용자가 동시에 응모할 수 있다.")
@Test
void applyTicketEventWithMultipleMembers() throws InterruptedException {
// given
int memberCount = 100;
final long eventId = 6L;
final int perPrice = 10000;
int eventAmount = 100000;
Event event = eventRepository.findById(eventId).orElseThrow();
ExecutorService pool = Executors.newFixedThreadPool(memberCount);
CountDownLatch startGate = new CountDownLatch(1);
CountDownLatch doneGate = new CountDownLatch(memberCount);
AtomicInteger successCount = new AtomicInteger();
AtomicInteger failCount = new AtomicInteger();
for (long id = 1; id <= memberCount; id++) {
Member m = memberRepository.findById(id).orElseThrow();
m.addPoint(perPrice);
memberRepository.save(m);
}
// when
for (long id = 1; id <= memberCount; id++) {
final long memberId = id;
pool.submit(() -> {
try {
startGate.await(); // 스레드 준비 완료
var authorities = List.of(new SimpleGrantedAuthority("MEMBER")); // 스레드 별 로그인 컨텍스트 세팅
var principal = new CustomUserDetails(
memberId,
"user" + memberId + "@test.com",
"pw" + memberId,
"유저" + memberId,
authorities
);
var authentication =
new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities());
TestSecurityContextHolder.setAuthentication(authentication);
eventService.applyTicketEvent(eventId);
successCount.incrementAndGet();
} catch (Exception e) {
failCount.incrementAndGet();
log.error("apply failed for member {}: {}", memberId, new String[]{e.getMessage()}, e);
} finally {
doneGate.countDown();
TestSecurityContextHolder.clearContext();
}
});
}
startGate.countDown(); // 모든 작업자 준비 후 동시에 출발
doneGate.await(); // 모두 끝날 때까지 대기
pool.shutdown();
// then
log.info("successCount = " + successCount.get());
log.info("failCount = " + failCount.get());
Event updated = eventRepository.findById(event.getId()).orElseThrow();
assertThat(updated.getAccrued()).isEqualTo(eventAmount);
}
}
낙관적 락 실습을 하며 겪은 트러블 슈팅
낙관적 락 동시성 처리 에러
들어가기 앞서..동시성 테스트 과정에서 발생한 트러블 슈팅입니다. 쿠폰 이벤트의 동시성 문제를 처리하기 위해 Lock 전략 중 낙관적 락을 먼저 도입해보기로했다. 처리하기 위해 Event 엔티티에
dev-haen.tistory.com
비관적 락 사용해보기
“우리는 티켓 이벤트 동시 응모 기능을 구현하면서 낙관적 락 기반의 충돌 제어를 먼저 적용하였다. 하지만 수십~수백 명이 동시에 동일한 이벤트에 참여하는 시나리오에서, OptimisticLockException이 빈번하게 발생해 다수의 요청이 실패하거나 재시도 비용이 과도하게 증가하는 문제가 있었다. 그리고 생각보다 느리다. 비관적 락을 사용해보기로했다. (4초걸림 테스트)
이 문제를 확인한 뒤, 충돌 빈도가 높은 상황에서는 낙관적 락 대신 비관적 락(Pessimistic Lock)을 사용해보고싶었다.
- OptimisticLockException이 많이 터짐
- 충돌이 너무 잦아서, 아예 DB 락을 걸고 순차적으로 처리해보고 싶다
비관적 락(Pessimistic Lock)이란?
“충돌이 날 것”을 전제로, 아예 먼저 잠그고(Update/Read) 처리하는 방식.
트랜잭션이 락을 잡는 동안 다른 트랜잭션은 대기하거나(대부분) 실패.
높은 경합·짧은 임계구역에서 재시도 난발을 막고, 정확성을 최우선으로 보장할 때 효과적.
언제 쓰나
- 재고/좌석/이벤트 누적금 같은 단일 카운터/재고를 동시에 갱신
- OptimisticLockException이 과도하고 재시도 비용이 큰 경우
- 실패/중복보다 정합성이 더 중요한 경우
주의점
- 락 보유 시간↑ → 대기/데드락 위험
- 트랜잭션 범위 최소화, 짧게 잡고 빨리 놓기, 인덱스가 필수
- 타임아웃/에러 핸들링을 꼭 준비
코드 예제 (JPA)
1) 엔티티 잠그기: @Lock(PESSIMISTIC_WRITE)
// EventRepository.java
public interface EventRepository extends JpaRepository<Event, Long> {
// 이벤트 1건을 쓰기락으로 잠금 (행 단위)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select e from Event e where e.id = :eventId")
Optional<Event> findForUpdate(@Param("eventId") Long eventId);
// (선택) 락 타임아웃 힌트: ms 단위
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "javax.persistence.lock.timeout", value = "2000"))
@Query("select e from Event e where e.id = :eventId")
Optional<Event> findForUpdateWithTimeout(@Param("eventId") Long eventId);
}
2) 서비스: 잠그고 → 검증 → 누적/완료 판단 → 좌석 배정
@Service
@RequiredArgsConstructor
public class EventCoreLockService {
private final EventRepository eventRepository;
private final MemberRepository memberRepository;
private final ReservationService reservationService;
@Transactional
public EventDecision applyCore(Long eventId, Long memberId) {
// 1) 이벤트 행 잠금 (PESSIMISTIC_WRITE)
Event event = eventRepository.findForUpdate(eventId)
.orElseThrow(() -> new BusinessException(ErrorCode.EVENT_NOT_FOUND));
if (!StatusIds.Event.IN_PROGRESS.equals(event.getStatus().getId())) {
throw new BusinessException(ErrorCode.EVENT_NOT_IN_PROGRESS);
}
// 2) 포인트 차감 (동일 트랜잭션 내에서 빠르게!)
short delta = event.getPerPrice();
var deducted = memberRepository.tryDeductPointReturning(memberId, delta);
if (deducted.isEmpty()) throw new BusinessException(ErrorCode.INSUFFICIENT_POINT);
// 3) 누적/완료 판단 (락 아래에서 안전하게 업데이트)
int acc = event.accumulate(delta); // 엔티티 메서드에서 누적값 조정
boolean completed = event.isGoalReached(); // 목표 달성 판정
if (completed) {
event.complete(); // 상태 → COMPLETED
}
Long seatId = null;
if (completed) {
// 4) 당첨자 좌석 배정 (별도 짧은 트랜잭션이면 더 안전)
seatId = reservationService.assignSeatForWinner(eventId, memberId);
}
return new EventDecision(eventId, memberId, delta, completed, acc, seatId);
}
}
마무리하며..
이번 프로젝트를 통해 동시성 문제를 단순한 코드 개선만으로는 해결할 수 없다는 사실을 다시금 깨달았다.
낙관적 락과 비관적 락, JPQL 벌크 업데이트 등 다양한 전략을 직접 적용하고 테스트하며 락 전략·트랜잭션 경계·재시도 정책이 모두 맞물려야 안정성이 확보된다는 것을 체감했다.
특히, “재시도 = 매번 깨끗한 트랜잭션”이라는 원칙과, 테스트 환경에서의 트랜잭션 격리/지연로딩 이슈까지 함께 경험하면서
앞으로는 서비스 특성에 맞게 성능과 정합성을 균형 있게 고려한 동시성 제어를 설계할 수 있는 눈을 키울 수 있었다.
레퍼런스
선착순 티켓 예매의 동시성 문제: 잠금으로 안전하게 처리하기
후의 참고 자료
참고했던 레퍼런스입니다.
'Spring' 카테고리의 다른 글
| [ Spring ] 비관적 락 성능 개선을 해보자 (0) | 2025.09.30 |
|---|---|
| [ Spring ] Redis의 분산 락을 적용하고 Pub/Sub로 동시성 문제를 해결하자. (0) | 2025.09.30 |
| [SQL]Error: 1406-22001: Data too long for column '?' at row 1 (0) | 2024.10.01 |
| [Spring Security] Security 기본 (0) | 2024.08.29 |
| [Spring] 컨트롤러 예외처리(Exception Handling) (0) | 2024.07.21 |