고정글

프론트엔드

BO에서 글을 수정해도 Web이 안 바뀌는 이유: 모노레포 환경 캐시 전략

BO에서 글 수정 시 Web에 미반영 문제 관리자 페이지(BO)에서 게시글을 수정하면 DB 및 BO에는 즉시 반영된다. 문제는 수정 직후 Web에서 해당 글을 확인하면, 내용이 이전 버전으로 계속 노출되었다. 관리자에서 수정이 완료되었는데 Web에는 반영되지 않는 상황이기 때문에, 운영 흐름상 수정 직후 화면에 변경 사항이 보이지 않는 것은 문제가 될 수밖에 없다. Web은 Next.js App Router + fetch 기반 SSR 구조로 구성되어 있었고, App Router의 fetch는 서버 레벨 캐싱을 기본으로 동작한다. 이는 일반적인 클라이언트 캐싱 라이브러리(예: React Query)와는 동작 방식이 다르다. App Router 환경에서 fetch는 기본적으로 force-cache로 동작한다. 단순 새로고침은 서버 재요청을 의미하지 않는다. 데이터는 빌드 시점 혹은 ISR 주기에 따라 캐시된다. 명시적으로 무효화하지 않는 한 캐시는 유지된다. 따라서 DB가 갱신되더라도, Web 서버의 캐시가 무효화되지 않으면 화면에는 반영되지 않는다. Web과 BO가 분리된 모노레포 구조 프로젝트는 pnpm 기반 모노레포로 구성되어 있다. 구조적으로 다음과 같은 특성을 가진다. web: SSR 기반 콘텐츠 렌더링 bo: CRUD 중심 관리자 기능 api: 데이터 처리 백엔드 이 세 애플리케이션은 같은 레포에 존재하지만, 런타임은 완전히 독립적으로 동작한다. 즉, 모노레포는 코드 레벨의 공유를 의미할 뿐, 런타임 공유를 의미하지 않는다. BO에서 게시글을 수정하면 DB는 갱신 되지만, Web 서버 내부 캐시는 별도의 프로세스로 유지된다. 따라서 BO에서 직접 revalidate를 호출하더라도 Web 서버의 캐시는 무효화되지 않는다. 설계한 캐시 무효화 전략 ① Web에 invalidation 전용 API route 생성 Web 서버 내부에서만 실행되는 캐시 무효화 엔드포인트를 별도로 구성하였다. 캐시 무효화는 반드시 Web 서버 내부에서 실행되도록 설계하였다. ② BO → API → Web Invalidation API 호출 게시글 생성/수정/삭제 성공 이후 BO가 호출한 백엔드 api는 Web의 invalidation API를 호출한다. 이를 통해 캐시 무효화 요청이 Web 서버로 전달된다. ③ Web 서버 내부에서 revalidateTag 실행 Web 서버 내부에서 revalidateTag를 호출하여 도메인 단위 캐시를 선택적응로 무효화한다. 게시글 상세는 도메인 단위 태그 + ID 단위 태그를 함께 사용했다. 전체 흐름은 다음과 같다. contracts 패키지에 revalidate 전략 포함하기 이 과정에서 contracts 패키지를 단순 타입 공유 계층으로 사용하지 않았다. 캐시 무효화 전략을 API 계약의 일부로 정의하였다. API contract는 다음과 같은 요소를 포함하였고, 캐시 전략을 위해서 여기에 revalidate 전략을 포함시켰다. HTTP Method Path Request Params / Body Response 타입 revalidate 즉, 특정 API가 호출되었을 때 어떤 캐시 태그가 무효화되어야 하는지까지 명시했다. 예시: UpdatePost Contract 설계 의도 이를 통해 다음과 같은 효과를 얻었다. BO와 Web이 동일한 무효화 규칙을 공유 도메인 단위 무효화 정책의 재사용 가능 캐시 전략을 명시적 설계 요소로 승격 결과적으로 contracts는 단순한 타입 패키지가 아니라 애플리케이션 간 계약을 정의하는 계층으로 확장되었다. 회고 이전에도 모노레포 구조를 구성해본 적은 있었지만, 두 개 이상의 애플리케이션을 동시에 개발하고 실제 데이터 흐름과 런타임 경계를 고려하며 설계한 것은 처음이었다. 특히 web, bo, api 세 애플리케이션과 contracts, ui 패키지가 유기적으로 연결된 구조에서 타입 공유만으로는 해결되지 않는 문제들이 드러났다. 같은 레포에 있다는 사실이 곧 실행 환경까지 공유한다는 의미는 아니었다. 런타임은 명확히 분리되어 있었고, 이번 캐시 이슈는 그 차이를 분명하게 보여준 사례였다. 또한 콘텐츠 운영 환경에서는 단순히 CRUD를 구현하는 것만으로는 충분하지 않다는 점을 알게 되었다. 데이터를 저장하는 것과 사용자 화면에 반영되는 것 사이에는 여러 계층이 존재한다. 이번 경험은 기능 구현을 넘어서 운영을 고려한 설계에 대해 고민하게 된 계기였다.

