| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 백엔드
- Kotlin
- JWT
- 크롤링
- 메일자동화
- 온디바이스AI
- sqlite
- typescript
- Vite
- javascript
- next.js
- Android
- 프론트엔드
- 소셜로그인
- 배포
- 상태관리
- electron
- playwright
- React
- FastAPI
- python
- 인증
- 보안
- github actions
- php
- 아키텍처
- contextBridge
- 도메인설계
- REST API
- 마이그레이션
- Today
- Total
HM소프트
엔지니어링 기업 홈페이지 개발기 (1) — Next.js 정적 사이트와 이중 언어 지원 본문

Next.js 14 App Router와 TypeScript로 전력 엔지니어링 기업 홈페이지 시안을 만든 기록. 다국어는 URL 라우팅 대신 Context 토글로 단순화하고, GitHub Actions로 정적 배포하며, as const가 만든 리터럴 유니온 타입 충돌을 정적 빌드에서 잡아 푼 과정을 담았다.
하룻밤 만에 "이런 톤으로 갑니다"를 만들어야 했다
한 전력 설비 분야 엔지니어링 기업의 공식 홈페이지 프로토타입 시안을 만들었다. 본 구축 계약 전에 "이런 구조와 이런 분위기로 갑니다"를 의사결정자에게 보여주는 단계라, 완성도보다 속도와 첫인상이 중요했다. 회사명·연락처 같은 실제 정보는 이 글에서 전부 일반화했지만, 작업 자체는 운영을 전제로 한 진짜 코드였다.
이 글의 재미있는 점은 커밋 로그가 곧 작업 타임라인이라는 것이다. 밤에 프로토타입 첫 커밋을 올리고, 다음 작업에서 구조를 다듬어 정식 시안 커밋을 찍고, 그다음 배포 파이프라인을 붙였더니 곧바로 빌드가 깨져서 타입 에러를 잡는 커밋이 하나 더 붙었다. 시안 한 장을 만드는 일이 "디자인 → 배포 → 빌드 신뢰성"이라는 작은 사이클을 그대로 한 바퀴 돈 셈이다.
스택은 Next.js 14 App Router + TypeScript + TailwindCSS다. 페이지는 메인·회사 소개(About)·서비스(Services)·기술 역량(Tech Power)·주요 실적(References)·문의(Contact) 여섯 개. 시안 단계지만 두 가지는 타협하지 않기로 했다. 한/영 이중 언어 지원과 반응형이다. B2B 전력 엔지니어링은 해외 발주처·파트너가 자료를 보는 일이 잦아서, 영문 페이지는 "있으면 좋은 것"이 아니라 처음부터 동등한 1급 요구사항이었다.
전체 구조 — 여섯 페이지와 공통 셸
먼저 사이트가 어떻게 조립되는지부터 그려 두자. App Router의 layout.tsx가 전역 셸이고, 그 안에 헤더·푸터가 고정으로 들어가며 가운데 main만 페이지별로 갈린다. 메인 페이지는 다시 Hero·통계·서비스·실적·파트너·문의 CTA 같은 섹션 컴포넌트의 세로 스택이다.

핵심은 두 개의 데이터 소스를 분리했다는 점이다. 번역 가능한 UI 문자열은 lib/i18n.ts에, 목록형 콘텐츠(파트너사, 실적 이력)는 lib/data.ts에 뒀다. 전자는 한/영 쌍으로 관리해야 하고 후자는 언어와 무관하게 그냥 배열이라, 성격이 다른 둘을 한 파일에 섞지 않았다. 이 작은 분리가 나중에 CMS를 붙일 때 "무엇을 번역 테이블로 빼고 무엇을 일반 콘텐츠 테이블로 둘지"의 밑그림이 된다.
라우팅 없는 다국어 — Context 하나로 끝내기
다국어라고 하면 보통 /ko, /en 같은 URL 라우팅 기반(예: next-intl, 미들웨어 로케일 감지, 경로 세그먼트)을 떠올린다. 정식 운영이라면 SEO와 공유 URL 때문에 그게 맞다. 하지만 시안 단계에서 그 인프라를 다 까는 건 과했다. 미들웨어·로케일 라우트·정적 경로 생성까지 얽히면, 정작 보여줘야 할 디자인보다 배관 작업에 시간을 더 쓰게 된다.
그래서 React Context 기반 언어 토글로 단순화했다. 우측 상단 버튼으로 ko/en을 전환하면, 메모리에 있는 번역 사전에서 해당 언어 묶음을 통째로 꺼내 컴포넌트 트리에 내려준다. URL은 그대로 두고 화면 텍스트만 바뀌는 방식이다.

