Frontend
홈페이지 만들기 쉽쥬 ?
김*한··수정됨 2026.07.22

1. 문제 상황
LED 디스플레이를 다루는 회사라 사용자가 만나는 시스템이 두 축이었다. 견적 시스템과 홈페이지.
견적은 작동은 했다. 그런데 추천이 자꾸 어긋났다. 홈페이지는 WordPress + 페이지 빌더로 굴러가고 있었는데, 이전 담당자가 떠난 뒤 새 제품 하나 등록할 때마다 우리가 알 수 없는 플러그인 조합을 더듬어야 했다.
2주 동안 두 시스템을 만들기로 결심했다.
2. 견적 시스템: 피치도 안 맞고 거리도 안 맞았다
세 가지 문제가 동시에 터졌다.
피치-거리 로직이 일관성 없었다. 한쪽에선 피치 × 100, 다른 쪽에선 피치 × 3을 권장 거리로 잡고 있었다.
데이터가 두 군데에서 따로 굴렀다. 견적은 "P2.5 모듈 320×160mm", 홈페이지는 "320×240mm". 한쪽이 옛 값을 들고 있는 게 정상이 돼버렸다.
결과가 즉시 안 왔다. 요청이 쌓이면 우리가 수동으로 답장. 사용자는 메일이 언제 올지 모른 채 기다렸다.
세 문제가 결국 "같은 데이터를 여러 곳에서 따로 계산한다" 는 한 원인이었다.
3. 해결: 제품 사양을 코드 한 곳에서만 관리
데이터는 단일 소스로, 상태는 단일 훅으로 모았다.
lib/product-detail.ts
세 문제가 결국 "같은 데이터를 여러 곳에서 따로 계산한다" 는 한 원인이었다. 데이터는 단일 소스로, 상태는 단일 훅으로 모았다.
먼저 모든 제품 사양을 lib/product-detail.ts 안의 PRODUCTS 객체 하나로 통합했다. 피치별로 키를 두고, 각 키 아래에 모듈 사이즈, 단가, 도면 PDF 같은 정보를 한 묶음으로 모았다.
export const PRODUCTS = {
'p2.5': {
spec: indoorSpec({ pitch: 2.5, moduleSize: { w: 320, h: 160 }, unitPrice: 220_000 }),
pdfsBySize: buildPdfsBySize({ /* ... */ }),
},
// 'p3', 'p3.91', 'p4' ... 같은 형식으로 다른 피치 추가
} as const;
새 제품을 추가하려면 객체 하나만 더 넣으면 된다. 견적 시스템, 홈페이지 제품 페이지, PDF 다운로드 화면 — 모두 이 객체를 import해서 쓴다. 한 군데를 바꾸면 양쪽에 자동 반영된다.
피치별 권장 시청 거리도 단일 함수로 분리했다. 업계 표준에 맞춘 계산식을 한 곳에 넣어, 견적 시스템과 제품 페이지가 같은 결과를 보여주도록 한다.
export const viewingDistanceFor = (pitch: number) => ({
min: pitch * 0.5,
optimal: pitch * 1.5,
max: pitch * 3,
});
이전엔 한쪽에서 피치 × 100, 다른 쪽에서 피치 × 3을 쓰던 게, 이제 한 함수 호출로 통일됐다. 추천 거리가 어긋날 일이 없어졌다.
4. useEstimate: 모든 견적 상태를 한 훅으로
견적 결과 화면에서 사용자가 만지는 입력은 피치 드롭다운, Over-fit / Under-fit 토글, 이메일 전송. 셋이 바뀔 때마다 모듈 수·실제 크기·해상도·가격이 모두 즉시 재계산되어야 한다.
핵심 패턴은 세 가지다.
useState로 피치·핏 모드 상태를 들고, useMemo로 계산을 캐싱하고, useCallback으로 메일 함수를 안정화한다.
useState로 피치와 핏 모드를 들고 있고, 값이 바뀌면 의존성이 바뀐 useMemo가 즉시 재계산을 시작한다. 같은 입력이면 캐시된 결과를 그대로 쓰기 때문에 불필요한 재계산이 없다.
가격 분해는 buildPricing 헬퍼로 분리했다. 모듈 비용, 예비부품 5%, 컨트롤러, 전원공급기, 프레임, 설치비를 한 번에 묶어 반환한다. 가격 정책이 바뀌면 이 함수만 수정한다.
useCallback은 sendEmail 함수의 참조를 안정화한다. 자식 컴포넌트가 불필요하게 재렌더링되지 않는다.
결과 페이지 컴포넌트는 이 훅 한 줄로 모든 상태를 다룬다.
app/estimate/_hooks/useEstimate.ts
const { pitch, setPitch, fitMode, setFitMode, result, sendEmail } = useEstimate({
width: 1920, height: 1080, initialPitch: 'p3.91',
});
피치 드롭다운, 핏 모드 토글, 가격 분해, 이메일 전송. 모든 UI가 같은 상태를 바라보고 한 곳만 수정하면 동기화 된다.
5. 회신 1일 → 1초
이메일 버튼을 누르면 API 라우트가 pdfkit으로 견적서를 즉시 생성하고 Nodemailer로 발송한다.
app/api/estimate/email/route.ts
export async function POST(req: Request) {
const body = await req.json();
const pdf = await generateEstimatePdf(body);
await sendMail({
to: body.to,
subject: LUMENIX 견적서 — ${body.pitch.toUpperCase()},
html: renderSummary(body),
attachments: [{ filename: 'estimate.pdf', content: pdf }],
});
return Response.json({ ok: true });
}
이전엔 우리가 견적의로가 오면 일일이 확인하며 답장했다. 이제는 클릭하는 그 순간 메일함에 알림이 들어와 사용자들의 견적을 바로 확인 할 수 있게 되었다.

