Navigation

LG Shield

LG전자의 제품 보안 프레임워크 LG Shield를 소개하는 국영문 대외 사이트와 그 콘텐츠를 운영하는 CMS를 함께 만든 프로젝트입니다. CMS 프론트엔드를 맡아 라우트 구조, API 계층, 공통 에디터와 관리 화면을 구현했습니다. 사이트에서는 API 계층과 서버 prefetch 구조, locale 미들웨어의 예외 경로, 오류 페이지, Docker와 Nginx 배포를 맡았고 퍼블리셔의 next-intl 적용과 Next.js 이관을 리딩했습니다.

핵심 기여

과제본인 기여확인한 동작
화면마다 반복되는 탐색 정보라우트 트리 하나에 메뉴, 브레드크럼, 제목과 설명을 둠엔트리 57개가 사이드바, 브레드크럼, 경로 조회의 공통 원본
조회와 무효화의 키 불일치, 동시 401API 함수를 { key, fetch } 쌍으로 정의하고 토큰 갱신을 한 번으로 묶음CMS 99개, 사이트 23개 함수가 같은 형태
운영자의 조회 맥락 유실입력 중인 조건과 적용된 조건을 나누고 URL과 history state로 복원목록 9개 화면에서 새로고침과 상세 복귀 뒤 조건 유지
에디터 이미지가 base64로 본문에 섞임툴바, 클립보드 파일, HTML 붙여넣기 세 경로를 presigned 업로드로 모음업로드에 성공하면 본문에는 CDN URL이 들어감
언어 경로 처리가 운영 요청을 가로챔health check, robots, 사이트맵을 locale 처리 앞에서 분리하고 오류 페이지를 앱으로 옮김상태 확인과 크롤러 파일이 리다이렉트 없이 응답
CI 서버 없는 두 환경 배포dev와 prod를 나눈 이미지, compose, 배포 스크립트버전 파일 하나로 이미지 태그와 라벨을 맞춤

개요

항목내용
기간2025.01 ~ 2025.04. 검수 대응과 후속 반영은 2025.06까지
역할CMS 프론트엔드 개발, 사이트 API 연동과 다국어 이관 리딩, Docker와 Nginx 배포 환경 구성
협업과 인원FE 1명(본인), 퍼블리셔 1명. 백엔드 API 명세와 고객 QA 티켓을 받아 반영
서비스솔루션, 글로벌 인증, B2B와 B2C 제품, 뉴스룸, FAQ, 백서와 문의, 약관을 국문과 영문으로 제공하는 대외 사이트와 그 콘텐츠를 관리하는 CMS
사이트 기술Next.js 15, React 19, TypeScript, React Query, next-intl, nuqs, next-sitemap
CMS 기술React Router 7, React 19, TypeScript, React Query, Zustand, Ant Design 5, nuqs, Quill 2, dnd-kit, Nice Modal
공통SCSS, axios, swagger-typescript-api, Docker, Nginx, AWS EC2, Google Analytics
링크lgshield.lge.com

진행 단계

구간시기주요 작업
세팅2025.01두 저장소 세팅, CMS 라우트 트리와 API 계층, 에디터 첫 버전, 사이드바와 브레드크럼. 사이트 라우터와 엔드포인트, 퍼블리셔의 next-intl 적용
구축2025.02 ~ 2025.03CMS 관리 화면 7개 묶음, 사이트 API 계층과 뉴스룸 시리즈 화면, health check와 미들웨어 예외, dev와 prod 배포 분리, CMS 첫 운영 배포(3월 27일), 취약점 대응
검수 대응2025.04 ~ 2025.05QA 티켓 대응, 에디터와 글자 수 제한 보정, 보안 심사 요구 반영, CMS 1.0.0(5월 13일), 크롤링 허용과 hreflang 사이트맵
후속2025.05 말 ~ 2025.06빌드 스크립트와 Dockerfile 정리, Google Analytics 반영

맡은 범위

본인 구현

리딩과 협업

동료 구현

문제와 해결

코드는 두 저장소의 본인 커밋에서 발췌했습니다. 서버 주소, 키 파일, 버킷과 내부 호스트 이름, 내부 오류 코드는 지우거나 이름으로 바꿨고 줄을 줄였지만 동작은 실제와 같습니다.

라우트 트리 하나로 메뉴와 브레드크럼, 제목과 경로 만들기

CMS의 관리 영역은 솔루션, 글로벌 인증, 제품, 뉴스룸, 지원, 약관, 관리자 일곱 묶음이고 묶음마다 목록, 등록, 상세, 수정 화면이 있습니다. 화면 하나를 더할 때마다 사이드바 메뉴, 브레드크럼, 화면 제목과 설명, 이동 경로를 따로 고치면 넷 중 하나는 빠지기 쉽습니다. 메뉴 설정을 라우트와 별도 파일로 두면 같은 화면의 정의가 다시 두 곳으로 갈라집니다.

React Router 7의 라우트 설정(RouteConfigEntry)을 확장해 id, label, menu, description을 얹고 사이드바, 브레드크럼, 화면 제목, 경로 조회가 모두 이 트리 하나를 읽게 했습니다. 화면 코드는 경로 문자열 대신 ROUTE_IDS를 넘겨 getPathById로 경로를 찾습니다.

구현 코드: 라우트 메타데이터와 경로 조회
라우트 메타데이터와 경로 조회ts
export type ExpandedRouteConfigEntry = Omit<RouteConfigEntry, "children" | "id"> & {
  id: ROUTE_IDS;
  label?: string;        // 메뉴와 브레드크럼, 화면 제목에 쓰는 이름
  menu?: boolean;        // 사이드바에 표시할지
  description?: string;  // 화면 제목 아래 설명
  children?: ExpandedRouteConfigEntry[];
};

export const ROUTES: ExpandedRouteConfigEntry[] = [
  {
    id: ROUTE_IDS.LAYOUT_DEFAULT, file: "../layouts/layout-default.tsx",
    children: [{
      id: ROUTE_IDS.LAYOUT_BREADCRUMB, file: "../layouts/layout-breadcrumb.tsx",
      children: [{
        id: ROUTE_IDS.NEWS_ROOM_MANAGE_LAYOUT, label: "뉴스룸 관리", path: "news-room-manage", menu: true,
        children: [
          { id: ROUTE_IDS.LATEST_NEWS_PAGE, label: "최신 소식", path: "latest-news", menu: true, description: "뉴스룸 > 최신 소식에 노출되는 게시물을 관리하실 수 있습니다." },
          { id: ROUTE_IDS.LATEST_NEWS_DETAIL_PAGE, label: "최신 소식 상세", path: "latest-news/:id" },
        ],
      }],
    }],
  },
];

// 경로 문자열 대신 id로 찾는다. 트리를 재귀로 내려가며 부모 경로를 합친다
export const getPathById = (pathId: ROUTE_IDS): string => {
  const search = (routes: ExpandedRouteConfigEntry[], basePath: string): string | undefined => {
    for (const route of routes) {
      const currentPath = route.path ? joinRoutePath(basePath, route.path, route.index) : basePath;
      if (route.id === pathId) return currentPath;
      const found = route.children && search(route.children, currentPath);
      if (found) return found;
    }
  };
  const result = search(ROUTES, "/");
  if (result === undefined) throw new Error(`Route with id "${pathId}" not found.`);
  return result;
};

사이드바는 트리를 재귀로 돌면서 menu: true인 항목만 메뉴로 만들고 레이아웃처럼 메뉴가 아닌 항목은 건너뛰어 자식만 올립니다. 브레드크럼 레이아웃은 현재 경로를 조각으로 잘라 트리에서 label과 description을 찾습니다. params 값과 같은 조각은 :id로 바꿔 동적 경로도 찾고 마지막 조각의 label과 description이 화면 제목과 설명이 됩니다. 상세 뒤에 수정이 붙는 경로(skill/:id/edit)에서는 중간의 상세 항목이 겹쳐 보이지 않도록 걸렀습니다.

