nextjs 401 에러 재인증 처리하기 3편

|

2편에서는 MSA 구조에서 Next.js 미들웨어의 한계와 race condition 문제를 다루었고,
그 해결책으로 리프레시 주도권을 클라이언트로 옮기는 구조를 채택했다.
이번 3편에서는 그 구조를 실제로 어떻게 구현했는지를 간단히 정리해본다.


서술

리프레시 주도권을 클라이언트로 옮기는 방법은 생각보다 다양했다.
대표적으로는 두 가지 방식이 있었다.

결론부터 말하자면, 나는 React의 cache로 감싸 함수를 반복 호출하는 방법을 선택했다.

  • 클라이언트 측에서 컴포넌트를 감싸 AuthBoundary 형태로 사용하는 방법
    → 서버에서 401이 발생하면 error.tsx에서 AuthRestore를 렌더

  • React의 cache를 이용해 서버 함수(fetchCurrentUser)를 반복 호출해도
    한 번만 실행되게 하는 방법

Next.js의 401 관련 사례들을 찾아보면 대부분 미들웨어 중심으로 처리하고 있었다.
하지만 지금 프로젝트는 msa구조고 그로인해 클라이언트에서 서버에 여러 요청을 짧은 시간 내에 보내게 되면 각 미들웨어가 각자 리프레시를 시도하므로 하나를 제외한 모든 요청이 실패하고 리다이렉트 루프에 빠지게된다. 이 루프에서는 동시에 들어온 요청들 중 처음 빠져나가는 하나만 탈출할 수 있기 때문에, 이론상 최악의 경우 요청 n개가 O(n^2)개쯤으로 증식해 서버를 터트릴수 있었다. 지금 프로젝트는 분산 시스템으로서 동시에 여러 서버 액션을 호출한다던가 하는 일이 잦을 수 있어 이 문제가 걱정스러운 수준까지 커질 수 있다고 생각한다는 팀장님의 의견이있었고, 여러방안을 모색하던중 일단은 이 방법을 채택하여 구현하게되었다.

여담이지만, 팀장님이 하신 말씀이 아직도 기억난다.

“이런 문제는 정답이 없다. 여러 솔루션의 장단점을 모두 고려하고,
장점이 의미없어지거나 단점이 극복 불가능해지면 유연하게 바꾸면 된다.”

그 말 덕분에, “지금은 최선이지만 영원한 정답은 아니다”라는 명언을 가슴속에 품고 코딩을 하게 되었다..


구현

serverAPI

서버와 클라이언트 양쪽에서 동일한 fetch 기반 API를 사용하기 위해
clientApiserverApi 두 가지 래퍼를 만들어 사용했다.

clientApi는 흔히 알려진,
“401 발생 시 이벤트 큐에 요청을 넣고 토큰 재발급 후 재시도하는 구조”라서 생략하고,
여기서는 SSR용 serverApi만 다룬다.

import "server-only";
import { cookies as nextCookies } from "next/headers";
import { ApiAuthError, ApiError } from "./api-error";

if (!process.env.NEXT_PUBLIC_BACKEND_URL) {
  throw new Error("NEXT_PUBLIC_BACKEND_URL is not defined");
}

export async function serverApi<T = any>(
  endpoint: string,
  options?: RequestInit
): Promise<T> {
  try {
    const cookies = await nextCookies();

    const res = await fetch(
      `${process.env.NEXT_PUBLIC_BACKEND_URL}${endpoint}`,
      {
        ...options,
        signal: options?.signal || AbortSignal.timeout(35000),
        headers: {
          "Content-Type": "application/json",
          Cookie: cookies.toString(),
          ...options?.headers,
        },
      }
    );

    if (res.status === 401) {
      throw new ApiAuthError();
    }

    const body = await res.json();
    if (!res.ok) {
      throw new ApiError(
        body.message || `요청 실패: ${res.status}`,
        res.status,
        body
      );
    }

    return body.data || body;
  } catch (error) {
    if (error instanceof ApiError) throw error;

    if (
      error instanceof Error &&
      (error.name === "AbortError" || error.name === "TimeoutError")
    ) {
      throw new ApiError(
        "요청 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.",
        408,
        "Request Timeout"
      );
    }

    if (error instanceof TypeError && error.message.includes("fetch failed")) {
      throw new ApiError("Network Error", 500, "Network Error");
    }

    throw error;
  }
}

