Navigation

LG Shield

LG전자의 제품 보안 프레임워크 LG Shield를 소개하는 국영문 대외 사이트와 그 콘텐츠를 운영하는 CMS를 함께 만든 프로젝트입니다. CMS는 처음부터 끝까지 혼자 구축했고 사이트는 퍼블리셔 한 명과 함께 만들면서 API 연동, 다국어 라우팅, 배포와 운영을 맡았습니다.

핵심 기여

과제본인 기여확인한 동작
반복되는 CMS 화면 구조라우트 메타데이터와 공통 폼, 표, 에디터 구축메뉴와 브레드크럼, 이동 경로가 같은 설정을 사용
운영 상태와 사이트 연결공개 여부와 순서 저장 뒤 재조회CMS와 국영문 사이트가 서버의 발행 상태를 기준으로 표시
배포와 라우팅 경계Docker 배포 스크립트와 Nginx, locale 예외 처리정적 자원과 크롤러 파일을 언어 경로 처리와 구분

개요

항목내용
기간2025.01 ~ 2025.06
역할CMS 단독 구축, 사이트 프론트엔드 연동, Docker와 Nginx 배포 운영
인원CMS는 본인 혼자. 사이트는 퍼블리셔 1명과 본인
서비스솔루션, 글로벌 인증, B2B와 B2C 제품, 뉴스룸, FAQ, 약관을 국문과 영문으로 제공하는 대외 사이트와 그 콘텐츠를 관리하는 CMS
링크lgshield.lge.com
사이트 기술Next.js 15, React 19, TypeScript, React Query, next-intl, nuqs, Swiper, GSAP, next-sitemap
CMS 기술React Router 7, React 19, TypeScript, React Query, Zustand, Ant Design 5, nuqs, Quill, Nice Modal, dnd-kit
공통SCSS, swagger-typescript-api, Docker, Nginx, Google Analytics

진행은 네 구간으로 나뉩니다.

구간시기내용
세팅2025.01두 저장소의 프로젝트 세팅, 라우터와 엔드포인트, 공통 컴포넌트
구축2025.02 ~ 2025.03로그인과 계정, 제품과 솔루션과 뉴스룸과 FAQ와 약관 관리 화면, 공통 에디터, 사이트 API 연동, 배포 환경 분리와 첫 운영 배포
검수 대응2025.04 ~ 2025.05QA 티켓 대응, 보안 심사 요구 반영, 취약점 대응, 1.0.0 배포
후속2025.06사이트맵, 미들웨어, 빌드 설정 후속 반영

맡은 범위

문제와 해결

코드는 두 저장소의 실제 코드를 발췌한 것입니다. 서버 주소, 키 파일, 버킷 이름, 내부 오류 코드, 티켓 번호는 지우거나 이름으로 바꿨고 동작은 실제와 같습니다.

라우트 메타데이터 하나로 메뉴와 브레드크럼과 경로를 만들기

문제. 관리 영역이 솔루션, 글로벌 인증, 제품, 뉴스룸, 지원, 약관, 관리자 일곱 묶음이고 묶음마다 목록, 등록, 상세, 수정 화면이 있습니다. 메뉴, 브레드크럼, 이동 경로를 따로 적으면 화면을 더할 때마다 세 곳을 고쳐야 합니다.

판단. React Router의 라우트 설정에 id, label, menu, description을 얹어 한 곳에 두고 사이드바, 브레드크럼, 경로 조회가 모두 이 트리를 읽게 했습니다. 경로 문자열은 코드에서 직접 쓰지 않고 id로 찾습니다.

구현. 라우트 트리는 레이아웃을 포함해 중첩돼 있고 menu: true인 항목만 사이드바에 나타납니다. 브레드크럼은 현재 경로를 조각으로 잘라 트리에서 label과 description을 찾고 동적 세그먼트는 params로 치환합니다.

구현 코드: 라우트 메타데이터와 경로 조회
라우트 메타데이터와 경로 조회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;
};
브레드크럼이 같은 트리를 읽는다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 };
});
flowchart LR
    R["ROUTES 트리: id, label, menu, description"] --> S["사이드바: menu가 true인 항목"]
    R --> B["브레드크럼: 경로 조각마다 label"]
    R --> P["getPathById: 이동과 리다이렉트"]
    R --> C["React Router 설정"]

