캐시의 CPU와 메모리는 여유로운데 네트워크 전송량만 높았다.
광고 서버 여러 대가 캠페인 설정 정보를 캐시에서 주기적으로 조회하고 있었다. 캠페인 설정은 자주 바뀌지 않는 데이터였다. 그런데 변경 여부와 관계없이 매번 전체 데이터를 가져오는 구조였다. 서버 수가 늘면서 네트워크 전송량이 인스턴스의 허용 범위에 근접했고, 스케일 다운도 할 수 없는 상황이었다.
데이터 분리
캐시에 저장된 캠페인 데이터를 들여다보니 성격이 다른 데이터가 하나로 묶여 있었다.
메타데이터. 캠페인 메타 정보, 타겟팅 조건 같은 데이터는 변경 빈도가 낮다. 광고주가 캠페인을 수정할 때만 바뀐다.
상태 데이터. 예산 소진 현황 같은 데이터는 광고가 노출될 때마다 갱신된다. 항상 최신 상태를 유지해야 한다.
공유 데이터. 광고 소재 정보는 여러 캠페인이 같은 콘텐츠를 참조하는 경우가 많다. 캠페인 안에 통째로 포함시키면 동일한 소재가 캠페인마다 중복으로 들어가서 저장 공간과 전송량이 같이 늘어난다. 소재를 별도 키로 빼고 캠페인은 참조 ID만 들고 있도록 바꿨다.
세 가지를 분리했다. 메타데이터와 공유 데이터는 변경분만 갱신하고, 상태 데이터는 매번 갱신하는 구조로 바꿨다.
변경분 갱신
전량 갱신을 변경분 갱신으로 바꾸려면 “무엇이 변경되었는가"를 알 수 있어야 한다.
배치가 DB에서 최신 데이터를 가져온 뒤, 캐시에 저장된 데이터와 비교한다. 내용이 다른 항목만 캐시에 쓴다. 같은 시점에 변경 인덱스에도 timestamp를 기록했다. Sorted Set의 score로 timestamp를 저장하면 특정 시점 이후 변경된 항목만 범위 조회로 가져올 수 있다. 읽는 쪽은 마지막으로 조회한 시점을 기억하고 있다가, 그 이후 score 범위만 조회해서 변경된 ID 목록을 받고 해당 항목만 따로 가져온다.
flowchart LR
subgraph Write ["쓰기 경로"]
DB["DB"] --> BATCH["배치"]
BATCH --> CMP{"캐시와
비교"}
CMP -->|"변경됨"| WRITE["캐시 갱신
+ timestamp 기록"]
CMP -->|"동일"| SKIP["건너뜀"]
end
subgraph Read ["읽기 경로"]
SVC["서비스"] --> TS{"마지막 갱신
이후 변경분?"}
TS -->|"있다"| FETCH["변경분만 조회"]
TS -->|"없다"| LOCAL["로컬 데이터 유지"]
end
쓰기 경로와 읽기 경로를 분리한 것이 핵심이었다. 배치는 변경분만 쓰고, 서비스는 변경분만 읽는다.
소비자별로 필요한 데이터 범위가 다르다는 점도 함께 신경 썼다. 어떤 서비스는 메타데이터만 필요하고, 어떤 서비스는 메타데이터와 소재까지 필요하다. 읽기 인터페이스를 소비자 단위로 분리해서 각 서비스가 자기가 쓰는 범위만 가져가도록 했다.
결과
네트워크 전송량이 크게 줄었다. 변경이 없는 주기에는 거의 전송이 발생하지 않게 됐다. 캐시 인스턴스를 한 단계 낮은 타입으로 스케일 다운할 수 있었다.
대신 구조는 그만큼 복잡해졌다. 변경 감지 로직, timestamp 관리, 로컬 상태의 부분 갱신, 캐시-원본 불일치를 복구할 전량 동기화 메커니즘이 같이 따라왔다. 데이터가 작거나 변경이 빈번한 캐시였다면 여기까지 갈 이유가 없었을 거라고 봤다.
돌아보면 이 작업의 시작점은 “무엇이 병목인가"를 정확히 파악한 것이었다. CPU나 메모리가 아니라 네트워크가 병목이라는 것을 먼저 확인했기 때문에, 데이터 분리와 변경분 갱신이라는 방향이 자연스럽게 나왔다.