본문으로 건너뛰기

호출 300번을 1번으로 — 최적화가 실패 모드를 바꾼다는 걸 배운 과정

이 글의 순서10개 절

검증 단계가 타임아웃으로 죽었다. 30분 만에 N+1이니까 배치로 묶자는 답이 나왔고, 그건 절반만 맞았다. 최종 설계에 도착하기까지 두 번 방향을 틀어야 했는데, 남은 게 코드보다 그 과정이라서 정리해 둔다.

먼저 이 글이 따라갈 경로부터 그려 둔다.

증상검증 단계가 타임아웃으로 죽는다항목이 많을 때만첫 답 (30분)“N+1이니까 배치로 묶자”호출 수만 셌다방향 전환 1호출 1건의 비용을 안 봤다호출마다 인증 세션을 열고 닫고 있었다방향 전환 2배치로 묶어도 빨라지지 않는다벌크 API가 매번 전체 동기화를 돌렸다축이 바뀐 지점묶기가 아니라 동기화 시점동기화를 앞으로 빼 한 번만 · 세션 300 → 1
두 번의 방향 전환이 없었다면 배치만 하고 왜 안 빨라지냐고 했을 것이다

상황, 그리고 첫 번째 갈림길

사용자가 여러 리소스를 묶어 하나의 계획으로 저장하는 기능이 있다. 저장 직전에 이 리소스들이 실제로 존재하는지 확인하는 검증 단계가 붙는데, 담긴 항목이 많아지면 여기서 타임아웃이 났다. 적으면 멀쩡하고, 많으면 죽었다.

고치는 방향은 크게 둘이었고, 여기가 이 프로젝트의 첫 번째 트레이드오프였다.

비동기로 전환

  • 얻는 것: 타임아웃이 원천적으로 사라진다. 구현도 간단하다
  • 잃는 것: 저장을 누른 사용자가 결과를 바로 받지 못한다. 폴링, 알림 같은 상태 관리가 새로 필요해진다

동기 유지 + latency 개선

  • 얻는 것: 화면 흐름이 그대로다. 저장을 누르면 무엇이 틀렸는지 바로 안다
  • 잃는 것: 근본 원인을 실제로 찾아야 한다. 개선 폭이 모자라면 다시 터진다

후자를 골랐다. 근거는 제품 쪽에 있었다. 이 검증은 저장 전에 무엇이 잘못됐는지 알려 주는 화면 흐름의 일부다. 비동기로 바꾸면 사용자는 저장을 누르고 나서야 사실 안 됐다는 통보를 받게 되고, 그건 검증을 앞에 둔 이유 자체를 없애는 일이었다.

지금 돌아보면 이 결정이 뒤의 모든 작업의 제약 조건이 됐다. 동기를 유지한다고 못 박았기 때문에 latency를 실제로 줄여야 했고, 대충 넘어갈 여지가 사라졌다. 제약을 먼저 정해 둔 덕에 뒤의 선택지가 빨리 좁혀졌다.

첫 답: 호출 수를 세다

검증 코드를 열어 보니 리소스 1건마다 외부 서비스를 한 번씩 호출하고 있었다. 전형적인 모양이다.

검증 코드의 모든 호출이 이 형태였다
for _, id := range instanceIDs {
if _, err := resourceService.GetInstance(ctx, id, WithSync(true)); err != nil {
return err
}
}

세어 보니 이렇게 나왔다.

호출 수
N 인스턴스 개수
V 인스턴스 하나에 붙은 볼륨 개수
M 인스턴스 하나가 쓰는 네트워크 개수
호출 수 = N + N×V + 2×N×M + (그 외 종류별 distinct)
└ 네트워크마다 IP/MAC 중복 검사 2회
N=50, V=2, M=1 → 50 + 100 + 100 + α ≈ 300회 직렬 호출

게다가 중복 검사 쪽은 캐시조차 없어서 매번 나가고 있었다. 여기까지는 교과서적인 N+1이다. 배치로 묶자는 게 자연스러운 결론이었고, 실제로 나는 그렇게 제안했다.

방향 전환 ①: 문제는 호출 수가 아니었다

