개발자는 기록이 답이다
쿠폰 발급에 대한 동시성 처리 (2) - MySQL의 NamedLock, Redis의 분산락(Lettuce, Redisson) 본문
쿠폰 발급에 대한 동시성 처리 (2) - MySQL의 NamedLock, Redis의 분산락(Lettuce, Redisson)
slow-walker 2024. 3. 2. 21:532024.02.29 - [Spring/트러블 슈팅] - 쿠폰 발급에 대한 동시성 처리 (1) - synchronized, pessimisti Lock, optimistic Lock
지난 시간에 이어서 동시성 제어하는 방법에 대한 다른 방법들을 알아보겠습니다.
레코드 락을 사용한 비관적 락을 통해서도 여러 서버간의 동시성 문제를 해결할 수 있긴 하지만,
DB도 분산되어 있다면 해당 기법을 사용할 수 없습니다. 그래서 다른 Locking 방법에 대해도 알아보고자 합니다.
- 네임드락 (락을 트랜잭션 외부로, 트랜잭션 분리)
- Redis의 Lettuce
- Redis의 Redisson
1. 분산락
웹 애플리케이션의 프로세스가 단일 서비스에서 실행되는 경우 동시성 문제에 대한 처리가 비교적 단순하지만, 서버를 다중화하여 부하 분산을 수행하는 경우 여러 서버 간의 상호 배제가 필요합니다. 이러한 동시성 문제를 해결하기 위한 방법 중 하나가 분산 락입니다.
분산락을 구현하기 위해서는 락에 대한 정보를 '어딘가'에 공통적으로 보관해야 합니다. 그리고 여러 서버 간에 해당 락에 대한 정보를 공통된 저장소를 통해 공유하면서, 자신이 임계영역에 접근할 수 있는지 확인합니다. 이렇게함으로써 여러 서버간의 동기화를 보장하며, 원자성을 유지할 수 있게 됩니다.
그리고 그 '어딘가'로 활용되는 기술은 MySQL의 네임드락, Redis, Zookeeper등이 있습니다.
2. MySQL의 네임드락
네임드락은 이름을 가진 메타데이터 락입니다. 테이블이나 레코드 또는 AUTO_INCREMENT와 같은 데이터 베이스 객체가 아니라 단순히 사용자가 지정한 문자열(String)에 대해 락을 획득하고 반납(해제)하는 잠금 방식입니다. 한 세션이 Lock을 획득한다면, 다른 세션은 해당 세션이 Lock을 해제할때 까지 락을 획득할 수 없습니다.
MySQL에는 GET_LOCK()함수를 이용해 임의의 문자열에 대해 잠금을 설정할 수 있고, RELEASE_LOCK()함수를 통해 락을 해제할 수 있습니다.
Pessimistic Lock과 비슷한 것 같지만 다릅니다.
락을 설정하는 대상
- Pessimistic Lock은 row나 테이블 단위로 락을 걸지만, Named Lock은 메타데이터에 락킹하는 방법입니다.
- 다시 말해, Pessimistic Lock은 락킹을 할 레코드가 존재해야 하지만 네임드락은 임의의 문자열을 사용하기 때문에 상관없습니다.
- 이는 UPDATE작업이 아닌 INSERT 작업에서 정합성을 맞춰야 하는 경우에 기준을 잡을 레코드가 존재하지 않은 경우 비관적 대신 NamedLock을 사용하면 됩니다.
주의할 점
- Pessimistic Lock은 트랜잭션이 종료될때 자동으로 락이 해제됩니다.
- Named Lock은 별도의 명령어를 통해 락을 해제하거나 선점시간이 끝나야 락이 해제됩니다. 그래서 네임드 락은 락 해제 및 세션 관리를 잘 해주어야 하기 때문에 주의해서 사용해야 합니다.
네임드락을 적용해서 동시성을 제어하는 방법에는 2가지 방법이 있습니다.
- 락의 위치 선정을 트랜잭션 외부로
- 트랜잭션 분리
1) 락의 획득과 반납은 트랜잭션 외부에서 진행
락의 획득과 해제를 CouponIssueFacade클래스에서 명시해주고, 비즈니스로직인 couponIssueService를 호출해주도록 하겠습니다.
getLock메소드와 releaseLock 메소드는 "namedLock"이라는 문자열에 대해 락을 걸도록 해주었고, timeout은 3초로 설정해두었습니다.
CouponIssueService의 issueCoupon 비즈니스로직은 아래와 같습니다.
이전에 했었던 pessimistic Lock과 Optimistic Lock과 달리 `쿠폰 조회 및 발급 된 쿠폰 수 증가` 부분은 일반적인 로직과 동일합니다.
@Slf4j
@RequiredArgsConstructor
@Service
public class CouponIssueService {
private final EventRepository eventRepository;
private final CouponIssueRepository couponIssueRepository;
private final CouponRepository couponRepository;
@Transactional
public ResponseDTO issueCoupon(LocalDateTime currentDateTime, long eventId, long couponId, long memberId) throws Exception {
// 이벤트(Event 테이블) 기간 및 시간 검증
checkEventPeriodAndTime(eventId, currentDateTime);
// 쿠폰 조회 및 발급된 쿠폰 수 증가 (Coupon 테이블의 issuedQuantity)
increaseIssuedCouponQuantity(couponId);
// 중복 발급 제한 및 쿠폰 발급 이력 저장 (CouponIssue 테이블)
saveCouponIssue(memberId, couponId, currentDateTime);
return ResponseDTO.getSuccessResult("쿠폰이 발급 완료되었습니다. memberId : %s, couponId : %s".formatted(memberId, couponId));
}
@Transactional
public void increaseIssuedCouponQuantity(long couponId) {
Coupon existingCoupon = findCoupon(couponId);
Coupon updatecoupon = existingCoupon.increaseIssuedQuantity(existingCoupon);
couponRepository.increaseIssuedQuantity(updatecoupon);
}
@Transactional(readOnly = true)
public Coupon findCoupon(long couponId) {
return couponRepository.findCouponById(couponId)
.orElseThrow(() -> new CouponNotFoundException(COUPON_NOT_EXIST.formatted(couponId)));
}
...
}
CouponIssueFacade클래스의 IssueCoupon메소드를 통해 동시성 테스트를 진행해보겠습니다
100개의 스레드를 전부 처리하는데 소요된 시간은 2sec 408ms입니다. issued_quantity의 쿠폰 개수도 정합성이 맞는것을 확인할 수 있습니다.
락의 획득과 반납을 트랜잭션 외부로 두는 방법은 아래처럼 동작합니다. 이전 포스팅에서 synchronized를 사용했던 방식과 동일한 flow로 발생합니다.
- 네임드락 획득
- 트랜잭션 시작
- 비즈니스 로직 수행
- 트랜잭션 반납
- 네임드락 해제
이전에 언급했던 내용을 다시 한번 말하는 이유는 네임드락이 2번째 방식과는 다른점이 있기 때문입니다.
SQL로그를 확인해보겠습니다.
100개의 스레드를 동시에 실행했는데, 첫번째 사진부터 보면 스레드 11번이 먼저 getLock 명령어로 락을 획득하고 트랜잭션이 시작되는 것을 볼 수 있습니다. 이후로 두번째로직 이후로 비즈니스 로직을 수행하는데 트랜잭션을 실행하기전에 Lock을 걸어놨으니 issued_quantity에서 경합이 발생하는 것 없이 무사히 업데이트를 하는 것을 볼 수 있습니다.
이후로 모든 비즈니스 로직이 끝나면 트랜잭션이 커밋되고 releasLock을 통해 락을 해제합니다.
이후로 다음 3번스레드가 동일하게 락을 획득하고 다음 로직을 진행합니다. 이처럼 락 획득과 반납을 트랜잭션 외부에 둠으로써 공유자원에 대해 접근할 수 있는 실행자를 하나로만 제한할 수 있습니다.
스레드 개수를 1000개로 설정했을때 테스트 소요시간은 10sec 619ms가 걸립니다. MySQL에서 발생한 부하로는 CPU 점유율이 최대 31.02%까지 상승하고 Jmeter로 1000개의 요청을 보냈을때 TPS가 128.4/sec이 나오는것을 확인했습니다.
2) 트랜잭션 분리하기
위에서 진행했던것과 달리 락을 트랜잭션 내부에서 획득하고 반납하는 대신 동시성 이슈가 발생하는 부분만 별도의 트랜잭션으로 분리하는 방법입니다.
# NAMED LOCK을 위해 커넥션 풀 사이즈 설정
spring.datasource.hikari.maximum-pool-size=40
트랜잭션을 분리할때에는 커넥션 풀 사이즈를 조절해주는 것이 중요합니다. 그렇지 않으면 트랜잭션을 위한 JDBC 커넥션을 열 수 없다는 에러가 발생합니다.
20240306 12:21:43.504 [pool-3-thread-10]
TRACE o.s.t.i.TransactionInterceptor -
Completing transaction for
[com.flab.offcoupon.service.couponIssue.NamedLockCouponIssue.issueCoupon]
after exception: org.springframework.transaction.CannotCreateTransactionException:
Could not open JDBC Connection for transaction
spring.datasource.hikari.maximum-pool-size 설정은 HikariCP 커넥션 풀에서 관리하는 최대 커넥션 수를 지정합니다.
트랜잭션을 별도로 분리하면 각 트랜잭션에서 동시에 사용할 수 있는 커넥션의 수가 늘어나기 때문에 전체적으로 커넥션 풀의 크기를 적절하게 설정하는 것이 중요합니다.
Spring Boot의 HikariCP 커넥션 풀의 maximum-pool-size 이란?
유휴 상태와 사용중인 커넥션을 포함해서 풀이 허용하는 최대 커넥션 개수를 설정합니다.
풀이 해당 사이즈에 도달하고 유휴 커넥션이 없을때 connectionTimeout이 지날때 까지 getConnection()호출은 블로킹됩니다.
Default size는 10입니다.
이번 예시에서는 네임드락을 사용하기 위해 트랜잭션을 분리함으로써 커넥션도 2개를 사용합니다.
더 많은 동시 트랜잭션이 처리될 수 있도록 하기 위해 spring.datasource.hikari.maximum-pool-size 설정을 높게 조정했지만, 과도하게 높게 설정할 경우에는 서버 자원을 낭비하게 됩니다.
네임드락에서 동시성 이슈가 발생하는 부분만 별도로 트랜잭션 분리해서 적용해보도록 하겠습니다.
@Slf4j
@RequiredArgsConstructor
@Service
public class CouponIssueService {
private final EventRepository eventRepository;
private final CouponIssueRepository couponIssueRepository;
private final NamedLockRepository namedLockRepository;
private final IncreaseIssuedCoupon increaseIssuedCoupon;
@Transactional
public ResponseDTO issueCoupon(LocalDateTime currentDateTime, long eventId, long couponId, long memberId) throws Exception {
// 이벤트(Event 테이블) 기간 및 시간 검증
checkEventPeriodAndTime(eventId, currentDateTime);
try {
int getLock = namedLockRepository.getLock("namedLock");
log.info("getLock = {}", getLock);
// 쿠폰 조회 및 발급된 쿠폰 수 증가 (Coupon 테이블의 issuedQuantity)
increaseIssuedCoupon.increaseIssuedCouponQuantity(couponId);
} finally {
int releaseLock = namedLockRepository.releaseLock("namedLock");
log.info("releaseLock = {}", releaseLock);
}
// 중복 발급 제한 및 쿠폰 발급 이력 저장 (CouponIssue 테이블)
saveCouponIssue(memberId, couponId, currentDateTime);
return ResponseDTO.getSuccessResult("쿠폰이 발급 완료되었습니다. memberId : %s, couponId : %s".formatted(memberId, couponId));
}
@Transactional
public void saveCouponIssue(long memberId, long couponId, LocalDateTime currentDateTime) {
LocalDate currentDate = currentDateTime.toLocalDate();
checkAlreadyIssueHistory(memberId, couponId, currentDate);
CouponIssue couponIssue = CouponIssue.create(memberId, couponId);
couponIssueRepository.save(couponIssue);
}
@Transactional(readOnly = true)
public void checkAlreadyIssueHistory(long memberId, long couponId, LocalDate currentDate) {
if (couponIssueRepository.existCouponIssue(new CouponIssueCheckVo(memberId, couponId, currentDate))) {
throw new DuplicatedCouponException(DUPLICATED_COUPON.formatted(memberId, couponId));
}
}
@Transactional(readOnly = true)
public Event findEvent(long eventId) {
return eventRepository.findEventById(eventId)
.orElseThrow(() -> new EventNotFoundException(EVENT_NOT_EXIST.formatted(eventId)));
}
}
쿠폰 조회하고 업데이트하는 부분만 별도로 다른 클래스로 분리해서 Propagation.REQUIRES_NEW을 설정해줬습니다.
Propagation.REQUIRES_NEW : 매번 새로운 트랜잭션을 시작한다. ( 새로운 Connection을 생성하고 실행 )
REQUIRES_NEW를 사용하면 기존에 트랜잭션이 있다면 완전히 독립적인 단위로 작동합니다.
마찬가지로 스레드 100개로 동시성 테스트를 진행해보겠습니다.
테스트 소요 시간은 2sec 911ms이 걸렸고, 마찬가지로 정합성도 일치하는 것을 확인할 수 있습니다.
- A 트랜잭션 시작
- 비즈니스 로직 수행
- 네임드락 획득
- 동시성 이슈가 발생하는 부분의 B 트랜잭션 시작
- B 트랜잭션 반납
- 네임드락 해제
- A 트랜잭션 반납
이와 관련해서 SQL과 트랜잭션 로그를 확인해보겠습니다. 100여 개의 스레드가 트랜잭션을 시작하고 각각 이벤트 테이블에 대해 조회합니다.
그리고 21번 스레드부터 네임드락을 획득하고, 새로운 트랜잭션으로 쿠폰을 조회하고, 업데이트를 하는 순간에는 락획득으로 인해 어떤 다른 스레드도 접근하지 못하는 것을 볼 수 있습니다. 21번 스레드가 트랜잭션을 커밋하고 네임드락을 반납하면, 그 다음 블로킹 되어 대기중이던 3번 스레드가 락을 획득하고 다음 로직을 수행합니다.
스레드 1000개일 경우에는 테스트소요시간은 11sec 95ms, MySQL의 부하는 최대 46.81%까지 올라갔습니다.
동시에 커넥션이 2개씩 되니까 첫번째 NamedLock보다 더 부하가 많이 발생한 것 같습니다.
Jemter로 1000개로 테스트해봐도 HikariPool에서 커넥션을 가져오려고 시도하지만 일정 시간 내에 사용 가능한 커넥션 풀이 없어서 java.sql.SQLTransientConnectionException가 발생합니다.
20240306 18:33:23.323 [http-nio-8080-exec-66] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.transaction.CannotCreateTransactionException: Could not open JDBC Connection for transaction] with root cause
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30010ms.
at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:696)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:181)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:146)
at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128)20240306 18:33:23.323 [http-nio-8080-exec-66] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.transaction.CannotCreateTransactionException: Could not open JDBC Connection for transaction] with root cause
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30010ms.
at com.zaxxer.hikari.pool.HikariPool.createTimeoutException(HikariPool.java:696)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:181)
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:146)
at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128)
Thoughput도 4.1/sec로 지금까지 본 것 중 제일 낮은 처리량입니다. 아무래도 커넥션 풀 사이즈를 조정했어도 데이터 소스를 분리하지 않는 한 단일 데이터베이스에서는 동시에 처리할 수 있는 커넥션 생성에 무리가 많이 가는것 같습니다. 그래서 저는 프로젝트에서 네임드락으로 락을 처리할땐 1번 방법으로 사용했습니다.
3. Redis의 Lettuce (SETNX)
Redis의 Lettuce클라이언트를 이용하면 setnx명령어를 활용해서 분산락을 구현할 수 있습니다.
지금까지 해왔던 걸로 보아 알 수 있듯이 락을 획득한다 라는 것은 아래 2가지 연산이 atmotic하게 이루어집니다.
1. 락이 존재하는지 확인한다
2. 존재하지 않으면 락을 획득한다
Redis에는 "값이 존재하지 않으면 세팅한다"라는 setnx명령어가 있습니다.
- setnx (set if not exist) : key와 value를 set할때 기존에 값이 존재하지 않을 경우에만 SET하는 명령어 입니다.
Spinlock 이란?
대기중인 스레드가 공유 자원의 상태를 무한 루프를 이용해 확인하는 방식입니다.
락이 걸려있으면 작업하지 못하고, 락이 걸려있지 않다면 작업할 수 있어서 무한 반복으로 lock을 얻을 수 있을 때 까지 확인하면서 대기하는 것을 말합니다.
MySQL의 네임드락과 비슷하지만, setnx 명령어를 사용하면 session관리를 신경쓰지 않아도 됩니다.
반환값
- 1 : 성공
- 0 : 실패
spin lock이란 락을 획득하려는 스레드가 락을 사용할 수 있는지 반복적으로 확인하면서 락 획득을 시도하는 방식입니다.
스레드1이 key가 1인 데이터를 레디스에 SET하려고 합니다. 처음에는 레디스에 key가 1인 데이터가 없으므로 정상적으로 SET하게 되고, 스레드1에게 성공을 리턴하게 됩니다. 그 후 스레드2가 똑같이 key가 1인 데이터를 SET하려고 할때 이미 레디스에 key가 1인 데이터가 있으므로 실패를 리턴하게 됩니다. 스레드2는 락 획득에 실패를했기 때문에 일정 시간 이후에 락 획득을 재시도를 합니다.
setnx는 Spin lock 방식임으로 Retry로직을 개발자가 작성해줘야 하고 Redis에 부하를 줄수있습니다.
그래서 Thread.sleep을 통해 락 획득 재시도 간에 텀을 둬야 합니다
100개의 스레드로 테스트 시 소요시간은 4sec 508ms가 나옵니다.
1000개 시도했을때, MySQL과 Redis에 부하가 어느정도로 발생하는지도 확인해보겠습니다.
락 획득 반납에 대한 역할을 MySQL에서 Redis로 넘겨서 그런지 기존 네임드락에서 했을때와 비교해서 MySQL의 부하가 줄고, Redis의 부하가 살짝 올라갔습니다. 락의 역할만 다른 대상에게 넘겨줘도 부하를 줄일 수 있다면 좋은 선택인 것 같습니다.
하지만 TPS의 경우 네임드락 1번 방법보다 1/2이 낮아졌습니다. Spinglock을 구현할때 발생하는 오버헤드가 크면 TPS가 감소할 수 있다고 합니다. 정확하게 모르겠어서 이부분에 대해서 알고 계시는 분이 있으시다면 댓글 부탁드립니다!
Sprinlock이 좋지 않은 이유에 대해 알아보겠습니다.
레디스에 부담을 줄이기 위해 100ms만큼 sleep하면서 락 획득 재시도 로직을 수행했지만, 이는 100ms 간격으로 계속해서 레디스에 요청을 보내는 것이기 때문에 작업이 오래 걸리거나 요청 수가 많을수록 더 큰 부하를 야기할 수 있습니다.
가령, 500ms가 걸리는 동기화된 작업에 동시에 100개의 요청이 들어온다면, 처음으로 락을 획득한 1개의 요청을 제외하고 나머지 99개의 요청은 작업이 완료되는 500ms 동안 990회의 락 획득 요청을 하게 됩니다. 즉, 레디스에게 1초 동안 1980번의 요청을 보내게 됩니다.
(500ms / 50ms) * 99개 요청 = 990회 락 획득 재시도 요청
만약 이 요청 횟수를 줄이기 위해(레디스에 부담을 줄이기 위해) sleep 시간을 늘린다면, 늘린 시간만큼 대기하는 시간이 증가하게 되어 악순환이 발생할 수 있습니다.
또한 Redis Setnx 공식문서 에 따르면 해당 명령어는 deprecated로 레디스 버전 2.6.12이후로 권장하지 않는다고 합니다.
SETNX명령어는 기존에 락 기법으로서 사용되어왔지만, 2.6.12버전 부터는 Redlock알고리즘중 하나인 Redisson이나 Lua스크립트를 사용해서 더 간단하게 만들수 있다고 나와있습니다.
4. Redis의 Redisson (pub/sub)
먼저 Redisson라는 건 Jedis, Lettuce와 같은 자바 레디스 클라이언트입니다.
Lettuce와 비슷하게 Netty를 사용해서 non-blocking I/O를 사용합니다. 특징으로 직접 레디스 명령어를 사용하는 것이 아니라, Bucket이나 Map같은 자료구조나 Lock같은 특정한 구현체의 형태로 제공됩니다.
Redis에는 pub/sub(메세지 브로커)기능을 지원하는데, Redisson에서 해당 pub/sub 기능을 활용해서 스핀 락이 레디스에게 주는 엄청난 트래픽을 줄였습니다.
pub/sub기반은 채널을 하나 만들고 락을 점유중인 스레드가 락 획득하려고 대기중인 스레드에게 해제를 알려주면 안내를 받은 스레드가 락 획득을 시도하는 방식입니다. 스핀락 처럼 별도의 RETRY 로직을 작성하지 않아도 됩니다.
Redisson은 자신이 점유하고 있는 락을 해제할때 채널에 메시지를 보내줌으로써 락을 획득해야 하는 스레드들에게 락을 획득하라고 전달합니다. 채널을 구독하고 있던 스레드들은 메세지를 받았을 때 락 획득을 시도합니다.
그림으로 보면 아래와 같습니다. 채널이 하나 있고, 스레드 1이 먼저 락을 점유하고 스레드2가 이후에 시도를 하려고 한다면 스레드1이 락을 해제할때 끝났어라는 메세지를 채널로보내게됩니다. 스러면 채널은 스레드2에게 락 획득 시도해라는걸 알려주고 안내를 받은 스레드2는 락 획득을 시도하게 됩니다.
RedissonClient를 이용해서 RLock와 같은 락 객체를 가져올 수 있습니다. 이처럼 Lock관련 클래스를 Redisson에서 제공해주기 때문에 별도의 Repository를 만들지 않아도 됩니다.
트랜잭션 바깥에서 락 획득 반납을 해주면 간단하게 동시성 제어를 할 수 있고, 초록불이 뜨면서 정합성이 일치하는걸 확인했습니다.
마찬가지로 스레드 1000개를 시도했을때 부하가 어떻게 달라지는지 확인해봤습니다. Lettuce를 이용한 방식과 비슷하지만 Redis나 MySQL에 좀 더 낮은 부하를 주는 것을 확인했습니다. TPS도 Lettuce보다는 약간 더 높은 처리량을 보이고 있습니다.
그렇다면 무조건 Redisson을 이용한 방식이 좋은 걸까요?
Redis 서버가 다운되거나 문제가 발생하면 해당 Redis서버에 접근하는 모든 클라이언트가 영향받을 수 있기 때문에 Redis 서버가 SPOF가 될 수 있습니다. 하지만 이부분은 MySQL도 마찬가지라 락획득에 대한 역할을 Redis로 이동시켜서 부하를 줄여주는 관점에서 best방법이라고 생각합니다.
하지만 이전 포스팅에 비해 Throughput이 갈수록 낮아지고 있다는 점에서 좀 의아함이 있습니다.. 이부분은 다시 한번 알아봐야 할 것 같습니다
우아한 기술 블로그 : MySQL을 이용한 분산락으로 여러 서버에 걸친 동시성 관리
컬리테크 블로그 : 풀필먼트 입고 서비스팀에서 분산락을 사용하는 방법
'Spring > 트러블 슈팅' 카테고리의 다른 글
트러블 슈팅 - Junit 테스트를 하다가 Lock wait timeout exceeded 에러 발생. (0) | 2024.03.13 |
---|---|
트러블 슈팅 - private 메소드를 테스트하려 했지만, 문제는 테스트 코드 로직이었다. (0) | 2024.03.13 |
쿠폰 발급에 대한 동시성 처리 (1) - synchronized, pessimistic Lock, optimistic Lock (0) | 2024.02.29 |
DATETIME vs TIMESTAMP 둘 중 어느것이 더 나을까? (0) | 2024.02.11 |
@RequestBody는 어떻게 바인딩 되는걸까? (with. 디버깅 과정) (0) | 2024.02.09 |