Next.js App RouterTypeScript 타입 설계테크 딥다이브

쇼핑몰 팀 프로젝트 ③-1 읽기 경로 — 표가 타입, 컴포넌트, 주소가 되기까지

· 2026-10-22 발행· 30분 읽기

목차

10월 21일 아침, 갤러리에 이미지를 넘기는 한 줄에 빨간 줄이 생겼어요. img.image_url 이라고 썼는데 화면용 타입에는 그런 이름이 없다는 거였죠. 표의 열 이름을 그대로 쓴 겁니다. 변환 함수를 거친 뒤에는 화면 이름만 써야 한다는 걸 머리로는 알고 있었는데 손이 먼저 나갔어요.

이 글은 그 빨간 줄이 어디서 오는지를 설명하는 글이에요. 1편 에서 화면과 폴더 구조를 세웠고, ②-1 에서 고객 여정으로 엔티티를 뽑고 ②-2 에서 그걸 ERD(Entity-Relationship Diagram, 표와 표 사이 관계를 그린 그림) 여덟 표로 세웠어요. 3편은 둘로 나눴습니다. 이 글 ③-1 은 읽기 경로, 표에서 화면으로 가는 길이고, ③-2 는 쓰기 경로, 문의 폼에서 표로 가는 길이에요. 여기서는 그 여덟 표의 열과 관계선, 전부 UUID(긴 난수 식별자) 로 정한 키, NULL 을 허용한 열이 프론트엔드 쪽에서 무엇이 되는지를 다룹니다. 타입이 되고, 읽는 컴포넌트와 그리는 컴포넌트의 경계가 되고, 목록 요소와 빈 화면이 되고, 주소의 파라미터가 돼요. 개념은 수업에서 배운 것이고, 증거는 제 상품 상세 페이지에서 가져왔습니다. 이 글의 규칙은 하나예요. 실행해 보지 않은 코드는 싣지 않고, 틀렸던 순서를 지우지 않는다. 다 읽고 나면 남의 ERD 나 표 정의를 받아서 “이 열은 이 타입, 이 관계는 이 컴포넌트, 이 값이 비면 이 화면” 으로 한 장에 옮겨 적을 수 있을 거예요. 실제 연결(연결 객체, 환경 변수, 권한 정책 실측, 배포) 은 4편으로 넘깁니다.

이 글의 지도. 위에 2편의 결과인 여덟 표, 가운데에 네 부품과 조립·적용·교정·함정 띠, 아래에 10-16 부터 10-22 까지의 내 작업 타임라인이 있다 이 글의 지도. 개념은 가운데 네 부품에서, 증거는 아래 타임라인에서 가져온다

표 여덟 개는 어떤 길로 화면이 될까?

프론트엔드는 데이터 구조를 새로 정하는 곳이 아니라, 이미 정해진 구조를 화면의 말로 옮겨 적는 곳이다.

2편까지 하고 나니 결정은 다 돼 있었어요. 상품은 브랜드 정확히 하나를 가리키고, 이미지와 사이즈는 0개 이상이고, 키는 전부 UUID 고, 판매 종료는 지우지 않고 deleted_at 에 날짜를 적는다. 그런데 이 결정들이 화면 코드에 저절로 들어오지는 않아요. 누군가 옮겨 적어야 하고, 옮겨 적는 자리가 여러 곳이라 하나라도 빠뜨리면 그 자리에서만 조용히 어긋납니다. 10월 20일에 데이터가 안 나오던 원인이 다섯 겹이었던 것도 이 때문이었어요. 주소, 아이디 종류, 지운 함수, 환경 변수 이름, 그리고 표에 없던 열. 앞의 문제를 걷어 내기 전에는 뒤의 문제가 보이지도 않았습니다. 그 다섯 겹은 함정 절에서 한 겹씩 표로 다시 봐요.

읽기 경로는 표에서 화면으로 갑니다. 표 → 조회 → Row 타입(응답 한 줄의 모양) → 변환 함수 → 화면 타입 → props(부모 컴포넌트가 자식에게 건네는 값). 조회의 입력은 주소에서 오고, 조회 결과가 0줄이면 404 로 끝나요. 반대 방향, 폼에서 표로 가는 쓰기 경로는 이 글에서 다루지 않습니다. 상품 문의 폼이 표에 닿기까지는 ③-2 에서 따로 써요.

읽기 경로 여섯 상자가 화살표로 이어지고, 조회 상자 아래에 주소의 id 가 점선으로 들어오며, 아래에 쓰기 경로는 ③-2 에서 다룬다는 점선 띠가 있는 그림 전체 절차. 이 글은 위 한 줄, 읽기 경로만 다룬다. 원칙은 “규칙은 DB 에서 먼저, 타입은 DB 가 보장하는 만큼만”