브레드크럼과 제목이 같은 트리를 읽는다ts
const getRouteMetaRecord = (routes: ExpandedRouteConfigEntry[], parentPath = "") => {
  const record: Record<string, { id: ROUTE_IDS; label?: string; description?: string }> = {};
  for (const route of routes) {
    const path = joinRoutePath(parentPath, route.path, route.index);
    record[path] = { id: route.id, label: route.label, description: route.description };
    if (route.children?.length) _.assign(record, getRouteMetaRecord(route.children, path));
  }
  return record;
};

// /news-room-manage/latest-news/12 → params의 값과 같은 조각을 :id로 바꿔 트리에서 찾는다
const breadcrumbItems = _.map(_.compact(pathname.split("/")), (segment) => {
  let segmentKey = segment;
  for (const [key, value] of _.entries(params)) if (segment === value) segmentKey = `:${key}`;
  currentPath = currentPath ? `${currentPath}/${segmentKey}` : `/${segmentKey}`;
  const meta = routeMetaRecord[currentPath];
  return { key: currentPath, title: meta?.label || segmentKey, description: meta?.description };
});

// 마지막 조각의 label과 description이 화면 제목과 설명이 된다
const lastItem = _.last(items);
const pageTitle = lastItem?.title;
const pageDescription = lastItem?.description;
사이드바는 menu 표시와 메뉴 권한만 본다ts
const getMenuItems = (routes: ExpandedRouteConfigEntry[], parentPath = "") => {
  const items: Required<MenuProps>["items"] = [];
  for (const route of routes) {
    const fullPath = joinRoutePath(parentPath, route.path, route.index);
    // 관리자 계정 메뉴는 최고 관리자에게만 보인다. 경로 진입 자체를 막지는 않는다
    if (fullPath === "/admin-manage" && userStore.authorities !== "SUPER_ADMIN") continue;
    const childItems = route.children?.length ? getMenuItems(route.children, fullPath) : [];
    if (route.menu) items.push({ key: fullPath, label: route.label, children: childItems.length > 0 ? childItems : undefined });
    else items.push(...childItems); // 레이아웃처럼 메뉴가 아닌 항목은 자식만 올린다
  }
  return items;
};
flowchart LR
    R["ROUTES 트리: id, label, menu, description"] --> S["사이드바: menu가 true인 항목"]
    R --> B["브레드크럼: 경로 조각마다 label"]
    R --> T["화면 제목과 설명: 마지막 조각"]
    R --> P["getPathById: 이동과 리다이렉트"]
    R --> C["React Router 설정"]

최종 트리는 엔트리 57개(레이아웃 10개, 화면 47개)이고 그중 19개가 사이드바에 나타납니다. 화면 코드의 이동은 52개 파일, 104줄에서 getPathById를 거칩니다. 화면 하나를 더하면 라우트 항목 하나로 메뉴, 브레드크럼, 제목과 설명, 경로가 같이 생기고 경로를 바꿔도 트리 한 곳만 고칩니다.

권한은 메뉴 단계에서만 다뤘습니다. 관리자 계정 메뉴는 최고 관리자에게만 보이지만 이 제한은 사이드바 메뉴와 홈 화면 바로가기에만 걸었고 라우트 트리에는 권한 정보가 없습니다. 경로로 직접 들어오는 진입을 화면에서 막지 않으므로 메뉴 숨김을 접근 통제로 설명하지 않습니다. 묶음 레이아웃 두 곳의 기본 화면 리다이렉트도 경로 문자열로 남아 있어 트리 밖에 남은 예외입니다.

API 함수를 조회 키와 한 쌍으로 묶고 토큰 갱신은 한 번만 하기

CMS 목록은 조회 뒤 삭제, 공개 전환, 순서 변경을 하고 다시 목록을 받아야 합니다. React Query의 조회 키를 화면마다 배열로 직접 적으면 조회할 때와 무효화할 때 키가 어긋나기 쉽습니다. 관리자 세션은 액세스 토큰이 만료되는 순간 화면의 여러 요청이 동시에 401을 받습니다. 요청마다 각자 토큰을 갱신하면 갱신 요청이 겹치고 먼저 끝난 갱신이 다른 갱신의 토큰을 덮어씁니다.

API 함수마다 { key, fetch } 쌍을 두고 조회와 무효화가 같은 키 함수를 부르게 했습니다. 키는 [도메인, 리소스, 조건] 순서의 계층이고 조건 없이 부르면 상위 키를 돌려줘 조건과 상관없이 목록 전체를 무효화할 수 있습니다. 요청과 응답 타입은 백엔드 Swagger 명세에서 swagger-typescript-api로 생성한 타입을 함수 시그니처에 연결했습니다. CMS는 10개 모듈에 99개 함수를 이 형태로 정의했습니다.

API 함수는 조회 키와 한 쌍ts
const apiTechnologies = {
  getTechnologies: {
    // 조건 없이 부르면 상위 키를 돌려줘 조건과 상관없이 목록 전체를 무효화할 수 있다
    key: (query?: UndefinedToNull<Admin.GetTechnologies.RequestQuery>) => {
      const baseKey = [KEY.SOLUTION, KEY.TECHNOLOGIES];
      return _.isNil(query) ? baseKey : [...baseKey, query];
    },
    fetch: async () => {
      const response: AxiosResponse<RequiredDeep<Admin.GetTechnologies.ResponseBody>> = await apiClient.request({
        method: "GET",
        url: "admin/solution/technologies",
      });
      return response.data.body;
    },
  },
};

// 수정 뒤에는 목록 전체와 해당 상세만 다시 받는다
await Promise.all([
  queryClient.invalidateQueries({ queryKey: apiNewsrooms.searchNewsrooms.key() }),
  queryClient.invalidateQueries({ queryKey: apiNewsrooms.getNewsroomDetail.key({ id: _.toNumber(id) }) }),
]);

토큰 갱신은 axios 응답 인터셉터 한 곳에서 처리했습니다. 처음 401을 받은 요청만 갱신을 시작하고 갱신 중에 들어온 401 요청은 대기열에 넣었다가 새 토큰이 나오면 다시 보냅니다. 갱신에 실패하면 대기 중인 요청을 모두 실패시키고 토큰과 사용자 스토어를 비웁니다. 같은 요청이 두 번 갱신을 일으키지 않도록 재시도한 요청에는 표시를 남겼습니다. 같은 인터셉터는 HTTP 상태가 200이어도 본문의 응답 코드가 실패면 Error의 cause에 코드와 본문을 담아 실패로 돌립니다. 그래서 로그인 화면처럼 응답 코드별로 다음 화면이 달라지는 곳도 error.cause.code 하나로 분기할 수 있었습니다.

구현 코드: 동시에 받은 401은 한 번의 갱신을 기다린다
동시에 받은 401은 한 번의 갱신을 기다린다ts
let isTokenRefreshing = false;
let pendingRequests: ((token: string | undefined) => void)[] = [];

apiClient.interceptors.response.use(onFulfilled, async (error: AxiosError) => {
  const originalRequest = error.config as (InternalAxiosRequestConfig & { _retry: boolean }) | undefined;
  if (error.response?.status !== 401 || !originalRequest || originalRequest._retry) return Promise.reject(error);
  originalRequest._retry = true;

  // 갱신 중이면 새 토큰이 나올 때까지 대기열에서 기다린다
  if (isTokenRefreshing) {
    return new Promise((resolve, reject) => {
      pendingRequests.push((newToken) => {
        if (!newToken) return reject(error);
        originalRequest.headers.set("Authorization", `Bearer ${newToken}`);
        resolve(apiClient.request(originalRequest));
      });
    });
  }

  isTokenRefreshing = true;
  try {
    const newToken = await apiAuth.refreshToken.fetch({ refreshToken: tokenStore.refreshToken });
    tokenStore.setAccessToken(newToken);
    isTokenRefreshing = false;
    pendingRequests.forEach((callback) => callback(newToken)); // 기다리던 요청을 새 토큰으로 다시 보낸다
    pendingRequests = [];
    originalRequest.headers.set("Authorization", `Bearer ${newToken}`);
    return apiClient.request(originalRequest);
  } catch (refreshError) {
    isTokenRefreshing = false;
    pendingRequests.forEach((callback) => callback(undefined)); // 갱신 실패는 대기 요청 모두 실패
    pendingRequests = [];
    tokenStore.reset();
    userStore.reset();
    return Promise.reject(refreshError);
  }
});
sequenceDiagram
    participant A as 요청 A
    participant B as 요청 B
    participant I as 응답 인터셉터
    participant S as 인증 API
    A->>I: 401
    I->>S: 토큰 갱신 요청
    B->>I: 401
    I-->>B: 대기열에 보관
    S-->>I: 새 액세스 토큰
    I-->>A: 새 토큰으로 재요청
    I-->>B: 대기열 요청 재실행

