2025.12.20·

픽케어 프론트엔드 아키텍처 설계 후기

ReactNext.jsTypeScript아키텍처React QueryTipTap

현재 재직 중인 회사에서 픽케어 프론트엔드를 설계하고 개발하면서 내린 기술적 결정들을 정리했다. "왜 이렇게 했는지"가 코드에서 읽히는 것이 좋은 아키텍처라고 생각하는데, 각 패턴을 도입한 배경과 실제로 어떤 문제를 해결했는지 공유한다.

1. 선제적 토큰 리프레시 + 구독자 큐 패턴

JWT Token Refresh Flow
JWT 토큰 리프레시 흐름

인증 인터셉터를 설계하면서토큰 만료 60초 전에 선제적으로 리프레시하는 proactive 전략을 채택했다. 401이 터지면 그때 리프레시하는 단순한 방식은 쓰지 않았다. 사용자가 "갑자기 로그아웃됐어요"라고 느끼는 순간을 원천 차단하는 설계다.

Request → 토큰 만료 60초 전? → 선제 리프레시 → 정상 요청

Request → 401 발생 → 리프레시 시도 → 재요청

리프레시 중 다른 요청 → 구독자 큐 대기 → 토큰 갱신 후 일괄 처리

특히 동시 다발 요청 시 리프레시가 중복 호출되지 않도록 구독자 큐(subscriber queue) 패턴을 적용했다. 토큰 리프레시가 진행 중이면 다른 요청들은 큐에 대기했다가, 리프레시 완료 후 새 토큰으로 일괄 재시도한다. 이건 실무에서 race condition 버그를 직접 겪어보지 않으면 넣기 어려운 방어 로직이다.

동시성 처리와 선제 리프레시까지 고려한 건 사용자 경험과 안정성 양쪽 모두를 챙긴 설계였다. 대부분의 프로젝트는 401 → 리프레시 → 재시도의 단순 흐름만 구현한다.

2. Query Key Factory 패턴

TanStack React Query 로고

React Query를 쓰면서 query key를 문자열로 하드코딩하는 프로젝트가 많다. 픽케어에서는 쿼리 키 팩토리 패턴을 전체 프로젝트에 일관되게 적용했다.

export const magazineKeys = {
  all: ["magazine"] as const,
  lists: () => [...magazineKeys.all, "list"] as const,
  list: (params) => [...magazineKeys.lists(), params] as const,
  detail: (id) => [...magazineKeys.all, "detail", id] as const,
  votes: (id) => [...magazineKeys.all, "votes", id] as const,
};

이 패턴의 진짜 가치는 캐시 무효화가 계층적으로 동작한다는 점이다.magazineKeys.lists()를 무효화하면 모든 리스트 쿼리가 무효화되고,magazineKeys.all을 무효화하면 매거진 관련 전체 캐시가 날아간다.as const로 타입 추론도 잡혀서 실수가 어렵다.

TanStack Query 공식 문서에서도 권장하는 패턴이지만 실제 프로젝트 전반에 일관되게 적용하려면 아키텍처 레벨에서 컨벤션을 잡아야 한다.

3. 서버 상태와 클라이언트 상태의 명확한 분리

상태 관리에서 가장 흔한 실수는 "모든 걸 Redux에 넣는 것"이다. 픽케어에서는 이 경계를 아키텍처 레벨에서 명확하게 나눴다.

상태 유형관리 도구예시
서버 상태React Query콘텐츠, 프로필, 펫 정보, 매거진
글로벌 UIRedux인증, 모달, 로딩, 알림
지역 UIContext이미지 뷰어, 테마
컴포넌트useState입력값, 토글

Redux 슬라이스를 6개(auth, modal, loading, notification, adminSection, chatHistory)로 제한하고 서버 데이터는 전부 React Query가 담당하게 했다. Redux 보일러플레이트가 비대해지지 않으면서 서버 상태의 캐싱/재검증/갱신은 React Query가 알아서 처리한다.

"이 상태는 어디에 둬야 하지?"라는 질문에 대한 답이 아키텍처 레벨에서 이미 정해져 있다. 팀원이 합류해도 혼란 없이 따라갈 수 있는 구조다.

4. Presigned URL 기반 파일 업로드 전환

기존에는 클라이언트 사이드에서 S3에 직접 업로드하는 방식이었는데 AWS Access Key를 NEXT_PUBLIC_ 환경변수로 프론트에 노출하고 있었다. 이건 보안상 위험한 패턴이다.

Presigned URL 방식으로 전환하면서 프론트엔드에서 AWS 크레덴셜을 완전히 제거했다.

1. GET /api/files/upload-url → presigned URL + fileKey 발급

2. PUT presigned URL로 직접 업로드 (프론트 → CDN)

3. CDN URL 반환

기술 부채를 인지하고 실제로 개선한 것, 그리고 레거시 코드를 바로 삭제하지 않고deprecated로 관리한 것 모두 실무적인 판단이었다. 급하게 삭제했다가 의존하는 곳에서 터지는 것보다, 점진적으로 제거하는 게 안전하다.

5. TipTap 에디터 커스텀 확장 15개

TipTap Editor
TipTap - ProseMirror 기반 헤드리스 리치 텍스트 에디터

