Backend 개발을 진행하면서 고민한 성능 개선 방안들을 실제 실험을 통해 유의차를 확인했습니다.
Postman이나 Client 요청으로 일회성 측정시 응답 시간의 variation이 있어, 반복 측정이 필요했습니다.
성능 Test Tool인 Artillery
를 사용하여 반복적인 요청을 보내고, Artillery의 report를 확인하여 결과 확인 및 정리를 했습니다.
개별 응답시간 측정을 위해, 서버가 같은 시간에 하나의 요청을 처리할 수 있도록, 가상 사용자는 1명으로 설정하여 진행했습니다.
성능 개선 방안은 다음과 같습니다.
Cache Target Data 선정
우선 Client가 가장 자주 요청을 보내는 API Request를 조사했습니다.
- POST 채팅 저장
- GET 나의 커뮤니티 정보
- GET 특정 채널 정보
채팅 저장 로직은 READ 수행이 적고 WRITE는 비동기로도 수행할 수 있기 때문에 제외했습니다.
나의 커뮤니티 정보는 커뮤니티 뿐만 아니라 커뮤니티에 속한 채널과 사용자의 정보를 요구하고 있습니다.
특정 채널 정보는 채널의 기본 정보 및 참여한 사용자의 정보를 요구하고 있습니다.
(DB Collections : 사용자, 채널, 커뮤니티, 채팅)
전달하는 커뮤니티, 채널 정보의 경우 채널이 생성되거나 새로운 사용자의 참여의 로직이 실행되면 UPDATE가 빈번하게 발생했습니다.
그에 비해 사용자 기본 정보는 UPDATE가 적고 앞서 말한 상위 두 개의 GET 요청에서 필요하고있어 READ가 빈번하게 발생했습니다.
사용자 Collection 전체로 본다면 UPDATE가 자주 일어나지만, 변경이 적은 사용자 기본 정보만 조회하는 경우에 Cache Data를 사용하기 적합했습니다.
Caching을 위한 Code 작성
Look Aside 전략으로 Code를 작성했습니다.
사용자의 기본 정보를 필요로 하는 API는 4개가 있었으며, 이 요청들에 대하여 Cache에 먼저 접근하도록 구현했습니다.
const cache = await this.redis.get(`user/${_id}`);
if (cache) {
return JSON.parse(cache);
}
const result = await this.userModel.findById(_id);
await this.redis.set(`user/${_id}`, JSON.stringify(result));
return result;
}
Test Sequence
Artillery Test Script를 위의 실험 조건을 기반으로 작성했습니다.
config:
target : 'http://localhost:3000'
phases:
- duration: 40
arrivalRate: 1
name: Warm up
before:
flow:
- post:
url : '/api/user/auth/signin'
json:
id: '*@gmail.com'
password: '*'
capture:
json: '$.result.accessToken'
as: accessToken
expect:
- statusCode: 201
scenarios:
- flow:
- get:
url: '/api/channels/639846568ad1c10ed60d9653'
headers:
authorization: 'Bearer {{accessToken}}'
- get:
url: '/api/communities/63997033a59cf95317119f62/users'
headers:
authorization: 'Bearer {{accessToken}}'
- get:
url: '/api/communities'
headers:
authorization: 'Bearer {{accessToken}}'
max / medium / p95 (ms)
요청 API | cache X | cache O | 성능 비교 |
---|---|---|---|
나의 정보 | 24/13.1/21.1 | 17/4/7 | 227% |
특정 채널의 정보 | 44/23.8/40 | 28/13.9/21.1 | 71% |
커뮤니티 내 사용자 정보 | 38.4/14.7/22.4 | 31/12.1/25.8 | 18% |
나의 커뮤니티 정보 | 190/92.8/122.7 | 204/90.9/117.9 | 2% |
API 별로 비지니스 로직이 상이하기 때문에 전체 코드에서 사용자 정보 조회가 차지하는 비율은 다르지만, 성능 개선이 된 것을 확인할 수 있습니다.
max / medium / p95 (ms)
요청 API | For of | Promise.all | 성능 비교 |
---|---|---|---|
나의 커뮤니티 정보 | 77/55.2/68.7 | 47/30.9/44 | 79% |
2중 중첩문이 있는 나의 커뮤니티 정보를 요청하는 API 수행 시 응답 속도가 약 79% 좋아졌다.
사용자가 속한 커뮤니티 및 채널이 많다면 병렬 실행으로 인한 속도 개선은 더 일어날 것이다.
배열에 특정 값이 있는지를 확인할 때 Array.includes
를 사용했다.
코드 상으론 가벼운 한 줄이지만, 개념 적으로 접근한다면 시간 복잡도는 O(n)이다.
여기서 Array를 Set으로 변경하게 되면 Set는 해시 테이블 자료 구조처럼 키가 O(1)의 복잡도를 가질 것이라 생각했다.
팀원이 속도 차이를 코드로 직접 작성했는데, 내가 작성한 코드는 아니기 때문에 블로그에 자세히 적진 않겠다.
mongoDB도 Index를 사용하여 검색을 한다.
효율적인 Index 활용을 위하여 설정 전략이 필요하다.
Indexing 전략을 학습으로 얻은 요약 내용은 다음과 같다.
정확하게 검색하기 위한 좁은 범위의 Index를 만들자.
복합 인덱스는 ERS 규칙을 사용해 인덱스 키를 정렬하면 효율적인 복합 인덱스를 만들 수 있다.
최고의 인덱스 전략은 반복 테스트다.
인덱스 설정 시에도 안타는 것 같다면 hint로 강제로 태워 유의차를 확인 해보자.
이까지 학습 후, 이제 우리가 설계한 Database 구조에서 Indexing을 할 수 있는 부분이 어디가 있을지 확인 해 보았다.
우리는 이미 설계할 때 부터 대부분 기본키를 참조하도록 했다. 유일하게 기본키로 검색을 하지 않는 경우가 사용자 검색이었다.
기본키는 이미 default로 Indexing을 하고 있기 때문에, 성능 개선에 크게 도움 될 Field가 없었다.
내가 순간 뭔가 잘못된 포인트를 잡았나 고민이 되어 멘토님에게 질문드렸을 때, 이미 잘 설계를 했으니 추가로 indexing을 할 필요가 없는 것 아니겠냐고 해주셨다.
이번 프로젝트는 사용자가 특정 Field를 검색하는 부분이 없었기 때문에 그럴 수 있을 것 같았다.
추후 다른 프로젝트를 하게 된다면, Indexing Test를 하면서 유효성 검증을 해보고 싶다.