사이트에도 같은 모양의 API 계층(8개 모듈, 23개 함수)을 만들었습니다. 사이트는 서버 컴포넌트와 클라이언트 컴포넌트가 같은 데이터를 읽기 때문에 같은 { key, fetch } 쌍을 서버의 prefetch와 클라이언트의 조회가 함께 쓰도록 연결했습니다. 뉴스룸 시리즈 화면에서 서버 컴포넌트가 URL query를 nuqs의 서버 캐시로 파싱해 데이터를 미리 받고 그 캐시를 HydrationBoundary로 넘기면 클라이언트의 목록과 페이지네이션은 같은 URL query와 같은 키로 그 캐시를 이어받습니다. 요청에는 현재 언어 쿠키 값을 Accept-Language 헤더로 실었습니다. 서버 렌더링에서는 next/headers, 브라우저에서는 document.cookie로 같은 값을 읽습니다.

사이트: 서버 prefetch와 클라이언트 조회가 같은 쌍을 쓴다ts
// 서버 컴포넌트: URL query를 파싱해 같은 키로 미리 받아 둔다
const seriesSearchParams = await seriesSearchParamsCache.parse(searchParams);
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
  queryKey: apiNewsrooms.searchNewsrooms.key(seriesSearchParams),
  queryFn: () => apiNewsrooms.searchNewsrooms.fetch(seriesSearchParams),
});
return <HydrationBoundary state={dehydrate(queryClient)}>{/* 목록, 페이지네이션 */}</HydrationBoundary>;

// 클라이언트 컴포넌트: 같은 URL query, 같은 키로 서버가 받아 둔 캐시를 이어받는다
const [searchParams, setSearchParams] = useQueryStates(seriesSearchParams);
const { data } = useQuery({
  queryKey: apiNewsrooms.searchNewsrooms.key(searchParams),
  queryFn: () => apiNewsrooms.searchNewsrooms.fetch(searchParams),
});

// API 클라이언트: 요청마다 현재 언어를 실어 보낸다
config.headers["Accept-Language"] = await getCookieValue("NEXT_LOCALE");

CMS에서는 목록, 상세, 수정 화면이 같은 키 함수를 써서 무효화 범위를 키 계층으로 말할 수 있게 됐습니다. 사이트에서는 뉴스룸 시리즈 화면이 첫 예시가 되어 퍼블리셔가 만든 FAQ, 제품, 최신 소식, 검색 화면도 서버 prefetch와 클라이언트 조회를 같은 형태로 연결했습니다. 다만 사이트의 조회 키에는 언어가 없어서 언어를 바꾸면 이전 언어의 캐시가 남았고 퍼블리셔가 언어 선택 컴포넌트에서 캐시를 비우는 처리를 넣었습니다.

조회 조건을 입력값과 적용값으로 나누고 상세에서 돌아와도 유지하기

관리 목록마다 공개 여부, 카테고리, 등록일과 공개일 범위, 검색 옵션과 검색어, 페이지와 크기, 탭이 있습니다. 운영자가 상세를 보고 돌아오거나 새로고침하면 조건이 사라져 다시 고르는 일이 반복됩니다. 조건을 URL에 바로 묶으면 이번에는 날짜를 고르거나 검색어를 치는 도중에도 조회가 다시 나갑니다.

조건은 두 벌로 나눴습니다. 화면 상태(filters)는 운영자가 입력 중인 조건이고 nuqs로 정의한 URL query(qsFilters, qsPagination)는 실제로 조회에 적용된 조건입니다. 조회 버튼을 눌러야 입력값이 URL로 넘어가고 페이지는 1로 돌아갑니다. 조회 키는 URL query만 보므로 입력 중에는 조회가 나가지 않습니다. 상세로 갈 때는 적용된 조건을 history state에 실어 보내고 상세의 목록 버튼과 삭제 뒤 이동이 그 값을 그대로 돌려줍니다. 목록은 돌아온 history state로 입력값과 URL query를 모두 다시 채웁니다.

입력값과 적용값을 나눈 목록 화면ts
// 적용된 조건: URL query. 조회 키이자 공유 주소가 된다
const [qsFilters, setQsFilters] = useQueryStates({
  visible: parseAsBoolean,
  categoryId: parseAsInteger,
  createdStartDate: parseAsString,
  createdEndDate: parseAsString,
  searchOption: parseAsStringEnum<SEARCH_OPTION>(_.values(SEARCH_OPTION)),
  searchKeyword: parseAsString,
});
const [qsPagination, setQsPagination] = useQueryStates({ page: parseAsInteger.withDefault(1), size: parseAsInteger.withDefault(10) });

// 입력 중인 조건: 화면 상태. 상세에서 돌아오면 history state의 조건으로 시작한다
const [filters, setFilters] = useState<typeof qsFilters>(location.state ? _.omit(location.state, ["page", "size"]) : qsFilters);
useEffect(() => {
  if (location.state) {
    setQsFilters({ ..._.omit(location.state, ["page", "size"]) });
    setQsPagination({ ..._.pick(location.state, ["page", "size"]) });
  }
}, []);

// 조회 버튼을 눌러야 입력값이 URL로 넘어가고 페이지는 1로 돌아간다
const handleSearchButtonClick = async () => {
  await setQsFilters(filters);
  await setQsPagination({ page: 1, size: 10 });
};
구현 코드: 조회 키, 상세 이동, 빈 페이지 보정
조회 키, 상세 이동, 빈 페이지 보정ts
// 적용된 조건이 그대로 조회 키다
const { data } = useQuery({
  queryKey: apiNewsrooms.searchNewsrooms.key({ type: "NEWS", ...qsFilters, ...qsPagination }),
  queryFn: () => apiNewsrooms.searchNewsrooms.fetch({ type: "NEWS", ...qsFilters, ...qsPagination }),
});

// 상세로 갈 때 적용된 조건을 history state에 싣는다
navigate(generatePath(getPathById(ROUTE_IDS.LATEST_NEWS_DETAIL_PAGE), { id: data.key }), {
  state: { ...qsFilters, ...qsPagination },
});

// 상세 화면: 목록 버튼과 삭제 뒤 이동은 받은 state를 그대로 돌려준다
navigate(getPathById(ROUTE_IDS.LATEST_NEWS_PAGE), { state: location.state });

// 마지막 페이지의 항목을 모두 지우면 빈 페이지 대신 이전 페이지로 옮긴다
useEffect(() => {
  if (data && qsPagination.page > 1 && data.list.length === 0) {
    setQsPagination({ ...qsPagination, page: qsPagination.page - 1 }).then(() =>
      queryClient.invalidateQueries({ queryKey: apiNewsrooms.searchNewsrooms.key({ type: "NEWS", ...qsFilters, ...qsPagination }) }),
    );
  }
}, [data, qsPagination]);
flowchart LR
    I["입력 중인 조건: 화면 상태"] -->|조회 버튼| U["적용된 조건: URL query"]
    U --> K["React Query 조회 키"]
    U -->|상세 이동 시 history state| D["상세 화면"]
    D -->|목록 버튼, 삭제 뒤 이동| R["목록: state로 복원"]
    R --> I
    R --> U

목록 9개 화면이 모두 입력값과 적용값을 나누고 상세로 갈 때 조건을 실어 보내 돌아와도 같은 목록이 열립니다. 새로고침과 뒤로가기는 URL query가 맡습니다. 삭제나 공개 전환 뒤에는 같은 키를 무효화하므로 운영자가 보던 조건 그대로 목록이 갱신되고 마지막 페이지를 비우면 이전 페이지로 옮겨 빈 표가 남지 않습니다. 조건 복원은 상세 화면이 받은 history state를 다시 넘겨줄 때만 동작해서 등록 화면이나 메뉴를 거쳐 목록으로 돌아오면 조건이 비어 있는 상태로 시작합니다.