여기서 설계상 일부러 고른 게 하나 있다. 흔한 i18n 라이브러리는 t("hero.title")처럼 문자열 키를 받는 함수 t를 쓴다. 나는 그 대신 t를 현재 언어의 사전 객체 그 자체로 두기로 했다. 그래서 컴포넌트에서는 함수 호출 없이 점 접근으로 읽는다.
// 함수 호출(t("hero.title"))이 아니라, 그냥 객체 점 접근
<h1 className="whitespace-pre-line">{t.hero.title}</h1>
<p>{t.hero.subtitle}</p>
이 선택의 대가는 분명하다. TypeScript가 키를 끝까지 검사해 준다. t.hero.titel이라고 오타를 내면 빌드가 실패한다. 문자열 키 방식이라면 런타임에 undefined가 조용히 화면에 뜨거나, 키 그대로 노출되어 사용자가 hero.title이라는 날 텍스트를 보게 된다. 시안은 "깨진 게 바로 보이는" 게 가장 무섭다. 그래서 약간의 자유도(동적 키 조합)를 포기하고 컴파일 타임 안전성을 택했다. 이 결정이 곧이어 빌드 에러로 되돌아오는데, 그건 뒤에서 다룬다.
번역 사전 — 중첩 객체와 as const
번역 사전은 lib/i18n.ts 한 파일에 한/영 두 묶음으로 모았다. 평평한 "hero.title": "..." 형태가 아니라, 화면 영역별로 중첩한 객체다. nav, hero, stats, services처럼 섹션 단위로 묶으면 컴포넌트와 1:1로 대응돼 찾기 쉽다.

두 가지 디테일이 들어 있다. 첫째, title 안에 \n을 직접 넣고, 출력 쪽에서 Tailwind의 whitespace-pre-line으로 줄바꿈을 살린다. 디자이너가 의도한 "전력 엔지니어링의 / 새로운 기준" 두 줄 구성을 한국어·영어 각각 다른 위치에서 끊을 수 있다. 둘째, 맨 끝의 as const다. 이걸 붙이면 모든 문자열이 넓은 string이 아니라 '9GW+' 같은 리터럴 타입으로 고정되고, Messages 타입을 typeof messages.ko로 뽑아낼 수 있다. 이 한 줄이 "한글 사전 구조를 그대로 타입의 정답지로 삼는" 트릭의 핵심인데 — 동시에 곧 만날 빌드 에러의 원인이기도 하다.
헤더 — 스크롤에 반응하는 투명 → 화이트 전환
헤더는 시안의 첫인상을 좌우하는 부분이라 공을 들였다. 페이지 최상단에서는 Hero 배경 위에 투명하게 떠 있다가, 스크롤을 내리면 흰 배경 + 그림자로 바뀌며 가독성을 확보한다. 이 전환은 스크롤 위치를 상태로 들고 클래스만 토글해서 구현했다.