결과. 화면 하나를 더할 때 라우트 항목 하나만 추가하면 메뉴, 브레드크럼, 경로가 같이 생깁니다. 경로 문자열이 코드에 흩어지지 않아 경로를 바꿔도 한 곳만 고칩니다.

운영 조회 조건을 URL에 두고 상세에서 돌아와도 유지하기

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

판단. 조회 조건을 nuqs로 URL query에 두고 같은 값을 React Query 키로 썼습니다. 상세로 갈 때는 현재 조건을 history state에 실어 보내고 돌아올 때 그대로 URL에 되돌립니다. 입력 중인 값은 화면 상태로 두고 조회 버튼을 눌러야 URL에 반영합니다.

구현. 필터와 페이지네이션을 두 묶음의 parser로 정의했습니다. 조회 API, 삭제와 공개 전환 뒤의 재조회가 같은 키를 쓰므로 조건이 바뀌면 목록도 같이 바뀝니다.

구현 코드: URL query가 조회 키가 된다
URL query가 조회 키가 된다ts
// 쿼리스트링 - 필터
const [qsFilters, setQsFilters] = useQueryStates({
  visible: parseAsBoolean,
  categoryId: parseAsInteger,
  createdStartDate: parseAsString,
  createdEndDate: parseAsString,
  visibleStartDate: parseAsString,
  visibleEndDate: parseAsString,
  searchOption: parseAsStringEnum<SEARCH_OPTION>(_.values(SEARCH_OPTION)),
  searchKeyword: parseAsString,
});
// 쿼리스트링 - 페이지네이션
const [qsPagination, setQsPagination] = useQueryStates({
  page: parseAsInteger.withDefault(1),
  size: parseAsInteger.withDefault(10),
});
// 상세에서 돌아오면 history state에 실어 둔 조건을 URL로 되돌린다
useEffect(() => {
  if (location.state) {
    setQsFilters({ ..._.omit(location.state, ["page", "size"]) });
    setQsPagination({ ..._.pick(location.state, ["page", "size"]) });
  }
}, []);
// 같은 조건이 그대로 조회 키다
const { data } = useQuery({
  queryKey: apiNewsrooms.searchNewsrooms.key({ type: "NEWS", ...qsFilters, ...qsPagination }),
  queryFn: () => apiNewsrooms.searchNewsrooms.fetch({ type: "NEWS", ...qsFilters, ...qsPagination }),
});
// 상세로 갈 때 현재 조건을 같이 보낸다
navigate(generatePath(getPathById(ROUTE_IDS.LATEST_NEWS_DETAIL_PAGE), { id: data.key }), { state: { ...qsFilters, ...qsPagination } });

결과. 새로고침, 뒤로가기, 상세 복귀 뒤에도 운영자가 보던 목록 조건이 남습니다. 이 방식은 뒤에 HCMS와 SK AX에서 같은 원칙으로 이어졌습니다.

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

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

판단. 공개 전환과 순서 변경을 각각 하나의 API로 두고 CMS는 그 결과를 재조회해서만 보여 줍니다. 순서는 드래그로 바꾸되 확인 버튼을 눌렀을 때만 전체 순서를 한 번에 저장합니다. 비공개 항목은 순서 표에서도 흐리게 표시해 공개 상태를 같이 보게 했습니다.

구현. 순서 관리 모달은 dnd-kit의 sortable로 행을 끌고 arrayMove로 배열을 바꾼 뒤 확인 시 id 배열을 순서 변경 API에 보냅니다. 성공하면 목록 쿼리를 무효화해 다시 받습니다. 공개 전환은 확인 모달을 거쳐 같은 방식으로 재조회합니다.

드래그로 순서를 바꾸고 확인 때 한 번에 저장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.ChangeSequence.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)) });
};
flowchart LR
    A["CMS: 공개 전환, 순서 드래그"] -->|확인| API["API: visible, sequence"]
    API -->|재조회| A
    API --> H["사이트: 메뉴와 목록 순서, 공개 항목만"]

결과. 공개 상태와 순서의 기준이 서버 하나이고 CMS와 사이트가 그 값을 각자 읽습니다. 검수 중에 순서 변경 뒤 목록이 갱신되지 않던 문제와 비공개 항목 표시를 고쳐 운영자가 보는 것과 사이트가 보여 주는 것이 같아졌습니다.