공개 상태와 순서를 서버 기준으로 사이트에 반영하기

기술 메뉴, 제품 카테고리, 뉴스룸 분류, FAQ와 약관에는 공개 여부와 노출 순서가 있고 사이트는 그 값을 그대로 보여 줍니다. CMS 화면이 바꾼 값과 사이트가 보는 값이 다르면 운영자는 무엇이 공개됐는지 알 수 없습니다.

공개 전환과 순서 변경은 각각 API 하나로 두고 순서는 바뀐 한 항목이 아니라 전체 id 배열을 보냈습니다. 사이트의 기술 메뉴 구성을 바꾸는 순서는 모달에서 드래그한 뒤 확인을 눌러야 저장하고 성공하면 목록 키를 무효화해 서버 순서를 다시 받습니다. 뉴스룸, FAQ, B2C 대중소 카테고리 탭은 행을 놓는 즉시 전체 순서를 저장합니다. 비공개 항목은 순서 표에서도 흐린 색으로 보여 순서와 공개 상태를 같이 보게 했습니다.

드래그로 순서를 바꾸고 확인 때 한 번에 저장ts
const handleRowDragEnd = ({ active, over }: DragEndEvent) => {
  if (active.id !== over?.id) {
    const activeIndex = dataSource.findIndex((record) => record.key === active.id);
    const overIndex = dataSource.findIndex((record) => record.key === over?.id);
    setDataSource(arrayMove(dataSource, activeIndex, overIndex)); // 화면에서만 바뀐다
  }
};

const mutationChangeMenuSequence = useMutation({
  mutationFn: (query: Admin.ChangeSequence1.RequestQuery) => apiTechnologies.changeSequence.fetch(query),
  onError: () => message.open(toastCommonErrorProps()),
  onSuccess: () => queryClient.invalidateQueries({ queryKey: apiTechnologies.getTechnologies.key() }), // 서버 순서를 다시 받는다
});

const handleModalOnOk = () => {
  mutationChangeMenuSequence.mutate({ ids: _.map(dataSource, (record) => _.toNumber(record.key)) });
};

카테고리와 콘텐츠의 공개 상태가 어긋나는 경우도 입력 폼에서 막았습니다. FAQ, 최신 소식, 시리즈, B2C 제품의 등록과 수정 화면 8곳에서 비공개 카테고리를 고르면 콘텐츠의 공개 여부를 비공개로 바꾸고 선택을 잠급니다. 카테고리는 비공개인데 콘텐츠만 공개로 저장되는 조합을 입력 단계에서 막았습니다.

비공개 카테고리를 고르면 콘텐츠도 비공개로 고정ts
const selectedCategoryId = Form.useWatch(["categoryId"], form);

useEffect(() => {
  if (_.isNil(categories) || _.isNil(selectedCategoryId)) return;
  const selectedCategory = categories.find((category) => category.id === selectedCategoryId);
  if (!selectedCategory) return;
  if (!selectedCategory.visible) {
    form.setFieldValue("visible", "closed"); // 공개 여부를 비공개로 바꾸고 선택을 잠근다
    setIsClosedCategory(true);
    return;
  }
  setIsClosedCategory(false);
}, [selectedCategoryId, categories, form]);
flowchart LR
    A["CMS: 공개 전환, 순서 드래그"] -->|확인 또는 놓는 즉시| API["API: visible, sequence"]
    C["비공개 카테고리 선택"] -->|입력 폼에서 비공개로 고정| A
    API -->|목록 키 무효화 뒤 재조회| A
    API --> H["사이트: 공개 항목과 순서"]

공개 상태와 순서의 기준은 서버 하나이고 CMS와 사이트가 그 값을 각자 읽습니다. 검수 중에 순서 변경 뒤 목록이 갱신되지 않던 문제는 모달을 닫는 처리를 고쳐 해결했고 순서 표의 비공개 항목 색상도 같은 시기에 맞췄습니다. 카테고리 탭은 놓는 즉시 저장하고 실패하면 알림만 띄워서 저장이 실패한 경우 화면의 순서와 서버 순서가 어긋난 채 남을 수 있습니다. 모달처럼 실패 뒤 재조회를 붙이지 못한 부분입니다.

국문과 영문을 한 폼에서 쌍으로 다루기

사이트의 모든 콘텐츠는 국문과 영문으로 나가므로 CMS 입력 화면 대부분이 같은 콘텐츠의 두 언어 값을 함께 받습니다. 제목(titleKo, titleEn), 이름(nameKo, nameEn), 본문(contentKo, contentEn)이 쌍으로 움직이고 언어마다 길이와 허용 문자가 다릅니다. 본문은 Quill 에디터라서 Ant Design Form의 기본 입력처럼 오류 상태를 스스로 표시하지 않습니다.

그래서 필드 이름, 길이 제한, 검증 메시지를 언어별로 따로 두고 폼 오류를 에디터에 옮기는 연결을 화면마다 같은 형태로 넣었습니다. 영문 필드에는 공통 정규식으로 “영문으로 입력해주세요.” 검증을 걸었고 27개 파일이 이 정규식을 씁니다. 글자 수 제한은 같은 항목이라도 언어마다 다르게 두었고 실제 콘텐츠의 최대 길이에 3자를 더한 값으로 맞췄습니다. 최신 소식 제목은 국문 57자, 영문 84자입니다.

본문 검증은 필수 규칙만으로 부족했습니다. 빈 에디터도 <p><br></p> 같은 HTML을 값으로 가지므로 필수 규칙을 통과합니다. 태그를 걷어낸 텍스트가 비었는지 따로 보고 이미지만 있는 본문은 비어 있지 않은 것으로 봤습니다. 글자 수는 Quill 문서 길이에서 끝 줄바꿈 하나를 뺀 값으로 셉니다. 제출에 실패했을 때와 필드가 바뀔 때마다 국문과 영문 본문의 오류를 각각 계산해 에디터 테두리에 전달했습니다.

국문과 영문 제목, 본문 검증과 에디터 오류 연결tsx
<FormItemBasic<FormValues> label={"제목(국문)"} name={["titleKo"]} rules={[{ required: true, message: "" }]}>
  <InputTextArea maxLength={57} placeholder={"제목을 입력해주세요."} />
</FormItemBasic>
<FormItemBasic<FormValues> label={"제목(영문)"} name={["titleEn"]}
  rules={[{ required: true, message: "" }, { pattern: ENGLISH_REGEX, message: "영문으로 입력해주세요." }]}>
  <InputTextArea maxLength={84} placeholder={"Enter the title"} />
</FormItemBasic>

// 본문: 빈 문단과 글자 수 초과를 직접 검사한다
rules={[{ required: true, message: "" }, {
  validator: (_, value) => {
    if (isEditorBlank(value)) return Promise.reject(new Error(""));
    if (contentKoEditorRef.current && contentKoEditorRef.current.getLength() - 1 > EDITOR_KO_MAX_LENGTH) return Promise.reject(new Error(""));
    return Promise.resolve();
  },
}]}

// 폼 오류를 국문과 영문 에디터의 테두리 상태로 옮긴다
const handleFieldsChange: Callbacks<FormValues>["onFieldsChange"] = (_changedFields, allFields) => {
  const errorKo = allFields.find((field) => field.name.includes("contentKo"));
  const errorEn = allFields.find((field) => field.name.includes("contentEn"));
  setContentKoEditorError(_.size(errorKo?.errors) > 0);
  setContentEnEditorError(_.size(errorEn?.errors) > 0);
};
빈 본문 판정ts
export const isEditorBlank = (content: string | undefined): boolean => {
  if (content === undefined) return false;
  if (/<img\s+[^>]*src=["'][^"']+["'][^>]*>/i.test(content)) return false; // 이미지만 있어도 비어 있지 않다
  return content.replace(/<[^>]*>/g, "").trim() === "";
};

