쿠폰 발급 (2) - 부하테스트
새로운 요구사항
팀장: 잘 구현 해주셨군요. 대략적인 쿠폰 발행에 대해 이해도가 높아진것 같네요. 서버로 이제 구현해주세요.
세팅하기
# build.gradle
...
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'com.google.guava:guava:30.1-jre'
    implementation 'com.mysql:mysql-connector-j'
    testImplementation 'com.h2database:h2'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
...
---
# Application
@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}발급 서비스
@Component
public class CouponTicketDistribution {
    private final static int COUNT = 2000;
    private final Map<Long, LinkedBlockingQueue<CouponTicket>> tickets;
    private final CouponRepository couponRepository;
    public CouponTicketDistribution(CouponRepository couponRepository) {
        this.tickets = new HashMap<>();
        this.couponRepository = couponRepository;
    }
    public synchronized CouponTicket publish(Long couponId) {
        LinkedBlockingQueue<CouponTicket> queue = getCouponTickets(couponId);
        if (queue.isEmpty()) {
            try {
                List<CouponTicket> couponTickets = getTickets(couponId);
                couponTickets.forEach(queue::offer);
            } catch (IllegalArgumentException e) {
                return null;
            }
        }
        return queue.poll();
    }
    public List<CouponTicket> getTickets(Long couponId) {
        Coupon coupon = couponRepository.findById(couponId).get();
        List<CouponTicket> couponTickets = coupon.publish(COUNT);
        couponRepository.saveAndFlush(coupon);
        return couponTickets;
    }
    private LinkedBlockingQueue<CouponTicket> getCouponTickets(Long couponId) {
        LinkedBlockingQueue<CouponTicket> queue = tickets.get(couponId);
        if (queue == null) {
            queue = new LinkedBlockingQueue<>();
            tickets.put(couponId, queue);
        }
        return queue;
    }
}동시 요청 테스트
@SpringBootTest
@ActiveProfiles("test")
public class CouponTicketDistributionTest {
    @Autowired
    private CouponRepository couponRepository;
    @Autowired
    CouponTicketDistribution couponTicketDistribution;
    @Test
    void traffic() throws InterruptedException {
        final int LIMIT = 1000;
        Coupon coupon = couponRepository.save(Coupon.limitOf(LIMIT));
        int numberOfThreads = (int) (LIMIT * 1.5);
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        AtomicInteger activeCount = new AtomicInteger();
        AtomicInteger errorCount = new AtomicInteger();
        List<Long> times = new ArrayList<>();
        for (int i = 0; i < numberOfThreads; i++) {
            int finalI = i;
            executorService.execute(() -> {
                try {
                    long startTime = System.currentTimeMillis();
                    long stackTime = 0;
                    CouponTicket actual = couponTicketDistribution.publish(coupon.getId());
                    if (actual == null) {
                        errorCount.getAndIncrement();
                    } else {
                        activeCount.getAndIncrement();
                    }
                    times.add(timeLog(finalI, startTime, stackTime));
                } catch (Exception e) {
                    System.out.println("발급불가 : " + finalI + " / " + e.getMessage());
                }
                latch.countDown();
            });
        }
        latch.await();
        assertAll(
                () -> assertThat(activeCount.get()).isEqualTo(LIMIT),
                () -> assertThat(errorCount.get()).isEqualTo(numberOfThreads - LIMIT)
        );
    }
      
   
    
오래걸리는 작업은 126ms 까지 나왔다.
좀 더 빨리 할 수 없을까? 고민이 되었다.
이미 발급이 종료되었다면 빠르게 리턴하면 좋지 않을까?
    public synchronized CouponTicket publish(Long couponId) {
        if (isFinal) {
            return null; <== 빠르게 리턴
        }
        ...
   }
    public List<CouponTicket> getTickets(Long couponId) {
        Coupon coupon = couponRepository.findById(couponId).get();
        updateIsFinal(coupon); # <== 추가
        List<CouponTicket> couponTickets = coupon.publish(COUNT);
        couponRepository.saveAndFlush(coupon);
        return couponTickets;
    }테스트 결과 58ms로 절반 정도 시간이 단축되었다.
중간점검
- 쿠폰이 발급해야되는 양만큼 정확히 발급 테스트 완료
- 100ms 이하 처리 속도 테스트 완료
서버 부하 테스트를 위한 준비
컨트롤러
@RestController
public class CouponController {
    private final CouponTicketDistribution couponTicketDistribution;
    public CouponController(CouponTicketDistribution couponTicketDistribution) {
        this.couponTicketDistribution = couponTicketDistribution;
    }
    @GetMapping("api/index")
    public ResponseEntity<TicketDto> index() {
        CouponTicket couponTicket = couponTicketDistribution.publish(1L);
        if (couponTicket != null) {
            return ResponseEntity.ok().body(TicketDto.of(couponTicket.getCouponId()));
        }
        return ResponseEntity.ok().body(TicketDto.fail());
    }
}CouponTicket 또한 발급하고 데이터 베이스에 저장 코드를 추가하였다.
# CouponTicketDistribution
...
public synchronized CouponTicket publish(Long couponId) {
       ...
        CouponTicket couponTicket = queue.poll();
        if (couponTicket != null) {
            couponTicketRepository.save(couponTicket); <== 쿠폰티켓 DB저장 
        }
        ...
}
...부하 테스트
API 부하 테스트를 진행하고자 한다.
부하 테스트는 응답시간만 간단하게 체크 할 것이기 떄문에, K6 부하 테스트를 활용한다.
k6.io
K6 테스트 스크립트
# script.js
import http from 'k6/http';
import {check, sleep} from 'k6';
export const options = {
    stages: [
        {duration: "10s", target: 500},
        {duration: "10s", target: 3000},
        {duration: "10s", target: 3000},
        {duration: "10s", target: 0},
    ],
    thresholds: {
        http_req_duration: ['p(99)<100'],
    },
};
export default function () {
    const res = http.get('http://localhost:8080/api/index');
    check(res, {'status was 200': (r) => r.status === 200});
    check(res, {'error': (r) => JSON.parse(res.body).error === null});
    sleep(1);
}높은 트래픽에서의 속도만 측정할것이기 때문에 시간은 길게하지 않았다.

응답시간이 만족스럽지 않다.
어디에서 지연이 되는걸까?
아무래도 “CouponTicket”을 DB에 저장하는 비용이 가장 커보인다.
couponTicketRepository.save(couponTicket); <== 쿠폰티켓 DB저장
제거하고 다시 테스트 해본다.
결과
avg=4.5ms min=80µs med=1.11ms max=121.67ms p(90)=12.68ms p(95)=18.76ms
응답이 빨라졌다. 예상대로 DB저장할때 부하가 많이 발생하는것으로 확인했다.
부하로직을 비동기 처리로
굳이 DB 저장까지 동기적으로 처리 할 필요가 있을까?
부하가 걸리는 작업은 비동기로 분리하면 어떨까?
As-IS
      
   
    
To-Be
      
   
    
- 이벤트
public class RegisteredCouponTicketEvent {
    private final CouponTicket couponTicket;
    public RegisteredCouponTicketEvent(CouponTicket couponTicket) {
        this.couponTicket = couponTicket;
    }
    public CouponTicket getTicket() {
        return couponTicket;
    }
}- 핸들러
@Component
public class CouponTicketHandler {
    private final CouponTicketRepository couponTicketRepository;
    public CouponTicketHandler(CouponTicketRepository couponTicketRepository) {
        this.couponTicketRepository = couponTicketRepository;
    }
    @Async
    @EventListener
    public void save(RegisteredCouponTicketEvent event) {
        couponTicketRepository.save(event.getTicket());
    }
}- CouponTicketDistribution 수정
@Component
public class CouponTicketDistribution {
  private final ApplicationEventPublisher applicationEventPublisher; <== 추가
  ...
  public synchronized CouponTicket publish(Long couponId) {
  ...
     CouponTicket couponTicket = queue.poll();
     applicationEventPublisher.publishEvent(new RegisteredCouponTicketEvent(couponTicket));
     return queue.poll();
}다시 테스트 진행
결과
      
   
    
비동기로 처리하여 속도가 향상되었다. 하지만 문제점들이 보였다.
현재 문제점
⚡️로컬 캐쉬 이슈⚡️
1. 서버가 다운되면 로컬캐쉬가 모두 증발하기 때문에 데이터 불일치가 발생한다.
      
   
    
2. 안정된 서비스 운영을 위해 서버 이중화가 필요한데, 현재 구조로는 서버 하나일 경우만 데이터 무결성,정합성이 유지된다.
- 현재 구조에서 서버를 늘릴경우
      
   
    
쿠폰티켓은 발행 제한을 시켜야한다.
서버만 증설한다면 각자 캐쉬를 가지고 있기 때문에 정합성을 지킬 수 없다.
서버간의 존재를 중재해줄수 있는 역할이 필요하거나 글로벌 캐쉬 서버가 필요 할 것 같다.
3. CouponTicket 생성 이벤트가 실패한다면?
- 후속 처리가 필요하다. 유저는 이미 발급 성공을 받았는데 쿠폰 티켓이 존재하지 않는다면 서비스의 신뢰도가 하락 될 것이다.
단순화 하자
- 쿠폰 발행 입장
- 쿠폰을 발행만 한다.
 
- 쿠폰전달 입장
- 쿠폰이 있으면 전달만한다.
 
      
   
    
설명
- 서버의 입장에서는 쿠폰이 있으면 Queue 저장소에서 빼서 전달만하면 된다.
- 발급 서버는 쿠폰을 발급 하기만 하면 된다.
다음 포스팅에서는…
글로벌 캐쉬로 레디스를 사용해봐야겠다.