
팀에 합류했을 때, 서비스는 Vue2 기반으로 구성되어 있었다. 이 서비스를 1년정도 유지보수 및 추가 기능을 개발하면서, 뷰라는 프레임워크에 적응하게 되었다. 그러던 어느날 운영중이던 서비스를 새로운 UI/UX로 처음부터 다시 설계할 수 있는 기회가 생겼다.
우리는 새로운 서비스에 어떤 기술 스택 및 아키텍쳐를 적용할것인지 고민했다. 이 중에서도 나는 가장 뼈대가 되는 프레임워크로는 Next를 어필했었다. Vue는 특정 라이브러리를 사용할때 지원되는지 항상 체크해야 하는 불편함이 있었고, 프레임워크 자체의 업데이트도 느렸다. 그래서 레퍼런스가 풍부한 리엑트를 사용하지 않을 이유가 없다고 생각이 들었다. 또한, 회사 차원에서도 인력 충원의 기회가 온 경우에 Vue 보다는 React가 유리할 것이라고 예상했다.
하지만 Vue3로 결정되었다. 기존의 멤버들이 Vue에 익숙하다는 이유도 있었지만, 마이그레이션 할 시간도 매우 부족했기 때문이다. 일정은 정해져 있는데, 새로운 기술을 도입한다는 것이 얼마나 큰 부담인지 알고 있기에 납득할 수 밖에 없었다. 그래도 디자인 토큰 기반 디자인 시스템이나 Micro-Frontend Architecture와 같은 새로운 시도도 많이 했었기에 재미있었다.

마이그레이션이 끝난 시점에서 다시 생각해보면, 여전히 아쉬운 선택이었다고 생각한다. 그 이유는 Vue2에서 Vue3로 마이그레이션 하는 것도 꽤 큰 격변이었기 때문이다. Options API에서 Composition API로 바꾸는 것은 React의 클래스형에서 함수형으로 변경하는 것 만큼 큰 변화다. 심지어 우리는 그 중에서도 React와 유사한 형태로 코드를 설계할 수 있는 setup script 라는 문법을 사용했다. 즉, React를 사용했어도 비슷한 러닝 커브를 가졌을 것 같아 과거의 선택이 아쉬웠다.
하지만 이미 결정된 일. 나는 Vue3로 설계 하면서도 유지보수 하기 좋은 환경 및 코드를 작성하기 위해 최선을 다했다. 그러면서 React를 사용하지 못했던 아쉬움은 혼자 기술 블로그를 보며 염탐하거나, 사이드 프로젝트로 풀었다.
시간이 흘러, 회사의 홈페이지를 새로 만들 기회가 생겼다. 사이즈가 작은 프로젝트여서 새로운 기술을 도입하는데 부담이 적을것이라고 판단했고, 나는 이전 직장에서 리엑트를 사용했던 이력과 평소에 개인적으로 학습했던 경험을 바탕으로 팀장님을 설득했다. 이미 과거에도 많은 어필을 했어서 그런지 흔쾌히 승낙하셨다. 그래서 홈페이지는 넥스트로 설계해볼 수 있었다. 그럼에도 나는 여전히 더 고도화된 서비스를 넥스트로 만들어보고 싶다는 생각이 들었다.