저장한 값을 다시 보여 줄 때도 한 가지를 맞춰야 했습니다. API 응답의 문자열은 HTML 엔티티로 인코딩된 값이어서 화면에 내기 전에 디코딩해야 했습니다. 검수에서 이 문제가 여러 화면에 걸쳐 나와 디코딩 컴포넌트 하나에 HTML로 그리기, 태그를 뺀 텍스트, 원문 텍스트 세 방식을 두고 목록, 상세, 수정 화면이 골라 쓰게 했습니다. 수정 화면의 에디터에는 디코딩한 HTML을 붙여 넣어 시작합니다. 이 컴포넌트와 디코딩 호출은 48개 파일에서 씁니다.

국문과 영문 본문 에디터는 15개 화면에서 한 쌍씩 30개가 같은 규칙으로 움직입니다. 다만 영문 정규식은 ASCII 문자와 기호만 허용해서 둥근 따옴표나 악센트가 들어간 영문을 붙여 넣으면 검증에 걸립니다. 글자 수 제한도 실제 데이터에 맞춘 값이어서 콘텐츠가 길어지면 다시 조정해야 합니다.

에디터 이미지의 세 입력 경로를 업로드 URL 하나로 모으기

본문 에디터는 지원, 뉴스룸, 제품, 약관 네 묶음의 등록과 수정 화면 15곳에서 쓰고 썸네일과 아이콘 업로드 컴포넌트는 솔루션, 글로벌 인증, 뉴스룸, 제품 네 묶음의 13개 화면에서 씁니다. 영역마다 에디터와 업로드를 따로 만들면 글자 수 제한, 이미지 저장 위치, 실패 처리가 제각각이 됩니다.

에디터에는 더 까다로운 문제가 있었습니다. Quill은 이미지를 기본적으로 base64 데이터 URL로 본문 HTML에 넣습니다. 운영자가 이미지를 넣는 길은 툴바의 이미지 버튼, 스크린샷처럼 클립보드에 파일로 담긴 이미지, 다른 문서에서 복사한 HTML 안의 base64 이미지 세 가지였습니다. 한 경로만 막으면 나머지 경로로 이미지 데이터가 본문에 그대로 저장되고 사이트는 그 HTML을 그대로 그립니다.

세 경로를 모두 같은 업로드로 보냈습니다. 툴바 이미지 버튼은 핸들러를 바꿔 파일을 고르면 사전 서명 URL(presigned URL)을 받아 S3에 직접 올리고 CDN 주소만 본문에 넣습니다. 붙여넣기는 에디터 루트의 paste 이벤트에서 처리했습니다. 클립보드 항목 중 이미지 파일이 있으면 업로드한 뒤 Quill이 넣은 base64 이미지를 CDN 주소로 바꾸고 없으면 새로 넣습니다. 이미지 파일 항목이 없으면 HTML 데이터를 파싱해 data: 이미지를 찾고 Blob과 File로 바꿔 같은 업로드를 거칩니다.

붙여넣기: 클립보드 파일과 HTML의 base64 이미지ts
instance.root.addEventListener("paste", async (e: ClipboardEvent) => {
  if (!e.clipboardData) return;
  let handled = false;
  const items = e.clipboardData.items;

  // 1) 클립보드에 이미지 파일이 있으면 올리고 Quill이 넣은 base64 이미지를 CDN 주소로 바꾼다
  for (let i = 0; i < items.length; i++) {
    const file = items[i].type.indexOf("image") !== -1 ? items[i].getAsFile() : null;
    if (!file) continue;
    e.preventDefault(); e.stopImmediatePropagation(); handled = true;
    const presignedData = await mutationRequestPresignedUrl.mutateAsync({ type: presignedType, fileName: file.name || "pasted-image.png" });
    await mutationUploadFile.mutateAsync({ presignedUrl: presignedData.presignedUrl, file });
    const pastedImg = instance.root.querySelector('img[src^="data:"]');
    const range = instance.getSelection(true);
    if (pastedImg) pastedImg.setAttribute("src", presignedData.cdnUrl);           // base64 이미지를 CDN 주소로 교체
    else instance.insertEmbed(range.index, "image", presignedData.cdnUrl);      // 없으면 새로 삽입
    break; // 한 번 처리하고 끝낸다
  }

  // 2) 파일 항목이 없으면 HTML 안의 base64 이미지를 찾아 File로 바꾼 뒤 같은 업로드를 거친다
  if (!handled) {
    const img = new DOMParser().parseFromString(e.clipboardData.getData("text/html"), "text/html").querySelector('img[src^="data:"]');
    if (!img) return;
    e.preventDefault(); e.stopImmediatePropagation();
    const blob = dataUrlToBlob(img.getAttribute("src")!);
    const presignedData = await mutationRequestPresignedUrl.mutateAsync({ type: presignedType, fileName: "pasted-image.png" });
    await mutationUploadFile.mutateAsync({ presignedUrl: presignedData.presignedUrl, file: new File([blob], "pasted-image.png", { type: blob.type }) });
    // 이후 base64 이미지 교체 또는 새 삽입은 1)과 같다
  }
  // 실제 코드는 두 분기의 업로드를 try/catch로 감싸고 실패는 console.error로만 남긴다
});
구현 코드: 툴바 이미지 버튼을 presigned 업로드로
툴바 이미지 버튼을 presigned 업로드로ts
handlers: {
  image: async () => {
    const input = document.createElement("input");
    input.setAttribute("type", "file");
    input.setAttribute("accept", "image/*");
    input.click();
    input.addEventListener("change", async () => {
      const file = input.files?.[0];
      if (!file) return;
      // presigned URL을 받아 S3에 직접 올리고 CDN 주소만 본문에 넣는다
      const presignedData = await mutationRequestPresignedUrl.mutateAsync({ type: presignedType, fileName: file.name });
      await mutationUploadFile.mutateAsync({ presignedUrl: presignedData.presignedUrl, file });
      const range = editorRef.current?.getSelection();
      if (!editorRef.current || !range) return;
      editorRef.current.insertEmbed(range.index, "image", presignedData.cdnUrl);
      editorRef.current.insertText(range.index + 1, "\n");
      editorRef.current.setSelection(range.index + 2, 0);
    });
  },
},

업로드 컴포넌트는 파일 형식, 이름 길이, 크기 프리셋을 먼저 검사한 뒤 presigned URL 요청과 업로드를 순서대로 수행하고 CDN 주소를 폼 값으로 돌려줍니다. 여러 파일이 한꺼번에 실패해도 오류 모달은 한 번만 띄웁니다.

업로드 컴포넌트의 검사와 실패 처리ts
customRequest: async ({ file, onProgress, onSuccess, onError }) => {
  try {
    if (!(file instanceof File)) throw new Error("유효한 파일이 아닙니다.");
    if (file.name.length > 100) throw new Error("파일 이름은 100자 이하만 가능합니다.");
    if (file.size > maxFileSize) throw new Error(`파일 크기가 ${MaxFileSizeLabel}를 초과합니다.`);
    onProgress?.({ percent: 50 });
    const presignedData = await mutationRequestPresignedUrl.mutateAsync({ type: presignedType, fileName: file.name });
    onProgress?.({ percent: 100 });
    await mutationUploadFile.mutateAsync({ presignedUrl: presignedData.presignedUrl, file });
    onSuccess?.({ url: presignedData.cdnUrl } as unknown as T, file);
  } catch (error) {
    if (!isModalDisplayed.current) { modal.error(modalUploadImageErrorProps()); isModalDisplayed.current = true; } // 여러 파일이 실패해도 모달은 한 번
    onError?.(error as Error);
  }
},
flowchart LR
    Toolbar["툴바 이미지 버튼"] --> Upload["presigned URL 요청과 S3 업로드"]
    Clipboard["클립보드 이미지 파일"] --> Upload
    HTML["붙여넣은 HTML의 base64 이미지"] -->|Blob, File로 변환| Upload
    Upload --> CDN["CDN URL"]
    CDN --> Editor["본문: base64 이미지 교체 또는 새 삽입"]