배치 설계를 시작하려는데 뒤늦게 걸리는 게 있었다. 호출 수는 셌는데, 호출 1건이 얼마나 비싼지는 안 봤다. 외부 서비스 쪽 코드를 따라가 봤다.

WithSync(true)를 따라가 보니
GetInstance(id, WithSync(true))
└─ syncOne(id, kindInstance)
├─ newSession(clusterID)
│ ├─ 클러스터 정보 조회 + 자격증명 복호화
│ └─ 클라우드 플랫폼에 인증하고 토큰 받기 // 외부 왕복
├─ 리소스 1건 조회 // 외부 왕복
└─ session.Close() // 세션 종료

WithSync(true)는 내가 막연히 생각했던 캐시 갱신 플래그가 아니었다. 호출마다 인증 세션을 새로 열고 닫는다. 강제 플래그가 켜져 있어 중복 제거도 없었다. 즉 리소스 1건 검증이 인증 왕복, API 왕복, 세션 종료를 한 번씩 치른다. 그게 300번 직렬로 쌓이면 타임아웃이 나는 게 당연했다.

호출 1건을 펼치면인증 세션 수립조회종료↑ 필요한 건 이것뿐× 300번, 직렬로총 비용 = 호출 수 × 호출당 비용두 항은 곱해진다.지배적인 항을 모르면 엉뚱한 쪽을 줄인다.
호출 하나마다 인증 왕복과 세션 종료가 붙고, 그게 300번 직렬로 쌓였다

호출 수만 보고 배치로 갔다면 호출당 비용이 그대로 남아, 묶었는데 왜 안 빨라지냐고 묻고 있었을 것이다.

리포 안에 이미 답이 있었다

배치 설계를 새로 하려는데, 같은 패키지에 이미 그 패턴이 있었다. 같은 데이터를 다루는 조회 경로는 진작에 이렇게 하고 있었다.

조회 경로가 이미 쓰고 있던 패턴
collectRequired() // ① 플랜을 훑어 필요한 ID 집합만 모은다. 외부 호출 0회
fetchByKind() // ② 종류별로 목록을 1회씩, errgroup으로 병렬 조회
lookup(byID) // ③ 메모리에 올려 둔 맵으로 판정한다. 외부 호출 0회

검증 경로만 옛 방식으로 남아 있었던 것이다. 그래서 새로 만들지 말고 이걸 재사용하기로 방향을 잡았다. 여기서 부수적으로 하나 더 발견했는데, 이게 꽤 컸다.

같은 정보를 얻는 두 경로 구현 비용
목록 조회 API 저장된 데이터를 읽는다 저렴
단건 확인 API 매번 인증 세션을 열고 외부에 물어본 뒤 닫는다 인증 세션 1회

중복 검사에 쓸 수 있는 API가 둘인데 비용이 딴판이었고, 검증 쪽만 비싼 길을 택하고 있었다. 조회 경로는 이미 싼 길을 쓰고 있었고. 이런 건 코드 리뷰로 잡히지 않는다. 두 API가 같은 질문에 답한다는 걸 아는 사람이 비교해 봐야 보인다.

방향 전환 ②: 배치도 함정이었다

재사용하기로 한 그 배치 코드를 마저 읽다가, 내가 계산한 개선 효과가 틀렸다는 걸 알았다.

벌크 검색 API 내부
if req.Sync {
syncAll(clusterID, kindInstanceList) // 클러스터 전체 목록을 재동기화한다
}
return readByIDs(req.IDs) // 그다음에야 요청한 ID들을 읽는다

벌크 조회의 동기화 플래그는 요청한 ID와 상관없이 클러스터 전체 목록을 재동기화하고 있었다. 그리고 호출하는 쪽은 ID를 일정 개수씩 청크로 쪼개 보내고 있었다.

내가 기대한 것

  • ID 50개를 5개씩 묶어 10번 호출한다
  • 호출 수가 300에서 10으로 준다
  • 호출당 비용은 그대로일 테니 30배 개선

✕실제로 일어나는 것

  • 청크 10개가 각각 클러스터 전체 목록을 동기화한다
  • 전체 동기화가 10번 돈다
  • 호출 수는 줄지만 호출당 비용이 오른다. 순증인지 순감인지조차 계산으로는 알 수 없었다