용어를 몇 개 정해 둘게요. 이 글의 프론트엔드는 React(화면을 컴포넌트 단위로 만드는 라이브러리) 위의 Next.js(리액트 기반 웹 프레임워크) 이고, App Router(폴더 구조가 곧 주소가 되는 방식) 를 씁니다. 타입은 TypeScript(자바스크립트에 정적 타입을 더한 언어) 의 타입이에요. 데이터베이스(DB) 는 PostgreSQL 이고 Supabase(PostgreSQL 을 호스팅하고 권한·인증을 얹어 주는 서비스) 를 통해 붙입니다. 컴포넌트가 스스로 들고 있다가 바꾸는 값은 state 라고 불러요.

열과 관계는 어떻게 타입이 될까 — Row 타입과 화면 타입?

타입은 표를 두 번 옮겨 적는다. Row 타입은 응답 한 줄의 모양을 DB 이름 그대로 적고, 화면 타입은 컴포넌트가 쓰는 이름으로 적는다. 둘 사이의 변환은 한 함수에서만 한다.

쇼핑몰로 치면, 창고의 입고 전표와 매장의 가격표예요. 같은 물건인데 창고는 창고 코드로 부르고 매장은 손님이 읽는 이름으로 부릅니다. 둘을 섞어 쓰면 10월 21일의 제 빨간 줄이 돼요.

표기법은 간단해요. 열 하나가 필드 하나가 되는데, 열의 성격에 따라 타입의 모양이 달라집니다. 수업 가이드가 준 대응표를 제 표에 맞게 다시 적으면 이래요.

표에서 타입에서 제 프로젝트의 예
uuid 키 string id: string
NOT NULL 열 필수 필드 base_price: number
NULL 허용 열 T | null alt: string | null
CHECK 로 값 두 개만 허용 문자열 유니온 '일반배송' | '판매자배송'
내 표에 FK(외래키) 가 있는 관계 객체 하나 brands: { name: string } | null
상대 표에 FK 가 있는 관계 (1:N) 배열 product_images: {…}[]
논리 삭제 열 deleted_at 타입에 없음 조회 조건으로 걸러서 응답에 안 온다

왼쪽에 products·brands·product_images·product_options 네 상자, 가운데에 ProductRow 타입, 오른쪽에 Product 타입이 있고 열에서 필드로 화살표가 이어진 그림. 아래에 일곱 규칙 범례 표의 열이 타입의 필드가 되는 규칙. 가운데가 Row 타입, 오른쪽이 화면 타입이고 둘 사이가 변환 함수다

브랜드 줄이 이상해 보일 거예요. 2편에서 brand_id 는 NOT NULL 외래키였으니 상품에는 브랜드가 반드시 하나 있어요. 그런데 Row 타입에는 | null 이 붙어 있죠. 10월 20일에 한참 고민한 자리입니다. 데이터로는 비어 있을 수 없지만, 응답에서는 빠질 수 있어요. 브랜드 표의 읽기 권한 정책이 그 행을 막으면 상품은 오고 브랜드만 null 로 옵니다. 그러니까 이 | null 은 “브랜드 없는 상품” 이 아니라 “응답에서 안 보일 수 있음” 에 대한 대비예요. 변환 함수에서 ?? '' 로 빈 문자열을 채우고, 화면 타입의 brand 는 그냥 string 으로 둡니다. 타입은 DB 가 보장하는 만큼만 좁히고, DB 가 보장하는데 응답에서만 빠질 수 있는 값은 조회에서 막거나 타입에 null 을 남기고 변환에서 채운다. 둘 중 하나는 반드시 한다.

실제 데이터예요. 이 글을 쓰면서 팀 브랜치의 작업본에서 타입 검사를 돌렸습니다. 먼저 그대로 돌리니 오류가 0이었고, 그다음 일부러 두 줄을 틀린 파일을 넣었어요. 하나는 브랜드를 있다고 가정한 줄, 하나는 10월 21일에 제가 실제로 냈던 실수, 화면 타입에 DB 이름을 쓴 줄입니다.

터미널 출력. tsc 가 오류 0 으로 통과한 뒤, 일부러 틀린 파일에서 ‘row.brands’ is possibly ‘null’ 과 Property ‘image_url’ does not exist on type ‘ProductImage’ 두 오류가 난다 실제 데이터. 위 표의 ③ 행(객체 또는 null) 과 변환 뒤 이름 규칙이 컴파일에서 막히는 모습. 2026-10-22 작업본

두 번째 오류가 핵심이에요. 이름 바꾸기는 조회 문자열의 별칭으로도 할 수 있지만, 문자열 안의 별칭은 TypeScript 가 검사하지 않아요. 변환을 함수 한 곳에 두면 오타가 컴파일에서 잡힙니다. 사이즈 정렬과 빈 alt 를 상품명으로 채우는 일도 조회 문자열로는 못 하고요. 그래서 변환 함수가 한 곳에 있는 거예요.

누가 읽고 누가 그릴까 — 서버 컴포넌트와 ‘use client’ 경계?

App Router 의 컴포넌트는 기본이 서버 컴포넌트다. 조회는 서버에서 하고, 클릭과 선택이 있는 가장 작은 잎에만 ‘use client’ 를 붙여 그리기만 시킨다.