Over-fit / Under-fit: 모듈 단위로 떨어지지 않는 크기를 큰 방향·작은 방향 두 가지로 보여주고 선택하게 했다.
Pitch 변경 드롭다운: setPitch 한 번으로 가격·해상도·도면이 동시에 갱신된다.
가격 분해: 모듈, 예비부품, 컨트롤러, 전원, 프레임, 설치비를 항목별로 노출한다.
6. 홈페이지: WordPress가 우리한테 안 맞았다
세 가지 문제가 있었다.
이미지 하나에 5분. 미디어 라이브러리 → 페이지 빌더 블록 → 위젯 → 정렬 → 새로고침. 제품 10개 등록하면 종일이 갔다.
빌더 틀 안에 갇힘. 같은 시리즈 안에 P2~P10 다중 피치를 한 페이지에 넣는 것도, 3D 뷰어나 도면 임베드도 빌더가 지원하지 않았다.
느림. 플러그인이 쌓이면서 메인 LCP가 4초를 넘었다. 모바일에선 더 심했다.
7. 스택 선택: Next.js 16 + Tailwind v4 + Pretendard
App Router 서버 컴포넌트로 제품 데이터는 빌드 시점에 서버에서 읽고, 클라이언트 번들은 인터랙션 부분만 들어가게 했다.
generateStaticParams로 모든 제품·피치 페이지를 빌드 시점에 정적 생성. 런타임 DB 호출이 없다.
next/image가 AVIF/WebP, srcset, lazy loading을 모두 자동 처리. WordPress 플러그인 3개로 하던 일이 한 컴포넌트로 끝난다.
git push 하면 Vercel이 빌드해서 자동 배포한다. 미디어 라이브러리도, 빌더도, 플러그인도 없다.
8. 제품 사양 페이지: 3D / 2D / 다중 피치를 한 화면에

