동시성 문제가 매우 빈번하게 발생할 것이다 => 적극적으로 해결
DB 특성, 성질 이용
select 한 후, update를 수행할 것이므로 lock을 건 select 수행
// #1. 비관적 락
@Lock(LockModeType.PESSIMISTIC_WRITE) // select ~ for update
@Query("select c from Coupon c where c.id = :id")
Optional<Coupon> findByIdWithLock(@Param("id") Long id);
// #1. 비관적 락
Coupon coupon = couponRepository.findByIdWithLock(couponId)
.orElseThrow(() -> new IllegalArgumentException("It doesn't exist coupon"));
coupon.setQuantity(coupon.getQuantity()-1);

동시성 문제가 거의 발생하지 않을 것이다. 만약 발생하면 그것에 대응하는 코드로 해결
DB 특성, 성질 이용
select 한 후, update를 수행할 때, select때의 버전(동일 엔티티 조건)이 아니면 예외 발생
=> 특정 thread의 해당 엔티티 건에 대한 시도 실패
=> 실패로 그냥 끝냄(버리기) or 실패한 thread 다시 시도
동시성 문제가 많이 발생하면 수행 시간이 비관적 락보다 더 오래 걸림
거의 발생하지 않으면, 더 적게 걸린다
기본 코드와 동일 => facade 추가

생성
package com.mycom.myapp.service;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
// #02. 낙관적 락 시도할 때, CouponService의 시도에서 예외가 발생한 경우 처리
// CouponConcurrencyTest에서 CouponService 대신 사용
@Component
@RequiredArgsConstructor
public class CouponFacade {
private final CouponService couponService;
public void issue(Long couponId) throws InterruptedException{
// 특정 Thread가 호출하면 성공할 때까지 반복적으로 재시도
while(true) {
try {
couponService.issue(couponId);
// 예외 발생하지 않고 issue()가 수행되면 성공
break;
}catch(ObjectOptimisticLockingFailureException e) {
// 버전 출동 발생
// -> 잠깐 대기 후 다시 while을 통해서 재시도
System.out.println(e.getMessage());
Thread.sleep(50);
}catch(Exception e) {
break;
}
}
}
}
에 추가
// #2. 낙관적 락을 위한 버전 관리 필드
@Version
private Long version;
에 추가
// #2. 낙관적 락
@Autowired
private CouponFacade couponFacade;
// #0. 기본 코드, #1. 비관적 락 코드
//couponService.issue(1L);
// #2. 낙관적 락
couponFacade.issue(1L);
결과는 동일
Redis 사용 -> 건별 처리 어려움
https://github.com/redis-windows/redis-windows/releases