배치는 필요조건이었지 충분조건이 아니었다. 이 지점에서 설계의 축이 바뀌었다. 호출을 어떻게 묶을지 고민하던 질문이, 동기화를 언제 할지로 바뀌었다. 문제의 정체는 동기화 시점과 조회 시점이 붙어 있는 것이었고, 그 둘을 떼어 내야 했다.

기존: 조회마다 동기화가 딸려 온다리소스 1건 조회 + 동기화리소스 1건 조회 + 동기화… 300번 반복인증 세션 ≈ 300개변경 후: 동기화를 앞으로 빼서 한 번만검증 시작 전 1회 동기화여기서만 외부에 나간다종류별 목록 조회동기화 없이 읽기만메모리에서 판정외부 호출 0회인증 세션 1개
동기화를 조회 앞으로 빼서 한 번만 돌리면 인증 세션이 300개에서 1개로 준다

필요한 것만 불러오자를 세 갈래로 쪼개다

논의 중에 플랜이 가진 리소스만 불러오면 되지 않느냐는 방향이 나왔다. 맞는 말인데, 듣고 보니 세 가지로 읽혔다. 같은 문장을 서로 다르게 이해한 채로 설계를 진행하면 나중에 크게 어긋난다. 그래서 먼저 쪼갰다.

해석 뜻 판정
① 종류 선별 플랜이 안 쓰는 리소스 종류는 동기화를 건너뛴다 채택
② 개체 선별 플랜이 참조하는 ID 20개만 동기화한다 불가능
③ 필드 선별 검증이 실제로 쓰는 필드만 가져오고 나머지는 조립하지 않는다 채택

②는 확인해 보니 불가능했다. 동기화 단위가 종류 전체 아니면 개체 1건 둘뿐이었고, 개체 단위로 쪼개면 다시 N+1로 돌아간다. 외부 목록 API의 특성상 이 20개만이라는 요청이 성립하지 않았다. 하위 시스템이 제공하는 단위가 설계를 제약한 경우다.

③은 생각보다 컸다. 벌크 검색이 인스턴스마다 연결된 리소스를 전부 조립하고, 네트워크마다 추가 조회까지 하고 있었다. 인스턴스당 아홉 개 안팎의 DB 쿼리다. 그런데 검증은 그 데이터를 하나도 안 쓴다.

그래서 ①과 ③을 둘 다 하기로 했다. 하나만 고치면 나머지가 새 상한이 된다. 종류를 줄여도 필드 조립이 남으면 거기서 다시 막힌다. 앞의 그림에서 1회 동기화라고만 적었던 그 1회도, 결국 플랜이 실제로 쓰는 종류만 골라서 도는 것이다.

문제는 ③을 하려면 검증이 실제로 쓰는 필드가 무엇인지부터 정해야 한다는 것이었다. 그래서 검증 코드를 처음부터 다시 읽었고, 거기서 이 프로젝트에서 가장 큰 걸 발견했다.

가장 큰 발견: 검증은 응답을 통째로 버리고 있었다

종류별로 흩어져 있던 검증 함수를 전부 훑어봤더니, 예외 없이 이 모양이었다.

리소스 종류마다 검증 함수가 따로 있는데, 전부 이 형태였다
func validateVolume(ctx context.Context, id string) error {
_, err := resourceService.GetVolume(ctx, id)
return err // 응답은 버리고 err만 본다
}
func validateNetwork(ctx context.Context, id string) error {
_, err := resourceService.GetNetwork(ctx, id)
return err // 여기도 마찬가지
}
// … 10여 종이 전부 똑같았다

응답을 전부 _로 버리고 err만 본다. 예외가 두 종류 있었는데 그것도 결국 개수가 0인지와 존재하는지 여부였다.

검증에 필요한 건 100% 존재하는가 하나뿐이었다. 상세 조회는 처음부터 필요 없었다. 이걸 알고 나니 앞의 모든 논의가 다시 정리됐다. 우리는 필요 없는 데이터를 만들려고 300번 인증하고 있었던 것이다.