쇼핑몰로 치면, 주방과 홀이에요. 주방(서버) 이 재료를 꺼내 접시에 담고, 홀(브라우저) 은 접시를 받아 손님 앞에 놓고 주문을 받습니다. 홀 직원이 창고 열쇠를 들고 다니면 안 되죠.

제 상세 페이지의 구조는 이래요. 페이지 파일 하나에 함수가 둘 있습니다.

// app/(shop)/product/[id]/page.tsx — 요지만
export default function ProductDetailPage({ params }) {        // 동기 껍데기
    return (
        <main>
            <Suspense fallback={<div className="… bg-neutral-100" />}>
                <ProductDetail params={params} />             // 기다리는 일은 전부 여기
            </Suspense>
        </main>
    );
}

async function ProductDetail({ params }) {                     // 서버에서 조회
    const { id } = await params;
    const { data, error } = await supabase.from('products').select(/* … */).eq('id', id).maybeSingle();
    if (error) { if (error.code === '22P02') notFound(); throw error; }
    if (!data) notFound();
    const product = toProduct(data);
    return (<>{/* 갤러리 · 구매 패널 · 탭에 product 를 props 로 */}</>);
}

왜 하나를 둘로 나눴냐면요. Suspense(자식이 준비될 때까지 대신 보여 줄 화면을 두는 React 장치) 는 자기 안쪽 자식이 기다리는 것만 잡아요. 한 함수에서 await 를 다 끝낸 뒤에 Suspense 를 돌려주면, 그때는 이미 기다릴 게 없어서 장식이 됩니다. 10월 21일에 가장 오래 붙잡은 질문이었는데, 답은 문법이 아니라 순서였어요. 기다림이 시작되기 전에 Suspense 가 서 있어야 합니다. 이 프로젝트는 캐시 컴포넌트 설정이 켜져 있어서 껍데기에 await 가 있으면 빌드가 거절하기도 하고요.

왼쪽 서버 영역에 동기 껍데기와 Suspense 안의 async 조회 함수, 가운데 점선 경계, 오른쪽에 ‘use client’ 세 컴포넌트가 props 화살표로 이어진 그림 읽는 곳과 그리는 곳. 경계를 건너는 것은 값의 사본뿐이라 함수는 넘길 수 없다

실제 데이터로 세 컴포넌트가 받는 것을 적어 볼게요. 셋 다 파일 맨 위에 'use client' 가 있고, 셋 다 주소도 DB 도 몰라요.

컴포넌트 받는 props 자기 state 왜 클라이언트인가
DetailGallery images: string[], alt: string 고른 사진 번호 썸네일 클릭
DetailPurchasePanel product: Product 고른 옵션 id, 수량 사이즈 선택, 수량 증감, 담기 버튼
DetailTabs description: ReactNode 활성 탭 탭 전환, 스크롤 되돌리기

서버에서 클라이언트로 넘어가는 값은 글자로 바꿀 수 있어야 해요. Product 는 문자열·숫자·배열·일반 객체만 들어서 문제없고, 함수는 넘길 수 없습니다. 10월 16일에 구매 패널에 product: Product 를 적자마자 페이지 쪽에 “product 가 필요하다” 는 빨간 줄이 생겼던 것도 이 계약 덕이에요. 넘기는 쪽을 깜빡하는 실수를 타입이 먼저 알려 줍니다.

1:N 관계는 화면에서 무엇이 될까 — 갤러리, 사이즈 버튼, 빈 상태?

관계선 끝의 “0개 이상” 은 타입에서는 배열이고, 화면에서는 목록 요소와 빈 상태 한 줄이다. 0 이 오류가 아니라는 걸 화면이 알아야 한다.