업로드에 성공하면 입력 경로와 상관없이 본문에는 CDN 주소가 들어가고 이미지는 API 서버를 거치지 않고 S3로 갑니다. 검수 중 나온 에디터 문제(수정 화면 진입 때 포커스 이탈, 입력 중 포커스가 빠지는 문제, 캐럿 위치, 줄바꿈, 글자 수 제한 10,000자)는 이 컴포넌트 한 곳에서 고쳐 15개 화면에 한 번에 반영했습니다. 붙여넣기 처리에는 한계가 남았습니다. 한 번 붙여넣을 때 첫 번째 이미지 하나만 바꾸고 에디터 업로드가 실패하면 콘솔에만 기록되어 붙여 넣은 base64 이미지가 본문에 남을 수 있습니다. 업로드 컴포넌트처럼 실패를 화면에 알리는 처리는 에디터에 넣지 못했습니다.

보안 심사 요구를 서버 응답과 화면 안내로 나눠 반영하기

오픈 전 보안 심사에서 관리자 계정 보호와 개인정보 비노출이 요구됐습니다. 비밀번호를 다섯 번 틀리면 계정을 잠그고 남은 횟수를 알려야 했고 관리자 목록과 회원 정보에서 이메일과 연락처가 보이면 안 됐습니다. 화면 우클릭을 막아 달라는 요구도 있었습니다.

잠금 횟수와 잠금 여부는 서버가 판단하고 화면은 응답 코드에 따라 안내와 다음 화면만 맡게 나눴습니다. 앞에서 만든 API 인터셉터가 실패 응답의 코드와 본문을 cause에 담아 주므로 로그인 화면은 그 코드로 분기합니다. 처음 로그인한 계정은 바로 비밀번호 설정 화면으로, 비밀번호 변경이 필요한 계정은 안내 모달을 거쳐 같은 화면으로 보냅니다. 비활성 계정과 실패 횟수가 온 경우는 각각 다른 문구로 보여 줍니다. 개인정보는 가리는 대신 관리자 목록의 컬럼과 검색 옵션, 등록과 수정 폼, 회원 정보 조회에서 아예 뺐습니다. 비밀번호를 다시 설정할 때 기존 비밀번호를 함께 받게 했고 비활성 처리된 계정이 로그아웃될 때는 이유를 토스트로 알립니다.

로그인 실패 응답을 코드별로 나눈다ts
onError: (error, variables) => {
  const { code, body } = error.cause as { code: number; body: { lockCount?: string; authorizationValue?: string } };
  if (code === ERROR.FIRST_LOGIN) {
    tokenStore.setLoginId(variables.loginId);
    tokenStore.setAuthorizationValue(body.authorizationValue ?? "");
    navigate(getPathById(ROUTE_IDS.PASSWORD_SETTING_PAGE));
    return;
  }
  if (code === ERROR.PASSWORD_CHANGE_REQUIRED) {
    modal.info(modalChangePasswordProps({ onOk: () => { /* 같은 값을 저장하고 비밀번호 설정 화면으로 */ } }));
    return;
  }
  if (code === ERROR.INACTIVE_ACCOUNT) return setErrorMessage("비활성 처리된 계정입니다. 관리자에게 문의하세요.");
  if (code === ERROR.LOGIN_FAILED) return setErrorMessage("아이디 또는 비밀번호를 잘못 입력했습니다.");
  if (code === ERROR.LOGIN_FAILED_WITH_COUNT) {
    return setErrorMessage(`아이디 또는 비밀번호(${body.lockCount}/5)를 잘못 입력했습니다.`); // 5회면 서버가 잠근다
  }
  setErrorMessage(error.message);
},
우클릭 방지는 루트에서 한 번ts
useEffect(() => {
  const handleContextMenu = (e: MouseEvent) => { e.preventDefault(); return false; };
  document.addEventListener("contextmenu", handleContextMenu);
  return () => document.removeEventListener("contextmenu", handleContextMenu);
}, []);

심사 요구를 화면에서 반영해 CMS 1.0.0 배포에 넣었습니다. 계정 잠금과 실패 횟수의 기준은 서버에 있고 화면은 그 응답을 읽어 안내와 다음 화면을 연결할 뿐입니다. 우클릭 방지는 요구받은 UI 동작으로만 반영했고 개발자 도구나 직접 요청을 막지 못하므로 접근 통제로 설명하지 않습니다.

locale 미들웨어가 health check와 크롤러 파일을 가로채지 않게 하기

사이트는 /ko와 /en 아래에서 동작하고 locale이 없는 요청은 브라우저 언어를 읽어 해당 경로로 리다이렉트합니다. 이 미들웨어는 퍼블리셔가 next-intl을 적용하면서 작성했고 본인은 적용 방법을 안내했습니다. 그런데 이 미들웨어가 모든 경로에 걸려 언어와 무관한 요청까지 /en/...으로 보냈습니다. 운영에 필요한 요청이 생길 때마다 이 문제가 다시 나타났고 세 번에 걸쳐 경계를 고쳤습니다.

시점생긴 요청조치
2025.03.12컨테이너 상태 확인용 /api/health경로를 app/api로 옮기고 matcher에서 /api/ 접두어를 제외
2025.03.20 ~ 03.21robots.txt(오픈 전이라 전체 수집 금지)locale 처리보다 먼저 robots.txt를 통과
2025.05.15 ~ 05.21크롤링 허용, ko와 en hreflang 사이트맵sitemap.xml, sitemap-0.xml도 같은 예외에 추가

예외는 두 층에 나눠 뒀습니다. API 경로는 접두어가 분명해 matcher에서 아예 미들웨어를 타지 않게 했고 크롤러 파일은 루트의 정확한 파일 이름으로 비교해 언어 판단보다 먼저 통과시켰습니다. 사이트맵은 next-sitemap으로 빌드 뒤에 생성하고 API, 오류 페이지, 검색 경로를 제외한 뒤 국문과 영문 주소를 hreflang 대체 주소로 묶었습니다.

locale 처리 앞에 둔 예외ts
export default function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  // 크롤러 파일은 locale 리다이렉트에서 뺀다. robots.txt는 3월, 사이트맵 두 파일은 5월에 추가
  if (pathname === "/robots.txt" || pathname === "/sitemap.xml" || pathname === "/sitemap-0.xml") {
    return NextResponse.next();
  }

  // 이하 정적 경로 통과, Accept-Language 기반 locale 감지와 리다이렉트, next-intl 처리(퍼블리셔 작성)
}

export const config = {
  // health check 같은 API 경로는 미들웨어를 거치지 않는다
  matcher: ["/((?!api/).*)"],
};
사이트맵의 hreflang과 제외 경로js
module.exports = {
  siteUrl: "https://lgshield.lge.com",
  generateRobotsTxt: true,
  generateIndexSitemap: true,
  exclude: ["/api/*", "/404", "/500", "/search"],
  alternateRefs: [
    { href: "https://lgshield.lge.com/ko", hreflang: "ko" },
    { href: "https://lgshield.lge.com/en", hreflang: "en" },
  ],
  robotsTxtOptions: {
    policies: [{ userAgent: "*", allow: "/", disallow: ["/api/*", "/search"] }],
    additionalSitemaps: ["https://lgshield.lge.com/sitemap.xml"],
  },
};
flowchart TB
    Q["요청"] --> M{"matcher: /api/ 접두어?"}
    M -->|예| A["미들웨어 없이 API 라우트, health check"]
    M -->|아니오| C{"robots.txt, 사이트맵 파일?"}
    C -->|예| N["그대로 통과"]
    C -->|아니오| L{"locale이 있고 지원하는 값?"}
    L -->|아니오| D["Accept-Language로 감지해 리다이렉트"]
    L -->|예| I["next-intl 미들웨어"]

이제 상태 확인과 크롤러 파일은 리다이렉트 없이 응답하고 사용자는 브라우저 언어에 맞는 경로로 들어갑니다. 생성된 사이트맵에는 국문과 영문 대체 주소가 붙은 URL 41개가 들어 있습니다. 예외를 파일 이름으로 적은 탓에 사이트맵 설정을 바꾼 5월 16일부터 예외를 넣은 5월 21일까지는 사이트맵 주소도 locale 경로로 리다이렉트되는 상태였습니다. 새 파일이 생길 때마다 미들웨어도 같이 고쳐야 하는 구조는 그대로 남았습니다. 검색 노출이나 유입 변화는 측정하지 않았습니다.

