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.05 | QA 티켓 대응, 보안 심사 요구 반영, 취약점 대응, 1.0.0 배포 |
| 후속 | 2025.06 | 사이트맵, 미들웨어, 빌드 설정 후속 반영 |
맡은 범위
- CMS 구조. React Router 7의 라우트 메타데이터를 기준으로 메뉴, 브레드크럼, 경로를 만드는 구조와 인증, 기본, 브레드크럼 세 레이아웃.
- CMS 화면. 로그인과 비밀번호 설정, 관리자 계정, 솔루션 이미지와 기술, 글로벌 인증, B2C와 B2B 제품, 뉴스룸 시리즈와 최신 소식, FAQ, 이용약관과 개인정보처리방침과 이메일 수집 거부 관리.
- CMS 공통. Quill 기반 에디터, presigned URL 이미지 업로드, 테이블과 정렬 가능한 테이블, 브레드크럼, 모달, 토스트 패턴.
- 사이트. Next.js 15 초기 세팅, 라우터와 엔드포인트 연동, next-intl 국영문 라우팅과 미들웨어, 뉴스룸 시리즈 API와 페이지네이션, 약관과 지원 흐름.
- 운영. robots.txt와 sitemap, health check, Google Analytics, Docker와 Nginx 배포, 취약점 대응, 보안 심사 요구 반영.
- 협업. 백엔드 API 명세, 디자인과 퍼블리싱, 고객 QA 티켓을 받아 반영했습니다.
문제와 해결
코드는 두 저장소의 실제 코드를 발췌한 것입니다. 서버 주소, 키 파일, 버킷 이름, 내부 오류 코드, 티켓 번호는 지우거나 이름으로 바꿨고 동작은 실제와 같습니다.
라우트 메타데이터 하나로 메뉴와 브레드크럼과 경로를 만들기
문제. 관리 영역이 솔루션, 글로벌 인증, 제품, 뉴스룸, 지원, 약관, 관리자 일곱 묶음이고 묶음마다 목록, 등록, 상세, 수정 화면이 있습니다. 메뉴, 브레드크럼, 이동 경로를 따로 적으면 화면을 더할 때마다 세 곳을 고쳐야 합니다.
판단. React Router의 라우트 설정에 id, label, menu, description을 얹어 한 곳에 두고 사이드바, 브레드크럼, 경로 조회가 모두 이 트리를 읽게 했습니다. 경로 문자열은 코드에서 직접 쓰지 않고 id로 찾습니다.
구현. 라우트 트리는 레이아웃을 포함해 중첩돼 있고 menu: true인 항목만 사이드바에 나타납니다. 브레드크럼은 현재 경로를 조각으로 잘라 트리에서 label과 description을 찾고 동적 세그먼트는 params로 치환합니다.
구현 코드: 라우트 메타데이터와 경로 조회
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;
};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가 조회 키가 된다
// 쿼리스트링 - 필터
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에 보냅니다. 성공하면 목록 쿼리를 무효화해 다시 받습니다. 공개 전환은 확인 모달을 거쳐 같은 방식으로 재조회합니다.
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 업로드로
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);
});
},
},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 이벤트 하나로 처리했습니다.
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("아이디 또는 비밀번호를 잘못 입력했습니다.");
},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 경로도 두었습니다.
구현 코드: 크롤러 파일을 먼저 통과시키는 미들웨어
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;
}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 경로로 합니다.
구현 코드: 배포 스크립트의 뼈대
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
REMOTEFROM 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
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로 읽어 조회 키에 쓰고 표시 그룹은 현재 페이지로 계산한 초기값을 가진 상태입니다. 빈 목록 안내 이미지 경로가 잘못돼 오류가 나던 것도 같이 고쳤습니다.
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에 있어 공유와 새로고침에도 같은 목록이 열립니다.
결과와 근거
- 국영문 대외 사이트와 CMS를 함께 만들고 운영 배포까지 맡아 콘텐츠 발행 구조를 처음부터 끝까지 다뤘습니다.
- CMS의 관리 영역 일곱 묶음이 같은 라우트 메타데이터, URL 조회 상태, 에디터와 업로드 패턴을 씁니다.
- 공개 상태와 순서는 서버를 기준으로 CMS와 사이트에 같이 반영됩니다.
- 보안 심사 요구인 계정 잠금, 개인정보 비노출, 우클릭 방지와 취약점 점검 항목을 반영해 1.0.0을 배포했습니다.
- 사이트맵과 robots.txt, 미들웨어 예외, health check를 갖춰 검색 노출과 운영 확인이 됩니다.
배운 것
- 대외 사이트보다 CMS의 상태 구조를 먼저 잡는 것이 운영 속도를 정합니다. 라우트 메타데이터와 URL 조회 상태는 그 뒤 프로젝트에서 그대로 다시 썼습니다.
- 보안 요구는 규칙을 서버에 두고 화면은 안내만 맡을 때 가장 덜 흔들립니다.
- Nginx 라우팅과 앱 라우팅은 다른 층이라는 것을 운영하면서 몸으로 배웠습니다. 누가 답하는지를 먼저 정하면 오류 페이지 문제는 단순해집니다.
- CI 서버가 없어도 배포는 스크립트 하나로 고정할 수 있습니다. 버전 파일과 이미지 라벨이 있어야 운영 중 무엇이 떠 있는지 말할 수 있습니다.