쇼핑몰로 치면, 진열대예요. 사진이 한 장이면 썸네일 줄이 필요 없고, 사이즈가 다 팔렸으면 품절 표시가 있어야 하고, 문의가 하나도 없으면 “없다” 는 문장이 있어야 해요. 2편의 까마귀발 기호 o{ 가 화면에서는 이 세 가지로 풀립니다.

products 에서 product_images·product_options·product_inquiries 로 가는 세 열. 각 열에 배열 타입, 그리는 컴포넌트, 0개·1개·N개일 때의 화면이 적혀 있고 아래에 세 질문 1:N 을 화면으로 옮기는 세 질문. 0개일 때 무엇을, 고르는 상태는 무엇으로, 순서는 누가. 세 번째 열의 문의 표는 ③-2 에서 만든다

코드로는 세 자리예요. 변환 함수에서 배열을 만들고, 컴포넌트에서 map 으로 펼치고, 길이로 분기합니다.

// 변환: 이미지는 sort_order 순으로 이미 와 있고, 사이즈는 순서 열이 없어 여기서 정렬
images: row.product_images.map((img) => ({ url: img.image_url, alt: img.alt ?? row.name })),
options: row.product_options.map(/* … */).sort((a, b) => sizeRank(a.size) - sizeRank(b.size)),

// 화면: 길이로 분기하고, 고르는 상태는 표시 글자가 아니라 id 로
{images.length > 0 && <Image src={images[selected]} … />}     // 0장이면 회색 상자만
{images.length > 1 && /* 썸네일 줄 */}                           // 1장이면 고를 게 없다
{product.options.map((o) => <button disabled={o.stock === 0} onClick={() => setOptionId(o.id)}>…</button>)}

화면이 실제로 어떻게 생겼는지는 10월 19일 판으로 보여 드릴게요. 이 글을 쓰면서 그날의 커밋을 임시 작업본에 받아 개발 서버를 띄우고 /product/3 을 찍었습니다. 아직 mock(진짜 DB 대신 쓰는 가짜 데이터) 단계라 브랜드 칸에는 “브랜드명” 이라는 자리표시 글자가 그대로 있고, 사이즈 세 개와 가격은 코드 안의 상수예요. 그래도 사이즈가 버튼 목록이 되고 품절 하나가 비활성·취소선이 되는 모양, 고른 뒤에야 수량과 총액이 나타나는 순서는 이때 이미 정해져 있었어요. 10월 21일 DB 판은 팀 DB 에 붙어야 그려지는데 이 글을 쓰는 자리엔 로컬 DB 가 없어서 화면으로는 못 찍었습니다. 그쪽은 아래 상황표와 기록으로 대신해요.

10월 19일 mock 판 구매 패널 캡처. 브랜드명·상품명 자리표시, 정가 259,000 에 5% 할인 246,050, 사이즈 M·L·XL 중 M 이 선택되고 XL 은 취소선으로 비활성, 아래에 수량 1 과 총 상품 금액 246,050원, 담기·구매 버튼 실제 데이터. 10-19 mock 판의 구매 패널 (2026-10-22 재현 캡처). 브랜드명·상품명·가격·할인율은 전부 mock 예시값이고 실제 데이터가 아니다. 사이즈 목록·품절 비활성·선택 뒤 수량 노출은 이때 모양이고, 값만 10-21 에 DB 에서 오게 바뀐다

DB 판의 실제 데이터는 10월 21일에 만든 목업 30개예요. 화면이 받을 수 있는 “0개 이상” 의 경우를 일부러 다 넣었습니다. 아래는 그 기록에서 옮긴 상황표고, 그 기록 기준으로 이 시드는 PGlite(브라우저와 Node 에서 도는 PostgreSQL, 설치 없이 실행 가능) 로 두 번 실행해 오류 0 을 확인했지만 팀 DB 에는 아직 올리지 않은 상태였어요.

화면이 만나는 상황 목업 상품 번호 화면 규칙
일부 사이즈 품절 04 · 06 · 13 · 14 · 23 · 24 · 27 · 29 · 31 그 버튼만 비활성 + 취소선
전체 품절 07 (4사이즈) · 15 (FREE 하나) 고를 게 없어 담기 버튼 비활성
판매 종료 옵션 10 · 12 · 22 조회에서 걸러 버튼이 안 생긴다
판매 종료 상품 21 · 32 0줄 → 404
이미지 0장 · 5장 · alt 비움 · 역순 · 내린 사진 16 · 26 · 27 · 30 · 04 회색 상자 · 썸네일 줄 · 상품명으로 채움 · 순서 열 · 제외
숫자 · 특수 사이즈 09 · 10 · 23 · 24 · 30 정렬표에 없는 값은 맨 뒤

고르는 상태를 글자가 아니라 id 로 둔 이유도 적어 둘게요. 재고와 장바구니는 상품이 아니라 옵션(이 상품의 이 사이즈) 단위로 세요. 그래서 사용자가 누른 것은 “M” 이라는 글자가 아니라 그 옵션의 UUID 여야 하고, 나중에 장바구니에 보내는 값도 그겁니다. 위 10월 19일 캡처에서는 아직 글자 “M” 이 state 였고, 그게 10월 21일에 옵션 id 로 바뀐 자리예요.

주소는 어떻게 상품을 가리킬까 — [id], params, notFound?

대괄호 폴더가 주소의 한 조각을 이름 붙은 변수로 받고, 그 값은 언제나 문자열이며, Promise 로 오므로 열어야 읽는다. 있는지 없는지는 DB 가 판정한다.

쇼핑몰로 치면, 매장 안내 번호예요. “상품 ___번” 의 빈칸에 무엇이 오든 같은 상세 페이지가 받습니다. 그런데 손님이 적어 낸 번호가 번호의 모양조차 아닐 수도 있고, 모양은 맞는데 없는 상품일 수도 있어요. 둘은 다른 경우예요.

기호는 폴더 이름이에요. app/(shop)/product/[id]/page.tsx 의 [id] 가 /product/a0000000-…-0001 의 뒷부분을 id 로 받습니다. 그리고 이 값은 params 라는 Promise(나중에 값이 도착하는 상자) 에 담겨 와요. 그래서 const { id } = await params 로 열어야 하고, 그래서 조회 함수가 async 입니다.

주소에서 [id] 폴더, params Promise, 조회를 거쳐 22P02·0줄·1줄·그 밖의 오류 네 갈래로 나뉘고 각각 notFound·notFound·화면·오류 화면으로 가는 그림 주소가 상품을 가리키는 길. “없음” 과 “오류” 는 따로 둔다

실제 데이터는 10월 20일에 팀 DB 에 공개 키로 직접 던져 본 기록이에요. 처음엔 주소의 id 가 UUID 모양인지 코드로 미리 검사하려 했는데, id 열이 uuid 타입이라 PostgreSQL 이 이미 검사하고 있었습니다.

조건 결과 (10-20 팀 DB 기록) 페이지의 처리
id = 'abc' · '1' · 한 글자 모자란 UUID HTTP 400 · 22P02 invalid input syntax for type uuid notFound()
대문자 UUID · 하이픈 없는 32자리 HTTP 200 · 빈 배열 0줄이라 notFound()
모양이 맞고 있는 상품 HTTP 200 · 1줄 변환 후 화면
name = 'abc' (문자열 열) HTTP 200 · 빈 배열 (비교용) 문자열은 변환이 필요 없어 그냥 0줄

22P02 는 “검색했는데 없음” 이 아니라 “비교를 시작하기도 전의 거절” 이에요. WHERE id = 'abc' 를 평가하기 전에 'abc' 를 16바이트 uuid 로 바꾸려다 실패하니까요. 그래서 error 로 오고, 페이지는 그 코드 하나만 404 로 바꿉니다. 그 밖의 오류는 그대로 던져요. “없음” 과 “오류” 를 합쳐서 404 로 보내면 DB 장애가 “상품 없음” 으로 보여 원인을 못 찾거든요.

그 전 판에서는 같은 abc 가 다른 길로 404 가 됐어요. 10월 19일 판은 mock 의 id 가 숫자라 Number(id) 로 바꿔 비교했고, 'abc' 는 NaN 이 돼서 아무것도 못 찾고 404 였죠. 이 글을 쓰면서 그 판을 다시 띄워 확인했더니 응답 코드도 진짜 404 였습니다. 조회를 끝낸 뒤에 notFound() 를 부르고 그다음에 Suspense 를 돌려주는 구조라, 응답 머리가 나가기 전에 404 가 정해졌거든요. 10월 21일 DB 판은 기록상 화면은 404 인데 응답은 200 이에요. 왜 달라졌는지는 함정 절에서요.

터미널 출력과 화면. 10-19 mock 판을 다시 띄워 curl 로 /product/3 은 200 OK, /product/abc 는 404 Not Found 가 찍히고, 아래에 브라우저의 404 This page could not be found 화면 실제 데이터. 10-19 mock 판의 없는 번호 처리 (2026-10-22 재현). DB 판이 되면서 “없음” 의 판정은 NaN 에서 22P02 와 0줄로 옮겨 간다

네 부품은 어떤 규칙으로 맞물릴까?

부품을 따로 보면 네 개의 요령 같지만, 합치면 규칙은 하나예요. 같은 약속이 네 층을 통과한다는 것.

DB 제약·조회·타입·변환과 화면 네 층이 화살표로 이어지고, 각 층이 무엇을 언제 막는지 적힌 그림. 아래에 원칙 세 줄 네 부품이 맞물리는 규칙. 규칙은 DB 에서 먼저 막고, 타입은 DB 가 보장하는 만큼만 좁힌다

읽기 경로에서 타입이 두 벌인 이유를 같은 항목으로 견주면 이렇게 돼요.

항목 Row 타입 (ProductRow) 화면 타입 (Product)
누구의 말인가 DB 의 말 (base_price, image_url) 화면의 말 (basePrice, url)
모양을 정하는 것 조회 문자열의 select 와 중첩 컴포넌트가 받고 싶은 것
null 이 있는 자리 응답에서 빠질 수 있는 곳 전부 (brands | null, alt | null) 변환에서 채워 없앤 뒤 (brand: string, alt: string)
배열의 순서 DB 가 보낸 순서 (이미지는 sort_order) 변환이 정한 순서 (사이즈는 sizeRank)
누가 쓰나 조회 함수와 변환 함수만 세 ‘use client’ 컴포넌트의 props
누가 검사하나 .overrideTypes 로 TypeScript 에게 모양만 알려 준다 (DB 에 안 감) TypeScript 가 컴파일에서 (image_url 오류가 그 증거)

총정리 표는 ERD 요소에서 출발해요. 2편의 체크리스트 열이 이쪽에서 무엇이 되는지 한 줄씩이에요.

ERD 요소 타입 코드 위치 화면
uuid PK string 주소 [id] · DB 가 형식 판정 형식 오류·없음 둘 다 404
NOT NULL FK (N:1) 객체 하나, 응답 대비 | null 조회의 중첩 select, 변환의 ?? '' 빈 문자열이어도 안 깨진다
1:N “0개 이상” 배열 변환에서 정렬 map + 빈 상태 한 줄
NULL 열 | null 변환에서 채움 상품명으로 대체된 alt
CHECK 허용값 문자열 유니온 타입에만 (실행 중 검사 없음) 배송 방식별 안내 분기
논리 삭제 타입에 없음 조회 조건 .is('deleted_at', null) 사라진 사진·옵션·상품

상품 상세를 체크리스트로 풀면?

판정 항목을 네 개로 정하고, 상세 페이지의 요소 전부에 같은 열로 적용해 볼게요.

  1. 출처: 어느 표의 어느 열·관계에서 오는가
  2. 타입: Row 와 화면 타입에서 어떤 모양인가 (| null · 배열 · 유니온)
  3. 자리: 누가 읽고(서버) 누가 그리나(클라이언트)
  4. 빈 값·실패: 0개 · null · 없음 · 오류일 때 화면은 무엇인가
화면 요소 ① 출처 ② 타입 ③ 자리 ④ 빈 값·실패
브레드크럼의 분류명 products.category (문자열) string 서버가 그린다 없을 수 없다 (NOT NULL)
브랜드명 brands.name (N:1) {name} | null → string 서버 조회 → 패널 props null 이면 빈 문자열
갤러리 product_images (1:N) {url, alt}[] 서버 조회 → 갤러리 props 0장 회색 상자, 1장 썸네일 숨김
정가·판매가·할인 base_price · sale_price · 계산 열 number ×3 패널 할인 0 이면 취소선·% 숨김
사이즈 버튼 product_options (1:N) {id, size, stock, …}[] 패널 (optionId state) 재고 0 품절, 전부 품절이면 담기 비활성
수량·총액 고른 옵션의 stock · extra_price 계산값 패널 (quantity state) 안 골랐으면 - 표시

표를 채우니 두 가지가 보여요. 먼저 ④ 열이 한 칸도 비지 않는다는 것. 2편에서 관계선의 최소가 “0” 인 선이 많았는데, 그 0 들이 전부 화면의 빈 상태 한 줄이 됐어요. 다음으로 ③ 열에서 “서버 조회” 가 한 곳이라는 것. 여섯 줄 모두 읽는 곳은 페이지의 조회 함수 하나고, 그리는 곳만 패널·갤러리·탭으로 갈려요. 문의 탭의 목록은 표가 아직 없어 이 표에서 뺐고, 실제로 만들고 돌려 본 뒤 ③-2 에서 적습니다.

처음 만든 것은 무엇이 틀렸고 어떻게 고쳤나?

이제 처음으로 돌아갈게요. 10월 16일에 저는 mock 데이터로 상세 페이지에 처음 데이터를 흘렸어요. 메인 카드를 누르면 /product/3 으로 가고, 그 번호로 상품을 찾아 제목과 사진을 보여 주고, 없는 번호는 404 였죠. 위의 두 캡처가 그 판에 탭을 붙인 10월 19일 모습이에요. 그날은 꽤 뿌듯했습니다. 그런데 2편의 “전부 UUID” 결정을 들고 10월 20일에 돌아와 보니 여섯 자리가 어긋나 있었어요.

왼쪽에 10-16 mock 판, 오른쪽에 10-21 DB 판을 여섯 행으로 놓고 주소·조회 위치·1:N·N:1·값·없음 여섯 화살표로 이은 그림 여섯 자리가 바뀌었다. 로직이 바뀐 게 아니라 데이터 구조가 코드에 들어온 것이다

자리 전 (10-16) 무엇이 틀렸나 후 (10-21)
주소의 id Number(id) 로 숫자와 비교 UUID 는 숫자가 아니다. 그대로 두면 모든 id 가 NaN 이 되어 상품이 전부 404 문자열 그대로 .eq('id', id), 형식 판정은 DB
조회 위치 api/getProduct 가 찾기와 변환을 함께 팀 규칙에 api 폴더가 없고, 조회를 액션에 두면 공개 주소가 된다 페이지의 서버 컴포넌트가 직접 조회, 모양은 types, 변환은 utils
이미지 문자열 한 장을 한 장짜리 배열로 표는 0장 이상이고 alt 와 순서가 있다 {url, alt}[], 순서는 조회가, 내린 사진은 제외
브랜드 칸을 일부러 비움 mock 에 브랜드가 없어서였고, 표에는 N:1 로 있다 중첩 조회 + {name} | null + 변환에서 ''
가격·사이즈 상수 BASE_PRICE, SIZES 데이터가 와도 상수는 안 바뀐다 받은 product 의 값만 보여 준다
없음 undefined 면 notFound 하나 “없음” 과 “오류” 가 구분되지 않는다 형식 오류 → 404, 0줄 → 404, 그 밖은 throw

여섯 개를 한 번에 고친 건 아니에요. 두 날의 기록을 따라가면 순서가 있습니다. 10월 20일은 결정의 날이었어요. 먼저 팀의 폴더 규칙을 확인하면서 조회 위치가 정해졌고(api 폴더 없음, 페이지가 직접 조회), 그다음 공개 키로 팀 DB 에 abc 같은 값을 던져 보면서 “없음” 과 “오류” 를 가르는 기준이 22P02 로 정해졌고, 마지막으로 브랜드 칸을 | null 로 둘지가 결정됐어요. 그날 작업본에서 타입 검사는 통과했지만 화면은 못 봤습니다. 그 사이에 원인 다섯 겹이 있었거든요. 10월 21일은 코드의 날이었어요. Row 타입과 변환 함수를 먼저 써서 이미지가 배열이 되고 브랜드가 채워졌고, 페이지를 껍데기와 조회 함수 둘로 나눴고, 그 과정에서 갤러리의 image_url 실수를 냈다가 잡았고, 맨 마지막에 구매 패널의 상수 가격과 사이즈를 받은 값으로 바꿨어요. 주소의 Number() 는 페이지를 새로 쓰면서 같이 사라졌고요. 타입에서 화면 쪽으로, 안에서 바깥으로 간 순서였고, 그래서 바깥쪽인 패널을 고칠 때는 타입이 이미 뭘 넘겨 줄지 알고 있었습니다.

고치면서 제일 크게 배운 건 로직이 거의 안 바뀌었다는 거예요. 변환 함수의 모양도, 404 를 라우트가 결정한다는 원칙도, 갤러리가 배열을 받는다는 결정도 10월 16일 그대로였어요. 바뀐 건 그 자리에 들어오는 값의 출처와 모양뿐이었고, 그래서 바꾸는 데 하루면 됐습니다. mock 단계에서 미리 해 둘 일은 로직을 완성하는 게 아니라, 나중에 바뀔 자리를 데이터 구조의 모양대로 비워 두는 거예요. 10월 16일에 갤러리를 images: string[] 로 받게 하고 번호만 state 에 담아 둔 덕에, 10월 21일에 상태 코드는 한 글자도 안 바꿨어요.

반대로 못 미리 해 둔 것도 있어요. 최대 혜택가는 정가의 82% 로 계산하는 임시값인데 DB 에 쿠폰 데이터가 없어서 그대로 남겨 뒀고, 할인 없는 상품에도 18% 로 보여요. 위 10월 19일 캡처의 “18% 212,380원” 이 그 자리인데, 이 숫자 역시 mock 예시값이지 실제 혜택가가 아니에요. 설계 문서에는 “제거” 로 적혀 있어 문서와 코드가 아직 다릅니다.

직접 겪은 함정은 무엇이었나?

1. 변환 뒤에 DB 이름을 쓴다. 이 글의 첫 문장이에요. 위의 타입 검사 출력이 재현 근거고, 규칙은 하나입니다. DB 이름(image_url, base_price) 은 조회 문자열, Row 타입, 변환 함수 안에서만 쓴다.

2. “전부 UUID” 는 화면 쪽에서 Number() 를 지우는 일이었다. 2편 끝에서 예고한 첫 함정이에요. 10월 16일의 Number(id) 는 숫자 id 에서만 맞는 코드였고, UUID 가 오면 전부 NaN 이 돼요. 10월 21일에 걷어 내고 형식 판정을 DB 에 맡겼습니다. 대가도 있어요. 형식이 틀린 주소에서도 DB 요청이 한 번 나갑니다.

3. NOT NULL 인데 null 이 온다. 10월 20일 기록이에요. 데이터의 필수와 응답의 누락은 다른 층의 이야기였고, | null 을 남길지 조회에서 !inner 로 막을지는 코드 한 줄이 아니라 전략이었어요. 지금은 남기는 쪽입니다.

4. 원인이 다섯 겹이면 오류가 바뀌는 게 진전이다. 10월 20일에 팀 저장소를 건드리지 않으려고 작업본을 따로 복사해 개발 서버를 띄웠는데, 상세 주소에서 데이터가 안 나왔어요. 그날 기록에 따르면 원인은 다섯 겹이었고, 한 겹을 걷어 낼 때마다 다른 오류가 나타났습니다. 그 순서대로 적어요. “그때 의심한 것” 열은 그 오류가 가리키는 것처럼 보였던 자리이고, 기록에는 증상과 원인만 있어서 그 열은 제 기억으로 채웠어요.

겹 그때 본 것 (10-20 기록) 그때 의심한 것 실제 원인 고친 것
1 localhost:3000/b0000000-… 이 404 (경로 없음) 페이지 파일이 안 잡히나 주소에 /product/ 가 없었다 주소에 /product/ 를 넣었다
2 주소를 고쳐도 상품이 안 나온다 조회 조건이 틀렸나 b0000000-… 은 브랜드 id 였다. 상품은 a0000000-… 으로 시작한다 (시드 SQL 과 대조) 상품 id 로 바꿨다
3 500 · ReferenceError 새로 쓴 조회에 오타가 있나 페이지가 그날 지운 getProduct 를 아직 부르고 있었다 페이지가 직접 조회하게 바꿨다 (이슈 3·4 의 결정)
4 팀의 연결 파일을 가져오는 순간 URL and API key are required 키 값을 잘못 붙여 넣었나 제 .env.local 은 SUPABASE_URL, 팀 파일은 NEXT_PUBLIC_SUPABASE_URL 을 읽는다. 빈 문자열이 들어갔다 키 이름을 NEXT_PUBLIC_ 으로 고쳤다
5 42703 column products.deleted_at does not exist 조회 문자열의 오타인가 팀 DB 의 products 에 deleted_at 이 없었다. 노트의 DDL 과 실제 DB 가 달랐다 (공개 키로 열 하나씩 ?select={열}&limit=1 대조) ALTER TABLE … ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ 뒤 스키마 재적재

다섯 겹을 다 걷어 낸 뒤에도 화면은 비어 있었어요. 공개 키로 네 표를 읽으니 전부 0줄이었고, 시드가 없는 건지 읽기 정책이 막는 건지 공개 키로는 구분할 수 없었습니다. 그건 10월 21일에 읽기 정책을 열고서야 풀렸고(기록상 브랜드 2줄·상품 3줄·이미지 6줄·옵션 5줄), 그 실측은 4편의 몫이에요. 겹겹이 막힌 문제에서 “오류 메시지가 달라졌다” 는 후퇴가 아니라 한 겹을 통과했다는 신호예요. 메시지가 바뀔 때마다 그 메시지를 적어 두면 그게 곧 원인 목록이 됩니다.

5. Suspense 안의 notFound 는 화면은 404, 응답은 200 이다. 10월 21일 기록이에요. 스트리밍이 시작된 뒤라 응답 머리가 이미 나간 상태라서요. 위에서 재현한 10월 19일 판이 진짜 404 를 돌려준 것과 정확히 반대인데, 그 판은 Suspense 바깥에서 조회를 끝내고 notFound() 를 불렀기 때문이에요. 껍데기와 조회 함수를 나누면서 얻은 “먼저 그리기” 의 값이 “응답 코드” 였던 셈입니다. 검색엔진이나 모니터링이 중요해지면 빨리 판정할 수 있는 404 를 Suspense 바깥에서 먼저 거르는 걸 검토해야 합니다. 같은 기록에서 HTML 에 404 문구가 들어 있는 것까지는 확인했지만 DB 판의 브라우저 화면으로는 아직 못 봤어요.

6. 빈칸을 찾을 땐 .is, 값을 찾을 땐 .eq. 10월 21일 팀 DB 기록이에요. deleted_at 을 .eq(null) 로 하면 "null" 이라는 글자를 날짜로 바꾸려다 22007 오류가 나고, 문자열 열에 .eq(null) 을 하면 오류 없이 이름이 글자 그대로 “null” 인 상품을 찾아요. 두 번째가 제일 위험합니다.

요약

프론트엔드는 데이터 구조를 새로 정하지 않는다. 표의 열을 Row 타입에, 화면의 말을 화면 타입에 옮겨 적고, 둘 사이의 변환은 한 함수에서만 한다.

읽기는 서버 컴포넌트가, 그리기는 이벤트가 있는 가장 작은 ‘use client’ 잎이 한다. 경계를 건너는 것은 값의 사본뿐이다.

관계선의 “0개 이상” 은 배열이고 빈 상태 한 줄이다. 고르는 상태는 표시 글자가 아니라 id 로 든다.

주소의 조각은 문자열이고 Promise 로 온다. 있는지 없는지는 DB 가 판정하며, “없음” 과 “오류” 는 따로 둔다.

저는 이제 화면 코드를 쓸 때 ERD 를 옆에 띄워 놓고, 열 하나를 옮길 때마다 “이 값이 비면 화면은 무엇을 보여 주나” 를 먼저 적어요. 그게 이번 열흘이 남긴 습관입니다.

여기까지가 ③-1 이에요. 여덟 표가 타입, 경계, 목록 요소, 주소가 되는 규칙을 봤습니다. 다음 글 ③-2 쓰기 경로 는 반대 방향, 상품 문의 폼이 표에 닿기까지예요. 폼의 name 이 서버의 키가 되고, 실행 중 검증이 타입 단언을 대신하는 자리를 같은 방식으로 다룹니다. 그다음 4편이 프론트와 백엔드 연결, 서버와 브라우저의 두 연결 객체, 환경 변수, 권한 정책의 실측, 배포까지 실제로 잇는 과정이에요. 이 글에서 “기록에 따르면” 으로만 쓴 권한 정책의 빈 응답이 4편의 첫 함정이에요.

출처 — 개념은 수업의 React·Next.js·TypeScript 강의와 수업 가이드에서 배운 것을 제 말로 다시 썼다. 타입 검사는 2026-10-22 에 팀 브랜치의 작업본(Next.js 16.4, TypeScript 5) 에서 직접 얻었고, 10-19 mock 판의 구매 패널과 404 응답은 같은 날 그 커밋을 임시 작업본에 받아 개발 서버로 다시 띄워 찍었다. 날짜만 적은 기록은 10-16 · 10-19 · 10-20 · 10-21 작업일지다.

관련 키워드

Next.js App RouterTypeScript 타입 설계서버 컴포넌트use client동적 라우트Supabase 조회쇼핑몰 상세 페이지팀 프로젝트

앤디의 마케팅노트

마케팅을 시스템으로 만드는 과정을 매주 기록해요. 잘 된 것도, 안 된 것도 같이 봐요.

maily에서 구독하기