리치 텍스트와 이미지 업로드를 공통 발행 도구로 만들기

문제. 솔루션, 인증, 제품, 뉴스룸, 지원, 약관 여섯 영역이 모두 본문 에디터와 이미지를 씁니다. 영역마다 에디터와 업로드를 따로 만들면 글자 수 제한, 이미지 저장 위치, 실패 처리가 제각각이 됩니다.

판단. Quill 에디터와 Ant Design Upload를 감싼 공통 컴포넌트 두 개로 묶고 이미지는 영역별 presignedType으로 presigned URL을 받아 S3에 직접 올린 뒤 CDN 주소만 본문에 넣습니다. 업로드 실패는 발행 실패와 섞이지 않게 업로드 컴포넌트 안에서 모달로 알립니다.

구현. 에디터는 Quill을 동적으로 불러 생성하고 툴바의 이미지 버튼을 presigned 업로드로 바꿨습니다. 업로드 컴포넌트는 파일 형식, 이름 길이, 크기 프리셋을 검사한 뒤 presigned URL 요청과 업로드를 순서대로 수행합니다. 검수 중 에디터의 포커스 이탈, 캐럿 위치, 줄바꿈, 글자 수 제한 같은 문제를 같은 컴포넌트에서 고쳐 여섯 영역에 한 번에 반영했습니다.

구현 코드: 에디터 이미지 버튼을 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);
    });
  },
},
업로드 컴포넌트의 검사와 실패 처리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);
  }
},

결과. 여섯 영역이 같은 에디터와 업로드를 쓰고 이미지는 서버를 거치지 않고 S3로 갑니다. 에디터 문제는 한 곳에서 고치면 전부에 반영됩니다.

보안 심사 요구를 화면에서 막기

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

판단. 잠금 횟수는 서버가 세고 화면은 응답 코드에 따라 남은 횟수와 잠금 안내를 보여 줍니다. 개인정보는 가리는 대신 목록 컬럼과 검색 옵션에서 아예 뺐습니다. 우클릭은 루트에서 한 번만 막습니다.

구현. 로그인 실패 응답을 코드별로 나눠 비밀번호 변경이 필요한 계정은 변경 화면으로 보내고 실패 횟수가 온 경우는 n/5 형식으로 보여 줍니다. 관리자 목록에서 이메일과 연락처 컬럼과 이메일 검색 옵션을 제거했습니다. 우클릭 방지는 루트 컴포넌트의 contextmenu 이벤트 하나로 처리했습니다.

로그인 실패 응답을 코드별로 나눈다ts
onError: (error, variables) => {
  const cause = error.cause as { code: number; body: { lockCount?: string; authorizationValue?: string } };
  if (cause.code === ERROR.PASSWORD_CHANGE_REQUIRED) {
    modal.info(modalChangePasswordProps({
      onOk: () => {
        tokenStore.setLoginId(variables.loginId);
        tokenStore.setAuthorizationValue(cause.body.authorizationValue ?? "");
        navigate(getPathById(ROUTE_IDS.PASSWORD_SETTING_PAGE));
      },
    }));
    return;
  }
  if (cause.code === ERROR.LOGIN_FAILED_WITH_COUNT) {
    setErrorMessage(`아이디 또는 비밀번호(${cause.body.lockCount}/5)를 잘못 입력했습니다.`); // 5회면 서버가 잠근다
    return;
  }
  setErrorMessage("아이디 또는 비밀번호를 잘못 입력했습니다.");
},
우클릭 방지는 루트에서 한 번ts
useEffect(() => {
  const handleContextMenu = (e: MouseEvent) => { e.preventDefault(); return false; };
  document.addEventListener("contextmenu", handleContextMenu);
  return () => document.removeEventListener("contextmenu", handleContextMenu);
}, []);

결과. 심사 요구 세 가지를 화면에서 반영했고 1.0.0 배포에 포함했습니다. 서버의 실패 횟수와 잠금 응답을 읽어 상태별 안내와 다음 화면을 연결했습니다.

국영문 라우팅과 크롤러 경로가 부딪히지 않게 하기

문제. 사이트는 /ko와 /en 아래에서 동작하고 locale이 없는 요청은 브라우저 언어로 보내야 합니다. 그런데 robots.txt, sitemap, 정적 파일까지 locale 경로로 리다이렉트되면 크롤러가 사이트맵을 못 읽습니다. 실제로 오픈 뒤 robots.txt가 리다이렉트되는 문제가 있었습니다.

