비동기 로깅 배포 후기 (최종)
이전 글 #1, #2의 내용을 포함하도록 작성했습니다. 이전 글을 읽지 않으셔도 괜찮습니다.
1. 요약
K8s 환경에서 synchonized를 사용하는 잘못된 API 로깅을 개선했습니다. 배포 전 30일(3.1~3.30) 174만 건, 배포 후 30일(4.1~4.30) 192만 건의 트랜잭션을 비교했습니다.
- 개선율 = (3월 백분위수 - 4월 백분위수) * 100
- 초기 개선율이 낮은 이유:
- 크기가 작아서(7~20ms 수준)
- Queue.offer() 오버헤드가 발생해서
| 지표 | 2일치 (이전 글) | 30일치 | ||
|---|---|---|---|---|
| P95 | 2,072 → 1,753ms | ▼15% | 1,079 → 562ms | ▼48% |
| P99 | 2,530 → 1,921ms | ▼24% | 2,322 → 1,936ms | ▼17% |
- P95까지는 개선폭이 크지만, P99에서는 크지 않습니다.
- 특정 API(세부사업별 세출현황)가 전체 응답시간을 오염시키기 때문입니다.
| 지표 | 2일치 (이전 글) | 30일치 | ||
|---|---|---|---|---|
| 500ms+ 비율 | - | - | 1.83% → 0.006% | ▼99.7% |
- 비정상 지연은 사실상 사라진 것으로 추정됩니다.
- 500ms 초과 비율로 추산했습니다. 특정 API(세부사업별 세출현황)은 양상이 달라 제외했습니다.
이번 글은 읽기 쉽도록 Q&A 형식을 차용해 보겠습니다.
2. 무엇을 해결했나요?
연쇄 지연을 일으키는 API 로깅 코드를,
ConcurrentLinkedQueue<LogDto> q = new ConcurrentLinkedQueue<LogDto>();
@Around
public Object ... {
synchronized(q) { //
while (!q.isEmpty()) {
var dto = q.poll();
dao.insertLog(dto);
}
}
}
인메모리 DB로 개선했습니다.
@Around
public Object ... {
hazelcastQueue.offer(dto);
}
Q. 아무튼 동작한다면, 큰 문제가 없다면 그냥 둬도 되는 거 아닌가요?
-
API들은 주기적으로 크롤링됩니다. 요청들이 갑자기 늘거나 몰리면 비합리적인 대기가 발생하여 고쳤습니다.
-
예측이 어려운 행동 양식을 시스템에 방치해 두면, 나중에 원인 파악이 어려울 것 같아 고쳤습니다.
Q. 왜 스레드 안 쓰고 큐 쓰셨어요?
-
insertLog는 사실 9개의 쿼리로 이루어져 있습니다. 기록 행위가 많습니다. -
Spring Batch를 쓸 수 있습니다. 기록 부하를 분담할 수 있었습니다.
-
스레드에서 처리할 경우, JVM이 종료되거나 pod가 재시작될 경우를 추가로 고민해야 합니다.
3. 개선율이 너무 미미한 거 아니에요?
우리 API의 지연은 크게 두 가지 때문입니다.
| API / 원인 | synchronized 때문 | 페이지네이션 쿼리 때문 |
|---|---|---|
| 세부사업별 세출현황 API | ✅ | ✅ |
| 나머지 API | ✅ | ❌ |
이번 배포는 synchronized로 인한 지연을 제거했으므로, 페이지네이션 쿼리 지연은 그대로 남습니다. 이 지연이 전체 API 응답시간을 오염시키기 때문에 그렇게 보이는 것입니다.
Q. 분리하면요? 나머지 API는 극적으로 개선되나요?
- 네, 선형적으로 개선됩니다.
| 지표 | 전체 API | 나머지 API | ||
|---|---|---|---|---|
| 평균 | 167.9 → 122.2ms | ▼27% | 76.6 → 27.5ms | ▼64% |
| 중앙값 | 56 → 34ms | ▼39% | 41 → 23ms | ▼44% |
| P95 | 1,079 → 562ms | ▼48% | 170 → 53ms | ▼69% |
| P99 | 2,322 → 1,936ms | ▼17% | 997 → 134ms | ▼87% |
- 주요 지표를 보면 더욱 명확합니다.
Q. 다른 API는 하나도 안 쓰이는데, 그 API만 인기있어서 그런 거 아니에요?
| 엔드포인트 | 3월 호출량 | 4월 호출량 |
|---|---|---|
| WCEGCF | 922,383 | 435,147 |
| HCFDA | 398,629 | 424,408 |
| QWGJK (세부사업별 세출현황) | 330,917 | 910,213 |
| 그 외 | 90,420 | 154,473 |
| 합계 | 1,742,349 | 1,924,241 |
-
아닙니다. 다른 API도 많이 호출됩니다.
-
심지어 부하가 강한 세부사업별 세출현황 API는, 배포 후 호출량이 3배로 늘었습니다.
Q. 그럼 그 API에는 개선된 게 거의 없는 거에요?
-
세부사업별 세출현황 API도 전체적으로 개선됩니다.
-
p95+ 구간은 쿼리 떄문에 높은 응답시간을 가지고 있지만, 여전히 배포 후가 더 낫다는 걸 알 수 있습니다.
| 지표 | 전체 API | 나머지 API | 세부사업별 세출현황 API | |||
|---|---|---|---|---|---|---|
| 평균 | 167.9 → 122.2ms | ▼27% | 76.6 → 27.5ms | ▼64% | 557.4 → 227.6ms | ▼59% |
| 중앙값 | 56 → 34ms | ▼39% | 41 → 23ms | ▼44% | 173 → 70ms | ▼60% |
| P95 | 1,079 → 562ms | ▼48% | 170 → 53ms | ▼69% | 2,317 → 1,647ms | ▼29% |
| P99 | 2,322 → 1,936ms | ▼17% | 997 → 134ms | ▼87% | 2,672 → 2,082ms | ▼22% |
- 주요 지표도 그렇습니다. 평균, 중앙값은 확실히 나아졌습니다. 그러나 p95+ 구간은 소폭 개선되었죠.
4. synchronized 지연을 “추정”한 이유는 뭔가요?
현실적으로 지연 현상을 전수조사 할 수 없기 때문입니다.
Q. 왜 전수조사가 어려워요?
-
APM(제니퍼5)의 한계 때문입니다. 각 트랜잭션마다 기록이 일어났는지 봐야 하는데, 일일이 눌러보는 수밖에 없습니다.
-
csv export 기능에도 쿼리 실행 정보는 담기지 않습니다.
-
APM(제니퍼)의 JDBC 드라이버로 접속하면 찾을 수 있겠지만, 기술지원팀에서 허가하질 않았습니다.
Q. 추정은 얼마나 엄밀한가요?
-
대부분의 API는 300ms 안팎의 응답시간을 가지고, 500ms를 넘는 일이 거의 없습니다.
-
synchronized 지연 현상은 모든 API에 일어나므로, 어느 트랜잭션이든 500ms가 넘어간다면 아주 높은 확률로 synchronized 때문일 것이라고 추측했습니다.
-
하지만 세부사업별 세출현황 API는 쿼리 문제로 2,000ms 넘는 경우가 많았습니다. 그래서 이 API만 제외하고 500ms+ 트랜잭션 비율로 추산하게 되었습니다.
성과
- 각 요청이 독립적이 되면서, 트래픽 증가가 곧 성능 저하로 이어지는 악순환이 사라졌습니다.