1. 개요
마이데이터 서비스 운영을 맡으면서 가장 먼저 마주한 과제는 사용자 수가 빠르게 늘어나는 와중에 발생한 메인 화면 응답 지연이었습니다. 서비스 초기에는 큰 문제가 되지 않았던 구조가, 사용자 규모가 커지면서 체감 지연이라는 형태로 표면화되었습니다. 이 글은 당시 문제를 어떻게 진단하고 해결했는지, 그리고 그 해결책이 지금 돌이켜보면 어떤 기술적 흐름과 맞닿아 있었는지를 정리한 기록입니다.
당시 서비스는 Vue 2 기반의 WebApp이었고, 캐시는 클라이언트가 아니라 서버 측에 구현되어 있었습니다. 이 점에서 지금의 데이터 페칭 라이브러리들이 기본으로 제공하는 클라이언트 메모리 캐시와는 캐시가 위치한 계층 자체가 다릅니다. 다만 “오래된 데이터를 먼저 보여주고 백그라운드에서 갱신한다”는 전략 자체는, 지금 Vue 진영의 TanStack Query(예전 명칭 Vue Query)나 React 진영의 SWR이 표준으로 제공하는 Stale-While-Revalidate(이하 SWR) 캐시 전략과 본질적으로 같은 방향이었습니다. 이 글에서는 문제 진단부터 해결 설계, 적용 결과, 그리고 SWR 관점에서의 재해석까지 다루며, 트렌드를 의식하지 않고도 같은 결론에 도달할 수 있었던 이유를 함께 짚어보고자 합니다.
2. 문제 상황: 메인 화면 응답 지연
2-1. 기존 구조
마이데이터 서비스는 사용자가 메인 화면에 진입하는 즉시 여러 금융기관에 등록된 자산·거래 정보를 한 화면에 모아 보여주는 것이 핵심 가치였습니다. 이를 위해 화면 진입 시점마다 활성 사용자의 정보를 실시간으로 조회하여 최신 데이터를 보여주는 방식을 채택했습니다. 서비스 초기에는 사용자 수가 적어 이 구조에서 큰 무리가 없었고, 오히려 “항상 최신 데이터를 보여준다”는 단순하고 명확한 설계 원칙이 장점으로 작용했습니다.
2-2. 문제가 드러난 지점
사용자 수가 늘어나면서 문제가 서서히 드러났습니다. 화면 진입 시마다 여러 외부 기관에 동시다발적으로 조회 요청을 보내는 구조였기 때문에, 동시 접속자가 늘어날수록 외부 응답 대기 시간이 그대로 사용자 체감 지연으로 누적되었습니다. 운영 모니터링 지표를 통해 메인 화면 진입 후 화면이 표시되기까지 걸리는 시간이 사용자 수 증가 추이와 비례해서 늘어나는 패턴을 확인할 수 있었습니다.
특히 외부 기관 중 일부의 응답이 느려지는 경우, 그 지연이 전체 화면 로딩 시간을 끌어내리는 구조적 취약점도 함께 드러났습니다. “항상 최신 데이터”라는 원칙을 지키기 위한 구조가, 사용자 규모가 커지자 오히려 서비스 전체의 체감 성능을 좌우하는 병목이 된 것입니다. 이 시점에서 분명해진 것은, 모든 화면 진입에서 100% 최신 데이터를 보장하는 구조와 사용자가 즉시 화면을 받아볼 수 있는 구조를 동시에 만족시키기는 어렵다는 점이었습니다. 두 가지 가치 중 하나를 우선순위에 두고 설계를 다시 짜야 했습니다.
3. 해결 전략: 캐시 우선 렌더링 + 백그라운드 갱신
3-1. 설계 원칙
문제를 다시 정의하면서 채택한 원칙은 “즉시 응답”과 “최신성 확보”를 하나의 요청 안에서 동시에 처리하지 않고, 두 단계로 분리하는 것이었습니다. 화면 진입 시점에는 가장 최근에 조회되어 저장된 데이터를 즉시 보여주고, 그 직후 백그라운드에서 최신 데이터를 다시 조회하여 화면을 갱신하는 방식입니다. 사용자는 빈 화면이나 로딩 상태를 오래 바라보는 대신 즉시 의미 있는 정보를 받아볼 수 있고, 약간의 시간차를 두고 최신 정보로 자연스럽게 전환되는 경험을 하게 됩니다.
이 원칙을 적용하기 위해서는 가장 최근 조회 결과를 저장해 둘 캐시 저장소와, 화면 노출용 데이터 조회와 갱신용 조회를 분리해서 처리할 수 있는 비동기 처리 구조가 필요했습니다.
3-2. 처리 흐름
처리 흐름을 의사코드로 정리하면 다음과 같습니다.
function 메인화면진입(사용자ID):
캐시데이터 = 캐시저장소.조회(사용자ID)
if 캐시데이터 존재:
화면.즉시렌더링(캐시데이터)
else:
화면.로딩상태표시()
비동기작업큐.등록(갱신작업, 사용자ID)
return
function 갱신작업(사용자ID):
최신데이터 = 외부기관조회.전체조회(사용자ID)
캐시저장소.갱신(사용자ID, 최신데이터)
if 화면.현재활성상태(사용자ID):
화면.부분갱신(최신데이터)
이 구조의 핵심은 화면 렌더링 경로와 데이터 갱신 경로를 분리했다는 점입니다. 화면은 캐시 저장소만 바라보고 즉시 응답하고, 외부 기관 조회라는 무거운 작업은 별도 경로에서 비동기로 처리되어 화면 응답 시간에 영향을 주지 않습니다.
3-3. 메시지 큐를 통한 갱신 작업 분산
갱신 작업을 단순히 비동기로 돌리는 것만으로는 충분하지 않았습니다. 동시 접속자가 늘어나는 시점에는 갱신 작업 자체가 한꺼번에 몰리면서 외부 기관 조회 요청이 폭주할 수 있었기 때문입니다. 이를 완화하기 위해 갱신 작업을 메시지 큐에 적재하고, 워커가 일정한 처리량으로 큐를 소비하도록 구성했습니다.
사용자 입장에서는 화면 진입 시 즉시 캐시 데이터를 받아보기 때문에 큐 처리 속도와 무관하게 체감 응답 속도가 유지되었고, 시스템 입장에서는 외부 기관으로 나가는 요청량이 큐를 통해 평탄화되어 부하가 특정 시점에 몰리는 현상이 줄어들었습니다.
3-4. 구조 비교
[표 1] 구조 개선 전후 비교
|
구분 |
기존 구조 (As-Is) |
개선 구조 (To-Be) |
|---|---|---|
|
데이터 처리 시점 |
화면 진입 시 실시간 전체 조회 |
캐시 즉시 응답 + 백그라운드 갱신 |
|
응답 특성 |
외부 기관 응답 속도에 종속 |
캐시 조회 속도로 응답 시간 고정 |
|
부하 발생 패턴 |
동시 접속 시 외부 조회 요청 폭주 |
메시지 큐를 통한 요청 평탄화 |
|
사용자 경험 |
전체 조회 완료까지 대기 |
즉시 데이터 표시 후 자연스러운 갱신 |
4. 적용 결과
구조 개선 이후 가장 두드러진 변화는 화면 진입 시 체감 대기 시간이 크게 줄었다는 점입니다. 사용자는 외부 기관의 응답 속도와 무관하게 항상 일정한 속도로 화면을 받아볼 수 있게 되었고, 특정 기관의 응답이 느려지더라도 그 영향이 전체 화면 로딩으로 전파되지 않았습니다.
운영 측면에서도 외부 기관으로 나가는 조회 요청이 메시지 큐를 통해 평탄화되면서, 동시 접속자가 늘어나는 시간대에도 갱신 작업이 안정적으로 처리되었습니다. 무엇보다 “최신성”을 포기한 것이 아니라 “최신성을 보장하는 시점”을 화면 진입 시점에서 그 직후로 미뤘을 뿐이라는 점에서, 데이터 신뢰성과 사용자 경험을 모두 지킬 수 있었습니다.
5. 지금 보면: SWR 패턴과의 연결
5-1. SWR 패턴이란
Stale-While-Revalidate는 캐시된(오래된, Stale) 데이터를 먼저 반환하면서 동시에 백그라운드에서 검증(Revalidate)을 수행하고, 검증이 끝나면 최신 데이터로 교체하는 캐시 전략입니다. 이 전략에는 메커니즘이 다른 세 갈래가 있습니다. 첫째는 HTTP 프로토콜 레벨로, Cache-Control: stale-while-revalidate 헤더(RFC 5861)를 브라우저나 CDN, 리버스 프록시가 보고 자동으로 캐시된 응답을 먼저 내려준 뒤 백그라운드에서 재검증합니다. 둘째는 애플리케이션 레벨로, 프로토콜이나 라이브러리에 의존하지 않고 캐시 조회와 백그라운드 갱신 로직을 백엔드 코드로 직접 구현하는 방식입니다. 셋째는 클라이언트 라이브러리 레벨로, TanStack Query나 SWR. js처럼 같은 전략을 브라우저 메모리 위에서 선언적으로 제공하는 방식입니다.
이번 사례는 이 중 애플리케이션 레벨에 해당합니다. 프로토콜이나 라이브러리를 차용한 것이 아니라, 같은 전략을 독립적으로 코드에 옮긴 결과였다는 점에서 의미가 있습니다.
5-2. 직접 구현한 방식과 클라이언트 라이브러리의 구현 비교
메커니즘의 레이어는 다르지만, “오래된 데이터를 먼저 보여주고 백그라운드에서 갱신한다”는 전략을 구현 관점에서 비교하면 다음과 같습니다.
[표 2] 구현 방식 비교
|
항목 |
당시 직접 구현한 방식 |
TanStack Query / SWR. js 등 |
|---|---|---|
|
캐시 저장 위치 |
별도 캐시 저장소 (서버 측) |
클라이언트 메모리·스토리지 |
|
갱신 트리거 |
화면 진입 시 비동기 작업 등록 |
화면 진입, 포커스 복귀, 재연결 등 다양한 트리거 |
|
갱신 작업 분산 |
메시지 큐 기반 처리량 제어 |
요청 중복 제거(Dedupe), 재시도 정책 |
|
구현 난이도 |
직접 설계 및 구현 필요 |
선언적 설정으로 즉시 적용 가능 |
5-3. 같은 결론에 도달한 이유
당시에는 Stale-While-Revalidate라는 이름도, 이런 전략을 표준으로 제공하는 프로토콜이나 라이브러리의 존재도 알지 못했습니다. 다만 “사용자에게 즉시 보여줄 데이터”와 “정확성을 보장해야 하는 데이터”가 같은 시점에 같은 방식으로 처리될 필요가 없다는 점을 운영 데이터를 통해 체감했고, 그 깨달음을 구조로 옮긴 결과가 자연스럽게 같은 패턴으로 귀결되었습니다. 이는 좋은 아키텍처 패턴이 특정 기술 트렌드에서 시작되기보다, 실제 문제를 정확히 들여다보는 과정에서 독립적으로 재발견되는 경우가 많다는 것을 보여주는 사례라고 생각합니다.
6. 한계와 트레이드오프
이 구조에도 명확한 트레이드오프가 있었습니다. 화면 진입 시 보여주는 데이터는 정의상 약간 오래된(Stale) 데이터이기 때문에, 데이터의 신선도가 중요한 화면이나 기능에는 그대로 적용하기 어렵습니다. 또한 캐시 무효화 시점을 잘못 설계하면 사용자가 실제보다 오래된 정보를 더 길게 보게 되는 위험도 있습니다.
실제로는 화면별 데이터 특성에 따라 캐시 유지 시간과 갱신 우선순위를 다르게 적용해야 했습니다. 예를 들어 자산 총액처럼 노출 빈도가 높고 변동이 상대적으로 적은 데이터는 캐시 유지 시간을 길게 두었고, 거래 내역처럼 최신성이 중요한 데이터는 캐시 유지 시간을 짧게 두고 갱신 우선순위를 높였습니다. 이 기준을 세우는 과정 자체가 별도의 튜닝 작업이었고, 일괄적인 정답이 아니라 데이터 성격에 맞춘 개별 판단이 필요했습니다.
7. 운영 가이드로의 정착
한 번의 구조 개선으로 끝내지 않고, 데이터 유형별 캐시 유지 시간과 갱신 우선순위 기준을 팀 내부 가이드로 정리해 두었습니다. 신규 화면을 추가할 때마다 캐시 정책을 처음부터 다시 고민하는 대신, 데이터의 성격을 아래 기준에 대응시켜 빠르게 결정할 수 있도록 한 것입니다.
[표 3] 데이터 유형별 캐시 운영 기준 (예시)
|
데이터 유형 |
캐시 유지 시간 |
갱신 우선순위 |
|---|---|---|
|
자산 총액 |
길게 (변동 빈도 낮음) |
낮음 |
|
보유 상품 목록 |
중간 (가입·해지 반영 필요) |
중간 |
|
거래 내역 |
짧게 (최신성이 신뢰와 직결) |
높음 |
이 기준표가 갖는 의미는 단순한 설정값 모음이 아니라, “이 데이터는 얼마나 자주 바뀌고, 사용자는 얼마나 빠른 최신성을 기대하는가”라는 질문에 먼저 답하고 그 답을 캐시 정책으로 옮기는 사고 절차를 팀이 공유하게 되었다는 점입니다. 이후 새로운 화면을 기획하는 단계에서도 이 절차를 거치면서, 캐시 전략이 한 번의 문제 해결이 아니라 반복 가능한 운영 기준으로 자리잡았습니다.
8. 마무리
이 경험을 돌이켜보면, 트렌드나 정해진 패턴을 먼저 학습하고 적용한 것이 아니라 운영 중 마주한 문제를 정확히 분해하는 과정에서 해결책에 도달했습니다. 지금은 TanStack Query나 SWR. js 같은 라이브러리를 통해 같은 전략을 몇 줄의 선언적 코드로 적용할 수 있지만, 그 이전에 “왜 이 전략이 필요한가”를 직접 고민해 본 경험은 라이브러리를 단순히 가져다 쓰는 것 이상의 이해를 갖게 해주었습니다.
새로운 도구를 도입할 때, 그 도구가 해결하는 문제의 본질을 먼저 이해하고 있다면 적용 폭과 깊이가 달라진다는 것을 다시 한번 확인한 경험이었습니다.
cryss