레디스를 이용한 데이터 캐싱 도입기 - Part1에 이어서 서비스에 레디스 캐시를 적용 후 진행한 성능 테스트에 대해 이야기해보려고 한다.


서론

개발자라면 누구나 DB만 사용했을 때 보다 캐싱을 적용한 로직이 처리가 빠르다는 것을 안다.

하지만 추상적으로 받아들이는 것 말고 구체적으로 검증을 하고 싶었다.

이전과 비교해서 서비스 로직이 성능적으로 얼마나 좋아졌는지 확인하기 위해서 테스트를 진행했다. 

성능 테스트를 지원하는 도구는 여러 가지가 있지만 가장 익숙하고 간편한 nGrinder를 이용했다.


nGrinder

네이버에서 성능 측정을 목적으로 개발된 오픈소스 프로젝트이다. 이를 이용하면 서버의 부하를 테스트해서 성능을 측정할 수 있다.

nGrinder는 Controller와 Agent, 2가지 주요한 컴포넌트들로 이루어져 있다.

 

Controller는 성능테스트를 위한 웹 인터페이스를 제공한다. 테스트 프로세스들을 구성하고, 테스트 통계를 볼 수 있다.

그리고 테스트를 위한 스크립트를 생성하고 수정할 수 있도록 한다.

 

Agent는 타깃 서버에 부하를 주는 프로세스들과 스레드들을 동작시키는 역할을 한다.

그리고 타깃 시스템(서버)의 성능(CPU/Memory 등)을 모니터링할 수 있다. 

 

nGrinder 시스템의 구조

이번 글에서는 nGrinder에 대한 자세한 내용은 생략하고자 한다. 

좀 더 자세히 알고 싶다면 github의 userGuide 또는 다른 블로그들을 참조하는 것을 추천한다.


테스트

이제 테스트를 진행할 시간이다.

 

우선 시나리오는 두 개다.

1. 기존 로직에 대한 테스트 (DB)
2. 새로운 로직에 대한 테스트(Redis + DB) 

 

테스트 설정은 다음과 같이 하였다.

Target 서버: Local 환경, 개발서버
Vuser(virtural user로 동시에 접속하는 유저수를 의미) : 500명
테스트 시간: 10분

테스트 진행 시 겪은 시행착오

테스트를 진행했는데 생각보다 결과에서 차이가 없었다. 문제의 원인을 찾다가 redis를 잘못 적용했나 생각하며 꽤 오랜 시간 삽질을 했었다. 약간 창피하지만 혹시나 나와 같은 실수를 하는 사람을 위해 확인한 내용을 공유해 본다.

 

첫 번째는 테스트 데이터의 양이였다. API가 수행되는데 조회할 데이터 자체가 작다 보니 캐시 적용효과를 테스트에서 확인하기 어려웠다. 그래서 운영환경과 비슷하게 많은 데이터를 모집단으로 설정하는 게 필요하다 판단했고 테스트 관련 데이터는 운영환경에서 가져와서 처리했다. 

 

두 번째는 API 로직 이해 부족이었다. 첫 번째 원인을 수정하였는데도 두 가지 테스트에서 차이가 눈에 띌정도로 보이지 않았다. 확인해 보니 해당 API는 페이징 처리를 해두었는데 request에 paging 관련 값은 default 던지고 있었다. 그래서 계속 같은 데이터만 return 하는 구조였다. 이 원인은 테스트 스크립트의 api 호출 부분을 수정해서 처리하였다.

 

테스트환경, 데이터, 로직에 대한 정확한 이해를 기반으로
테스트를 진행해야겠다는 점을 다시 한번 느꼈던 상황들이었다. 😭😭

 

테스트 스크립트는 기본 template을 약간 수정해서 작성했다. 전체 코드는 아래 더보기로 확인하길 바란다.

  • Redis Test Script
더보기
import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import static org.hamcrest.Matchers.*
import net.grinder.script.GTest
import net.grinder.script.Grinder
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
// import static net.grinder.util.GrinderUtils.* // You can use this if you're using nGrinder after 3.2.3
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith

import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import org.ngrinder.http.cookie.Cookie
import org.ngrinder.http.cookie.CookieManager