ApiError와 ApiAuthError를 별도로 둔 이유는, 401 에러를 좀 더 명확하게 구분해 클라이언트 측에서 재인증(AuthRestore)을 트리거하기 위함이다.

즉, serverAPI는 서버에서 401을 감지해 클라이언트로 넘기는 첫 관문이 된다.

ProtectedRoute

import AuthRestore from "@components/common/auth-restore";
import { ApiAuthError } from "@lib/api-error";
import { fetchCurrentUser } from "@lib/api/users/me";

export default async function ProtectedRoute({
  children,
}: {
  children: React.ReactNode;
}) {
  try {
    await fetchCurrentUser();
    return <>{children}</>;
  } catch (e) {
    if (e instanceof ApiAuthError) {
      return <AuthRestore />;
    }
    throw e; // 다른 에러는 error.tsx로 전달
  }
}

로그인하지 않은 사용자나 리프레시 토큰이 만료된 사용자를 걸러내는 관문이다.

여기서 중요한 포인트는 fetchCurrentUser()AuthRestore이다.

  • fetchCurrentUser()

    • 로그인 여부를 판별하기 위함

    • 자식 컴포넌트의 SSR 단계에서 fetchCurrentUser()를 다시 호출할 때 발생할 수 있는 401을 선제적으로 처리하기 위함

만약 ProtectedRoute에서 fetchCurrentUser()를 호출하지 않고 자식 컴포넌트에서만 호출한다면, Next.js의 bottom-up 렌더링 구조상 ProtectedRoute가 그 에러를 잡지 못해 그대로 페이지가 깨지게 된다.

  • AuthRestore

서버에서 던진 401을 클라이언트로 넘겨주는 역할이다. serverApi에서 401을 ApiAuthError로 래핑한 이유도 바로 이것 때문이다.

AuthRestore

"use client";

import { clientApi } from "@lib/client-api";
import { signout } from "@lib/data/customer";
import { useRouter } from "next/navigation";
import { useEffect, useRef } from "react";

const MAX_ATTEMPTS = 2;

export default function AuthRestore() {
  const router = useRouter();

  const attemptsRef = useRef(0);
  const hasRunRef = useRef(false);

  useEffect(() => {
    if (hasRunRef.current) return;

    if (attemptsRef.current >= MAX_ATTEMPTS) {
      handleAuthFail();
      return;
    }

    hasRunRef.current = true;

    const restore = async () => {
      try {
        await clientApi(
          "/auth/restore-token",
          { method: "POST" },
          { signal: AbortSignal.timeout(3000) }
        );

        window.location.reload();
      } catch {
        attemptsRef.current++;

        if (attemptsRef.current < MAX_ATTEMPTS) {
          setTimeout(restore, 1000);
        } else {
          handleAuthFail();
        }
      }
    };

    restore();
  }, []);

  const handleAuthFail = async () => {
    const currentPath = window.location.pathname;
    const countryCode = currentPath.split("/")[1] || "kr";

    if (currentPath === "/" || currentPath === `/${countryCode}/`) {
      await signout(countryCode);
    } else {
      router.replace(`/${countryCode}/auth/login`);
    }
  };

  return (
    <div className="flex min-h-screen items-center justify-center">
      <p className="text-gray-500">인증 상태를 복구 중입니다...</p>
    </div>
  );
}

MAX_ATTEMPTS, attemptsRef, hasRunRef로 AuthRestore가 여러 번 렌더되더라도 리프레시 요청이 한 번만 실행되도록 안전장치를 추가했다.

Context API로 관리하는 방법도 고려했지만, Provider가 하나 더 생긴다는 점이 구조적으로 부담스럽다고 판단해 단일 컴포넌트 레벨의 방어 로직으로 마무리했다.

또 한 가지 중요한 부분은, /auth/restore-token 요청에 clientApi를 사용했다는 점이다.

이유는 간단하다.

clientApi는 401 응답 시 이벤트 큐에 요청을 쌓고 토큰을 재발급한 뒤 쿠키를 브라우저에 반영하는 로직이 내장되어 있다.

즉, 단순한 fetch()보다 쿠키 동기화가 훨씬 안정적이다. 이를 통해 무한 리다이렉트 문제를 원천 차단할 수 있다.

fetchCurrentUser