이번에는 확실한 기회가 찾아왔다. 서비스마다 팀이 존재하는 체계에서 스쿼드라는 형태로 조직 체계의 대격변이 이루어진 것이다. 이제는 팀에 소속되었던 서비스만 개발하는 것이 아닌, 다른 팀의 서비스를 개발할 수도 있게 되었다. 심지어 새로운 서비스를 만드는곳에 투입되는 것도 가능해졌다.
이 말의 뜻은 현재 내가 만들고 있는 서비스도 다른 팀에 있었던 개발자가 이어서 개발할 수도 있게 된 것이다. 그러다 보니, 기술 스택, 컨벤션, 배포 구조, CI/CD 등을 맞추는 것이 유지보수 하기에 매우 유리한 포인트가 되었다.
또한, 우리는 디자인 시스템이 적용된 공통 컴포넌트를 만들고, 이 컴포넌트를 모든 서비스에서 사용할 수 있는 구조까지 생각하고 있었다. 이를 위해서는 더더욱 기술 스택을 맞추는 것이 선행되어야 했다. 그 결과 비교적 짧은 고민 끝에 앞으로 새로 만드는 서비스는 넥스트로 설계하기로 결정 되었다.
굉장한 과도기에 바로 새롭게 개발해야 하는 서비스가 나타났다. 새로운 리드 개발자분도 나의 니즈를 알고 있었기에 기회를 주셨다. 드디어 기업에서 출시하는 서비스를 넥스트로 만들어 볼 수 있게 된 것이다. 이 회사에서 리엑트를 사용하게 될 일은 없다고 생각했는데, 꾸준히 관심을 기울이며, 실제 업무의 작은 부분에서라도 시도했다 보니, 기회가 찾아 왔을 때 잡을 수 있게 된 것 같다.
새로운 방법론이나 기술 스택을 선택하고 적용하는 것에는 부담이 있다. 시간이 무한했다면 상관 없겠지만, 일정은 항상 빠듯하게 정해져 있기 때문이다. 그렇다고 도전하지 않는다면, 결국 그 자리에 계속 머무를 뿐이기에 시도하게 되는 것 같다.
3년만에 다시 넥스트를 사용하려다 보니 많은 것이 달라져 있었다. 기존에 알고 있던 CSR, SSR, SSG, ISR과 같은 개념들은 눈에 익었지만, 이 마저도 넥스트의 버전이 올라가면서 사용법 자체가 달라져 있었다. 심지어는 서버 컴포넌트나 서버 액션 등의 개념들까지 등장했다.
다행히 평소 기술 블로그나 유튜브를 통해 접하며 이론적으로는 이해하고 있었다. 하지만 역시 현업에서 직접 사용해보니 여러가지 시행착오를 겪게 되었다. 그 중에서도 특히 프로젝트 초반에 여러 체계를 잡는 상황이 재미(?)있었는데, 오늘은 넥스트에서 어떤 형식으로 클라이언트 및 서버 패치 함수를 만들고, 인증 및 인가 체계를 구성했는지 위주로 소개해보려고 한다.

내가 서비스를 설계할 때, 가장 먼저 하는일은 컨벤션 부터 정하는 것이다. 동료 개발자가 작성한 코드와 내가 작성한 코드의 스타일이 비슷하면 유지보수에 유리하기 때문이다. 다음은 방법론을 위한 구조 설계이다. monorepo나 마이크로 프론트엔드와 같은 전체 프로젝트에 영향을 주는 구조를 선택한 경우에 먼저 적용하는 것이다. 이후에는 eslint, prettier, stylelint 등을 구성하며 프로젝트의 초기 셋팅을 한다. 이후에는 FSD와 같은 디렉터리 구조에 대한 방법론에 맞게 구성한다. 이제부터는 코드를 작성할 준비가 되었다.
어떤 코드부터 작성해볼까? 나는 보통 로그인부터 설계하는 것을 좋아한다. API 요청을 위한 구조, 패치 공통 함수, 네비게이션 가드, 에러 핸들링 구조 등 자연스럽게 다양한 부분을 고민하게 되기 때문이다. 이런 것들이 처음에는 귀찮은 작업이지만, 한 번 제대로 구성해두면 나중에 건들일이 별로 없기도 하다. 또한, 로그인 작업이 되었다는 것은 서비스를 만들 준비가 되었다는 의미이기도 하다.