/**
* A simple example using the HTTP plugin that shows the retrieval of a single page via HTTP.
*
* This script is automatically generated by ngrinder.
*
* @author admin
*/
@RunWith(GrinderRunner)
class TestRunner {

	public static GTest test
	public static HTTPRequest request
	public static Map<String, String> headers = ["x-balcony-id":"BOMTOON_COM"]
	public static Map<String, Object> params = ["groupMenu":"SCHEDULE_ALL","isIncludeAdult":"true","contentsThumbnailType":"MAIN","isIncludeTen":"false","isIncludeTenComplete":"false","sort":"POPULAR"]
	public static List<Cookie> cookies = []
	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		test = new GTest(1, "http://localhost:8080")
		request = new HTTPRequest()
		grinder.logger.info("before process.")
	}

	@BeforeThread
	public void beforeThread() {
		test.record(this, "test")
		grinder.statistics.delayReports = true
		grinder.logger.info("before thread.")
	}

	@Before
	public void before() {
		headers.put("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im1DbnNLTWhGNFZiMGJZK0g0amlwNWVSNHF4QTJwaHhQaDA3SlNwTm1Oc1pCZXJGS2Vkcmpidm05aVY2RzVkZkgiLCJzbnNQcm92aWRlciI6IkgxK3RUdU9EQ1lBQ3ZFKzdwNGJNR1E9PSIsImlzQWR1bHQiOiJjYUZmRU00Z0VIUmZXd215VDNCMXlnPT0iLCJ1c2VySWQiOiJYR3k1c1ByQmxFWmxpZXVNenp2cDZBPT0iLCJ1dWlkIjoiUERnelE3NVR5S3UzQWhpb2gzQTZtRHBheDhmQk10cUdnT3ZOL3hBa0VRVGN3V1QxQ1MyQVk3bmx6WWNtQ3dMQSIsImlhdCI6MTY3OTk2ODEzMywiZXhwIjoxNjgwMDU0NTMzfQ.ezMtHPpfpXEeQlF0aYxfyC8dZFgPGMfYMaeQkT6pl6Q")
		request.setHeaders(headers)
		CookieManager.addCookies(cookies)
		grinder.logger.info("before. init headers and cookies")
	}

	@Test
	public void test() {
		int page = (int)(Math.random()*500)
		params.put("page", Integer.toString(page))
		HTTPResponse response = request.GET("http://localhost:8080/v2/contents/group/new", params)

		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
	}
}
  • DB Test Script
더보기
import static net.grinder.script.Grinder.grinder
import static org.junit.Assert.*
import static org.hamcrest.Matchers.*
import net.grinder.script.GTest
import net.grinder.script.Grinder
import net.grinder.scriptengine.groovy.junit.GrinderRunner
import net.grinder.scriptengine.groovy.junit.annotation.BeforeProcess
import net.grinder.scriptengine.groovy.junit.annotation.BeforeThread
// import static net.grinder.util.GrinderUtils.* // You can use this if you're using nGrinder after 3.2.3
import org.junit.Before
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith

import org.ngrinder.http.HTTPRequest
import org.ngrinder.http.HTTPRequestControl
import org.ngrinder.http.HTTPResponse
import org.ngrinder.http.cookie.Cookie
import org.ngrinder.http.cookie.CookieManager

/**
* A simple example using the HTTP plugin that shows the retrieval of a single page via HTTP.
*
* This script is automatically generated by ngrinder.
*
* @author admin
*/
@RunWith(GrinderRunner)
class TestRunner {

	public static GTest test
	public static HTTPRequest request
	public static Map<String, String> headers = ["x-balcony-id":"BOMTOON_COM"]
	public static Map<String, Object> params = ["groupMenu":"SCHEDULE_ALL","isIncludeAdult":"true","contentsThumbnailType":"MAIN","isIncludeTen":"false","sort":"POPULAR"]
	public static List<Cookie> cookies = []
	@BeforeProcess
	public static void beforeProcess() {
		HTTPRequestControl.setConnectionTimeout(300000)
		test = new GTest(1, "http://localhost:8080")
		request = new HTTPRequest()
		grinder.logger.info("before process.")
	}