덕분에 ③의 답도 확정됐다. 필요한 필드는 하나도 없고, 필요한 건 있다/없다 한 비트뿐이다. 그런데 기존 조회 API는 상세 객체를 조립해 돌려줄 뿐이라 그렇게 물어볼 방법이 없었다. 존재 여부만 답하는 가벼운 조회 경로를 외부 서비스에 새로 추가해야 한다는 뜻이었고, 이게 뒤에서 배포 순서 문제로 돌아온다.

이 발견이 늦게 나온 게 아쉽다. 이 코드가 무엇을 필요로 하는지는 최적화를 시작할 때 가장 먼저 물었어야 했다. 나는 어떻게 빨리 가져올까부터 물었고, 가져올 필요가 있느냐는 세 번째 단계에서야 물었다.

하나로 합치자를 반박하다: 소비자가 둘이면 정책도 둘이다

조회 경로와 검증 경로가 비슷한 일을 하니, 불러오는 곳을 하나로 올려서 양쪽이 같이 쓰자는 방향이 나왔다. 중복이 사라지고 관리 지점이 하나가 되니 매력적이었다. 그런데 반박해야 했다.

소비자 필요한 데이터 리소스가 없을 때
검증 존재 여부만 fail-hard: 요청을 거절한다
조회 상태 값까지 (경고 메시지용) fail-soft: 경고만 남기고 계속한다

둘은 필요한 데이터도 다르고 실패 정책도 정반대였다. 가져오는 부분을 통째로 합치면 검증이 필요 없는 상세 데이터를 다시 끌고 오게 되어, 방금 얻은 경량화를 스스로 되돌린다.

그렇다고 공유할 게 없는 건 아니었다. 진짜 공유 가치가 있는 건 이 플랜이 어떤 리소스를 참조하는지 뽑아내는 수집 로직이었다. 플랜 구조가 바뀌면 한 곳만 고치면 되는 부분이다. 나눌 이음매는 수집과 조회 사이에 있었다.

그리고 여기에 이번 설계의 최대 위험이 있었다. 조회 쪽 로직은 fail-soft다. 그 정책이 공유 계층을 타고 검증 경로로 새어 들면 존재하지 않는 리소스가 검증을 통과한다. 성능을 고치려다 정합성을 깨는 건 최악이다.

✕공유 계층이 정책을 갖는다

  • 공유 계층이 조회 쪽 정책(fail-soft)을 그대로 갖는다
  • 조회는 원래 그래도 된다
  • 검증은 없는 리소스가 통과해 버린다

✓공유 계층은 에러를 보관만 한다

  • 공유 계층은 에러를 모아 두기만 한다
  • 조회가 경고로 처리할지 정한다
  • 검증이 거절할지 정한다

그래서 공유 계층은 에러를 보관만 하고 정책을 갖지 않게 했다. 무엇을 가져오는지는 합쳤고, 없을 때 어떻게 할지는 소비자마다 남겼다.

최적화는 실패 모드를 바꾼다

동기화를 요청당 1회로 모으기로 하면서 껄끄러운 질문이 남았다. 그 1회가 실패하면?

기존 동작을 확인해 보니 동기화 실패는 로그만 남기고 오래된 데이터로 진행하고 있었다. 그러니까 지금도 검증은 최선 노력일 뿐 실시간 보장은 없었다. 내가 따져야 할 건 실패의 폭발 반경이었다.

기존: 흩어진 부분 실패몇 건이 실패해도(빨강) 나머지는 신선하다변경 후: 한 번에 전체 실패✕ 동기화 1회 실패그 1회가 실패하면 전부 오래된 데이터(회색)얻는 것: 세션 300개 → 1개, 타임아웃 해소주는 것: 실패가 부분에서 전체로
동기화를 한 번으로 모으면, 몇 건씩 흩어지던 실패가 한 번의 전체 실패로 바뀐다

고민 끝에 오래된 데이터 허용을 유지하기로 했다. 근거는 셋이었고, 순서대로 무게가 달랐다.

  1. 회귀가 없다. 현행 동작과 같다. 이번 변경으로 새로 생기는 위험이 아니다.
  2. 가용성이 우선이다. 외부 시스템의 일시 장애로 저장 자체가 전면 차단되는 게 더 나쁘다.
  3. 실시간 보장은 애초에 성립하지 않는다. 검증을 통과한 직후에 리소스가 삭제될 수 있다. 검증이 실시간을 보장한다는 건 원래 불가능했다.

