-
Notifications
You must be signed in to change notification settings - Fork 2
Project | Troubleshooting
설계
-
부분적인 Scale Out, 서비스 및 배포 독립성 등을 이유로 모놀리스에서 MSA로 전환하고자 했을 때 가장 어려웠던 부분은 '어떤 방식으로 서비스를 분리할 것인가'였습니다. 적절한 서비스의 경계를 나누는 설계가 MSA를 전환하는데 있어 큰 도움이 될 것이라고 생각했습니다.
-
따라서 MSA 도입과 함께 진행된 것은 도메인 주도 설계였습니다. 각 도메인의 역할을 분리하고 policy를 적절하게 설정하는 것이 중요하다고 생각했습니다.
-
따라서 이벤트 스토밍 방식을 사용하여 추출한 Binding Context를 기준으로 도메인 분할 전략을 수립했습니다. 좋은 MSA 설계란 도메인 간의 낮은 의존성이 동반되어야 한다고 생각했기 때문에 최소한의 policy를 구축하고자 했고, 그에 따라 경매 상품과 판매 상품을 분리하여 결합도를 낮췄습니다.
-
또, 이번 프로젝트에서 서비스의 전체 프로세스를 작은 이벤트 단위로 식별해서 이해하고자 했습니다. 예를 들어 로그인 시도, 로그인 됨/ 상품 전체 조회, 상품 검색 조회/ 이벤트 페이지 입장. 이벤트 쿠폰 발급과 같이 작은 단위로 구분하고 공통적인 특성을 띄는 이벤트들을 하나의 컨텍스트로 묶어 이를 도메인으로 추출했습니다. 즉, 기능을 구분하여 도메인을 먼저 나눈 것이 아닌, 발생하는 이벤트를 기반으로 하나의 Binding Context를 식별하고 이를 도메인으로 추출하는 과정으로 도메인 주도 설계를 수립했습니다.
적용
-
기존 모놀리스와 달리 서비스가 개별적으로 배포됨에 따라 몇 가지 고려해야할 사항들이 발생했습니다.
-
먼저 분리된 서비스에서 장애발생 지점과 병목 지점 파악을 위해 분산 추적의 필요성을 느끼게 되었고 이는 Zipkin과 Micrometer를 이용하여 구현했습니다.
-
또 연결되어 있는 서비스들에서 특정 서비스가 장애를 일으키게 되면 장애 전파를 통해 전체 서비스 마비가 발생할 수 있다는 생각을 하게 되었고, 이는 곧 서비스의 장애내성과 회복탄력성이 필요하다고 생각했습니다. 실제로 로컬 환경에서 회원 서비스가 구동되지 않은 상태에서 경매 입찰을 시도하게 되었을 때, 회원 서비스가 정상적이지 않다는 에러가 반환되는 것을 확인할 수 있었습니다.
-
따라서 Resiliece4j를 도입하여 CircuitBreaker 패턴을 이용하였는데, 요청 수 대비 실패 비율, 응답 시간 등을 기준으로 특정 요청을 close, open, half-open으로 나누어 전달 여부를 결정했습니다. 회원 도메인에 장애가 발생하여 CircuitBreaker가 open상태로 변경되면 연쇄적으로 장애가 전파될 수 있는 경매와 주문 도메인에서 각각 희망 낙찰가를 보여주거나, 결제 상품과 연관되어 있는 다른 상품들을 보여주는 방식과 같은 대안적인 서비스를 추가적으로 구상했으나, 시간적인 문제로 현재는 예외를 던지는 방식을 채택하고 있습니다.

대기열 서버
-
요구사항 정립에서 요청 데이터의 순서를 보장해야 한다고 설정했습니다.
-
Kafka와 Redis 모두 대기열 Queue로 활용할 수 있지만, Kafka의 경우 단일 파티션이 아니라면 순서 보장이 불가능했습니다.
-
Kafka를 단일 파티션으로 사용할 시 Kafka의 장점인 분산된 환경에서의 높은 처리량을 살릴 수 없을 것 같다고 판단했습니다.
-
Single Thread의 특징과 SortedSet자료구조를 사용하여 데이터의 무결성과 순서를 보장할 수 있고, In-memory DB의 높은 처리량의 장점을 가진 Redis로 대기열을 구현하기로 결정했습니다.
-
대기열 서버 구현 방식 후보
-
"1안" 분산 락을 활용한 대기열 서버
-
Redis에 이벤트 총원에 대한 정보를 저장하여, 대기열 key의 크기와 이벤트 총원을 비교합니다.
-
대기열 key에 들어온 요청을 Message Queue를 이용하여 쿠폰 발급 서버에 전달하는 방식입니다. 대기열 key의 크기와 이벤트 총원을 비교할 때 race condition 이 발생했습니다.
-
Redis Redisson 의 분산 락을 활용하여 동시성 문제 해결하고자 했습니다.
-
락을 지원하는 Lettuce 와 Redisson 중 Redisson을 선택한 이유
- Lettuce 는 스핀 락 기반이라 성능저하에 큰 요인이라고 판단. Pub/Sub 방식인 Redisson을 선택했습니다.
-
"2안" 스케줄러를 활용한 대기열 서버
-
Redis의 Sorted Set자료구조를 사용하여 사용자의 요청 데이터를 시간 순서대로 정렬한 후, 스케쥴러를 통해 주기적으로 일정량의 요청 데이터를 Message Queue를 이용하여 쿠폰 발급 서버에 전달하는 방식입니다.
-
-
1안 방식 테스트 결과