오류 페이지를 Nginx 정적 파일에서 다국어 앱 페이지로 옮기기

취약점 점검에서 서버 버전 노출과 기본 오류 페이지가 지적됐습니다. 같은 404라도 Nginx가 답할 때와 Next.js가 답할 때 보이는 페이지가 달랐고 처음에는 어느 쪽을 고쳐야 하는지 헷갈렸습니다.

첫 시도는 Nginx에서 모두 처리하는 방식이었습니다. 설정 파일에 http 블록을 만들어 server_tokens off를 두고 4xx와 5xx 응답을 정적 HTML 두 개로 연결했습니다. 그런데 이 정적 페이지는 앱 밖에 있어 next-intl 번역을 쓸 수 없었고 국문 고정 문구로만 만들 수 있었습니다. 영문 사이트 방문자에게도 국문 오류 페이지가 나가는 구조였습니다. 설정 위치도 맞지 않았습니다. 이 파일은 Nginx 컨테이너의 conf.d에 마운트되어 기본 설정의 http 블록 안에 포함되므로 그 안에 http 블록을 다시 둘 수 없습니다. 또 proxy_pass 뒤의 앱이 돌려준 404는 proxy_intercept_errors를 켜지 않으면 error_page 대상이 아니어서 이 설정이 실제로 바꾸는 것은 Nginx가 직접 만든 오류뿐이었습니다.

같은 날 http 블록과 error_page를 걷어내고 server 블록에 server_tokens off만 남겼습니다. 다음 날에는 앱 쪽에 locale 경로의 오류 경계(error.tsx)를 추가해 렌더링 오류도 사이트 레이아웃 안에서 언어별 문구로 보이게 했습니다. 없는 경로는 퍼블리셔가 만든 not-found 페이지와 catch-all 경로가 이미 같은 번역으로 처리하고 있었습니다. Next.js의 X-Powered-By 헤더를 끄고 이미지 원격 호스트 허용 목록에서 쓰지 않는 도메인도 지웠습니다. CMS는 Nginx가 정적 빌드를 내보내면서 모든 경로를 index.html로 넘기고 404는 React Router가 그립니다. 버전 노출은 CMS의 Nginx에서도 껐습니다.

첫 시도: 서버 블록용 설정 파일에 http 블록과 error_pagenginx
http {
  server_tokens off;
  error_page 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 /error/custom_4xx.html;
  error_page 500 501 502 503 504 505 /error/custom_5xx.html;
  server {
    listen 80;
    location / { proxy_pass http://front:3000; }   # 앱이 돌려준 404는 여기서 바뀌지 않는다
    location = /error/custom_4xx.html { root /usr/share/nginx/html; internal; }
    location = /error/custom_5xx.html { root /usr/share/nginx/html; internal; }
  }
}
최종: 오류도 locale 레이아웃 안에서 번역 문구로tsx
"use client";

export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
  const t = useTranslations("NotFound"); // not-found 페이지와 같은 번역 키
  return (
    <main id="notfound">
      <div className="notfound">
        <img src="/images/ico/ic-not-found.svg" alt="" />
        <h2>{t("title")}</h2>
        <p>{t("desc")}</p>
        <Link href="/">{t("link")}</Link>
      </div>
    </main>
  );
}
구현 코드: CMS는 SPA fallback, 사이트는 reverse proxy
CMS는 SPA fallback, 사이트는 reverse proxynginx
# CMS: Nginx 이미지가 정적 빌드를 낸다
server {
    listen 5173;
    server_tokens off;
    root /usr/share/nginx/html;
    location / { try_files $uri $uri/ /index.html; }   # 404는 React Router가 그린다
}

# 사이트: Nginx 컨테이너가 Next.js 앞에 선다
server {
    listen 80;
    server_tokens off;
    location / {
        proxy_pass http://front:3000;                   # 오류는 Next.js가 그린다
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
flowchart TB
    Q["요청"] --> N["Nginx: server_tokens off"]
    N -->|앱이 응답| A["Next.js"]
    A --> F["없는 경로: not-found, 언어별 문구"]
    A --> E["렌더링 오류: error.tsx, 언어별 문구"]
    N -->|앱에 닿지 못함| D["Nginx 기본 오류 응답, 버전 표시 없음"]

점검 항목을 닫았고 앱이 응답할 수 있는 오류는 앱이 언어별로, 컨테이너가 응답하지 못할 때만 Nginx가 버전 정보 없이 답하도록 책임이 나뉘었습니다. 남은 문제도 있습니다. 오류 경계가 not-found 번역 키를 그대로 써서 렌더링 오류에도 “페이지를 찾을 수 없습니다” 문구가 나오고 첫 시도에서 만든 정적 오류 파일은 연결이 끊긴 채 저장소에 남았습니다.

환경별 이미지와 배포 스크립트로 EC2에 올리기

별도의 CI 서버가 없어 사이트와 CMS를 직접 AWS EC2에 올려야 했습니다. 개발과 운영 두 환경이 있고 환경마다 API 주소와 앞단 구성이 달랐으며 지금 어떤 버전이 떠 있는지 말할 수 있어야 했습니다.

배포를 스크립트 하나로 고정하고 환경 차이는 파일로 나눴습니다. deploy.sh dev|prod가 프로파일을 받아 Dockerfile, compose 파일, 이미지 이름 접미사를 고릅니다. Dockerfile은 프로파일별 빌드 명령(build:dev, build:prod)으로 환경 파일을 골라 빌드합니다. 버전은 version.txt에서 읽어 이미지 태그와 라벨에 같이 씁니다. 이미지는 로컬에서 linux/amd64로 빌드해 tar로 옮긴 뒤 서버에서 올리고 실행은 비루트 사용자로 합니다.

두 환경의 앞단은 compose 파일에서 갈랐습니다. 개발 환경은 Next.js 컨테이너가 80번 포트를 바로 받고 운영 환경은 Next.js를 내부 포트로만 열고 Nginx 컨테이너가 앞에서 받습니다. nginx.conf는 운영 배포일 때만 함께 보냅니다. CMS도 같은 구조의 스크립트를 쓰고 런타임 이미지는 Nginx가 정적 빌드를 내보내는 형태입니다. 5월 말에는 빌드와 tar 저장까지만 하는 스크립트도 따로 뒀습니다.

구현 코드: 배포 스크립트의 뼈대
배포 스크립트의 뼈대sh
set -e
profile=$(echo "$1" | tr '[:upper:]' '[:lower:]')   # dev | prod
case "$profile" in
  dev)  COMPOSE_FILE="dev.compose.yml";  IMAGE_SUFFIX="-dev";  DOCKERFILE="docker/dev.dockerfile" ;;
  prod) COMPOSE_FILE="prod.compose.yml"; IMAGE_SUFFIX="-prod"; DOCKERFILE="docker/prod.dockerfile" ;;
  *) echo "유효하지 않은 프로파일입니다. [ dev | prod ]를 사용해주세요."; exit 1 ;;
esac
VERSION=$(cat version.txt)
LOCAL_IMAGE_NAME="front${IMAGE_SUFFIX}:${VERSION}"

docker build --build-arg VERSION="${VERSION}" --platform linux/amd64 -t "$LOCAL_IMAGE_NAME" -f "$DOCKERFILE" .
docker save -o "front${IMAGE_SUFFIX}.tar" "$LOCAL_IMAGE_NAME"
scp -i "$KEY_FILE" "front${IMAGE_SUFFIX}.tar" "$DEPLOY_USER@$HOST:$REMOTE_IMAGE_PATH"
scp -i "$KEY_FILE" "docker/${COMPOSE_FILE}" "$DEPLOY_USER@$HOST:$DOCKER_COMPOSE_PATH"
[ "$profile" = "prod" ] && scp -i "$KEY_FILE" nginx.conf "$DEPLOY_USER@$HOST:$NGINX_CONFIG_PATH"