2026년 02월 19일 16:20

프론트엔드

URL을 상태로 관리한 방법: TanStack Router

인턴 기간 동안 백오피스 프로젝트에서 React Router 기반 레거시 코드를 마이그레이션 하는 작업을 맡았다. 단순히 마이그레이션 하기 보다는, 기존 JavaScript의 상태를 TypeScript로 마이그레이션 하면서 어떻게 런타임과 컴파일 타임 둘다 안정성을 잡을 수 있을지에 대한 고민이 많았다. 레거시 React Router 구조 기존 구조에서는 많은 필터 값을 url에 올리지 않고, 내부 상태로만 관리하고 있었다. 여기에서의 문제점은 사용자가 새로고침 시, 설정해놓은 필터 값들이 사라진다는 것이다. 따라서 UX를 향상하기 위해 사용자가 필터 값들을 새로고침 시에도 상태가 변경되지 않게 URL에 올리기로 결정하였다. URL로 상태를 옮기며 마주한 문제 ① 타입 안정성 저하 예를 들어 이런 URL이 들어올 수 있다 page, size는 number 타입이어야 하지만 실제로 사용자가 임의로 주소창에 입력한 값은 string이다. 사용자가 직접 값을 주입하였을 때, 서버로 값이 들어갈 수 있다는 점에서 라우터 단의 타입 관리가 필요해보였다. ② 상태의 기준점 모호 URL에 필터값을 올리는 방식을 채택하였을 때 필터 상태가 다음과 같이 여러 군데에 흩어져 있는 문제가 있다. useState로 관리되는 내부 상태 서버 요청 시 사용하는 query 객체 URL search 파라미터 따라서 이 기준점을 어디에 잡아야할지도 논의 및 합의가 필요한 부분이었다. 라우터 단에서 상태를 정규화하기 TanStack Router는 React Router와 달리 라우터 단에서 validateSearch를 제공한다. search 파라미터를 읽는 것과 동시에, 컴파일 및 런타임에서 zod로 검증 및 정규화를 수행할 수 있다. 라우터 단에서 다음의 기능을 수행하였다. 허용된 값만 통과 타입 자동 추론 실패 시 기본값 정규화 잘못된 쿼리는 서버에 전달되지 않음 상태 업데이트 규칙을 통일하기 URL 쿼리 값 정규화 문제는 해결하였지만, 가장 큰 문제는 필터가 있는 화면에서 코드가 계속 반복된다는 점이었다. 부분 업데이트 로직 기본값 처리 URL과 useState, URL과 서버 상태의 동기화 로직 이런 로직들이 페이지마다 반복되고 있었으며, 개발자마다 다른 로직을 사용하여 코드를 알아보기에도 쉽지 않았다. 이 문제는 useSearchParams 커스텀 훅을 만들어 해결하였다. useSearchParams Custom Hook 이 훅의 목적은 단순하다. search는 항상 타입 안전해야 하고 업데이트 방식은 팀 내에서 동일해야 한다. ① URL에 들어갈 수 있는 타입 제한 Date, 객체, Map 같은 값은 애초에 타입 단계에서 차단했다. search는 URL 직렬화 가능한 원시값만 가진다는 규칙을 강제했다. ② 업데이트 방식을 통일 이 구조로 인해서 페이지에서 search를 다루는 방식이 완전 동일해졌다. 부분 업데이트는 applySearch 전체 리셋은 resetSearch undefined는 무시 defaultSearch는 항상 기준점 적용 이후의 변화 이 커스텀 훅을 사용함으로써 다음의 변화가 생겼다. 프론트엔드 개발자 3명이 동일한 navigation 규칙 사용 필터 로직 중복 코드 약 40% 감소 공유, 새로고침, 뒤로가기 상황에서 상태 유지 신규 페이지 추가 시, navigation 로직 추가 간편화 회고 URL을 상태로 쓰는 건 생각보다 까다롭다. 페이지 내부 상태로만 관리할 때는 "내가 넣은 값만 존재한다"는 가정이 가능하지만, URL로 올리는 순간부터는 “누군가 아무 값이나 넣을 수 있다” 는 전제가 시작된다. 그래서 오히려 상태가 더 불안정해질 수도 있다. 재미있게도, 이런 종류의 문제는 결국 라이브러리 선택보다 팀 규칙의 문제다. 정규화 지점이 어디인지, 기본값이 무엇인지, 업데이트가 어떤 규칙을 따르는지. 이걸 문서가 아니라 코드로 강제했을 때 팀 생산성이 가장 크게 달라졌다. 전체 useSearchParams 코드 ts const { search, applySearch, resetSearch } = useSearchParams({ defaultSearch: { page: 1, keyword: '' }, Route, }) applySearch({ keyword: 'abc', page: 1 }) applySearch({ keyword: '' }) // keyword 제거 의도 applySearch({ tags: ['a', '', 'b'] }) // tags -> ['a','b'] applySearch({ tags: [] }) // tags 제거 의도 resetSearch() *

2026년 02월 12일 03:56