	@BeforeThread
	public void beforeThread() {
		test.record(this, "test")
		grinder.statistics.delayReports = true
		grinder.logger.info("before thread.")
	}

	@Before
	public void before() {
		headers.put("Authorization", "Bearer eyJhbGciOiJIUzI1NiJ9.eyJlbWFpbCI6Im1DbnNLTWhGNFZiMGJZK0g0amlwNWVSNHF4QTJwaHhQaDA3SlNwTm1Oc1pCZXJGS2Vkcmpidm05aVY2RzVkZkgiLCJzbnNQcm92aWRlciI6IkgxK3RUdU9EQ1lBQ3ZFKzdwNGJNR1E9PSIsImlzQWR1bHQiOiJjYUZmRU00Z0VIUmZXd215VDNCMXlnPT0iLCJ1c2VySWQiOiJYR3k1c1ByQmxFWmxpZXVNenp2cDZBPT0iLCJ1dWlkIjoiUERnelE3NVR5S3UzQWhpb2gzQTZtRHBheDhmQk10cUdnT3ZOL3hBa0VRVGN3V1QxQ1MyQVk3bmx6WWNtQ3dMQSIsImlhdCI6MTY3OTk2ODEzMywiZXhwIjoxNjgwMDU0NTMzfQ.ezMtHPpfpXEeQlF0aYxfyC8dZFgPGMfYMaeQkT6pl6Q")
		request.setHeaders(headers)
		CookieManager.addCookies(cookies)
		grinder.logger.info("before. init headers and cookies")
	}

	@Test
	public void test() {
		int page = (int)(Math.random()*500)
		params.put("page", Integer.toString(page))
		HTTPResponse response = request.GET("http://localhost:8080/v2/contents/group", params)

		if (response.statusCode == 301 || response.statusCode == 302) {
			grinder.logger.warn("Warning. The response may not be correct. The response code was {}.", response.statusCode)
		} else {
			assertThat(response.statusCode, is(200))
		}
	}
}

테스트 결과

테스트 결과는 다음과 같았다.

  • DB Test 결과

  • Redis Test 결과

 

TPS 테스트시간만 체크했다.

 

TPS는 초당 트랜잭션 완료수이다. 높을수록 성능이 높다는 것을 의미한다.

테스트 시간은 user가 request 한 시점에서 시스템이 response 한 시점, 즉 API 수행시간이다.

 

두 개의 결과에 대한 차이는 10 이상 것을 확인할 수 있었다.


결론

DB만 사용할 때보다 캐싱을 도입했을 때 성능적인 차이를 구체적인 수치로 확인할 수 있어서 좋았다.

나와 같은 멍청한 실수를 하지 않으려면 몇 가지만 신경 쓰고 테스트를 진행하길 바란다.

 

구현한 로직 프로세스, 모집단의 데이터의 , 서버 성능에 따라서 테스트의 결과는 확연하게 달라질 있다는을 인지하는 게 중요할것 같다. 

이번 글에서는 레디스를 도입할 때 고민했던 내용에 대해 공유해보고자 한다.


일반적으로 캐시는 메모리를 사용하기 때문에 DB 보다 훨씬 빠른 성능을 기대한다. 

하지만 올바르지 않은 방법으로 사용하게 된다면 데이터에 문제가 생길 수도 있고, DB만 사용하는 것보다 더 비효율적이 될 수도 있다. 

효율적으로 사용하기 위해서 캐시에 저장할 데이터의 기준확립 캐싱 전략 중요하다고 생각했다.


캐시 전략 패턴

캐시를 사용하게 되면 데이터의 정합성 문제에 직면하게 된다. 

서로 다른 두 저장소(메모리와 DB)에 데이터를 저장하게 되니 불일치가 발생할 가능성이 높다.

이 문제를 방지, 극복하기 위해선 적절한 캐싱 전략을 사용해야 하기 때문에 먼저 어떤 방법들이 있는지 찾아보았다.

 

크게 캐시는 읽기 전략과 쓰기 전략으로 나뉜다.

 

캐시 읽기 전략(cache read strategy)