ssh -i "$KEY_FILE" "$DEPLOY_USER@$HOST" <<REMOTE
  set -e
  docker-compose -f ${DOCKER_COMPOSE_PATH} down || true
  docker images --filter=reference="front${IMAGE_SUFFIX}:*" --format "{{.ID}}" | xargs -r docker rmi -f
  docker load -i ${REMOTE_IMAGE_PATH}
  docker-compose -f ${DOCKER_COMPOSE_PATH} up -d
  docker system prune -f || true
REMOTE
dev와 prod compose의 차이yaml
# dev.compose.yml: Next.js 컨테이너가 80번 포트를 바로 받는다
services:
  next-app:
    image: ${APP_NAME}:${VERSION}
    environment: { NODE_ENV: development }
    ports: ["80:3000"]

# prod.compose.yml: Next.js는 내부 포트만 열고 Nginx 컨테이너가 앞에 선다
services:
  next-app:
    image: ${APP_NAME}:${VERSION}
    environment: { NODE_ENV: production }
    expose: ["3000"]
  nginx:
    image: nginx:stable-alpine
    ports: ["80:80", "443:443"]
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro   # 기본 설정의 http 블록 안에 포함된다
      - ./nginx-logs:/var/log/nginx
    depends_on: [next-app]
빌드와 실행을 나눈 이미지dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
ARG VERSION
LABEL version=$VERSION
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build:prod

FROM node:20-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
USER nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
flowchart LR
    V["version.txt"] --> B["docker build linux/amd64, 프로파일별 Dockerfile"]
    B --> T["docker save tar"]
    T --> S["scp: tar, compose, 운영이면 nginx.conf"]
    S --> R["ssh: down, rmi, load, up, prune"]
    R --> D["dev: Next.js 컨테이너가 직접 응답"]
    R --> P["prod: Nginx 컨테이너가 Next.js 앞에서 proxy"]
    R --> H["health 경로로 상태 확인"]

배포는 명령 하나가 됐고 어떤 버전이 떠 있는지는 이미지 라벨과 버전 파일로 확인합니다. CMS는 3월 27일 첫 운영 배포부터 5월 1.0.0까지 이 스크립트로 반복 배포했고 사이트의 버전 커밋은 퍼블리셔와 나눠 남겼습니다. 이미지를 로컬에서 빌드해 옮기는 방식이라 배포하는 사람의 로컬 환경에 의존하고 롤백하려면 이전 버전 이미지를 다시 빌드해 올려야 한다는 점이 한계였습니다.

뉴스룸 페이지네이션을 그룹 단위로 움직이게 하기

뉴스룸 시리즈와 최신 소식 목록은 페이지 번호를 다섯 개씩 묶어 보여 줍니다. 처음에는 이전 버튼을 누르면 앞 그룹의 마지막 페이지로 이동하게 만들었습니다. 이렇게 하면 버튼 하나가 그룹과 페이지를 함께 바꾸므로 같은 날 이전과 다음 버튼은 그룹만 옮기고 페이지는 번호 버튼을 눌렀을 때만 바뀌도록 다시 고쳤습니다. 페이지는 URL query에 두고 표시 그룹은 현재 페이지로 계산한 초기값을 가진 화면 상태로 뒀습니다.

검수에서는 시리즈 목록에서 페이지 번호를 누르면 오류 화면이 뜨는 문제가 나왔습니다. 원인은 목록이 12개 이하일 때 아무것도 그리지 않는 조기 반환이 useState 호출보다 앞에 있던 것이었습니다. 다른 페이지를 누르면 새 조회 키의 데이터를 받는 동안 전체 개수가 0이 되어 훅을 부르기 전에 반환했고 렌더링마다 훅 개수가 달라져 React가 오류를 냈습니다. 조기 반환을 모든 훅 뒤로 옮겨 고쳤습니다. 같은 시기에 UX 정의에 따라 페이지가 다섯 개 이하면 그룹 버튼을 숨기도록 바꿨고 최신 소식, 제품, 검색, FAQ의 페이지네이션에도 같은 규칙을 넣었습니다.

페이지는 URL, 그룹은 화면 상태, 조기 반환은 훅 뒤에tsx
const [searchParams, setSearchParams] = useQueryStates(seriesSearchParams);
const { data } = useQuery({ queryKey: apiNewsrooms.searchNewsrooms.key(searchParams), queryFn: () => apiNewsrooms.searchNewsrooms.fetch(searchParams) });
const { page: currentPage, size: pageSize } = searchParams;
const totalCount = data?.totalCount || 0;
const totalPages = _.ceil(totalCount / pageSize);
const groupSize = 5;
const [displayGroup, setDisplayGroup] = useState(_.floor((currentPage - 1) / groupSize)); // 조기 반환보다 먼저 호출
const groupStart = displayGroup * groupSize + 1;
const groupEnd = _.min([totalPages, groupStart + groupSize - 1]) || 0;
const hasPrevGroup = displayGroup > 0;
const hasNextGroup = groupEnd < totalPages;
const showGroupButtons = totalPages > 5; // 페이지가 6개 이상일 때만 그룹 버튼

// 이전과 다음은 그룹만 옮긴다. 페이지는 번호 버튼에서만 바뀐다
return totalCount <= 12 ? null : (
  <div className="pagination">
    {showGroupButtons && <button className="prev" disabled={!hasPrevGroup} onClick={() => hasPrevGroup && setDisplayGroup(displayGroup - 1)} />}
    {pages.map((page) => (
      <button key={page} className={page === currentPage ? "active" : ""} onClick={() => setSearchParams({ page })}>{page}</button>
    ))}
    {showGroupButtons && <button className="next" disabled={!hasNextGroup} onClick={() => hasNextGroup && setDisplayGroup(displayGroup + 1)} />}
  </div>
);

페이지 이동과 그룹 이동이 분리되고 오류 화면 문제가 사라져 검수 티켓이 닫혔습니다. 페이지 번호가 URL에 있어 공유와 새로고침에도 같은 목록이 열리고 서버 컴포넌트가 같은 URL query로 첫 화면을 미리 받아 둡니다.

결과와 근거

국영문 대외 사이트와 CMS를 함께 만들고 운영 배포까지 맡아 콘텐츠 입력부터 언어별 노출, 배포까지 이어지는 흐름을 다뤘습니다. 확인한 결과와 근거는 아래와 같습니다.

결과근거
라우트 트리 하나가 메뉴, 브레드크럼, 제목과 설명, 경로 조회의 원본CMS app/routes.ts(엔트리 57개, 화면 47개), 사이드바와 브레드크럼 레이아웃
API 함수와 조회 키의 쌍, 동시 401의 단일 갱신CMS apis/features/*(10개 모듈, 99개 함수), apis/api-client.ts, 사이트 apis/features/*(8개 모듈, 23개 함수)
서버 prefetch와 클라이언트 조회의 연결사이트 뉴스룸 시리즈 화면과 API 계층 도입 커밋 faa6245
입력값과 적용값 분리, 상세 복귀 시 조건 복원목록 화면 9개의 조건 상태와 상세 이동 코드
국문과 영문 쌍 필드와 검증영문 정규식 사용 27개 파일, 본문 에디터 15개 화면, 글자 수 제한 커밋 0735ddb
에디터 이미지 세 경로의 업로드 전환components/editor/editor-basic.tsx, components/upload/upload-basic.tsx
보안 심사 요구 반영계정 잠금 ed3feef, 개인정보 삭제 3bf4c0b, 6fe3f2f, 9f06d70, 우클릭 방지 86c53eb, CMS 1.0.0 1001ebe
locale 미들웨어 예외와 사이트맵be3ff59, 2b38423, ee27b14, 94b04dc, c00aa61
오류 페이지 전환Nginx 시도와 철회 48739bb, c80ccaa, 앱 오류 경계 36b069f
환경별 배포deploy.sh, build.sh, docker/*, nginx.conf, 배포 환경 분리 8fd4b25
역할 구분저장소별 작성자 대조(merge 제외): CMS 130건 중 125건, 사이트 209건 중 42건

두 저장소에는 자동화 테스트가 없고 동작은 고객 QA 티켓 대응과 단계별 배포로 확인했습니다. 검색 순위, 운영 장애 감소, 콘텐츠 발행 시간의 변화는 측정하지 않았습니다.

배운 것