마지막으로, 개인적으로 가장 뿌듯했던 부분이다. React 19에서 새롭게 추가된 cache() API를 직접 적용했다. 덕분에 같은 SSR 요청 안에서는 여러 컴포넌트가 fetchCurrentUser()를 호출해도 한 번만 실행되게 만들 수 있었다.

게다가 fetch의 cache: ‘no-store’ 옵션을 함께 사용해 CDN이나 Next.js의 fetch 캐시가 개입하지 않도록 막았고, 요청마다 항상 최신 유저 데이터를 받아오게 했다.

"use server"

import { ApiError } from "@lib/api-error"
import { serverApi } from "@lib/server-api"
import { UserDetailsResponseDto } from "@lib/types/dto/user"
import { UserBasicInfo as User } from "@lib/types/ui/user"
import { toUserBasicInfo } from "@lib/utils/transformers/user.transformer"
import { cookies, headers } from "next/headers"
import { cache } from "react"

export const fetchCurrentUser = cache(async (): Promise<User | null> => {
  const cookieStore = await cookies()
  const headersList = await headers()
  const pathname = headersList.get("x-pathname") || ""
  const countryCode = pathname.split("/")[1] || "kr"

  try {
    const dto: UserDetailsResponseDto = await serverApi("/users/detail", {
      cache: "no-store",
    })
    return toUserBasicInfo(dto)
  } catch (error) {
    if (error instanceof ApiError) {
      if (error.message === "Network Error") {
        throw new Error("Network Error")
      }

    throw error
  }
})

이 구조 덕분에 SSR 렌더링 중에 동일한 유저 정보가 여러 번 필요한 경우에도 API 호출은 한 번으로 끝나고, 서버 리소스 낭비 없이 React가 동일 Promise를 재사용한다. 그리고 요청이 끝나면 캐시는 자동으로 해제되어 다음 요청에서는 항상 새로운 유저 정보를 불러온다.

마무리

전체 흐름을 정리하면 이렇다.

serverApi — 서버에서 401을 감지하고, 이를 ApiAuthError로 감싼 커스텀 에러 객체로 변환해 전달한다.

ProtectedRoute — SSR 단계에서 인증 상태를 판별

AuthRestore — CSR 단계에서 토큰 복구 및 재요청

fetchCurrentUser — React 19의 cache()로 중복 호출을 줄이고, no-store로 최신 상태를 보장

이 네 가지가 톱니처럼 맞물리며, Next.js App Router 환경에서 안정적인 401 인증 복구 사이클이 완성되었다.

nextjs 401 에러 재인증 처리하기 2편

|

1편에서는 Next.js App Router 환경에서 SSR 렌더링이 시작되기 전에 토큰을 리프레시하는 미들웨어 방식을 다뤘다.
이번 글에서는 그 구조가 가진 race condition 문제와, 이를 해결하기 위한 클라이언트 주도형 리프레시 접근을 정리한다.


미들웨어 사전 리프레시의 한계

1편에서 사용한 방식은 렌더링 전에 토큰을 갱신해 SSR을 안정적으로 보장하는 구조였다.
하지만 이 방식은 여러 요청이 동시에 들어오는 상황에서 심각한 부작용을 일으킬 수 있다.

팀장님께선 이 문제를 아래와같이 설명해주셨다.

“간단한 서비스는 이게 큰 문제가 되지는 않아서 사용자 경험이나 SEO를 생각해 사전리프레시를 하기도 하는 것 같은데, 우리가 개발하려는 사이트는 분산 시스템으로서 동시에 여러 서버 액션을 호출한다던가 하는 일이 잦을 수 있어 이 문제가 걱정스러운 수준까지 커질 수 있다고 생각합니다.”

문제 상황: race condition

race condition = 여러 요청이 동시에 동일한 자원(여기서는 refreshToken)에 접근해, 실행 순서에 따라 결과가 달라지는 불안정한 상태

예시

  • /dashboard, /me, /notifications 페이지를 동시에 요청
  • accessToken이 만료되어 있음
  • 각 요청의 미들웨어가 각자 동시에 /auth/restore-token 호출
  • 첫 번째 요청만 성공하고, 나머지는 “이미 사용된 refreshToken”으로 실패
  • 실패한 요청들이 /login으로 리다이렉트
  • 브라우저는 리다이렉트 루프에 빠짐 🔁
  • 심한 경우 요청이 O(n²) 수준으로 증식 → 서버 과부하

단일 서버에서는 눈에 잘 안 띄지만, 분산 시스템(예: 지금 프로젝트의 사이트 구조)에서는 쉽게 재현되는 문제다.


클라이언트 주도형 리프레시 (벨로그의 어떤분이 제안한 방식)

이 문제를 해결하기 위해 리프레시 주도권을 클라이언트로 옮긴 구조가 현재 시간을 아끼는 방법중 가장 베스트로 채택되었다. 즉, 401을 받은 후 클라이언트가 재요청하는 구조다.

흐름 요약

  1. 서버에서 401 발생
  2. 클라이언트의 axios interceptor가 이를 감지
  3. /auth/restore-token 요청
  4. 새 accessToken 발급 후 원래 요청을 재시도
  5. 성공 시 쿠키 동기화 완료

nextjs 401 에러 재인증 처리하기 1편

|

Next.js App Router 환경에서 401 에러를 어떻게 처리할지 정리한 메모.
서버사이드에서 쿠키가 갱신되지 않는 문제를 여러 방식으로 실험하면서, 결국 미들웨어가 유일하게 일관된 해법이라는 결론에 도달했다.
아래는 그 과정에서 시도해봄직한 여러 방법들과 정리이다.

401 에러 처리 흐름

App Router 기반의 SSR 환경에서는 accessToken이 만료되었을 때
백엔드(NestJS)에서 refreshToken으로 재발급하더라도
Next 서버가 그걸 브라우저 쿠키로 전달하지 못함.

즉, Nest에서는 새 쿠키를 Set-Cookie로 내려보냈는데
Next.js는 이미 스트리밍이 시작되어 cookies()가 고정된 상태라
새 쿠키를 반영할 수 없음.

결과적으로 백엔드에서는 재발급 성공 → Next 서버는 이전 쿠키로 요청 → 또 401.


원인 요약

  • App Router는 서버컴포넌트 렌더링과 동시에 스트리밍이 시작됨
  • cookies()read-only라 한 번 읽으면 수정 불가능
  • SSR 렌더링 도중 쿠키 갱신이 일어나도 반영 불가

결국 렌더링 전에(스트리밍 시작 전) 토큰을 갱신해야 함.
이걸 할 수 있는 위치는 middleware밖에 없음.


미들웨어 방식 (결론)

렌더링 시작 전에 accessToken이 만료되어 있으면 refreshToken으로 새로 발급받고
그걸 response.cookies에 세팅한 다음 redirect로 새 요청 사이클 시작시킴.
즉, SSR 이전에 쿠키 교체 완료 후 렌더링이 시작되도록 하는 구조.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
import { USER_SERVICE_BASE_URL } from './const';

const JWT_SECRET = new TextEncoder().encode(process.env.AUTH_SECRET);

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  if (
    pathname.startsWith('/_next/') ||
    pathname.startsWith('/api/') ||
    pathname.startsWith('/favicon.ico') ||
    pathname.includes('.')
  ) {
    return NextResponse.next();
  }

  const accessToken = request.cookies.get('accessToken')?.value;
  const refreshToken = request.cookies.get('refreshToken')?.value;

  if (!accessToken && !refreshToken) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  try {
    if (accessToken) await jwtVerify(accessToken, JWT_SECRET);
  } catch {
    if (!refreshToken) {
      return NextResponse.redirect(new URL('/login', request.url));
    }

    const res = await fetch(`${USER_SERVICE_BASE_URL}/auth/restore-token`, {
      method: 'POST',
      credentials: 'include',
      headers: { Cookie: request.cookies.toString() },
    });

    if (!res.ok) return NextResponse.redirect(new URL('/login', request.url));

    const data = await res.json();
    const newResponse = NextResponse.redirect(request.url);
    newResponse.cookies.set('accessToken', data.accessToken);

    // 새 요청 사이클로 교체
    return newResponse;
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

특징

  • 렌더링 전에 새 토큰 확보 가능

  • serverUserApi.getMe()에서 401이 더이상 안터짐

  • 브라우저와 Next 서버 간 쿠키 싱크 문제 해결

단점

  1. 401 발생 시 요청 2번 (성능 이슈)

  2. 무한 리다이렉트 위험 있음

→ 해결책

  • 401 자체가 자주 안 나오는 구조로 설계

  • 재시도 횟수를 쿠키나 헤더에 기록해서 제한


server-api 쪽에서 해볼만한 시도들

  • 현재 일하고있는 회사의 개발팀장님께서 제시해준 대안과

  • Velog의 NARARIA03 님께서 다듬어준 App Router의 구조상 완벽하진 않지만 참고할 만한 접근들임


1. axios 내부에서 1회성 캐싱 토큰으로 해결 시도

렌더링 사이클 안에서만 유효한 newAccessToken 변수를 둠.
401 → refresh → newAccessToken 저장 → 이후 요청부터 이 값 사용.

import axios from 'axios';
import { cookies } from 'next/headers';

export const serverApi = () => {
  let newAccessToken: string | null = null;

  const instance = axios.create({
    baseURL: process.env.NEXT_PUBLIC_USER_SERVICE_URL,
  });

  instance.interceptors.request.use(async (config) => {
    const store = await cookies();
    const oldAccessToken = store.get('accessToken')?.value;
    const token = newAccessToken ?? oldAccessToken;
    if (token) config.headers.Authorization = `Bearer ${token}`;
    return config;
  });

  instance.interceptors.response.use(
    (res) => res,
    async (error) => {
      if (error.response?.status === 401) {
        const res = await fetch(
          `${process.env.NEXT_PUBLIC_USER_SERVICE_URL}/auth/restore-token`,
          { method: 'POST', credentials: 'include' }
        );
        if (res.ok) {
          const data = await res.json();
          newAccessToken = data.accessToken;
          return instance(error.config);
        }
      }
      throw error;
    }
  );

  return instance;
};

2. 에러 바운더리에서 401 처리 (클라이언트 위임)

이건 NARARIA03 님이 제안한 실험적인 방식.
서버 컴포넌트에서 401이 발생하면 ErrorBoundary로 던지고 클라이언트에서 refresh → router.refresh()로 SSR 재요청.

// app/error.tsx
'use client';
import { useRouter } from 'next/navigation';
import { useEffect, startTransition } from 'react';

export default function Error({ error, reset }) {
  const router = useRouter();

  useEffect(() => {
    const tokenRefresh = async () => {
      if (error.digest === 'UNAUTHORIZED' || error.message === 'UNAUTHORIZED') {
        const res = await fetch('/auth/refresh', { method: 'POST' });
        if (res.ok) {
          startTransition(() => {
            router.refresh();
            reset();
          });
        }
      }
    };
    tokenRefresh();
  }, [error, reset, router]);

  return null;
}
  • 서버는 단순히 401 던짐

  • 클라이언트가 토큰 갱신 후 페이지 새로 렌더

  • 자연스러운 UI 흐름 가능

  • 단, 공식적인 방법은 아니고 트릭에 가까움

결론

결론적으로 App Router 기반 SSR에서는 미들웨어에서 리프레시 처리 → redirect로 새 요청 시작 이게 현재까지 가장 깔끔한 구조.

참고자료

쿠키 전송- server api, client api

|

내가 작성한 코드였는데도, 쿠키 관련 개념을 자꾸 헷갈려서 정리함.

회사 프로젝트를 진행하면서 fetch를 server 환경과 client 환경에 맞게 분리해줬었다.
이렇게 나눈 가장 큰 이유는 바로 쿠키(Cookie) 때문이었다.


핵심 개념

Client API (브라우저 환경)

브라우저에서는 쿠키가 자동으로 포함된다.
즉, axios나 fetch 요청 시 withCredentials: true 옵션만 주면 브라우저가 알아서 쿠키를 첨부한다.
헤더에 직접 쿠키를 넣을 필요가 없다.

Server API (Node.js 환경)

반면, 서버에서 실행되는 코드(Next.js server actions, getServerSideProps, route handlers 등)는 브라우저 환경이 아니기 때문에 쿠키를 자동으로 포함하지 않는다.
따라서 직접 쿠키를 읽어서 헤더에 실어줘야 한다.


Server API 예시

import { cookies } from 'next/headers';
import axios from 'axios';

export async function serverApi(endpoint) {
  const cookieStore = cookies();

  const res = await axios.get(
    `${process.env.NEXT_PUBLIC_BACKEND_URL}${endpoint}`,
    {
      headers: {
        Cookie: cookieStore.toString(), // 🍪 직접 쿠키 전달
      },
    }
  );

  return res.data;
}

Client API 예시

import axios from 'axios';

axios.get('/api/current-user', {
  withCredentials: true, // ✅ 브라우저가 자동으로 쿠키를 첨부
});

NestJS에서 레이어별 책임 분리 - 컨트롤러와 서비스의 역할

|

배경

지금까지 컨트롤러는 API 엔드포인트를 제공하고 서비스와 연결하는 통로 역할만 한다고 생각했고, 에러 처리는 주로 서비스 레이어에서 해왔다.

그런데 팀장님의 코드를 보니 서비스에서는 null 반환이나 일부 에러만 처리하고, 컨트롤러에서 HTTP 상태 코드와 함께 상세한 에러 처리를 하는 패턴을 발견했다.

질문

이런 설계 철학의 배경과 장점이 무엇인지 궁금해서 여쭤보게 되었다.

답변 요약

핵심 원칙: 각 레이어의 책임 분리

  • 컨트롤러: 통신 수단에 종속된 것들에 대한 책임 (HTTP, GraphQL, WebSocket 등)
  • 서비스: 통신 수단과 무관한 비즈니스 로직에 대한 책임
  • 레포지토리: DB 접근 등 외부 인프라와 데이터를 주고받는 책임

왜 이렇게 분리하는가?

  1. 재사용성: 동일한 서비스를 HTTP API, GraphQL, WebSocket 등 다양한 방식의 요청에 동일한 서비스레이어 제공 가능
  2. 테스트 용이성: 서비스 레이어는 HTTP와 무관하게 순수 비즈니스 로직만 테스트할 수 있고, 컨트롤러는 e2e 테스트시에 오히려 서비스 클래스를 뽑아버리고, 컨트롤러가 적절하게 에러를 응답으로 매핑하는지만 확인해볼 수 있게된다.
  3. 관심사의 분리: “서비스는 HTTP를 몰라야 함” - 각 레이어가 자신의 책임에만 집중

실제 코드 예시

회사 코드라 많은 부분을 생략했지만, 핵심은 service에서 master를 찾지 못했을 때 return null을 하고, 컨트롤러에서 HTTP 에러 처리를 하는 패턴에 주목하면 된다.


// controller.ts
  @Get(':id')
  async getMasterDetail(@Param('id') id: string): Promise<MasterDetailDto> {
    try {
      const master = await this.productMastersService.getMasterDetail(id);

      if (!master) {
        throw new HttpException('Master not found', HttpStatus.NOT_FOUND);
      }

      return master;
    } catch (error) {
      if (error.message === 'Master not found' || error.status === HttpStatus.NOT_FOUND) {
        throw new HttpException('Master not found', HttpStatus.NOT_FOUND);
      }
      throw new HttpException('Failed to get master detail', HttpStatus.INTERNAL_SERVER_ERROR);
    }
  }

  // service.ts
    async getMasterDetail(masterId: string, tx?: DbTransaction): Promise<MasterDetailDto | null> {
    const client = this.getClient(tx);

    const master = await this.getMasterById(masterId, tx);
    if (!master) {
      return null;
    }
    const optionGroups = await client...

    const optionGroupsWithValues: any[] = [];
    for (const group of optionGroups) {
      ....

    }

    const variants = await client
      .select()
      .from(productVariants)
      .where(eq(productVariants.masterId, masterId))
      .orderBy(productVariants.displayOrder);

    const channelProducts = [];

    return {
      ...master,
      optionGroups: optionGroupsWithValues,
      variants: variants.map(v => ({ ...v, optionValues: [] })),
      channelProducts
    };
  }

깨달음

"서비스는 HTTP를 몰라야 한다"는 원칙이 핵심이었다. 서비스 레이어를 순수하게 유지하면 테스트도 쉬워지고, 다양한 전송 방식으로 확장하기도 용이하다.

앞으로의 계획

  • 새로 작성하는 코드는 이 원칙을 적용
  • 기존 코드는 점진적으로 리팩토링

참고 자료

NestJS 공식 문서를 다시 찾아보니, 컨트롤러에서 HTTP 상태 코드와 함께 에러를 처리하는 예제들이 있었다.
처음 문서를 읽을 때는 단순히 예제 코드라서 간단하게 컨트롤러에서 처리한 거라고 생각하고 넘어갔었다. 지금 생각해보니 공식 문서도 레이어별 책임 분리 원칙을 따르고 있었던 것 같다. 내가 그때는 이 설계 원칙의 중요성을 제대로 이해하지 못해서 놓쳤던 부분인듯 싶다.