넥스트는 풀스택 프레임워크이다. 서버가 내재되어 있기 때문에, Client Side와 Server Side로 요청해볼 수 있다. 나는 가장 먼저 로그인 API를 클라이언트단에서 요청해보기로 결정했다. (회원 정보는 이미 DB에 넣어 두었다.)
결과는 당연하게도 CORS로 인한 실패였다. localhost를 사용하는 브라우저에서 오리진이 다른 API 서버에 요청을 보냈으니 허용해줄리가 없었다. 보통 넥스트에서는 클라이언트 사이드 요청으로 인해 CORS가 발생하는 경우에 next.config.js의 리버스 프록시 설정을 통해 간단하게 우회한다. 하지만 넥스트의 리버스 프록시는 요청 경로를 내부적으로 변경하여 타겟 서버에 연결하는 URL 매핑역할만 수행하기 때문에 한계가 명확하다는 특징이 있다.
Route Handlers의 확장성
반면에 Route Handlers 는 BFF(Backend for Frontend), API Gateway, 통합 API 와 같이 실제 서버에서 할 수 있는 대부분의 역할을 할 수 있다. 그리고 애초에 서버이기 때문에 CORS가 발생할 일도 없다. 또한, /api/[...path]/route.ts 와 같이 설계한다면, 클라이언트 사이드에서 요청했을 경우 무조건 라우트 핸들러를 한 번 거쳐가는 구조가 된다. 우리의 서비스에서는 요청 및 응답 데이터 가공을 할 수 있고, 인증, 권한, 로깅 등의 비지니스 로직을 포함시킬 수 있다는 이유에서 API Route를 사용하기로 결정했다.
리버스 프록시는 넥스트만의 개념은 아니다. Nginx, Varnish 등에서도 존재하는 개념이다. 다만, 넥스트의 리버스 프록시에서는 할 수 있는게 많이 없는 것이고, Nginx나 Varnish 등에서는 넥스트의 라우트 핸들러처럼 다양한 역할을 수행할 수 있다.
서비스에서 사용할 API의 인증 및 인가 체계는 토큰 기반으로 설계 되어 있었다. 우리는 이것을 그대로 사용하기로 했고, 나는 프론트 내에서의 토큰 관리 체계만 먼저 구상했다. (실제로는 통합 로그인 개념도 있지만 여기서는 제외하고 설명하겠다.)
HttpOnly 쿠키로 저장set-Cookie로 심어주는 리프레시 토큰 확인두 개의 토큰이 쿠키에 잘 심어진 것을 확인한 후에는, 추후에 발생하는 API 요청의 Bearer에 엑세스 토큰을 담아줘야 한다. 이와 같은 공통 함수를 만든 후에는 여러가지 상황들을 생각해보며 설계를 이어나가면 된다. 예를 들어, 엑세스 토큰이 만료된 경우에는 리프레시 토큰을 이용한 재갱신 한다던가, 토큰 갱신 실패시 남아있는 쿠키들을 제거하는 등 부가적인 플로우들을 고려해보는 것이다.