-
2안 방식 테스트 결과

-
테스트 환경
- Tool : nGrinder
- 대기열 서버 :1대 기준 (t3.medium)
- VUser : 400,
- Runtime : 2m
-
결과
-
TPS : 262.1 -> 1495.1
-
MTT : 1541.52 MS -> 267.82 MS
-
처리한 요청 수 : 28,352 → 170,530
-
1안의 경우, Scale-up 및 Scale-out을 통해 성능 개선을 시도했으나, Lock 으로 인해 성능 개선의 한계가 존재했습니다.
-
또한, 분산 락은 대기하는 쓰레드의 순서를 보장하지 않아 순서 보장이라는 요구사항을 충족시키지 못하여 2안을 선택했습니다.
-
Redis 순서 보장
-
Redis의 데이터를 일정 개수 만큼 POP 하는 명령어인 ZPOPMIN
$(O(log(N) * M)$ 을 사용해 조회한 요청 데이터들의 순서와 Redis에 저장된 순서가 일치하지 않는 문제가 발생했습니다. -
ZRANGE
$(O(log(N) + M)$ 명령어로 일정 개수만큼 순서가 보장된 데이터를 조회 후 쿠폰 발급 서버로 전달한 후, ZREMRANGEBYRANK$(O(log(N) + M))$ 명령어로 일정 개수만큼 삭제시키는 로직으로 변경했습니다.
API Gateway 병목 현상
-
대기열 서버를 Scale-up, Scale-out 해도 Gateway의 CPU 사용량이 급증하는 문제로, TPS 및 MTT가 현저히 낮아지는 문제 발생했습니다.
-
Gateway server Scale-up 으로 병목 문제 해결했습니다.
스케줄링 중복 실행
-
구현 초기 스케줄링 로직은 대기열 서버에 위치했기 때문에, 대기열 서버가 다중 서버로 변경됨에 따라 스케줄링이 중복 실행되는 문제가 발생했습니다.
-
따라서 스케줄링 서버를 분리하고자 했습니다.
- 스케쥴링 서버 분리 이유 : 대기열 서버는 높은 트래픽을 처리해야하는 서버로서, 요청을 안정적으로 저장하고 관리하는 역할을 수행해야 합니다. 따라서, 부가적인 작업을 최소화하고, 주 업무에만 집중하는 것이 더 나을 것으로 판단했습니다.
- 스케줄링 서버를 단일로 구성할 시 장애에 취약하므로 다중 서버로 구성했습니다.
- 각 스케줄링 서버마다 스케줄러가 중복 실행되므로, ScheduleLock을 이용하여 Lock을 선점한 스케줄러가 동작하도록 구현했습니다.
정리
-
트래픽을 견디기 위해서는 대기열이 필요하다고 생각했고 이러한 대기열 구현기술을 레디스와 카프카 중 어떤 것을 이용할지 고민했습니다. 프로젝트에서 서비스 요구사항에 요청 순서를 보장해야한다고 설정하였기 때문에 단일 파티션이 아니라면 순서를 보장할 수 없는 카프카가 불가능하다고 판단했습니다. 카프카의 장점인 분산 처리에 의한 높은 처리량은 단일 파티션으로는 살릴 수 없을 것이라고 생각했습니다.
-
처음에는 레디스에서 대기열 key의 크기과 이벤트 총원을 비교한 후, 요청을 저장하고 메세지큐를 이용해 쿠폰 발급 서버에 해당 요청을 전달하는 방식을 생각했습니다.이러한 과정에서 대기열 key의 크기를 획득할 때 race condition이 발생하였고, 레디슨의 distributed lock을 사용하여 동시성을 제어하고자 했습니다. 하지만 이러한 방식은 lock으로 인한 성능 개선에 한계가 존재하였고 대기하는 쓰레드의 순서를 보장하지 않아서 요구사항을 충족시킬 수 없는 문제가 발생했습니다. 이를 해결하고자 레디스의 Sorted Set을 활용한 Scheduler 방식으로 이벤트 설계를 변화시켰습니다. 사용자의 요청 데이터를 시간순으로 정렬하고 스케쥴러와 메세지큐를 이용하여 주기적으로 일정량의 요청 데이터를 쿠폰 발급서버에 전달하는 대기열 서버를 구현했습니다.
-
하지만 이러한 스케줄러 방식에서 대기열 서버가 다중 서버로 변경되면 스케줄링이 중복 실행되는 문제가 다시 발생했습니다. 이를 해결하고자 스케줄링 서버를 분리하기로 결정했습니다. 대기열 서버는 높은 트래픽을 처리해야하는 서버입니다. 요청을 안정적으로 저장하고 관리하는 역할을 하는 서버이기 때문에 이에 집중하고 부가적은 다른 작업들은 최소화하는 것이 적합한 방식이라고 판단했습니다.
-
최종적으로 스케줄링 서버를 단일로 구성하게 되면 장애에 취약하므로 다중 서버로 구성하였고 각 스케줄링 서버마다 스케줄리의 중복실행 방지를 위해 락을 선점한 스케줄러가 동작할 수 있도록 SchedulerLock을 이용하여 이벤트 아키텍처를 설계 및 구현할 수 있었습니다.
-
대량 데이터를 조회하는데 JPQL을 사용하였는데 이는 복잡한 동적 쿼리로 인하여 코드 가독성 문제를 발견했고,조회의 성능면에서도 평균 50초라는 상당히 긴 응답 시간 문제를 확인할 수 있었습니다. 또 페이지로 조회되는 방식에서 전체 페이지의 크기가 증가함에 따라 조회해야하는 컬럼의 개수도 함께 증가하면서 성능저하의 문제가 발생하는 것을 확인할 수 있었습니다.
-
복잡한 동적 쿼리에 대한 문제 해결은 쿼리dsl로 해결해보고자 했습니다. 컴파일 단계에서 확인할 수 있는 오류, 비즈니스의 요구에 따라 동적 쿼리의 조건이 바뀌어도 최소화할 수 있는 변경지점등을 고려하여 해당 기술의 사용의 JPQL의 문제점을 해결했습니다.
-
DB 인덱싱을 이용하여 불필요한 조회를 줄였고 주어진 카테고리와 검색어 조건절을 만족하는 커버링 인덱싱을 추가적을 도입해서 더욱 빠른 성능을 보여줄 수 있도록 쿼리 튜닝을 추가적으로 진행했습니다. 그리고 양쪽으로 와일드 카드를 사용한 패턴 매칭을 시도하는 기존 비트리 인덱스를 풀텍스트 인덱싱으로 전환하여 좀 더 유연하고 정확한 검색이 가능한 서비스를 구축할 수 있었습니다.
-
페이지 조회는 no offset 방식을 사용하여 다음 페이지의 유무만을 파악하는 방식으로 성능 최적화를 시도했습니다. 기존 offset에서는 마지막 페이지 조회를 위해 모든 데이터를 조회해야 했기 때문에 불필요한 조회도 발생했으나 현재는 마지막을 조회한 데이터의 id 값을 기준으로 요청한 페이지의 데이터만 조회할 수 있게 변경했습니다.
-
동시에 여러 요청이 들어올 경우 동시성 문제가 발생하여 입찰 금액이 비즈니스 규칙에 어긋나게 되는 상황이 발생했습니다. 처음에는 해당 입찰 메서드에 synchronized를 이용해서 해결하려고 했으나 Scale Out을 고려하게 된다면 이러한 방식도 문제가 될 것이라고 예상했습니다.
-
따라서 기존 경매 엔티티에 version필드를 만들고 낙관적 락을 적용함으로써 문제를 해결하고자 했습니다. 그러나 낙관적 락을 사용하게 됐을 때, 입찰 금액 수정을 먼저 선점한 트랜잭션이 끝나게 되면 동시에 들어온 더 높은 입찰 요청 금액이 version의 변경으로 트랜잭션을 rollback하게 되는 상황이 발생했습니다.
-
결과적으로 읽기와 쓰기 모두 잠금을 걸어서 사용하는 비관적 락을 사용해서 경매 입찰 금액의 정확성과 동시성을 제어했습니다. 비관적 락 사용을 통해 모든 트랜잭션이 순차적으로 해당 경매 엔티티의 입찰 금액 수정을 할 수 있게 되었습니다.
-
분산락을 이용한 동시성 제어도 고려해봤으나, 분산락의 락 획득,해제 과정에서 발생하는 시간 지연이 비관적 락보다 클 것이라고 예상했기 때문에 비관적 락을 사용했습니다.