언어 토글 버튼은 별도 폼이나 라우팅 없이 setLocale(locale === 'ko' ? 'en' : 'ko') 한 줄이다. Context가 바뀌면 t를 구독하는 모든 컴포넌트(헤더 네비, Hero 문구, 통계 라벨, 푸터까지)가 한 번에 다시 렌더된다 — 이게 라우팅 기반 대신 Context를 고른 보상이다. 언어 전환이 페이지 이동 없이 즉각적이고, 스크롤 위치도 유지된다. 모바일에서는 햄버거 메뉴 안에 같은 토글을 "English / 한국어" 풀 라벨로 한 번 더 둬서, 좁은 화면에서도 헤맬 일이 없게 했다.
네비 항목을 JSX에 하드코딩하지 않고 navItems 배열로 뽑은 것도 의도다. 라벨이 t.nav.*에서 오기 때문에, 배열 한 곳만 보면 "이 사이트에 어떤 메뉴가 있고 각 라벨이 어느 번역 키에 묶이는지"가 한눈에 들어온다. 데스크톱 가로 네비와 모바일 세로 메뉴가 같은 배열을 두 번 렌더하므로, 메뉴가 늘어도 한 곳만 고치면 양쪽이 동기화된다.
디자인 토큰 — "AI가 만든 듯한" 화면을 피하려고
시안에서 가장 피하고 싶었던 건 어디서 본 듯한 기본값 범벅 화면이었다. 그래서 색·폰트·애니메이션을 Tailwind 설정에 브랜드 토큰으로 못 박아 두고, 컴포넌트에서는 bg-brand-primary처럼 의미 있는 이름으로만 쓰게 했다. 전력·엔지니어링 업종에 맞춰 신뢰감 있는 진한 네이비를 주색으로, 포인트는 한 톤 밝은 블루로 잡았다.
// tailwind.config.ts — 브랜드 색/폰트/모션 토큰
extend: {
colors: {
brand: {
primary: '#0E3967', // 딥 네이비 (신뢰·전력 인프라)
secondary: '#4472C4', // 포인트 블루 (CTA·강조)
dark: '#0a2a4d',
light: '#f5f7fa' // 섹션 교차 배경
}
},
fontFamily: {
heading: ['Poppins', 'Pretendard', 'sans-serif'],
body: ['Pretendard', 'Lato', 'sans-serif']
},
animation: {
'fade-up': 'fadeUp 0.8s ease-out',
'fade-in': 'fadeIn 1s ease-out'
}
}
색을 RGB 값으로 컴포넌트마다 흩뿌리지 않고 토큰 네 개로 통제하니, "주색을 한 단계 어둡게 해보자" 같은 의사결정자 피드백이 와도 설정 한 줄로 전체가 바뀐다. 폰트는 제목용 Poppins와 본문용 Pretendard를 분리해 영문/국문이 섞인 B2B 사이트 특유의 위계를 줬다. 섹션마다 배경을 흰색과 brand-light(아주 옅은 회청색)로 번갈아 깔아, 스크롤할 때 영역이 자연스럽게 끊어지게 한 것도 같은 토큰 체계 덕분에 일관되게 유지됐다.
시안 공유는 정적 배포로 — GitHub Actions + Pages
시안을 의사결정자에게 보여주려면 결국 클릭 가능한 URL이 있어야 한다. 캡처 이미지로는 반응형도, 언어 토글도, 스크롤 인터랙션도 전달되지 않는다. 그런데 이 사이트는 폼 백엔드도 DB도 아직 없는 순수 정적 콘텐츠다. 서버를 띄울 이유가 전혀 없었다.
그래서 정적 내보내기(static export) + GitHub Pages 조합을 골랐다. Next.js의 output: 'export'로 HTML/CSS/JS 산출물만 뽑고, 푸시가 들어오면 GitHub Actions가 빌드해 Pages에 올린다.

워크플로를 build와 deploy 두 잡으로 나누고 permissions에 pages: write와 id-token: write를 명시한 게 포인트다. Pages 배포는 OIDC 토큰으로 권한을 받기 때문에 id-token이 없으면 배포 단계에서 막힌다. 비용 0원, 관리할 환경 변수 0개, 푸시 한 번이면 시안 URL이 갱신된다. 의사결정자에게는 "이 링크 새로고침하시면 최신입니다" 한마디면 끝이라, 시안 공유 사이클이 극도로 짧아졌다.
정적 내보내기를 켜면서 next.config.mjs에 같이 손본 설정이 두 가지 더 있다.