1. Look Aside Pattern

  • 캐시 우선 확인 전략, 캐시에 없으면 DB조회.
  • 레디스 장애 시 DB에서 데이터를 가져올 수 있기 때문에 서비스에 문제없이 운영 가능.
  • 캐시 connection이 많이 있던 경우 순간적으로 DB에 부하가 몰릴 수 있음.
  • 반복적인 읽기가 많은 호출에 적합.

 

2. Read Through Pattern

  • 캐시에서만 데이터를 읽는 전략
  • cache miss가 많을 경우 look aside 전략보다 전체적인 성능이 느릴 수 있음.
  • 레디스 다운 시 서비스 이용에 문제가 생김. 이중화 구조 필요.

 

캐시 쓰기 전략(cache write strategy)

1. Write Back Pattern

  • 데이터를 DB에 바로 저장하지 않음.
  • 캐시에 모아놨다가 DB에 저장. (DB 쓰기 비용, 부하 줄일 수 있음)
  • Write가 빈번한 서비스에 적합.
  • 레디스 장애 시 데이터 영구 소실가능.

2. Write Through Pattern

  • 데이터를 캐시와 DB에 함께 저장하는 방식
  • 캐시와 DB의 데이터 일관성 유지 가능.
  • 데이터 유실 발생 가능성 적음.
  • 매 요청마다 두 번의 write 발생.
  • 빈번한 생성, 수정이 있는 서비스에서는 성능 이슈가 발생할 수 있음.

3. Write Around Pattern

  • 모든 데이터를 DB에 저장.
  • Write Through보다 훨씬 빠름.
  • 캐시와 DB 데이터 불일치 가능성 있음.

전략패턴 조합

캐시 읽기 전략과 쓰기 전략을 조합해서 사용한다.

 

ex)

  • Look Aside + Write Around
  • Read through + Write Around
  • Read through + Write Through

나의 도메인에 가장 적합한 방식은 Look Aside + Write Around라고 생각했다.

 

웹툰 서비스의 특성상

읽기가 빈번히 발생하고 레디스 장애 시에도 정상적으로 서비스 제공이 가능하기 때문에 Look Aside를 생각했고

상대적으로 뜸한 쓰기(작품 등록, 수정) 때문에 Write Around 적합하다고 생각했다.

 

 


캐싱 데이터의 기준

두 번째로 아래의 원칙을 가지고 캐싱 데이터를 정리하였다.


  • 자주 사용 되면서 자주 변경되지 않는 데이터를 추출.
  • 중요한 정보, 민감 정보는 저장하지 않음.
  • 휘발성 염두하여 데이터가 유실, 또는 정합성이 맞지 않아도 괜찮은 데이터 저장.

 

(간단하게 데이터를 표현하면 다음과 같다.)

AS-IS 콘텐츠 데이터:

성인작품여부
완결여부
제목
회차수
이벤트여부
오픈기간(시작, 끝)
뷰카운트
작가정보
썸네일정보
태그정보

우선 다른 도메인의 성격을 띠는 썸네일 정보, 태그 정보를 분리하였다.

그다음으로 상대적으로 자주 변경되는 데이터에 속하는 회차수, 이벤트 여부, 뷰카운트 정보를 분리하였다.

 

TO-BE 콘텐츠 데이터:

성인작품여부
완결여부
제목
오픈기간(시작, 끝)
작가정보

크게 변한 것은 없어 보이지만 원칙을 가지고 기준에 맞는 데이터만 남긴 부분이 추후 유지보수 할 때 용이할 것이라는 생각이 들었다.

또한, 캐시 관련 로직을 심플하게 작성할 수 있었다.


마무리

이번 경험을 통해서 수많은 문제 해결 방법 중에 하나의 적절한 방법을 결정하는 게 더 어렵다고 느꼈다.

나의 기준이 다른 도메인에서는 올바르지 않을 수 있다.

그렇기 때문에 문제를 해결하는 데 있어 중요한 것은 도메인에 대한 이해라고 생각했다.

 

Part2 글에서는 도입한 캐시를 가지고 진행한 성능 테스트에 대해 이야기해 보도록 하겠다.

 

참고 블로그

- 레디스를 이용한 캐싱 설계 전략

배경