어쨌든 이런 플로우는 Next에서 제공하는 리버스 프록시만으로 해결할 수가 없다. 클라이언트 사이드에서는 HttpOnly 쿠키에 접근할 수 없기 때문에, 로그인을 성공해서 엑세스 토큰 정보를 받아도 쿠키를 심을수가 없는 것이다. 또한 쿠키 정보를 가져오지 못해 Bearer에 값을 넣어줄 수도 없고, API에서 심어주는 리프레시 토큰 정보를 가져오지 못해 엑세스 토큰을 재갱신하지도 못한다.
해결책은 넥스트의 서버인 라우트 핸들러를 사용하는 것이다. 라우터 핸들러는 HttpOnly 쿠키에 접근할 수 있고, 토큰의 값을 가져와 담아줄 수 있다. 라우트 핸들러를 사용함으로서, 클라이언트 사이드에서도 정상적인 통신을 할 수 있게 되는 것이다.
추후에 로그인 요청은 서버 액션을 통해 하게 된다. 지금은 라우트 핸들러를 설명하기 위해, 클라이언트 단에서 먼저 로그인 요청해보는 것을 예시로 들고 있다.
라우트 핸들러는 결국 넥스트에서 제공해주는 서버이다. 이번에는 넥스트 서버의 또 다른 활용 방식도 알아보자. 로그인이 성공했을 때, API 서버에서 HttpOnly/Secure 옵션의 Refresh Token 정보가 담긴 쿠키를 set-cookie 해주고 있다. 역시나 리버스 프록시만으로는 브라우저에 이 쿠키가 심어지지 않을 가능성이 높다.
[리버스 프록시의 네트워크 계층에서의 동작]
클라이언트 http://localhost:3000에서 요청 하고, API 서버가 Set-Cookie: refreshToken=xxx; Domain=api.example.com을 내려 주었다고 생각해보자. 넥스트 서버에서 도메인이 api.example.com 인 리프레시 토큰을 localhost 도메인의 쿠키에 심으려고 할 때 문제가 발생한다.
브라우저 입장에서는 localhost와 api.example.com은 다른 도메인 이기 때문에, "내가 이 도메인에서 요청한 게 아닌데?" 하며 쿠키 저장을 무시하게 되는 것이다. 즉, 쿠키 저장/전송 여부는 브라우저의 보안 정책이기 때문에, 프록시가 중간에서 아무리 전달해줘도 안 먹힌다.
const apiResponse = await fetch(...);
// 1. API 서버가 보낸 헤더에서 토큰 '값'만 추출
const setCookieHeader = apiResponse.headers.get('set-cookie');
const refreshTokenValue = parseRefreshToken(setCookieHeader); // 'xxx' 값만 파싱하는 함수
// 2. 브라우저를 위해 'localhost' 도메인으로 쿠키를 새로 생성
cookies().set('refreshToken', refreshTokenValue, {
domain: 'localhost', // 개발 환경에서는 명시적으로 localhost 지정 (또는 생략)
httpOnly: true,
path: '/',
// ... 기타 옵션
});
이 경우에 사용할 수 있는 것이 넥스트 서버(라우트 핸들러, 서버 액션, middleware 등) 이다. 지금 발생하고 있는 문제는 개발 환경에서 브라우저의 보안 정책으로 인해 HttpOnly 옵션의 리프레시 토큰을 Set-Cookie 할 수 없다는 것인데, 개발 환경에서는 서버가 내려주던 Domain=api.example.com 정보를 의도적으로 무시를 한다면 해결할 수 있게 된다.
리프레시 토큰 기반으로 엑세스 토큰을 갱신하는 API 가 있어도, 도메인 정보를 다시
Domain=api.example.com로 바꿔서 요청할 필요는 없다. API 서버는 쿠키의 Domain 속성에 관심이 없기 때문이다. API 서버는 오직 요청 헤더에 담겨온 refreshToken의 값(value)이 유효한지만 검사한다.

만약 도메인 정보를 무시하지 말아야 하는 상황이라면 어떻게 할까? 예를 들어, 어떤 이유로든 API 서버가 내려준 Set-Cookie 헤더를 단 한 글자도 바꾸지 않고 그대로 브라우저에 전달해서 쿠키를 심어야만 하는 상황인 것이다. 이 경우에는 hosts 파일을 수정하여 도메인 통일을 하면 된다.
호스트 파일 수정 후에 127.0.0.1 example.com로 접근한다고 가정 해보자. 브라우저는 요청 Origin을 http://example.com:3000으로 인식한다. api 서버가 Domain=.example.com로 쿠키를 내려주면, 브라우저가 "아, 지금 내 Origin이 example.com이니까 이 쿠키 저장해야겠다" 하고 받아들인다. 브라우저가 인식하는 Origin과 쿠키의 Domain을 일치시키는 게 핵심이다.
이제 우리는 CORS를 우회하면서 서버에서만 접근 가능한 쿠키들도 사용할 수 있게 되었다. 먼저 클라이언트 단에서 요청해보기 위해 라우트 핸들러를 알아보았지만, 서버 액션이나 SSR은 애초에 넥스트 서버에서 실행되므로, CORS를 우회할 필요가 없다. 라우트 핸들러에서 HttpOnly 쿠키를 받기 위해 작성한 쿠키 관련 로직들만 넣어주면 될 것이다.
우리는 넥스트의 서버인 라우트 핸들러, 서버 액션, 미들웨어 등에서 사용할 공통 패치 함수를 만들어서 사용하고 있다.
우리는 아직도 리프레시 토큰을 받을 수 없다. 브라우저가 Secure 옵션의 쿠키를 무시해버리기 때문인데, 정상적으로 받기 위해서는 Https 환경이 전제 되어야 한다. 이를 해결하기 위해서는 OpenSSL 설정을 해주면 된다.