redis-server.exe 실행시키면 됨. 6379번 포트 사용
build.gradle에 추가
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation("org.redisson:redisson-spring-boot-starter:4.1.0")
추가
spring.data.redis.host=localhost
spring.data.redis.port=6379
//#3. redis를 통한 분산 락 구현
@Component
@RequiredArgsConstructor
public class CouponFacade {
private final RedissonClient redissonClient; // 라이브러리 클라이언트
private final CouponService couponService;
public void issue(Long couponId) {
RLock lock = redissonClient.getLock("coupon_lock:"+couponId); // 락 이름 설정(couponId 별 유일)
try {
// 락 획득 시도 (줄 서기)
// waitTime : ~동안 기다릴거야
// leaseTime : 락을 얻고난 후 자동 반납 시간(길면 락을 통한 작업을 안정적으로 할 수 있지만, 시간이 오래 걸림
// 반대면 시간은 짧지만, 덜 안정적으로 처리
boolean available = lock.tryLock(10, 1, TimeUnit.SECONDS); // 10초 대기
// 대기 결과 확인
if(!available) {
// 비즈니스 로직별 처리
System.out.println("There is too many users");
// retry or termination
return; // 이 thread는 실패로 종결
}
// 락 획득 성공
couponService.issue(couponId);
} catch(Exception e) {
throw new IllegalArgumentException("system exection");
} finally {
// 락 반납
// 락이 잠겨 있고, 현재 thread에 의한 것이면
if(lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}

System.out.println("There is too many users");에 재시도를 안하고 그냥 진행해서 -900과 차이가 발생하는 것임
newFixedThreadPool(32) 값이 커지면, 실패할 확률이 높아짐. 낮을수록 실패할 확률 낮아짐
6조로 나뉘어지고 5~6명으로 구성
~1/9 기획안
15일 멘토링, 2번의 팀원 평가
개인 과제 산출물 및 팀 산출물 존재
25분+Q&A 5분 발표
시상 2팀
멘토링은 한 조당 40분
각 조당 네이버페이 5만원 지원금 ex. openAI, aws
queue에 쌓아서 꺼내는 작업인 batch로 선착순 controll 진행
package com.mycom.myapp.repository;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;
import lombok.RequiredArgsConstructor;
// Redis Queueing 작업 관련 처리
@Repository
@RequiredArgsConstructor
public class CouponRedisRepository {
private final RedisTemplate<String, String> redisTemplate;
// queue에 추가(producer)
public void addToQueue(Long userId) {
// coupon_queue 이름의 리스트에 userId를 오른쪽에 추가
redisTemplate.opsForList().rightPush("coupon_queue", String.valueOf(userId));
}
// queue에서 꺼내기(consumer)
public Long popFromQueue() {
// coupon_queue 이름의 리스트에 가장 먼저 들어온 userId를 왼쪽에서 꺼낸다
String userId = redisTemplate.opsForList().leftPop("coupon_queue");
return userId != null ? Long.valueOf(userId) : null;
}
// queue의 크기
public Long getSize() {
return redisTemplate.opsForList().size("coupon_queue");
}
}
package com.mycom.myapp.service;
import org.springframework.stereotype.Service;
import com.mycom.myapp.entity.Coupon;
import com.mycom.myapp.exception.SoldOutException;
import com.mycom.myapp.repository.CouponRedisRepository;
import com.mycom.myapp.repository.CouponRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class CouponServiceImpl implements CouponService {
private final CouponRepository couponRepository;
private final CouponRedisRepository couponRedisRepository;
// producer
// 사용자가 쿠폰을 요청하면 queue에 넣고 마무리
@Override
public void apply(Long userId) {
couponRedisRepository.addToQueue(userId);
}
// consumer
// queue에서 꺼낸 유저에게(userId) 쿠폰 발급(DB)
@Transactional
@Override
public void publish(Long userId) {
Coupon coupon = couponRepository.findById(1L)
.orElseThrow();
// 단순 동시성 제어가 아닌 100개 선착순 제한 로직 추가
if(coupon.getQuantity() <= 0) {
throw new SoldOutException(coupon.getName() + "All coupon have been used up");
}
// 소진되지 않은 경우
coupon.setQuantity(coupon.getQuantity()-1);
// 해당 user에게 쿠폰 발급 처리
// 현재는 단순 출력
// 사용자가 발급 여부 조회 요청을 하면 처리하는 코드 필요
// db or 파일
System.out.println(coupon.getName()+"issued completed, user :"+userId+", remaining quantity :"+coupon.getQuantity());
}
}
package com.mycom.myapp.exception;
// 쿠폰 발급 완료를 의미하는 사용자 정의 예외
public class SoldOutException extends RuntimeException{
private static final long serialVersionUID = 1L;
public SoldOutException(String message) {
super(message);
}
}
package com.mycom.myapp.schedule;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.mycom.myapp.exception.SoldOutException;
import com.mycom.myapp.repository.CouponRedisRepository;
import com.mycom.myapp.service.CouponService;
import lombok.RequiredArgsConstructor;
// 스프링에서 제공하는 간단한 배치 작업 용도 @Scheduled
// redis queue에 있는 userId를 순차적으로 가져와서 couponService의 publish 호출
@Component
@RequiredArgsConstructor
public class CouponScheduler {
private final CouponRedisRepository couponRedisRepository;
private final CouponService couponService;
private boolean isSoldOut = false;
// 스프링 스케줄러에 의해 호출되면 메소드가 끝날 때까지 반복적으로 redis queue에서 userId를 꺼내서 couponService publish 호출
@Scheduled(fixedDelay = 100) // 1/10초 마다 호출
public void couponEventScheduler() {
while(true) {
Long userId = couponRedisRepository.popFromQueue();
// userId x
if(userId == null) {
break;
}
// sold out
if(isSoldOut) {
// 다양한 비즈니스 로직 및 처리 방안에 의해 코드 작성
// userId 요청 시점에 soldout된 상태를 DB 또는 파일 저장 -> 이후 요청에 대응
continue;
}
// userId + sold
try {
couponService.publish(userId);
} catch(SoldOutException e) {
isSoldOut = true;
// 다양한 비즈니스 로직 필요
// db에 저장, 기록 후 더 이상의 사용자 요청을 받지 않도록 처리 등
System.out.println("FCFS coupon has expired");
} catch(Exception e) {
e.printStackTrace();
}
}
}
}
package com.mycom.myapp;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import com.mycom.myapp.entity.Coupon;
import com.mycom.myapp.repository.CouponRepository;
import com.mycom.myapp.service.CouponService;
// 선착순
@SpringBootTest
public class CouponConcurrencyRedisTest {
@Autowired
private CouponRepository couponRepository;
@Autowired
private CouponService couponService;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@BeforeEach
void setUp() {
couponRepository.save(new Coupon("FCFS coupon", 100));
redisTemplate.delete("coupon_queue"); // 초기화
}
@Test
void queueingTest() throws Exception{
int threadCount = 1000; // thread 1개 = user 1명
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch latch = new CountDownLatch(threadCount);
for(int i=0;i<threadCount;i++) {
long userId = i; // 현재 코드 시나리오는 0번 userId부터 999번 userId까지 순차적으로 호출
executorService.submit( () -> {
try {
couponService.apply(userId);
}catch(Exception e) {
e.printStackTrace();
}finally {
latch.countDown();
}
});
}
latch.await();
// 1000명의 대기 큐 완성
// scheduler가 일하는 시간 부여
Thread.sleep(10000); // 10s
long finalQuantity = couponRepository.findById(1L).orElseThrow().getQuantity();
System.out.println("finalQuantity:"+finalQuantity);
assertEquals(0, finalQuantity);
}
}
SpringBootJpaBackendIssue2Application에 @EnableScheduling 추가
전통적인 개발 모델의 문제점 <- 애자일, 폭포수 모두 충분히 테스트가 가능한 모델
애자일과 전통적 방법 차이점 <- 계획 반복
스프린트 회고 3가지 - Keep, Problem, Try
프로덕트 백로그 우선 순위 결정 - PO
DB 어려운 문제 - 생성, 읽기, 갱신, 삭제
매일 아침 하는 거 - 일일 스크럼