얼마 전 기획팀에서 쿠폰 기능에 개수 제한 기능을 추가하고 싶다는 요청이 들어왔다. 로직 설계는 심플했다. 

갯수에 대한 상태 프로퍼티를 추가한 뒤 요청이 올 때마다 상태 변경한 뒤 쿠폰을 지급 또는 exception 처리를 하면 되었다. 

비즈니스 로직 설계

하지만 이 경우, 동시성 이슈가 발생해서 간헐적으로 설정한 갯수보다 더 많은 쿠폰을 발급할 가능성이 있었다. 

결국 이 요청사항의 핵심은 동시성 이슈를 해결하는 것이었고 이에 대한 방법을 찾아보고 도입해 보기로 했다. 

 

동시성 이슈를 해결하는 방법

1. Synchronized 키워드 

synchronized 키워드

가장 먼저 떠오른 방법이었다. 멀티스레드 환경에서 동기화 처리를 위해 자바에서 제공하는 키워드로서 synchronized 블록 단위로 Lock을 걸기 때문에 동시성을 보장할 수 있었다. 

하지만, 어노테이션이 적용되는 범위가 메서드이다보니 불필요한 로직들도 lock이 잡히고, 성능상 오버헤드가 심하다고 한다.
또한 단일서버가 아닌 멀티서버 환경에서는 동시성을 보장받지 못하는 점
때문에 이 방법은 적용해보지 않았다.

 

2. Optimistic Lock(낙관적 잠금)