OpenSSL은 HTTPS 통신을 위한 SSL/TLS 인증서를 생성하고 관리할 수 있는 오픈소스 도구다. 개발 환경이나 온프레미스 환경에서 HTTPS를 적용하기 위해 가장 많이 사용된다.
[HTTPS의 핵심 역할]
Secure 쿠키가 제대로 전달되기 위해 필요한 신뢰할 수 있는 암호화 채널을 제공하는 게 OpenSSL인 것이다.
이제 클라이언트 단에서 요청해도 라우트 핸들러를 통해 넥스트 서버를 한 번 거칠 수 있게 되었다. 여기서 한 번 고민해볼만한 주제가 있다. '어떤 상황인지에 따라 클라이언트 혹은 서버 사이드 요청을 할 것인가' 이다.
우리는 HTTP 메소드인 GET 요청은 왠만하면 클라이언트 사이드 요청 하기로 결정했다. 앞에서 말한 '왠만하면' 이라는 단어를 보면 알겠지만, GET 요청이라고 해서 무조건 클라이언트 사이드에서 요청하자는 것은 아니다. 인증정보 포함되어 있다거나, SSR이 필요한 페이지 등에서는 서버 사이드로 요청해야한다.
POST, PATCH, DELETE 와 같은 메소드들은 서버 사이드 요청을 하기로 결정했다. 이것도 마찬가지로 무조건은 없다. UX상으로 즉각적으로 반응해야 하는 좋아요 라던가, Body 제한이나 serialization 문제로 인해 서버 액션에 부담이 간다던가, Presigned URL 업로드를 하는 경우에는 클라이언트 사이드에서 해야한다.