세 번째가 결정적이었다. 검증과 실제 사용 사이에는 어차피 시간 간격이 있고, 그 사이에 상태가 바뀔 수 있다. 이 TOCTOU(확인 시점과 사용 시점의 차이)를 인정하고 나니 그림이 달라졌다. 진짜 안전망은 실행 시점의 재확인이고, 저장 시점의 검증은 그 앞에서 걸러 주는 단계라는 게 분명해졌다. 나는 검증에 과한 의미를 부여하고 있었던 셈이다.

대신 실패를 조용히 삼키지 않고 드러내기로 했다. 그리고 이 결정은 ADR로 남겼다. 되돌리기 어렵고, 맥락 없이 보면 의아하고, 진짜 트레이드오프라서다. 나중에 누군가 왜 여기서 실패해도 그냥 진행하냐고 물을 때 답이 코드에 없으면 안 된다.

폴백을 두지 않기로 한 이유

앞에서 말한 가벼운 조회 API를 외부 서비스에 추가하기로 하면서 배포 순서가 생겼다. 상대가 구버전이면 어쩌나. 안전하게 새 API가 없으면 옛 경로로 폴백하자는 안이 자연스럽게 나왔다.

✕폴백을 둔다

지금 타임아웃을 내고 있는 그 코드를 영구히 살려 두게 된다. 죽은 코드는 지우면 그만이지만 폴백은 가끔 도는 느린 코드라 더 나쁘다. 테스트도 사실상 안 되고, 어느 환경이 그 경로를 타는지 추적도 어렵다.

✓두지 않는다

배포 순서를 지켜야 한다. 대신 고쳐야 할 코드가 확실히 사라진다.

두지 않기로 했다. 결정적인 근거는 인터페이스 정의를 모듈로 고정하는 구조라 빌드 시점에 순서가 이미 강제된다는 점이었다. 남는 건 런타임 호환뿐이었고, 그건 배포 절차로 푸는 게 맞다고 봤다.

이번 경우 폴백으로 남을 경로가 바로 지금 타임아웃을 내고 있는 그 경로였다.

숫자에 대한 정직함

최종 설계의 기대 효과는 이렇게 나왔다.

요청 1건 기준 현재 변경 후
외부 인증 세션 약 300회 1회
서비스 간 왕복 약 300회 2회

그럴듯해 보이지만 이건 호출당 소요 시간을 가정하고 돌린 산수다. 실제로 잰 값은 아직 없다. 그래서 계측을 첫 번째 작업으로 넣었다. baseline은 고치고 나면 못 딴다. 그때는 얼마나 좋아졌는지를 영영 말할 수 없게 된다.

이 프로젝트에서 나는 계산을 두 번 틀렸다. 한 번은 호출당 비용을 안 봐서, 한 번은 벌크 API의 내부 동작을 안 봐서. 그래서 세 번째 계산은 하지 않고, 고치기 전에 baseline부터 재기로 했다.

정리

  • 호출 수와 호출당 비용은 곱해진다. 어느 쪽이 지배적인지부터 쟀어야 했다.
  • 리포 안에 있던 배치 코드도 끝까지 읽어 보니 함정이 있었다.
  • 같은 정보를 얻는 API가 둘이었고, 비용이 딴판이었다.
  • 조회와 검증은 가져오는 것만 합치고, 없을 때의 정책은 각자 남겼다. fail-soft가 검증으로 새면 정합성이 깨진다.
  • 동기화를 한 번으로 모으면서 흩어진 부분 실패를 한 번의 전체 실패로 바꿨고, 그 결정을 ADR로 남겼다.
  • 기대 효과는 아직 산수다. baseline은 고치기 전에 딴다.

처음 30분 안에 나온 배치 답이 조금 덜 그럴듯했다면 나는 더 오래 들여다봤을 것이다. 두 번의 방향 전환은 둘 다 그 답을 의심하고 코드를 한 단계 더 읽었을 때 나왔다.

GitHub에서 고치기
esc

최근 글

↑↓ 이동↵ 열기검색 페이지에서 보기 ›