프론트엔드

토큰 만료(401)와 인터셉터 재설계 기록

백오피스를 마이그레이션하면서 가장 먼저 마주한 문제는 401이었다. 단순한 인증 에러처럼 보였지만, 실제로는 인증 흐름 전반을 다시 설계해야 하는 문제였다. 이번 글은 기존 코드를 그대로 사용하는 대신, Axios 인터셉터와 토큰 갱신 전략을 처음부터 다시 설계한 과정에 대한 기록이다. 기존 코드 사용으로 시작한 인터셉터 초기에는 기존 프로젝트의 Axios 인터셉터를 그대로 가져와 사용했다. 기능 자체는 동작했지만, 내부 동작 원리를 충분히 이해한 상태는 아니었다. 코드 리뷰에서 “구조를 이해하고 다시 설계해보자”는 피드백을 받았고, 그 시점에서 인터셉터의 전체 인증 흐름을 다시 정리하기 시작했다. 마침 면접 당시 받았던 질문이 떠올랐다. 사실 이번 인턴 면접에서 토큰 기반 인증에 관한 질문이 있었고, 한 페이지에서 여러 요청이 동시에 401이 발생하면 어떻게 처리할 것인가? 그때는 명확히 답하지 못했지만, 이번에 실제로 같은 상황을 마주하게 되었다. 첫 문제: 401 이후 403 예상 시나리오는 다음과 같았다. 오래된 at로 api 호출 서버가 401 던짐 인터셉터가 refresh 호출 새 토큰으로 요청 재시도 … 되어야 하는데 accessToken 만료 → 401 자동 refresh 호출 → 403 원인을 확인해보니 accessToken과 refreshToken의 만료 시간이 동일하게 설정되어 있었다. accessToken이 만료되는 시점에는 이미 refreshToken도 만료된 상태였다. at가 만료되었을 때 rt도 만료가되어 refresh를 할 수 있는 시간이 없었던 것이다. accessToken 만료 → refresh 요청 시도 근데 refreshToken도 이미 만료 → 403 그래서 당연히 403이 나는 구조였다. 근데 백엔드를 바꿔달라고 할 수가 없는 상황 이걸 계기로 오랜만에 조금 머리를 굴려보았다. 자동 재발급 전략 재설계 처음에는 이런 식의 자동 로직을 생각했다. accessToken 만료 5분 전에 자동으로 refresh 수행 하지만 백오피스 특성상, 사용자가 아무 행동을 하지 않아도 토큰을 갱신하는 방식은 적절하지 않았다. 그래서 방향을 바꿨다. 사용자가 실제 api 요청 시 만료 시간이 5분 이하라면 그때 refresh 하자 즉 활성 사용자만 재발급 → 비활성 사용자 세션은 종료 동시 요청 문제 → Pending Queue 도입 다음 문제는 이거였다. 이거는 내가 면접 때 질문을 받고 대답을 못했던 그 문제이기도 한데 A API 요청 시 → 토큰 만료 → refresh 시작 그 사이에 B API 요청 → 또 토큰 만료 → refresh 또 호출 이러면 refresh api가 동시에 여러번 호출되며 경합이 발생한다. 그래서 필수적으로 refresh 중엔 다른 요청을 멈춰서 줄세우는 로직이 필요하였다. 그것이 바로 pending queue ~~내가 자료구조 수업에서 듣고 코테 풀때만 쓰던 큐를 진짜 써보는 날이 오다니~~ 요약하자면 refresh 중이면 → 요청을 queue 에 쌓아둠 refresh 끝나면 → queue에 있던 요청들을 새 토큰으로 재요청 이렇게 하면 동시 재발급 요청 문제는 해결된다. 로그아웃 중복 실행 문제 refresh 실패 시 로그아웃을 처리하도록 구성했지만, 동시에 여러 API가 실패할 경우 alert가 여러 번 표시되는 문제가 발생했다. 이를 방지하기 위해 isLoggingOut 상태를 추가하여 로그아웃이 한 번만 실행되도록 제어했다. 또한 이 상태값이 persist에 저장되지 않도록 설정하여 재로그인 이후 정상 동작하도록 수정했다. 최종 Axios 인터셉터 구조 정리 최종적으로 인터셉터 구조는 다음과 같이 정리되었다. 요청 시 Authorization 자동 주입 만료 5분 이하 → refresh 시도 refresh 중에는 pending queue 대기 refresh 실패 → 단일 로그아웃 처리 상태 관리는 zustand와 연계 정리하자면.. 이번에 인터셉터를 처음부터 설계하면서 느낀 점: 토큰 구조를 이해해야 한다 세션 만료 UX까지 고려해야 한다 동시 요청 문제까지 잡아야 한다 상태관리까지 연계해야 한다 인터셉터는 그냥 훅 두 개가 아니라 프로젝트의 전체 인증 흐름을 담당하는 중요한 부분임을 깨달았다. 그리고 무엇보다 절대… 생각 안하고 코드 복붙부터 시작하지 말자.

2025년 11월 20일 00:33