즉, 어느 정도 룰을 정하는 것이지, HTTP 메소드만으로 클라이언트/서버 사이드 요청 위치를 결정할 수는 없다. 데이터의 민감도, 초기 렌더 필요성, UX 요구사항, 캐싱 전략 등을 종합적으로 고려하여 서버에서 요청할지 클라이언트에서 요청할지를 결정해야 한다.
서버 사이드에서만 접근 가능한 next/headers 의 headers 같은 것들은 클라이언트 사이드 함수에서 사용하면 에러가 발생한다. 반대로 클라이언트 사이드의 쿠키 접근하는 방식이나 로컬 스토리지 같은 것을 서버 사이드 함수에서 사용해도 에러가 발생한다. 그래서 클라이언트에서 사용할 패치 함수와 서버 사이드에서 사용할 패치함수 2개로 나누어서 설계했다. 클라이언트 단에서 사용하는 패치 함수는 clientFetch 를 사용하고, API Route / Server Action / SSR /middleware(Navigation Guard)와 같은 서버에서는 serverFetch를 이용하면 된다.
탠스택 쿼리에는 크게 useQuery 와 useMutate라는 두 가지 훅이 있다. useQuery는 클라이언트에서 사용이 가능하며, 서버에서는 사용할 수 없다. useQuery는 내부적으로 QueryClient의 캐시를 참조하고, 이 캐시는 리엑트의 상태와 이벤트 루프에 의존을 한다. 즉, 내부적으로 리엑트에서 제공하고 있는 클라이언트 사이드 훅인 useEffect나 useState 등을 사용하고 있기 때문이다. useMutation는 mutate()를 명시적으로 호출하는 시점에서 실행한다. 그래서 클라이언트는 물론이고, 서버 사이드에서도 선언이 가능하며, 실행 함수로 서버 액션을 사용할 수도 있다.
이와 같은 성격들을 조합하여, Create / Update / Delete 성격을 지닌 대부분의 API 통신은 useMutate와 서버 액션 조합을 사용하기로 했고, Get 요청은 useQuery 조합을 사용하기로 결정했다.
위에서 언급했던 것 처럼 대용량 이미지를 올리는 경우에는 클라이언트 사이드에서 수행하고, 리프레시 토큰을 통한 엑세스 토큰 재발급을 하는 Get 요청은 서버 사이드에서 하는 등의 예외 상황은 언제나 존재하고 있다.
app/
└── posts/
└── [id]/
├── page.tsx // 서버 컴포넌트 (SSR prefetch)
└── PostDetail.tsx // 클라이언트 컴포넌트
Get 요청 + useQuery 조합을 사용한다고 해서, SSR의 이점을 가져가지 못하는 것은 아니다. 상세 페이지를 예로 들어보자.
// 상세 페이지
export default async function Page({ params }: PageProps) {
const postId = Number(params.id);
const queryClient = getQueryClient();
// SSR prefetch
await queryClient.prefetchQuery({
queryKey: [POST_DETAIL, postId],
queryFn: fetchPostDetail,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostDetail postId={postId} />
</HydrationBoundary>
);
}
상세페이지에 진입 했을 때, prefetch 를 이용하여 데이터를 SSR 환경에서 받아온다.
'use client';
export default function PostDetail({ postId }: Props) {
const queryClient = useQueryClient();
// 초기값은 SSR에서 Hydrate된 데이터
const { data: post, isFetching } = useQuery({
queryKey: [POST_DETAIL, postId],
queryFn: fetchPostDetail,
});
const handleUpdate = async () => {
// 예: 게시글 수정 (POST, PATCH, DELETE 등)
await fetch(`/api/posts/${postId}`, {
method: 'PATCH',
body: JSON.stringify({ title: '새 제목' }),
});
// 수정 후 즉시 refetch
queryClient.invalidateQueries({ queryKey: [POST_DETAIL, postId] });
};
if (!post) return <p>Loading...</p>;
return ...
}
이제 클라이언트 사이드 컴포넌트에서 사용하는 useQuery에는 SSR의 프리패치에서 사용했던 쿼리 키인 POST_DETAIL를 사용해주면 된다. 이렇게 하면 PostDetail의 렌더링 시점에서는 클라이언트 사이드를 다시 요청하지 않고, SSR에서 받아온 정보를 사용하게 된다. 이후부터는 클라이언트 사이드 요청을 하게 되는데, 게시글을 수정한 후에 상세 정보를 다시 불러온다던가의 액션이 있을때 invalidateQueries 와 같은 함수를 사용하면 손쉽게 관리할 수 있다. 우리는 이처럼 SSR과 useQuery의 장점을 모두 가져갈 수 있는 구조를 사용하고 있다.
이번 글에서는 Next.js를 사용하면서 서버 패치와 클라이언트 패치, 서버 컴포넌트와 클라이언트 컴포넌트의 조합, 그리고 TanStack Query와의 연계를 어떻게 설계할지 고민한 과정을 공유했다.
스쿼드 조직으로 통합되며 프론트엔드 체계를 새로 세워야 했기에 부담도 있었고, 익숙하지 않은 프레임워크라 어려움도 있었지만 그만큼 기대하던 일이었기에 재미와 성취감이 컸다. 그리고 한 가지 확실히 느낀 건, 비록 Vue를 중심으로 일해왔더라도 하나의 프레임워크를 깊게 이해하고 있다면 새로운 기술은 결국 익숙해지는 과정일 뿐이라는 점이었다. 물론 좋은 동료가 있었기에 성공할 수 있었다.
(나중에는 앱 개발에도 도전해보고 싶다.)
또 소개하고 싶었던건 공통 컴포넌트인데, 과거처럼 디자인 토큰 기반으로 디자인 시스템을 잡은건 동일하지만, 이번에는 next 환경에 맞게 설계했고, 컴파운트 컴포넌트 패턴을 사용해서 다양한 조합과 커스텀을 할 수 있는 구조를 만들었다. 헤드리스까지 하고싶었지만, 애초에 우리 디자인 시스템만을 위한 컴포넌트들이기도 하고, 여기에는 기본적인 스타일이 있는게 더 옳은 방향인거같아서 녹아져 있다. 대신 서비스마다 레퍼런스 토큰의 값은 다를텐데, 그것만 다르게 해준다면 같은 공통 컴포넌트를 사용하더라도 서비스별로 아예 다른 UI를 보여줄 수 있는 구조로 만들었다. 이 과정도 소개하고 싶었는데, 여기서 다루기에는 또 길어질거 같아서 제외했다. 어쨌든 이번 작업은 정말 재미있었다!