판단. 미들웨어에서 크롤러 파일과 정적 경로를 먼저 통과시키고 그다음에 locale을 판단합니다. 언어 감지는 Accept-Language의 우선순위를 그대로 따릅니다. 사이트맵은 next-sitemap으로 만들고 국문과 영문을 hreflang으로 묶었습니다.

구현. 미들웨어는 robots.txt와 sitemap, /_next/static과 이미지 경로를 제외한 뒤 locale이 없거나 지원하지 않는 값이면 감지한 locale로 리다이렉트하고 나머지는 next-intl 미들웨어에 넘깁니다. 사이트맵 설정은 API와 오류 페이지와 검색 경로를 제외하고 ko와 en을 대체 주소로 선언합니다. 컨테이너 상태 확인용 health 경로도 두었습니다.

구현 코드: 크롤러 파일을 먼저 통과시키는 미들웨어
크롤러 파일을 먼저 통과시키는 미들웨어ts
export default function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
  // 크롤러 파일은 locale 리다이렉트에서 뺀다. 오픈 뒤 robots.txt가 리다이렉트되던 문제의 수정
  if (pathname === "/robots.txt" || pathname === "/sitemap.xml" || pathname === "/sitemap-0.xml") return NextResponse.next();
  const staticPaths = ["/_next/static", "/favicon.ico", "/images", "/js", "/lib"];
  if (staticPaths.some((path) => pathname.startsWith(path))) return NextResponse.next();

  const locale = pathname.split("/").filter(Boolean)[0];
  if (!locale || !supportedLocales.includes(locale)) {
    const acceptLanguage = request.headers.get("accept-language");
    const detectedLocale = acceptLanguage ? detectLocale(acceptLanguage, supportedLocales, defaultLocale) : defaultLocale;
    return NextResponse.redirect(new URL(`/${detectedLocale}${pathname}`, request.url));
  }
  return intlMiddleware(request);
}

// Accept-Language의 q 값 순서대로 지원 언어를 고른다
function detectLocale(acceptLanguage: string, supportedLocales: string[], defaultLocale: string) {
  const languages = acceptLanguage.split(",")
    .map((lang) => { const [locale, quality] = lang.split(";q="); return { locale: locale.trim(), quality: Number.parseFloat(quality) || 1.0 }; })
    .sort((a, b) => b.quality - a.quality);
  return languages.find((lang) => supportedLocales.includes(lang.locale))?.locale ?? defaultLocale;
}
사이트맵의 hreflang과 제외 경로js
module.exports = {
  siteUrl: "https://lgshield.lge.com",
  generateRobotsTxt: 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"] }] },
};
flowchart TB
    Q["요청"] --> C{"robots, sitemap, 정적 파일?"}
    C -->|예| N["그대로 통과"]
    C -->|아니오| L{"locale이 있고 지원하는 값?"}
    L -->|아니오| D["Accept-Language로 감지해 리다이렉트"]
    L -->|예| I["next-intl 미들웨어"]

결과. 검색 엔진이 robots.txt와 사이트맵을 바로 읽고 사용자는 브라우저 언어에 맞는 경로로 들어갑니다. 오픈 뒤 크롤링 허용과 사이트맵 수정까지 같은 미들웨어 규칙 안에서 끝났습니다.

Jenkins 없이 Docker와 Nginx를 쉘 스크립트로 배포하기

문제. 별도의 CI 서버가 없어서 사이트와 CMS를 직접 서버에 올려야 했습니다. 개발과 운영 두 환경이 있고 버전마다 어떤 이미지가 떠 있는지 알 수 있어야 했습니다.

판단. 배포를 스크립트 하나로 고정했습니다. 환경은 인자로 받고 버전은 파일에서 읽으며 이미지는 로컬에서 빌드해 tar로 옮긴 뒤 서버에서 올립니다. 이미지에는 버전 라벨을 붙이고 실행은 비루트 사용자로 합니다. Nginx는 사이트 앞의 reverse proxy이고 CMS는 정적 파일을 SPA fallback으로 냅니다.