에디터 기능은 프론트엔드에서 가장 구현 난이도가 높은 영역 중 하나다. TipTap 3.0 기반으로 15개 이상의 커스텀 익스텐션을 직접 구현했다.

  • CustomImage — 이미지 사이즈 조절
  • ImageGroup — 다중 이미지 그룹
  • ImageSlider — 이미지 캐러셀
  • CustomLocation — 위치 정보 임베딩
  • LinkPastePreview — 링크 붙여넣기 시 OG 미리보기
  • ClipboardImagePaste — 클립보드 이미지 붙여넣기
  • DragHandle — 블록 드래그 정렬
  • BlockDropGuard — 드롭 방어 로직
  • MaxLength — 글자수 제한
  • Collage — 사진 콜라주

ProseMirror 기반의 에디터 확장을 이 수준으로 구현하려면 Document Model, Node/Mark 체계, Plugin 시스템을 상당히 깊이 이해해야 한다. "에디터 라이브러리 갖다 쓴 것"과 "에디터 확장을 직접 설계한 것"은 차원이 다른 작업이었다.

6. Atomic Design + Feature 기반 하이브리드 구조

Atomic Design 패턴
Brad Frost의 Atomic Design 5단계

Atomic Design의 계층(Atoms → Molecules → Organisms → Templates)을 기본 뼈대로 잡되Molecules/Organisms 내부는 feature 기반으로 디렉토리를 분리했다. 220개 이상의 컴포넌트를 관리하면서도 구조가 무너지지 않은 이유다.

Molecule/
├── comments/     (댓글 기능 11개 컴포넌트)
├── navigation/   (내비게이션)
├── dropdowns/    (드롭다운)
├── myPet/        (반려동물 등록)
├── lounge/       (라운지 카드)
└── admin/        (관리자 UI)

순수 Atomic Design만 고집하면 "이 컴포넌트가 Molecule인가 Organism인가"로 끝없이 논쟁하게 된다. feature 디렉토리를 섞어 실용적으로 풀었다. 아키텍처 원칙을 교조적으로 따르지 않고 팀의 현실에 맞게 변형한 것이 오히려 더 나은 결과를 만들었다.

7. Next.js Route Group을 활용한 인증 경계

Next.js App Router의 Route Group을 활용해서 레이아웃 레벨에서 인증 경계를 분리했다.

app/
├── (route)/        → 공개 라우트
├── (protected)/    → 인증 필요 라우트
├── (footer)/       → 푸터 있는 레이아웃
└── api/            → API 라우트

(protected) 그룹의 layout에서 인증 체크를 한 번만 하면 하위 모든 페이지에 자동 적용된다. 개별 페이지마다 useAuth 체크를 반복하지 않아도 되는 구조다. App Router의 설계 의도를 정확히 이해하고 활용한 패턴이라고 생각한다.

8. 접근성(a11y) 기본기

화려하지는 않지만 기본기를 잡아두는 데 신경 썼다.

  • Skip Link: AppShell에 "본문 바로가기" 링크 (sr-only → 포커스 시 노출)
  • 시맨틱 HTML: <main>, <section>, <nav> 적절히 사용
  • 키보드 내비게이션: 이미지 뷰어에서 ESC(닫기), 화살표(이전/다음) 지원
  • ARIA 레이블: 버튼과 인터랙티브 요소에 aria-label 부여
  • 포커스 스타일: focus:ring-2 focus:ring-main/20 패턴 일관 적용

사이드 프로젝트에서 접근성까지 챙기는 경우는 드물지만, 기본 구조를 잡아두면 나중에 확장하기가 훨씬 수월하다.

9. Optimistic Update를 통한 즉각적 피드백

좋아요/싫어요 기능에서 서버 응답을 기다리지 않고 UI를 먼저 업데이트하는 Optimistic Update를 적용했다.

개념은 쉽지만 엣지 케이스가 까다로웠다. 특히 isLiked 동기화 누락 버그를 잡으면서, 서버 상태와 로컬 상태의 동기화가 Optimistic Update에서 가장 어려운 부분이라는 걸 체감했다. 네트워크 에러 시 롤백 처리, 동시 클릭 시 상태 일관성 유지 등을 직접 부딪히며 해결했다.

10. 개발 환경 DX 고려

const isDev = process.env.NODE_ENV === "development";
if (isDev) console.log('🚀 [API REQUEST]', { url, method, params });
  • 개발 모드에서만 API 요청/응답 로그 출력
  • NEXT_PUBLIC_MOCK_AUTH로 로컬에서 관리자 계정 시뮬레이션
  • Turbopack 활용으로 빠른 HMR
  • 환경변수 검증 유틸리티 (validateEnv)

"내가 쓰기 편하게" 만든 부분이지만 이런 DX 투자가 개발 속도에 직접적으로 영향을 준다. 팀 프로젝트에서는 온보딩 시간도 줄었다.

마무리

이 글에서 정리한 10가지 패턴의 공통점은, 각각의 결정에 실무적인 근거가 있다는 것이다. 토큰 리프레시에 구독자 큐를 넣은 이유, Query Key를 팩토리로 만든 이유, S3 직접 업로드를 deprecated 처리한 이유 — 전부 실제 문제를 겪고 나서 도입한 해결책이다.

물론 개선할 여지도 있다. 에러 바운더리 활용도를 더 높이고 싶고, 테스트 코드도 보강해야 한다. 하지만 아키텍처의 일관성을 유지하면서 각 영역의 기술적 깊이를 확보한 건 팀과 프로덕트 모두에게 좋은 투자였다고 생각한다.