상단의 P2 / P2.5 / P3 / P3.076 / P4 / P5 / P6 / P6.67 / P8 / P10 탭으로 같은 시리즈의 다른 피치를 즉시 전환한다. 모듈 사이즈, 해상도, 캐비닛, 재질, 밝기, 시야각, 재생률, IP 등급 같은 사양이 탭 전환과 동시에 바뀐다.
탭 컴포넌트는 활성 키만 useState로 들고 PRODUCTS[activePitch]를 읽어서 그린다. 단일 데이터 소스 덕분에 로직이 십수 줄로 끝난다.
9. 3D 모델: 동적 임포트 + useEffect
model-viewer는 Google이 만든 웹 컴포넌트다. 브라우저 window가 있어야 동작하기 때문에 SSR에서 그대로 import하면 빌드가 깨진다. 해결책은 클라이언트 사이드 동적 로드.
먼저 'use client'로 컴포넌트를 클라이언트 전용으로 분리해 SSR 단계를 건너뛴다. 그 다음 useEffect 안에서 import()를 동적으로 호출해 model-viewer 번들을 가져온다.
app/products/[slug]/_components/ProductViewer.tsx
useEffect(() => {
let cancelled = false;
import('@google/model-viewer').then(() => {
if (!cancelled) setReady(true);
});
return () => { cancelled = true; };
}, []);
import()는 일반 import와 달리 빌드 시점이 아니라 런타임에 번들을 가져온다. 약 200KB의 model-viewer 코드가 초기 페이지 번들에서 빠지기 때문에 LCP에 영향이 없다.
cancelled 플래그는 cleanup 용도다. 사용자가 모델 로드 전에 페이지를 떠나면 unmount 시점에 cancelled가 true로 바뀌고, 그 뒤 .then() 콜백이 실행되더라도 setReady가 호출되지 않는다. 메모리 누수와 "state update on unmounted component" 경고가 동시에 막힌다.
빈 의존성 배열 []은 컴포넌트가 처음 마운트될 때 한 번만 실행되도록 보장한다. src prop이 바뀌어도 같은 model-viewer 모듈을 재사용한다.
로드가 끝나면 ready가 true로 바뀌고, 로딩 중에 자리를 차지하던 ViewerPlaceholder가 실제 model-viewer 웹 컴포넌트로 교체된다.
if (!ready) return <ViewerPlaceholder />;
return (
/ / @ts-expect-error — 웹 컴포넌트라 JSX 타입에 없음
<model-viewer
src={src}
camera-controls
auto-rotate
class="aspect-square w-full"
/>
);
@ts-expect-error 주석은 TypeScript가 model-viewer JSX 태그를 모르기 때문에 필요하다. 표준 웹 컴포넌트라 별도 타입 선언을 추가하면 제거할 수 있지만, 한 곳에서만 쓴다면 주석으로 빠르게 해결한다.
사용자는 마우스로 제품을 자유롭게 돌려보고 확대해볼 수 있다.
10. 도면: 빌드 시점에 PDF → PNG
같은 제품에 모듈 크기가 960×960, 960×1120, 1280×960, 1280×1120 네 종류. PDF를 그대로 띄우면 PDF 뷰어를 거쳐야 해서 무거웠다.
빌드 시점에 PDF를 PNG로 변환해서 정적 파일로 저장하는 방식으로 바꿨다.
scripts/build-drawings.ts — prebuild로 실행
for (const [size, pdfPath] of Object.entries(pdfsBySize)) {
const pages = await pdfToPng(pdfPath, { viewportScale: 2.0 });
await fs.writeFilepublic/drawings/${slug}-${size}.png, pages[0].content);
}
페이지에선 일반 next/image로 즉시 뜬다. 원본 PDF가 필요한 사용자에겐 PDF 다운로드 버튼을 별도로 제공한다.
11. 달라진 것
견적 추천 정확도: 같은 입력에 다른 결과 → 일관된 추천
견적 회신 시간: 평균 1일(수동) → 클릭 즉시 메일
견적 상태 관리: 페이지마다 따로 → useEstimate 훅 하나로 통합
제품 신규 등록: 한나절 → 객체 하나 추가, 30분
메인 페이지 LCP: 4초 이상 → 1초 미만
3D 모델 표시: 불가능 → 동적 임포트로 즉시 임베드
다중 피치 비교: 페이지 분리 → 한 페이지 탭 전환
12. 마치며
견적이 자꾸 어긋났고 홈페이지가 너무 느렸다. 처음엔 "고쳐 쓰면 되지" 했지만, 고쳐야 할 곳이 너무 많았다.
두 시스템이 같은 데이터를 공유한다는 걸 알게 된 순간 한 번에 정리하지 않으면 끝이 안 난다는 게 분명해졌다. 단일 소스 + 단일 훅으로 묶인 지금은 새 제품을 객체 하나로 추가하고, 사용자는 그 변경을 즉시 받아본다.
기술 스택이 멋있어서가 아니라, 우리가 매일 만지는 시스템이 우리 손에 맞게 됐다는 것 — 그게 이 2주의 성과다.
읽기 도구
약 12분 읽기
이 글이 도움이 되었나요?