구현. 스크립트는 환경 검증, 버전 읽기, linux/amd64 빌드, tar 저장, scp 전송, ssh로 중지와 이미지 교체와 기동, 정리 순서로 끝까지 실패 시 멈춥니다. Dockerfile은 빌드 단계와 실행 단계를 나누고 Next.js standalone 출력만 복사합니다. 상태 확인은 health 경로로 합니다.

구현 코드: 배포 스크립트의 뼈대
배포 스크립트의 뼈대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
빌드와 실행을 나눈 이미지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"]
    B --> T["docker save tar"]
    T --> S["scp: tar, compose, nginx.conf"]
    S --> R["ssh: down, rmi, load, up, prune"]
    R --> H["health 경로로 확인"]

결과. 배포가 명령 하나가 됐고 어떤 버전이 떠 있는지 이미지 라벨과 버전 파일로 확인합니다. 운영 배포는 3월 말 첫 배포부터 5월 1.0.0까지 이 스크립트로 반복했습니다.

오류 페이지를 Nginx와 앱 중 누가 답할지 정하기

문제. 취약점 점검에서 서버 버전 노출과 기본 오류 페이지가 지적됐습니다. 같은 404라도 Nginx가 답할 때와 React Router나 Next.js가 답할 때가 달라서 어느 쪽 페이지가 보일지 헷갈렸고 처음에는 둘 다 손대야 했습니다.

판단. 앱이 살아 있을 때의 오류는 앱 라우터가 답하고 앱에 닿지 못할 때만 Nginx가 정적 페이지로 답하도록 역할을 나눴습니다. CMS는 모든 경로를 index.html로 넘겨 React Router가 404를 그리고 사이트는 Next.js가 그립니다. 버전 노출은 두 층 모두에서 껐습니다.

구현. Nginx에 server_tokens off를 두고 Next.js는 poweredByHeader를 껐습니다. 4xx와 5xx용 정적 오류 페이지를 만들어 두고 Nginx 설정에 연결했다가 서버 블록 단위 설정 파일에는 http 블록을 둘 수 없다는 것을 알고 설정 위치를 바로잡았습니다. 이미지 원격 호스트 허용 목록에서 불필요한 도메인도 지웠습니다.

구현 코드: CMS는 SPA fallback, 사이트는 reverse proxy
CMS는 SPA fallback, 사이트는 reverse proxynginx
# CMS
server {
    listen 5173;
    server_tokens off;
    root /usr/share/nginx/html;
    location / { try_files $uri $uri/ /index.html; }   # 404는 React Router가 그린다
}

# 사이트
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;
    }
}

결과. 점검 항목을 닫았고 Nginx 라우팅과 앱 라우팅의 책임이 분명해졌습니다. 이 구분을 이해한 뒤로는 오류 페이지를 어디서 고칠지 고민하지 않았습니다.

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

문제. 뉴스룸 시리즈와 최신 소식 목록은 페이지 번호를 다섯 개씩 묶어 보여 줍니다. 이전과 다음 버튼이 페이지를 옮길지 그룹을 옮길지 섞여 있어 검수에서 이전 페이지로 갈 때 엉뚱한 그룹으로 가거나 버튼이 눌리지 않는 문제가 나왔습니다.

판단. 페이지는 URL query에 두고 그룹은 화면 상태로 뒀습니다. 이전과 다음 버튼은 그룹만 바꾸고 페이지는 번호 버튼을 눌렀을 때만 바뀝니다. 그룹 버튼은 페이지가 여섯 개 이상일 때만 보입니다.

구현. 페이지와 크기는 nuqs parser로 읽어 조회 키에 쓰고 표시 그룹은 현재 페이지로 계산한 초기값을 가진 상태입니다. 빈 목록 안내 이미지 경로가 잘못돼 오류가 나던 것도 같이 고쳤습니다.

페이지는 URL, 그룹은 화면 상태ts
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 totalPages = _.ceil((data?.totalCount || 0) / 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;
// 이전과 다음은 그룹만 옮긴다. 페이지는 번호 버튼에서만 바뀐다
<button disabled={!hasPrevGroup} onClick={() => hasPrevGroup && setDisplayGroup(displayGroup - 1)} />
<button className={page === currentPage ? "active" : ""} onClick={() => setSearchParams({ page })}>{page}</button>

결과. 페이지 이동과 그룹 이동이 분리돼 검수 티켓이 닫혔고 페이지 번호가 URL에 있어 공유와 새로고침에도 같은 목록이 열립니다.

결과와 근거

배운 것