이 세 줄은 정적 배포에서 안 맞추면 반드시 사고가 나는 항목이다. GitHub Pages는 사이트를 도메인 루트가 아니라 저장소명 하위 경로로 서비스하므로 basePath를 안 주면 모든 CSS·링크가 404가 난다. trailingSlash: true는 서버 라우팅이 없는 정적 호스팅에서 /about 요청을 /about/index.html로 안전하게 떨어지게 한다. 그리고 정적 export에는 Next의 이미지 최적화 서버가 따라오지 않으므로 images.unsplash 같은 원격 이미지는 unoptimized: true로 그대로 내보내야 깨지지 않는다. "호스팅 환경의 제약을 빌드 설정으로 흡수"하는 전형적인 작업이다.
정적 빌드가 잡아낸 타입 에러 — as const의 역습
배포 워크플로를 붙이고 첫 푸시를 하자마자 빌드가 빨갛게 깨졌다. 로컬 dev 서버에서는 멀쩡히 돌던 코드가 CI의 정적 빌드에서 TypeScript 타입 에러로 멈췄다. dev 모드는 요청이 들어온 페이지만 느슨하게 컴파일하고 넘어가지만, 정적 export는 모든 페이지를 미리 렌더링하면서 프로젝트 전체의 타입 검사를 엄격히 통과해야 하기 때문이다. 평소 숨어 있던 구멍이 배포 순간에 드러난 것이다.
원인은 앞서 깐 복선, as const였다. messages에 as const를 붙이면 messages.ko와 messages.en은 각자 자기 문자열 리터럴로 이뤄진 서로 다른 타입이 된다. 예컨대 한글의 hero.title은 '전력 엔지니어링의\n새로운 기준'이라는 리터럴 타입, 영문은 'A New Standard in\nPower Engineering'이라는 또 다른 리터럴 타입이다. 그래서 messages[locale]로 꺼내면 그 결과는 "ko 타입 또는 en 타입"의 유니온이 되고, 이걸 t: Messages(= typeof messages.ko) 자리에 그대로 넣으려니 "en 쪽 리터럴이 ko 타입에 대입되지 않는다"는 충돌이 났다.
해결은 한 줄이었다. 꺼낸 값을 우리가 약속한 사전 형태로 명시적으로 좁혀서 넣었다.

as Messages 단언이 안전한 근거는 한글 사전과 영문 사전의 키 구조가 100% 동일하기 때문이다. 두 언어는 값(문자열 내용)만 다를 뿐 모양은 같으므로, messages.ko로 뽑은 Messages 타입이 두 언어 모두를 정확히 설명한다. 즉 이 단언은 "타입을 속이는" 게 아니라 "컴파일러가 리터럴 때문에 과하게 좁힌 걸 우리가 알고 풀어주는" 쪽에 가깝다.
물론 컨텍스트 소비 측 안전장치도 같이 둔다. useI18n 훅에서 Provider 밖 사용을 막는 가드다.
export function useI18n() {
const ctx = useContext(LanguageContext);
if (!ctx) throw new Error('useI18n must be used within LanguageProvider');
return ctx; // 여기서부터 ctx는 null이 아님이 보장된다
}
타입 에러가 잡히고 다시 빌드가 통과하기까지의 흐름을 그리면 이렇다.

