Contents
- 시작하기
- Part 1. 요구사항 정의하기
- Part 2. 워크플로우 정리하기
- Part 3. 위치정보 저장 (Write Operation) 워크플로우 구성
- Part 4. 클래스별 구현 및 설명
- Part 5. Integration 테스트 작성
- 테스트를 위한 테스트 환경 셋업
- 테스트 셋업 코드
- 테스트 케이스 1
- 테스트 케이스 2
- 테스트 케이스 3
- Part 6. 마치며
시작하기
오늘은 우아한 중계서비스 프로젝트 진행과정에서 요구사항/추가기능 발생시 어떻게 구현하는지를 예제를 통해 소개하려고합니다. 이번 블로그는 리액티브한 코드작성과 테스트 작성에 무게를 두고 작성해보겠습니다.
Part 1. 요구사항 정의하기
기능 소개 : 라이더 위치 기능?
라이더 위치 기능을 하이레벨 뷰로 한번 보겠습니다.
라이더 위치 정보는 주문을 픽업한 시점부터 배달을 완료하는 시점까지 라이더님의 위치정보를 다루는 기능을 의미합니다.
위치정보는 픽업 완료 시점부터 배달앱을 통해 일정 주기로 중계서버에 전송되며, 해당 정보는 주문을 기다리는 고객님, 또는 배달 현황을 확인하는 사장님께서 참고 가능합니다.
요구사항 분석 내용
- 위치정보기능은 Network I/O 에 부담이 큰 기능이므로 최대한 격리되어야 한다
- Horizontally Scale이 가능해야 한다.
- Write과 Read 쿼리 모두 Max 500ms 안에 응답 가능해야 한다.
- Write와 Read Operation 포함 2000 request/s를 (vCPU 1 / 2GB 메모리) 인스턴스를 가지고 안정적으로 처리할 수 있어야 합니다.
- write-heavy & read-heavy operation. 위치정보는 픽업 완료 시점부터 배달완료 시점까지 수시로 업데이트가 되어야 하며 조회는 고객님과 사장님 애플리케이션 최소 두 곳에서 조회를 할 여지가 있기 때문에 write과 read operation 모두 큰 비중을 차지할 것을 예상하고 작업합니다.
- 위치정보는 riderId, deliveryId longitude, latitude로 잡고 시작하겠습니다.
여기서 핵심은 1번 항목의 격리하는 부분입니다. Network I/O 에 부담이 큰 기능이므로 최대한 격리되어야 한다는 이야기를 풀어서 설명해보겠습니다.
하나의 주문에서 발생 가능한 요청의 수의 비율을 위치정보 API와 주문 관련 API으로 나눌 시 비율은 위의 그래프와 같습니다.
주문 접수부터, 라이더 요청, 픽업 완료, 배달완료까지 주문 하나의 라이프사이클을 통해 발생되는 주문 관련 API 발생 횟수는 10회로 잡았습니다.
위치정보 API는 배달시간은 짧게 10분(600초)으로 잡고 5초 interval 기준으로 주문한 건당 120회의 write operation, 이미 서버 전체 I/O Throughput의 92.3%를 차지할 것을 예상할 수 있습니다.
사장님과 고객님으로부터 발생하는 read operation은 포함하지 않은 경우에도 커다란 비율 차이가 발생합니다.
Part 2. 워크플로우 정리하기
위치정보 저장(Write Operation)의 워크플로우는 라이더님의 배달 앱에서부터 시작합니다.
- 배달앱에서 서버로 위치정보를 업데이트한다는 HTTP 요청을 전송합니다.
- 위치정보가 이미 존재하는지 확인합니다.
- 위치정보가 이미 존재하면 update 해줍니다.
- 존재하지 않는다면 첫 위치정보 전송이므로, 주문 상태가 픽업 완료인지(위치정보기능의 대상인지)를 확인합니다. 맞으면 3번을 실행합니다.
- 캐시에 저장합니다.
위치정보조회(Read Operation)는 Write에 비해 단순하게 구성했습니다.
- 사장님/고객님/다른 서버로부터 라이더 위치 조회 요청을 받고
- 조회 가능 여부를 확인한 후 위치정보, 또는 에러를 반환해줍니다.
Part 3. 위치정보 저장 (Write Operation) 워크플로우 구성
우선은 단순하게 구성을 해보겠습니다.
RiderLocationRepository는 Redis를 데이터 소스로 사용합니다. 라이더의 위치는 라이더님과 1대 1로 매핑되니 key:value = id:location로 설정하면 read와 write 작업 모두 O(1)의 시간 복잡도를 가지게 됩니다. 추가로 Redis는 Reactive Support가 있으며 스프링 WebFlux에서도 지원됩니다.
DeliveryRepository는 WebClient를 활용해서 DeliveryInfoService를 데이터 소스로 사용하겠습니다. WebClient의 대안으로 활용되는 OpenFeign은 현재 리액티브 서포트가 없기 때문에 아직 비공식 단계이니 간단하게 WebClient로 시작하겠습니다.
워크플로우 순서
- 배달앱에서 위치정보 업데이트 요청을 전송합니다.
- 클라이언트의 편의를 위해 PUT과 POST 를 동일하게 동작하도록 구성합니다.
- Redis Cache를 상대로 setIfPresent(K, V)를 호출합니다.
- 키값이 있을경우에 Write을 합니다.
- 이미 존재하는 데이터는 유효한 정보이기 때문입니다.
- 그러므로 overwrite 해주고 true를 그대로 리턴합니다.
- false 일경우 새로 시작되는 위치정보이므로 3번으로 이어집니다.
- 해당 배달건은 현재 픽업 완료상태인가요?
- Delivery-Info-Service 서버로 HTTP 요청을 통해 쿼리 합니다.
- 스키마는 GET /is-picked-up/${deliveryId}입니다.
- false를 리턴하면 false를 그대로 리턴해줍니다.
- true를 리턴하면 다시 4번으로 이어집니다.
- Redis Cache를 상대로 setIfAbsent(K, V)를 호출합니다.
- 키값이 없을 경우에 Write을 합니다.
- 여기서 발생하는 값은 true/false 무관하게 리턴해줍니다.
Part 4. 클래스별 구현 및 설명
RiderLocationController
- PUT과 POST 매핑을 모두 적용한 부분입니다. 서비스 레이어로 메시지를 전달만 해줍니다.
RiderLocaionService
- Write Operation의 로직은 거의 여기서 발생합니다.
- Line 14 : write operation에서 유일하게 외부로 노출되는 메서드입니다.
- Line 15 : repository.setIfPresent()를 호출합니다. 성공 여부를 orElseSetNew(T, U) 메서드로 매핑해줍니다.
- Line 18 : Line 15에서 넘어오는 결괏값과 요청에서의 위치정보를 파라미터로 받습니다.
- Line 19 : 처음 setIfPresent()에서 true를 리턴한 경우 그대로 모노를 리턴해주고 아닐 경우 새로 캐시에 write 하는 스트림을 리턴합니다.
- Line 22 : 캐시에 새로 작성할 Location을 파라미터로 받습니다.
- Line 24 : Mono <Boolean> DeliveryRepository.isPickedUp()을 호출해서 해당 위치정보의 배달 건은 픽업 완료한 상태인지를 Boolean으로 리턴합니다.
- Line 25 : 픽업 완료 여부를 받아서 다시 setIfPickedUp을 호출해줍니다.
- Line 28 : 픽업완료 여부와 위치정보를 파라미터로 받습니다.
- Line 29 : 픽업 완료한 경우 repository.setIfAbsent()를 호출해줍니다. 그러면 키값이 존재하지 않는 경우에만 Redis Cache에 Write을 합니다.
RiderLocationRepositoryImpl
- RiderLocationRepository의 구현체로 라이더 위치정보 관련 CRUD 작업과 데이터 소스의 중간자 역할을 수행합니다.
- 여기서는 Redis 관련 Operation들이 서비스 레이어에서 의도한 대로만 작동하게 하는 기능을 수행합니다.
- Line 18 & 23 : locationOperations.opsForValue()는 Redis의 Strings 관련 메서드들을 수행하는 ReactiveValueOperations <K, V>를 리턴합니다. 저는 그것을 그대로 가지고 setIfPresent(), setIfAbsent() 작업들을 수행했습니다.
- Line 27 & 32 : deleteAll()과 findAll() 메서드는 테스트에서 필요한 메서드들입니다.
- Line 28 & 33 : keys(Pattern) 은 파라미터의 패턴과 일치하는 키값들을 전부 리턴합니다. deleteAll()과 findAll()의 특성상 와일드카드를 입력해서 모든 키를 받습니다. keys()가 리턴하는 Flux <K>를 다시 그대로 메서드 참조해서 delete과 get을 수행합니다.
DeliveryRepositoryImpl
- DeliveryRepository의 구현체입니다. Delivery-Info-Service 서버와 통신하며 배달 관련 CRUD 작업을 수행합니다.
- Line 12 & 20-22 : 배달 관련 정보를 쿼리 할 Delivery-Info-Service 서버의 URI입니다. 프로젝트에 아직 ServiceDiscovery 관련 기능은 넣지 않아서 이렇게 우선 설정해두었습니다.
- Line 25 : 픽업 완료 여부를 쿼리 하는 메서드입니다. 배달정보 ID를 파라미터로 받습니다.
- Line 26-30 : 우선은 단순 리턴으로 작성했습니다.
Part 5. Integration 테스트 작성
위치정보 저장 (Write Operation)에 대한 Integration 테스트를 몇 가지 공유하겠습니다.
단위 테스트는 양이 많으니 마지막에 제가 PR 링크를 공유해놓겠습니다.
- 이미 Cache에 있다. > Cache Write > True 리턴
- Cache에 없다 > 배달완료상태의 주문이 아니다 > == False 리턴
- Cache에 없다 > 배달완료상태의 주문이다 > == True 리턴
위에서 보여드렸던 위치 저장(Write) 작업에서 발생하는 3가지 워크플로우입니다.
위 세 가지 케이스의 테스트를 작성해보겠습니다.
테스트를 위한 테스트 환경 셋업
- 서버에 요청을 전송하는 부분에서는 WebTestClient를 활용하겠습니다.
- 참고로 현시점 WebTestClient는 버전 2.0.0 기준 WebFlux 애플리케이션만 지원하는 것으로 알고 있습니다.
- JUnit 플랫폼에서 테스트를 합니다.
- WireMockServer는 HTTP Request를 모킹 하는 라이브러리입니다.
- 현재는 Spring-Cloud-Contract의 일부로 포함되어있습니다.
- WireMockServer를 통해 Delivery-Info-Service 서버로 전송되는 픽업 완료 상태 쿼리를 모킹 하겠습니다.
- 참고로 일반적인 모킹 방식은 I/O 가 발생하지 않기 때문에 로드테스트 시 WireMockServer를 통해 HTTP 요청을 Stubbing 하고 Network I/O 로드를 발생시킬 수 있습니다.
- Redis 서버는 TestContainer 라이브러리를 활용해 직접 컨테이너로 띄워놓고 테스트를 하겠습니다.
테스트 셋업 코드
- Line 34 : 애플리케이션을 임의의 포트에 매핑해줍니다.
- Line 35 & 42 : WebTestClient를 자동으로 설정해줍니다. 여기서 자동에는 애플리케이션의 baseUrl을 매핑해주기 때문에 별도 url 설정 없이 get() post() 등을 호출할 수 있습니다.
- Line 36 : AutoConfigureWireMock 어노테이션을 설정하면 테스트 시 WireMockServer 인스턴스 (실제 물리적으로 포트를 점유하는 서버)를 생성합니다. 여기서 port = 0 설정 시 다이내믹 포트 바인딩이 이루어집니다.
- Line 37 : Junit Jupiter의 익스텐션으로 테스트에 사용되는 컨테이너들의 라이프사이클을 관리해줍니다.
- Line 40 : Jackon의 모듈로 WireMockServer에서 Http Response를 스텁 하기 위해 활용합니다.
- Line 44-46 : 각 요청 별 내부 컴포넌트들의 메서드 호출 여부를 테스트 결과에 포함시키기 위해 Spy 하는 부분입니다.
- Line 50 : TestContainers 익스텐션이 @Container 필드들을 찾아 명시된 LifeCycle 메서드들을 실행합니다. 여기서 주의할 점은 static 필드로 선언 시 테스트 메서드들 사이에서 공유되며 인스턴스 필드로 선언 시 테스트 메서드마다 시작과 정지를 반복합니다. (참고자료 링크)
- Line 53 : 실제로 호스트 머신에서 redis:latest 컨테이너를 생성합니다. 찾아보시면 더 상세한 설정도 가능합니다.
- Line 54 : withExposedPorts(int)로 매핑할 컨테이너 내부 포트를 명시해줍니다. TestContainer에서 다이내믹한 포트 바인딩을 발생시킵니다. 예를 들어 "52131:6379" 이런식으로 바인딩이 됩니다. 로컬의 고정된 포트로 매핑하는 방법도 있지만 그럴 경우 테스트 실패 가능성이 생기며 공식문서에서도 추천하지 않는다고 명시되어있습니다. (버전 몇 단계 후에 Deprecate 되고)
- Line 57 : @DynamicPropertySource는 Redis 컨테이너를 애플리케이션 설정 정보에 바인딩하기 위한 어노테이션입니다. 스프링 5.3 버전부터 포함된 이 어노테이션은 처음에 TestContainer의 포트 바인딩을 목적으로 만들어졌다고 합니다. from 공식문서
- Line 59-61 : 컨테이너가 정말 실행되는지를 확증하는 부분입니다.
- Line 62 : getMappedPort()를 통해 파라미터로 입력한 번호의 포트와 매핑된 호스트의 포트 번호를 확인할 수 있습니다.
- Line 63 : Redis 컨테이너가 매핑된 포트 번호를 "spring.redis.port" 설정 정보에 오버라이드 해줍니다. 그러면 Application의 스타트업 타임에 RiderLocationRepository가 Redis로 연결됩니다. 호스트 머신에서 컨테이너가 작동하기 때문에 기본 설정해놓은 "spring.redis.host=127.0.0.1"로도 충분합니다.
테스트 케이스 1
- Line 78-79 : 이번 테스트 시나리오의 설정의 핵심 부분입니다. Cache Write을 실행하고 block() 블로킹함으로써 확실히 캐시에 있다는 것을 보장하겠습니다.
- Line 81-89 : WebTestClient 가 PUT /rider/location 요청을 날리니 true를 반환하는 것을 확인하며. 캐시에 단번에 저장되었다는 것을 검증합니다.
- Line 92-94 : 이번 테스트에서 핵심 검증사항입니다. 캐시에 이미 존재하는 key-value 페어에 write을 시도하니 setIfPresent()가 true를 반환할 테고 그 이후의 isPickedUp() 부분은 아예 동작을 하지 않습니다. setIfAbsent는 이미 Line 78에서 강제 주입을 했기 때문에 0번 실행을 검증하려면 추가적인 작업이 필요해서 제외했습니다.
테스트 케이스 2
- Line 131-137 : Delivery-Info-Service 서버로 픽업 완료 여부를 쿼리 하는 요청의 Response를 Stub 하는 부분입니다.
- Line 132 : GET 리퀘스트에 대한 URL 매칭을 하는 부분입니다. urlMatching() 외에도 urlEquals, urlPathMatching 등 여러 방법으로 접근할 수 있습니다.
- Line 135-137 : Stub의 Response를 설정하는 부분입니다. True를 리턴하게 했으니 픽업 완료 여부가 True 라는 뜻이 되겠군요.
- LIne 139-146 : 성공으로 True를 반환하는 부분입니다. 캐시에는 없지만 픽업완료 상태이니 캐시에 Write 하고 True를 리턴한 것입니다.
- Line 148 : Cache Write을 시도했습니다. @BeforeEach setUp() 메서드에서 클린업 한상 태이니 false를 리턴합니다.
- Line 149 : 라인 132~137에서 Stub 한 픽업 완료 쿼리 메서드입니다. 이 또한 실행됐습니다.
- Line 150 : 캐시에는 없지만 픽업 완료된 부분이니 setIfAbsent() 메서드가 호출됩니다.
테스트 케이스 3
- 위 두 가지 테스트 케이스와 매우 유사한 부분이니 간략하게 설명하겠습니다.
- Line 102-108 요청을 Stub 하는 부분입니다. 이번에는 픽업 완료상태가 아니라는 시나리오를 위해 GET /is-picked-up/${delievryId} 요청에 False로 응답합니다.
- Line 120 : Cache Write 이 실패합니다.
- Line 121 : 캐시에는 없지만 픽업 완료 상태인지를 확인합니다. False를 리턴하게 Stub 해두었습니다.
- Line 122 : 픽업 완료 상태가 아니므로 setIfAbsent 메서드는 실행되지 않습니다.
***주의사항***
테스트 케이스 1,2,3에서 stub 했던 HTTP Request의 URI를 DeliveryRepository의 WebClient를 어떻게 아는 거지?
에 대한 답변입니다. WireMockServer으로 테스트 시 정말 중요한 부분인데요, 문맥을 최대한 살리고자 보너스로 남겨두었습니다. WireMock 익스텐션의 또 다른 기능입니다.
default, 또는 @ActiveProfiles("something")로 설정된 애플리케이션의 설정 파일을 스캔해서 ${wiremock.server.port}로 본인의 포트번호를 바인딩도 해줍니다.
그리고 해당 정보는 DeliveryRepository에서 아래처럼 바인딩시켜두었습니다.
주의사항에 대한 추가 정보는 아래 링크를 통해 확인 가능합니다.
Part 6. 마치며
여기까지가 라이더 위치정보 저장 (Write Operation) 기능의 첫 단계였습니다.
이번 블로그에서 설명한 내용들은 아래 commit 버전으로 확인이 가능합니다.
https://github.com/f-lab-edu/in-bob-we-trust/tree/de72a82569325a3b4a9ae4f0d186096d5ddc5bc8
Part 7. 더 나아가서
모든 내용을 블로깅 하는것은 힘들겠지만 이후 라이더 위치정보 기능에 추가될 내용들을 공유해봅니다.
- 위치정보 Cache Eject 정책 설정
- 위치정보조회 기능 추가
- 위치정보서버 ServiceDiscovery추가
- Write Operation에 대한 Server-side Throttle 설정하기
- Read & Write Operation 로드테스트
- Redis Cluster/Shards 스케일 정책 적용
'in-bob-we-trust' 카테고리의 다른 글
서버 성능테스트 이야기 1 [ Overview ] (0) | 2022.01.26 |
---|---|
프로젝트 비용 최적화를 위한 Reactive + MongoDB Atlas Serverless 적용 및 예시 (Spring Webflux) (0) | 2022.01.25 |
Github 프로젝트 & Intellij 전반에 걸쳐 Google Java Style Guide 를 강제하기 (0) | 2022.01.09 |
중계서비스 Swagger 도큐먼트 툴 적용 + 단점 보완 (0) | 2021.12.13 |
배달 중계서비스를 설계하고있습니다. part.1 (0) | 2021.12.10 |