낙관적 잠금의 프로세스 ( https://sabarada.tistory.com/175 블로그 참고)

데이터의 버전을 체크하면서 데이터의 정합성을 유지하는 방법이다. 실제로 lock을 잡는 방식이 아니기 때문에 성능적으로도 좋다고 한다.

하지만, 정합성 체크를 커밋 시점에 하기 때문에 충돌이 발생한 경우 해당 트렌젝션에 대한 롤백 처리가 필요하다.

여러 트렌젝션으로 묶인 로직이라면 롤백 처리가 복잡해질 수 있고 충돌이 자주 발생한다면 롤백으로 인한 오버헤드가 크게 발생할 수 도 있다고 한다. 

쿠폰 발급 처리의 경우 보통 이벤트로 인해 요청이 몰리게 되는 경우가 많다. 그렇기 때문에 해당 방식도 적합하지 않다고 판단하였다.

 

3. Pessimistic Lock(비관적 잠금)

비관적 잠금의 프로세스 ( https://sabarada.tistory.com/175 블로그 참고)

비관적 잠금은 트렌젝션이 시작할때 lock을 거는 방식이다.

배타락(Exclusive Lock), 공유락(Shared Lock) 두 가지 옵션이 있다.

  • 배타락은 하나의 쓰레드에서 lock을 획득했으면 해제되기 전까지 다른 스레드에서 읽기/쓰기가 모두 불가능한 방식.  (쿼리로 표현하면 select for update)
  • 공유락은 하나의 쓰레드에서 lock을 획득했으면 해제되기 전까지 다른 스레드에서 읽기는 가능 쓰기는 lock을 획득해야 가능한 방식.

비관적 잠금은 작업 처리 과정 중 데드락이 발생할 가능성이 있다는 단점이 있고 처리시간이 길 경우 대기시간에 따른 성능이 저하될 수 있다고 한다. 또한, JPA와는 달리 R2DBC에서는 낙관적 락만 지원, pessimistic lock은 지원하지 않았기 때문에 다른 방식을 찾아보았다.
 

4. DB Internal Lock

MySQL internal Lock은 데이터의 동시성 처리를 위해 MySQL 서버에서 제공하는 방법이다. 총 3가지로 제공하고 있다.

  • Row-Level Locking
  • Table-Level Locking
  • User-Level Locking

Row-Level Locking은 Row 수준으로 Locking을 하는 것이고, Table-Level Locking은 개별 테이블 단위로 설정되는 Locking이다.
User-Level Locking은 사용자가 지정한 문자열에 대해 키를 생성하고 해당 키로 잠금을 거는 방식이다.  

보통 서비스에서 db 연동은 해두고 쓰기 때문에 초기구축이나 작업 비용이 적다는 장점이 있었다. 하지만 동시에 여러 프로세스가 접근한다면 lock을 얻기위한 시도로 인해 부하 발생이 가능했고 트랜잭션에 대한 connection pool 관리가 필요했다.

 

5. Redis를 활용한 방법

  • lettuce를 활용한 방식

set when not exists(setnx) 방식으로 cache setting을 통해 lock을 거는 방법이다.

만약 spring framework에서 이미 redis를 사용한다면 라이브러리 추가 없이 사용가능하다.

lettuce는 spring-data-redis 라이브러리에서 제공하는 클라이언트이다. 또한 트랜잭션에 대한 connection pool 관리 필요 없다. 

하지만 스핀락 방식이기 때문에 동시에 많은 스레드가 대기 상태라면 redis에 부하를 줄 수 있다고 한다. 

 

  • redisson을 활용한 방식

Pub-Sub 기반이라서 thread의 재시도에 따른 redis 부하가 발생하지 않는 방식이었다.

별도의 라이브러리를 사용해야 한다는 점이 걸리긴 했지만 생각보다 구현이 간단했다.

DB와 redis의 부하를 신경 쓰지 않으면서 비즈니스 로직에 좀 더 집중할 수 있는 이 방식을 선택해서 서비스에 적용해 보았다.

 

Redisson 도입기

Redisson dependency 설정(gradle)

implementation("org.redisson:redisson:3.16.3")

redission client 객체를 생성을 위한 설정 로직

    fun redissonClient(): RedissonClient {
        val config = Config()
        config.useSingleServer().address = "redis://${redisWriteProperties.host}:${redisWriteProperties.port}"
        return Redisson.create(config)
    }

lock 관리 로직

    suspend fun receiveLimitCoupon(userId: Long, couponId: Long): List<CouponUser?> {
        val context = newSingleThreadContext("couponId")
        return withContext(CoroutineScope(context).coroutineContext) {
            val lock = redissonClient.getLock("limitCoupon")
            val maxRetry = 3
            var initRetry = 0

            while (!lock.tryLock(10, 3, TimeUnit.SECONDS)) {
                if (++initRetry == maxRetry) {
                    logger.info("threadID: ${Thread.currentThread().id} lock 획득 실패 Timeout")
                    return@withContext emptyList()
                }
            }

            try {
                logger.info("threadID: ${Thread.currentThread().id} lock 획득 성공")
                receiveCouponWithCouponId(couponId = couponId, userId = userId)
            } finally {
                lock.unlock()
                logger.info("threadID: ${Thread.currentThread().id} lock 반환 성공")
            }
        }
    }

1. "limitCoupon"이라는 name으로 lock을 생성한다.
2. tryLock method를 이용해서 락 획득을 시도하다. 

3. 락을 획득한 상태에서 비즈니스 로직(쿠폰발급)을 수행한다. 

4. 명시적으로 lock을 해제한다.

 

락을 요청하는 횟수를 정할 수 있다. 소스에서는 총 3번 요청하고 그 사이에 획득을 하지 못한다면 emptyList를 return 하도록 했다.


요청할 때 파라미터를 지정할 수 있다. tryLock method의 파라미터를 살펴보면 첫 번째는 waitTime(락을 기다리는 시간)이다. 두 번째는 leaseTime(획득한 락을 자동으로 해제하는 시간)이다. 세 번째는 시간단위를 나타낸다. 

waitTime동안 lock을 획득하지 못한다면 tryLock 함수는 false를 리턴하게 된다. 참고로 waitTime을 명시하지 않으면 즉시 값을 return 하게 되고, leaseTime이 명시되어 있지 않으면 watchDogTimeout값이라는 것을 이용하는데 default로 30초이다.

 

waitTime과 leaseTime을 적절하게 setting 하는 것은 중요하다.

만약 비즈니스 로직이 leaseTime보다 오래 걸린다면 처리과정 중 lock이 해제되고 해당 락은 다른 thread에 의해 선점될 수 있다. 그리고 락이 해제되었기 때문에 unlock() 메서드를 실행할 때 예외가 발생하게 된다.

 

동시성 테스트

 

1. Test1: 동시성을 고려하지 않은 로직에 coroutine을 이용해서 쿠폰 발급받기

쿠폰 제한 수량은 100개로 등록, 유저 100명의 요청을 10개의 worker로 나눠서 동시에 발급 요청했음. 

    test("case1 동시성 고려하지 않은 쿠폰 발급(10개 thread 동시에 처리)") {
        val headerMap = mutableMapOf<String, String>()
        headerMap["x-balcony-id"] = "BOOMTOON_COM"
        val reactorContext = Context.of(
            "BALCONY_CONTEXT",
            HttpHeaders.readOnlyHttpHeaders(LinkedMultiValueMap<String, String>().apply { setAll(headerMap) })
        ).asCoroutineContext()
        val userIds = List(100) { i -> i + 1 }
        
        withContext(reactorContext) {
            serviceOperator.execute {
                userIds.chunked(10).forEach { userIds ->
                    launch {
                        userIds.forEach {
                            couponRedisService.notConcurrentlyReceiveLimitNormalCoupon(it.toLong(), 96)
                        }
                    }
                }
            }
        }
    }

테스트 결과 로그는 위와 같았다.
해당 값은 발급 전 남은 쿠폰의 개수를 출력한 것이다. 
실제로 DB에 저장된 데이터를 확인해 보면 100명 모두 쿠폰을 발급받았지만 남아있는 쿠폰은 81개였다. 

데이터 불일치 문제가 발생한 것이다.

 

 

2. Test2: redisson을 이용한 로직으로 동시에 쿠폰 발급받기

조건은 test1과 동일했다. 단지 발급로직에 redisson을 이용해서 lock 처리가 추가되었다. 

    test("Redis coupon 100번 동시 호출") {
        val headerMap = mutableMapOf<String, String>()
        headerMap["x-balcony-id"] = "BOOMTOON_COM"
        val reactorContext = Context.of(
            "BALCONY_CONTEXT",
            HttpHeaders.readOnlyHttpHeaders(LinkedMultiValueMap<String, String>().apply { setAll(headerMap) })
        ).asCoroutineContext()
        val userIds = List(100) { i -> i + 1 }
        withContext(reactorContext) {
            serviceOperator.execute {
                userIds.chunked(10).forEach { userIds ->
                    launch {
                        userIds.forEach {
                            couponRedisService.receiveLimitCoupon(it.toLong(), 96)
                        }
                    }
                }
            }
        }
    }

테스트 결과는 다음과 같았다.

2개의 쿠폰이 남았길래 확인해 보니까 2명이 lock try timeout으로 받지 못한 것을 확인할 수 있었다.
하지만, 테스트 결과는 정상이었다. 총 98명이 받았다.
테스트를 로컬환경에서 진행 중이었는데 서버의 성능에 영향을 많이 받는 것을 알 수 있었다.

 

waitTime을 약간 늘려서 다시 테스트해 보았을 땐 정상으로 전부 처리된 것을 볼 수 있었다. 
위에서 언급한 'waitTime과 leaseTime을 적절하게 setting 하는 것은 중요하다.'를 다시 한번 느꼈다.

 

100명의 유저가 80개의 쿠폰을 발급받는 테스트도 진행해 보았다.

테스트 결과, 쿠폰 발급 수는 80개였고 20명은 발급받지 못한 것을 확인할 수 있었다.

 

마무리

한 가지 문제를 해결하는 데는 다양한 솔루션이 있다.

동시성 이슈를 해결하는 방법도 위에서 언급한 것처럼 다양하게 있다. 

어떤 솔루션이 자신의 상황에 가장 적절한지 충분히 고민해 본 다음에 적용하고 테스트해 보는 습관을 들이자.

 

참고자료

https://sabarada.tistory.com/175

https://dealicious-inc.github.io/2021/04/05/distributed-locking.html

https://happy-coding-day.tistory.com/entry/%EB%B9%84%EA%B4%80%EC%A0%81-%EC%9E%A0%EA%B8%88-%EB%82%99%EA%B4%80%EC%A0%81-%EC%9E%A0%EA%B8%88-%EA%B7%B8%EB%9F%B0-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%9D%B4%EC%8A%88-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0

+ Recent posts