"dev에서 되는데 빌드에서 깨진다"는 짜증나는 상황이지만, 뒤집어 보면 정적 빌드가 CI에서 타입 구멍을 대신 막아 준 것이다. 만약 배포 파이프라인을 시안 막바지에 붙였다면, 이 에러를 "왜 안 올라가지?" 하며 마감 직전에 만났을 것이다. 배포를 일찍 붙일수록 이런 문제를 일찍, 싸게 만난다 — 이번 건은 그 원칙을 다시 확인한 사례였다.
익명화도 설계의 일부였다
이 글을 쓰며 새삼 느낀 건, 시안 코드에는 실데이터가 잔뜩 박혀 있다는 점이다. 회사 정식명·주소·대표 전화·이메일은 번역 사전과 푸터에, 파트너사·발주처 이름과 실제 사업 실적은 lib/data.ts 목록에 그대로 들어간다. 시안이라 해도 이건 운영 정보다.
그래서 이번 개발기를 정리하며 모든 식별 정보를 일반화했다 — 회사명은 "한 전력 엔지니어링 기업", 파트너·실적은 "발주처", "데이터센터 계통해석" 같은 분야 설명으로 바꿨다. 이 작업을 하다 보니 자연스러운 교훈이 나왔다. 번역 사전과 콘텐츠 목록을 코드 본문에서 분리해 둔 구조가, 나중에 실데이터를 한곳에서 통제하기 쉽게 만든다는 것이다. 실명·연락처·실적이 컴포넌트 JSX 여기저기 흩어져 있었다면 익명 버전을 만들 때도, 실제 CMS로 빼낼 때도 훨씬 고생했을 것이다. "데이터를 코드에서 분리"라는 원칙은 유지보수뿐 아니라 개인정보·기밀 통제의 관점에서도 옳았다.
정리하며
- 시안 단계 기업 홈페이지를 Next.js 14 App Router + TypeScript + TailwindCSS, 여섯 페이지 + 공통 셸 구조로 만들었다. 번역 문자열(
i18n.ts)과 목록 콘텐츠(data.ts)를 성격별로 분리한 게 이후 모든 작업의 토대가 됐다. - 다국어는 URL 라우팅 대신 Context 기반 토글로 단순화했다.
t를 함수가 아니라 사전 객체 자체로 둬서, 점 접근에 대해 TypeScript가 키를 끝까지 검사하도록 했다 — 시안에서 "깨진 텍스트가 조용히 노출되는" 사고를 컴파일 타임에 차단한다. - 디자인은 색·폰트·모션을 Tailwind 브랜드 토큰으로 못 박아, 의사결정자 피드백에 설정 한 줄로 대응할 수 있게 했다.
- 공유용 배포는 정적 내보내기 + GitHub Actions + Pages.
basePath·trailingSlash·images.unoptimized세 줄로 서브경로 호스팅의 제약을 빌드 설정에서 흡수했고, 비용·환경변수 0으로 푸시 한 번에 시안 URL이 갱신된다. - 정적 빌드는 dev 서버보다 엄격하다.
as const가 만든 리터럴 유니온 타입 충돌을as Messages단언으로 풀었고, 이 에러를 마감이 아니라 초반에 만난 건 배포 파이프라인을 일찍 붙인 덕이었다.
시안이 승인되면 다음 단계는 CMS 연동, 문의 폼 백엔드, 지도/위치 연동 같은 운영 기능이다. 그때는 Context 토글을 URL 로케일 라우팅으로 승격하고, 지금 data.ts에 둔 목록을 콘텐츠 테이블로 옮기게 될 것이다 — 지금의 분리 구조가 그 전환의 발판이 되도록 깔아 둔 셈이다.
'외주 개발일지' 카테고리의 다른 글
| 취향 큐레이션 커머스 개발기 (1) — Laravel 모놀리스로 멀티롤 쇼핑몰 골격 잡기 (0) | 2026.06.23 |
|---|---|
| 전화 금융사기 탐지 앱 개발기 (1) — 온디바이스 Whisper·LLM 파이프라인 (0) | 2026.06.23 |
| 엔지니어링 기업 홈페이지 개발기 (1) — Next.js 정적 사이트와 이중 언어 지원 (0) | 2026.06.23 |
| 모바일 축구 커리어 게임 개발기 (4) — AdMob 보상형 광고와 게임 재화 연동 (0) | 2026.06.23 |
| 브랜드 웹사이트 개발기 (2) — 기획 문서와 콘텐츠를 정확히 맞추기 (0) | 2026.06.23 |