티스토리 뷰

💡 최상용님의 실습으로 배우는 선착순 이벤트 시스템 강의를 듣고 정리한 내용입니다.

 

목차

     
    글 목록

     

    배경

    실시간 이벤트를 진행하거나 트래픽이 순간적으로 몰릴 때(혹은 그러한 상황이 예상될 때) 발생할 수 있는 이슈와 해결방안에 대해 알아보기위해 인프런의 실습으로 배우는 선착순 이벤트 시스템 강의를 수강하였다. 평소 이런 이슈를 고민해보고 방안을 알고 있는 것과 모르는 것만으로도 큰 차이를 낳을 수 있다고 생각한다. 강의를 통해 대용량 트래픽 요청이 예상되는 실시간 이벤트를 구현하는 경우 고려해야할 점과 해결 방안들을 알아보았다. 이전과 마찬가지로 강의에서 새로 배운 내용과 내가 원래 알고 있던 내용, 강의를 들으면서 추가적으로 궁금했던 내용들을 정리해보았다. 학습한 내용을 더 깊이있게 이해하고 다른사람과 공유하며 의견을 나누기 위해 블로그에도 기록한다.

     

    강의 소개

    실무에서 서비스를 개발을 하다보면 선착순 이벤트 시스템을 개발해야할때가 있다. 예를 들어 선착순 100명 쿠폰 지급, 선착순 100명 이자 10% 적금 이벤트 등이 있고 다양한 도메인에서 선착순 이벤트를 진행한다. 강의에서는 선착순 이벤트 시스템을 개발하면서 어떤 문제가 발생할 수 있고 어떻게 해결하는지 학습한다.

     

    선착순 이벤트를 진행하는 경우 쿠폰이 정해둔 개수보다 더 발급되거나, 이벤트 페이지 다운, 이벤트랑 상관없는 서비스에 영향을 주는 문제가 발생할 수 있다. 강의에서는 Redis를 통해 동시성 이슈를 해결하고 쿠폰발급 개수를 보장한다. 또한 Kafka를 통해 다른 서비스에 대한 영향도를 줄이고 시스템 안정성을 높인다.

     

    프로젝트 요구사항

    선착순 쿠폰 발급 이벤트를 구현하기에 앞서 현재 다음과 같은 요구사항이 있다고 가정한다.

     

    • 선착순 100명에게만 지급되어야한다.
    • 101개 이상이 지급되면 안된다.
    • 순간적으로 몰리는 트래픽을 버틸 수 있어야 한다.

    프로젝트 세팅

    프로젝트 환경은 Java, Spring Boot, Spring Data JPA, Docker, Mysql, Redis로 세팅하였다. 각각의 버전은 다음과 같다.

     

    • 스프링 부트 : 3.3.2
    • 자바 : 17
    • 의존성 : Spring Web, MySQL Driver, Spring Data Jpa

     

    쿠폰발급 로직 작성

    먼저 Java와 Spring Boot 만으로 쿠폰 발급 로직을 작성하고, 동시에 여러개의 요청이 몰리는 경우 발생할 수 있는 문제를 알아보자.

     

    Coupon

    Database의 Coupon 테이블과 매칭될 엔티티 클래스이다.

    package org.example.api.domain;
    
    import jakarta.persistence.Entity;
    import jakarta.persistence.GeneratedValue;
    import jakarta.persistence.GenerationType;
    import jakarta.persistence.Id;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    
    @Entity
    @NoArgsConstructor
    public class Coupon {
    	@Id
    	@GeneratedValue(strategy = GenerationType.IDENTITY)
    	private Long id; // coupon id
    
    	@Getter
    	private Long userId; // 쿠폰을 발급받은 사용자의 id
    
    	public Coupon(Long userId) {
    		this.userId = userId;
    	}
    }

     

    CouponRepository

    쿠폰 엔티티에 대한 CRUD를 제공한다. 스프링 데이터 JPA를 사용한다.

    package org.example.api.repository;
    
    import org.example.api.domain.Coupon;
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface CouponRepository extends JpaRepository<Coupon, Long> {
    }

     

    ApplySerivce

    쿠폰에 대한 CRUD를 통해 비즈니스 로직 즉, 쿠폰 발급 로직을 작성한다.

    package org.example.api.service;
    
    import org.example.api.domain.Coupon;
    import org.example.api.repository.CouponRepository;
    import org.springframework.stereotype.Service;
    
    import lombok.RequiredArgsConstructor;
    
    @Service
    @RequiredArgsConstructor
    public class ApplyService {
    	private final CouponRepository couponRepository;
    
    	// 쿠폰 발급 로직
    	public void apply(Long userId) {
    		// 쿠폰 개수 조회
    		long count = couponRepository.count();
    
    		// 쿠폰의 개수가 발급 가능한 개수보다 많은 경우 -> 발급 불가
    		if(count > 100) {
    			return;
    		}
    
    		// 발급이 가능한 경우 ->  쿠폰 새로 생성(발급)
    		couponRepository.save(new Coupon(userId));
    	}
    }

     

    ApplyServiceTest

    기본적인 쿠폰 발급 로직을 작성하였다. 먼저 쿠폰 1개가 정상적으로 발급되는지 테스트해본다.

    package org.example.api.service;
    
    import static org.assertj.core.api.Assertions.*;
    
    import org.example.api.repository.CouponRepository;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    
    @SpringBootTest
    class ApplyServiceTest {
    	@Autowired
    	private ApplyService applyService;
    
    	@Autowired
    	private CouponRepository couponRepository;
    
    	@Test
    	public void 한번만응모() {
    		applyService.apply(1L);
    
    		long count = couponRepository.count();
    
    		// 쿠폰 1개가 정상적으로 발급되었는지 검증
    		assertThat(count).isEqualTo(1);
    	}
    }

     

    테스트가 성공하는 것을 볼 수 있다.

     

    다음으로 동시에 여러개의 요청이 들어오는 상황을 테스트해본다. 아래 코드에서는 멀티 스레드 환경에서 동시에 1000개의 요청이 동시에 들어오는 경우를 테스트하였다.

     

    멀티 스레드 환경에서 테스트를 하기위해 ExecutorServiceCountDownLatch를 사용하였다.

    ThreadPool을 편리하게 관리할 수 있는 자바 API인 ExecutorService를 통해 병렬 작업을 간단하게 수행할 수 있다. 또한 CountDownLatch을 통해 여러 스레드가 특정 시점까지 대기하거나, 특정 조건이 만족될 때까지 실행을 지연하는 메커니즘을 구현하였다. 결과적으로 CountDownLatch를 통해 다른 스레드에서 진행중인 작업이 모두 완료할 떄까지 대기하고 테스트를 검증할 수 있다.

    @Test
    public void 여러명응모() throws InterruptedException {
    	// 동시에 여러 요청이 들어오기 때문에 멀티쓰레드를 사용한다.
    	// 1000개의 요청이 동시에 들어오는 경우를 가정한다.
    	int threadCount = 1000;
    	
    	ExecutorService executorService = Executors.newFixedThreadPool(32);
    
    	// count 값을 threadCount 값으로 초기화
    	CountDownLatch latch = new CountDownLatch(threadCount); 
    		for(int i = 0; i < threadCount; i++) {
    			long userId = i;
    			executorService.execute(() -> {
    				try {
    					applyService.apply(userId);
    				} finally {
    					// count 값을 1 감소
    					latch.countDown();
    				}
    
    			});
    		}
    
    		// await() 이후 로직은 count 값이 0이 되고 나서 실행된다.
    		latch.await();
    
    		// 100개의 쿠폰이 생성된 것을 예상
    		long count = couponRepository.count();
    		assertThat(count).isEqualTo(100);
    	}

     

    처음 요구사항이였던 100개보다 더 많은 118개의 쿠폰이 발급되고 테스트는 실패한 것을 볼 수 있다.

     

     

    테스트 실패 원인

    동시에 여러명이 요청하는 경우 테스트가 실패한 이유는 멀티 스레드 환경에서 경합 상태(Race Condition, 두 개이상의 스레드가 공유 데이터에 접근하고 동시에 작업을 할 때 발생하는 문제)이 발생했기 때문이다.

     

    우리는 다음과 같은 상황을 생각하고 100개가 발급되는 것을 예상하였다.

     

     

    하지만 실제 동작은 다음과 같았고, 결과적으로 100개보다 더 많은 쿠폰이 발급된 것이다.

     

    즉, Thread-1이 쿠폰 개수를 조회하고 이떄 발급된 쿠폰 개수가 99개이므로 발급 로직으로 진행한다. 이 시점에 Thread-2도 발급된 쿠폰의 개수를 조회힌다. Thread-1이 아직 쿠폰을 발급하지 않았으므로 이시점에 발급된 쿠폰의 개수는 여전히 99개이다.

    그 후 Thread-1이 쿠폰을 발급하고 데이터베이스에 변경사항을 반영한다. 이제 쿠폰의 개수는 100개가 된다. Thread-2도 쿠폰을 발급한다. Thread-2의 쿠폰 개수 조회시점엔 99개였으므로 쿠폰 발급 로직을 수행하고 결과적으로 101개의 쿠폰이 생성된다.

     

    동시성 문제 해결하기

    현재 경합상태는 ApplyService의 apply() 메서드에서 발급된 쿠폰의 개수를 조회하는 로직과 실제 쿠폰을 발급하는 로직간의 시점 차이로 인해 발생하였다. Race Condition는 두 개 이상의 스레드가 공유 데이터에 액세스하고 작업을 할 때 발생한다. 따라서 가장 단순한 방법으로 싱글 스레드에서 작업한다면 Race Condition은 발생하지 않을 것이다. 하지만 쿠폰 발급 로직 전체를 싱글 스레드로 구현한다면 성능 저하가 발생할 것이다. 

     

    다른 해결책으로 자바의 synchronized 키워드를 두어 apply() 메서드가 한 번에 한 스레드만 접근하도록 수행되는 것도 고려할 수 있다. 하지만 자바의 Synchronized 키워드는 서버가 여러대인 경우 여전히 Race Condition이 발생하므로 적절하지 못하다.

     

    다음으로는 경합 상태를 데이터베이스 레벨에서 Database Lock으로 동시성 문제를 해결하는 것을 고려해보자.

    이를 위해서는 쿠폰 발급 로직에서 쿠폰 개수를 조회하는 부분부터 Lock을 걸어야한다. 즉 쿠폰 개수 조회부터 발급까지 모든 구간에 Lock을 걸어야하는데, 여전히 성능 저하 이슈가 예상된다. 예를 들어 쿠폰 발급 로직이 모두 수행하는데 2초가 소요되면, Lock이 풀리는데 2초가 소요되고 요청 사용자들은 모두 그만큼 기다려야한다. 4명의 사용자가 대기하는데 각 사용자에게 쿠폰은 발급하는데 2초가 걸린다면, 4번째 요청자는 쿠폰 발급이 완료될때까지 8초를 대기해야한다.

     

    또는 발급된 쿠폰의 개수를 관리하는 별도의 테이블을 두고, 이 개수를 업데이트하는 로직을 별도의 트랜잭션으로 분리하는 방법이 있을 것이다. 하지만 별도의 테이블을 관리해주어야하고 로직이 금방 복잡해진다.

     

    Redis로 해결하기

    더 간단한 방법으로 Redis를 통해 경합 상태를 해결해본다.

    이 프로젝트에서 동시성 이슈가 발생하는 근본적인 원인이자 해결하는 핵심 키는 발급된 쿠폰의 개수이다. 즉 발급된 쿠폰의 개수에 대한 정합성만 관리하면 경합 상태를 방지할 수 있다.

     

    레디스는 key에 대한 value를 1씩 증가시키는 incr이라는 명령어를 제공한다. 이  incr 명령어는 빠른 성능(O(1))을 제공한다. (출처 : http://redisgate.kr/redis/command/incr.php)

     

    레디스는 싱글 스레드 기반으로 동작하므로 Race Condition을 방지할 수 있다. 따라서 레디스의 incr 명령어로 문제를 해결하면 Race Condition을 해결하면서 성능 저하도 방지할 수 있다.

     

    build.gradle

    Redis를 사용하기에 앞서 Redis 의존성을 추가해준다.

    dependencies {
        implementation 'org.springframework.boot:spring-boot-starter-data-redis'
        
        ...
    }

     

    External Libraries 탭에서 spring-boot-starter-data-redis 의존성이 추가된 것을 확인할 수 있다.

     

    CouponCountRepository

    레디스에 대해 CRUD를 제공하는 리포지토리, RedisTemplate를 사용한다.

    package org.example.api.repository;
    
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.stereotype.Repository;
    
    import lombok.RequiredArgsConstructor;
    
    @Repository
    @RequiredArgsConstructor
    public class CouponCountRepository {
    	private final RedisTemplate<String, String> redisTemplate;
    
    	// count 1 증가
    	public Long increment() {
    		return redisTemplate
    			.opsForValue()
    			// incr 명령어 호출
    			.increment("coupon-count");
    	}
    }

     

    ApplyService

    mysql로 쿠폰 개수를 조회하던 로직을 레디스(redis)로 조회하도록 수정한다.

    package org.example.api.service;
    
    import org.example.api.domain.Coupon;
    import org.example.api.repository.CouponCountRepository;
    import org.example.api.repository.CouponRepository;
    import org.springframework.stereotype.Service;
    
    import lombok.RequiredArgsConstructor;
    
    @Service
    @RequiredArgsConstructor
    // 쿠폰에 대한 CRUD를 통해 비즈니스 로직을 제공
    public class ApplyService {
    	private final CouponRepository couponRepository;
    	private final CouponCountRepository couponCountRepository;
    
    	// 쿠폰 발급 로직
    	public void apply(Long userId) {
    		// 쿠폰 개수 조회(mysql)
    		// long count = couponRepository.count();
    
    		// 쿠폰 개수 조회 로직을 레디스의 incr 명령어를 호출하도록 변경한다.
    		// 즉 쿠폰 발급 전에 발급된 쿠폰의 개수를 1 증가하고 그 개수를 확인한다.
    		Long increment = couponCountRepository.increment();
    
    		// 쿠폰의 개수가 발급 가능한 개수보다 많은 경우 -> 발급 불가
    		if(increment > 100) {
    			return;
    		}
    
    		// 발급이 가능한 경우 ->  쿠폰 새로 생성(발급)
    		couponRepository.save(new Coupon(userId));
    	}
    }

     

     

    그 후 실패했던 테스트 코드를 다시 실행해보면 성공하는 것을 볼 수 있다.

     

     

    문제 해결 원리를 시간에 따라 정리해보면 다음과 같다. 여기서 핵심은 레디스는 싱글 스레드 기반으로 동작한다는 것이다.

     

    먼저 Thread-1이 incr 명령어를 통해 쿠폰 카운트 1 증가 로직을 실행한다. Thread-1의 incr 명령어가 완료되기 전에 Thread-2도 동일한 명령어를 실행한다. 이때 Thread-2는 Thread-1에서 레디스에 쿠폰 카운트 증가 작업을 진행하는 동안 대기하고 있다가 Thread-1의 작업을 완료되면 실제 1 증가 로직을 실행한다. 쉽게 말해 발급된 쿠폰의 개수 조회만 싱글 스레드로 하며 성능 저하를 방지하는 것이다.

    결과적으로 Redis의 싱글 스레드를 통해 모든 스레드에서 언제나 가장 최근의 coupon count 값을 조회하고, 실제로 쿠폰이 100개 이상 발급되는 현상은 발생하지 않는다.

     

    또 다른 문제

    Redis를 도입하고 싱글 스레드의 특성을 통해 멀티 스레드 환경에서 coupon count의 값을 보장하였다. 이번에는 기존 문제 해결 방안에서 추가적으로 발생할 수 있는 문제를 알아본다.

     

    현재 쿠폰 발급 로직을 살펴보면 다음과 같다.

     

    1. Redis에 현재 발행된 쿠폰의 개수를 조회한다(항상 최근의 개수를 조회하도록 보장)
    2. 발급이 가능한지 확인한다.(e.g. 발행된 쿠폰의 개수가 100개 미만인지)
    3. 발급이 가능하다면 데이터베이스에 쿠폰을 저장하여 발급한다.
    package org.example.api.service;
    
    import org.example.api.domain.Coupon;
    import org.example.api.repository.CouponCountRepository;
    import org.example.api.repository.CouponRepository;
    import org.springframework.stereotype.Service;
    
    import lombok.RequiredArgsConstructor;
    
    @Service
    @RequiredArgsConstructor
    public class ApplyService {
    	private final CouponRepository couponRepository;
    	private final CouponCountRepository couponCountRepository;
    
    	// 쿠폰 발급 로직
    	public void apply(Long userId) {
    		// 레디스의 incr 명령어를 호출하여 쿠폰 개수 조회 로직
    		Long increment = couponCountRepository.increment();
    
    		// 쿠폰의 개수가 발급 가능한 개수보다 많은 경우 -> 발급 불가
    		if(increment > 100) {
    			return;
    		}
    
    		// 발급이 가능한 경우 ->  쿠폰 새로 생성(발급)
    		couponRepository.save(new Coupon(userId));
    	}
    }

     

    여기서 발생할 수 있는 문제는 데이터베이스에 직접 쿠폰을 발급하면서 발생한다. 선착순 이벤트 특성상 짧은 시간에 순간적으로 트래픽이 몰리고 발급하는 쿠폰의 개수가 많아질수록 데이터베이스에 부하를 주게된다. 만일 다양한 서비스에 운영중인 RDB에 위와 같이 쿠폰을 발급한다면 다른 서비스로 장애가 이어질 수도 있다.

     

    예시

    현재 mysql은 1분에 100개의 insert가 가능하다고 가정해보자. 다음과 같이 요청이 들어오는 상황을 가정해보자

     

    첫번째로 발생할 수 있는 문제는 쿠폰 생성 요청이 오래 걸리고 타임아웃 정책에 따라 일부는 발급이 누락될 수 있다. 현재 가정한 데이터베이스의 스펙은 1분에 100개의 Insert만 가능하므로 총 10000개의 쿠폰을 생성하려면 100분이 소요된다.

    또한 쿠폰 생성 요청 이후에 들어온 주문 생성과 회원가입 요청은 무려 100분 뒤에 처리된다. 심지어 타임아웃 정책에 따라 누락될 수 있다. 결과적으로 짧은 시간에 순간적으로 많은 요청을 전달한다면 DB에 부하로 이어질 수 있고 서비스 지연 혹은 오류로 이어질 것이다.

     

     

    부하테스트

    실제로 부하테스트를 진행해보자. 테스트는 K6를 사용하여 진행하였다.

     

    ApplyController

    부하 테스트 툴로부터 API 요청을 받기 위해 컨트룰러를 추가로 구현하였다.

    package org.example.api.controller;
    
    import org.example.api.domain.UserRequest;
    import org.example.api.service.ApplyService;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    
    @Slf4j
    @RestController
    @RequestMapping("/coupon")
    @RequiredArgsConstructor
    public class ApplyController {
    	private final ApplyService applyService;
    
    	@PostMapping("/apply")
    	public void applyCoupon(@RequestBody UserRequest request) {
    		log.info("apply coupon request by userId = {}", request.getUserId());
    		applyService.apply(request.getUserId());
    	}
    }

     

    UserRequest

    컨트룰러 요청에 매핑될 클래스이다.

    package org.example.api.domain;
    
    import lombok.AllArgsConstructor;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    
    @Getter
    @NoArgsConstructor // ObjectMapper(Jackson 라이브러리)를 사용하는 경우 기본생성자가 필요함
    @AllArgsConstructor
    public class UserRequest {
    	private Long userId;
    }

     

    CouponCountRepository

    테스트 코드의 멱등성을 보장하기 위해 테스트 전후로 레디스에 적재된 key를 제거하기 위해 deleteByKey() 메서드를 추가하였다.

    package org.example.api.repository;
    
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.stereotype.Repository;
    
    import lombok.RequiredArgsConstructor;
    
    // 레디스에 대해 CRUD를 제공하는 리포지토리
    @Repository
    @RequiredArgsConstructor
    public class CouponCountRepository {
    	private final RedisTemplate<String, String> redisTemplate;
    
    	...
    
    	// 등록된 key 모두 제거(테스트코드용)
    	public void deleteByKey(String key) {
    		redisTemplate.delete(key);
    	}
    }

     

    ApplyControllerTest

    부하테스트를 진행하기에 앞서 단위 테스트를 작성하여 컨트룰러가 제대로 동작하는지 확인해보자.

    package org.example.api.controller;
    
    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
    
    import org.assertj.core.api.Assertions;
    import org.example.api.domain.UserRequest;
    import org.example.api.repository.CouponCountRepository;
    import org.example.api.repository.CouponRepository;
    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.Test;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
    import org.springframework.boot.test.context.SpringBootTest;
    import org.springframework.http.MediaType;
    import org.springframework.test.web.servlet.MockMvc;
    import org.springframework.transaction.annotation.Transactional;
    
    import com.fasterxml.jackson.databind.ObjectMapper;
    
    @SpringBootTest
    @AutoConfigureMockMvc
    @Transactional // 테스트 후 롤백을 자동으로 수행
    class ApplyControllerTest {
    	@Autowired
    	private MockMvc	mockMvc;
    
    	@Autowired
    	private CouponRepository couponRepository;
    
    	@Autowired
    	private CouponCountRepository couponCountRepository;
    
    	@Autowired
    	private ObjectMapper objectMapper;
    
    	@AfterEach
    	void tearDown() {
    		// Redis 키 제거
    		couponCountRepository.deleteByKey("coupon-count");
    		couponCountRepository.deleteByKey("applied-user");
    	}
    
    	@Test
    	void applyCoupon() throws Exception {
    		// 요청 파라미터
    		UserRequest request = new UserRequest(1L);
    
    		// API 요청 및 응답 확인
    		mockMvc.perform(post("/coupon/apply")
    				.contentType(MediaType.APPLICATION_JSON)
    				.content(objectMapper.writeValueAsString(request)))
    			.andExpect(status().isOk());
    
    		// 쿠폰 발급 확인
    		Assertions.assertThat(couponRepository.findById(request.getUserId()))
    			.isNotEmpty();
    	}
    }

     

    테스트가 정상 수행되는 것을 볼 수 있다.

     

     

    스크립트 실행 (coupon-apply-script.js)

    컨트룰러를 구현했으니 부하 테스트를 본격적으로 실행해보자.

    부하 테스트 요청을 전송할 스크립트는 다음과 같다. 초당 5000명의 user가 10초간 요청을 보내는 상황을 가정한다.

    import http from 'k6/http';
    import { check, sleep } from 'k6';
    
    // 초당 1000명의 user가 30초간 요청을 보내는 상황을 가정
    export const options = {
      // A number specifying the number of VUs to run concurrently.
      vus: 5000,
      // A string specifying the total duration of the test run.
      duration: '10s',
    };
    
    export default function() {
      // 요청 본문 데이터
      const userId = __VU; // __VU : 현재 가상 사용자 ID
      const payload = JSON.stringify({
        userId: userId.toString(),
      });
    
      // POST 요청 헤더
      const headers = {
        'Content-Type': 'application/json',
      };
    
      // POST 요청
      const response = http.post('http://localhost:8080/coupon/apply', payload, {headers});
    
      // 응답 검증
      check(response, {
        "is OK": (response) => response.status === 200,
      });
    }

     

    스크립트 실행 명령어는 다음과 같다.

    k6 run coupon-apply-script.js

     

    부하테스트 진행 결과는 다음과 같다.

     

    초당 트랜잭션 처리 건수는 2750.53 tps이며 전체 요청 중 84%만이 성공한 것을 확인할 수 있다. 순간적으로 데이터베이스에 요청이 몰려 일부 요청이 실패한 것을 볼 수 있다.

     

    다음 글에서는 데이터베이스에 바로 요청을 하면서 DB에 부하가 발생하는 상황을 Kafka를 도입하여 개선해볼 것이다.

    Comments