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편까지 하고 나니 결정은 다 돼 있었어요. 상품은 브랜드 정확히 하나를 가리키고, 이미지와 사이즈는 0개 이상이고, 키는 전부 UUID 고, 판매 종료는 지우지 않고 deleted_at 에 날짜를 적는다. 그런데 이 결정들이 화면 코드에 저절로 들어오지는 않아요. 누군가 옮겨 적어야 하고, 옮겨 적는 자리가 여러 곳이라 하나라도 빠뜨리면 그 자리에서만 조용히 어긋납니다. 10월 20일에 데이터가 안 나오던 원인이 다섯 겹이었던 것도 이 때문이었어요. 주소, 아이디 종류, 지운 함수, 환경 변수 이름, 그리고 표에 없던 열. 앞의 문제를 걷어 내기 전에는 뒤의 문제가 보이지도 않았습니다. 그 다섯 겹은 함정 절에서 한 겹씩 표로 다시 봐요.
읽기 경로는 표에서 화면으로 갑니다. 표 → 조회 → Row 타입(응답 한 줄의 모양) → 변환 함수 → 화면 타입 → props(부모 컴포넌트가 자식에게 건네는 값). 조회의 입력은 주소에서 오고, 조회 결과가 0줄이면 404 로 끝나요. 반대 방향, 폼에서 표로 가는 쓰기 경로는 이 글에서 다루지 않습니다. 상품 문의 폼이 표에 닿기까지는 ③-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 |
타입에 없음 | 조회 조건으로 걸러서 응답에 안 온다 |
표의 열이 타입의 필드가 되는 규칙. 가운데가 Row 타입, 오른쪽이 화면 타입이고 둘 사이가 변환 함수다
브랜드 줄이 이상해 보일 거예요. 2편에서 brand_id 는 NOT NULL 외래키였으니 상품에는 브랜드가 반드시 하나 있어요. 그런데 Row 타입에는 | null 이 붙어 있죠. 10월 20일에 한참 고민한 자리입니다. 데이터로는 비어 있을 수 없지만, 응답에서는 빠질 수 있어요. 브랜드 표의 읽기 권한 정책이 그 행을 막으면 상품은 오고 브랜드만 null 로 옵니다. 그러니까 이 | null 은 “브랜드 없는 상품” 이 아니라 “응답에서 안 보일 수 있음” 에 대한 대비예요. 변환 함수에서 ?? '' 로 빈 문자열을 채우고, 화면 타입의 brand 는 그냥 string 으로 둡니다. 타입은 DB 가 보장하는 만큼만 좁히고, DB 가 보장하는데 응답에서만 빠질 수 있는 값은 조회에서 막거나 타입에 null 을 남기고 변환에서 채운다. 둘 중 하나는 반드시 한다.
실제 데이터예요. 이 글을 쓰면서 팀 브랜치의 작업본에서 타입 검사를 돌렸습니다. 먼저 그대로 돌리니 오류가 0이었고, 그다음 일부러 두 줄을 틀린 파일을 넣었어요. 하나는 브랜드를 있다고 가정한 줄, 하나는 10월 21일에 제가 실제로 냈던 실수, 화면 타입에 DB 이름을 쓴 줄입니다.
실제 데이터. 위 표의 ③ 행(객체 또는 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 가 있으면 빌드가 거절하기도 하고요.
읽는 곳과 그리는 곳. 경계를 건너는 것은 값의 사본뿐이라 함수는 넘길 수 없다
실제 데이터로 세 컴포넌트가 받는 것을 적어 볼게요. 셋 다 파일 맨 위에 '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{ 가 화면에서는 이 세 가지로 풀립니다.
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 판의 구매 패널 (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 입니다.
주소가 상품을 가리키는 길. “없음” 과 “오류” 는 따로 둔다
실제 데이터는 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 판의 없는 번호 처리 (2026-10-22 재현). DB 판이 되면서 “없음” 의 판정은 NaN 에서 22P02 와 0줄로 옮겨 간다
네 부품은 어떤 규칙으로 맞물릴까?
부품을 따로 보면 네 개의 요령 같지만, 합치면 규칙은 하나예요. 같은 약속이 네 층을 통과한다는 것.
네 부품이 맞물리는 규칙. 규칙은 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) |
사라진 사진·옵션·상품 |
상품 상세를 체크리스트로 풀면?
판정 항목을 네 개로 정하고, 상세 페이지의 요소 전부에 같은 열로 적용해 볼게요.
- 출처: 어느 표의 어느 열·관계에서 오는가
- 타입: Row 와 화면 타입에서 어떤 모양인가 (
| null· 배열 · 유니온) - 자리: 누가 읽고(서버) 누가 그리나(클라이언트)
- 빈 값·실패: 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) | 무엇이 틀렸나 | 후 (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 조회쇼핑몰 상세 페이지팀 프로젝트