Skip to content

BackEnd‐Data

LeeSongMi edited this page Oct 9, 2026 · 3 revisions

🗃️ Data

DB 스키마를 타입의 기준으로 사용

데이터 타입을 직접 선언하면 DB 스키마와 TypeScript 타입을 따로 관리해야 하고, 둘 중 하나만 바뀌면 불일치가 생긴다.

그래서 Prisma 스키마를 데이터 구조의 단일 기준으로 두고, Prisma가 생성한 모델 타입을 애플리케이션 전체에서 그대로 사용한다.

// 프론트엔드에서 interface Product를 따로 선언하지 않고, Prisma가 생성한 타입을 그대로 사용
import type { Product } from '@/generated/prisma/client';
schema.prisma 수정
 └─ prisma generate
     └─ Prisma 타입 + Zod 스키마 재생성
         └─ 해당 타입을 쓰는 코드에서 컴파일 에러로 변경 지점 확인

가격은 Int

원화는 소수점이 없으므로 가격을 Int(원 단위)로 저장한다. Decimal은 객체라서 Server Component에서 Client Component로 전달할 수 없고, JSON으로 보내면 문자열이 된다.


입력값 검증 — Zod 자동 생성

TypeScript 타입은 실행 시점에 사라지기 때문에, 주문서나 관리자 상품 등록처럼 사용자가 보낸 값은 서버에서 따로 검증해야 한다.

Zod 스키마를 직접 작성하면 Prisma 스키마와 같은 정의를 두 번 관리하게 되므로, prisma-zod-generator로 schema.prisma에서 Zod 스키마를 자동 생성한다. 검증 규칙도 schema.prisma 주석에 작성한다.

model Product {
  id    Int    @id @default(autoincrement())
  /// @zod.min(1)
  name  String
  /// @zod.positive()
  price Int
}
데이터 방향 담당
DB → 화면 (조회 결과) Prisma 생성 타입
사용자 → DB (폼 입력) Prisma 스키마에서 생성된 Zod 스키마로 Server Action에서 검증

Zod 스키마가 DB 구조와 어긋나면 prisma.product.create({ data: parsed }) 호출부에서 컴파일 에러가 나므로, 사람이 두 정의를 맞춰 볼 필요가 없다. 자동 생성된 스키마가 의도대로 검증하는지는 Jest 테스트로 확인한다.


PostgreSQL 선택

Prisma 7은 PostgreSQL과 MySQL을 모두 지원한다. 다만 차세대 버전인 Prisma 8은 PostgreSQL을 먼저 지원하며, Data Contract(contract.d.ts)를 기준으로 타입을 관리하는 구조를 제공한다.

Prisma의 최신 타입 시스템과 migration 구조로 넘어갈 수 있도록 PostgreSQL을 선택했다. Prisma 8은 아직 RC 단계라, 개발은 Prisma 7로 진행한다.


데이터 정의

모델 3개(schema.prisma)로 쇼핑몰과 관리자가 같은 데이터를 쓴다. 상태 값(카테고리 · 주문 상태 · 결제 수단)은 enum으로 두었다.

모델 역할 결정한 점
Product 상품 slug(주소용, unique), 가격 Int, 검색용 영문 부제 nameEn, 원재료 · 보관 · 알레르기 안내, isBest, salesCount(아래 참고)
Order 비회원 주문 orderNo(YYYYMMDD-NNNN, 한국 시간 기준 일자별 일련번호)로 조회. 상태 5단계(접수 · 배송 준비중 · 배송중 · 배송 완료 · 주문 취소)
OrderItem 주문 상품 줄 주문 시점의 상품명 · 단가를 스냅샷으로 저장. 상품이 수정 · 삭제돼도 주문 내역이 유지되도록 productId는 삭제 시 null(SetNull)

주문 생성

  • 클라이언트가 보낸 가격은 쓰지 않는다. 상품 id로 DB 가격을 읽어 소계 · 배송비(30,000원 이상 무료) · 합계를 서버에서 다시 계산한다. 결제창에 보여 주는 금액도 같은 계산(priceOrder)을 쓴다.
  • 주문번호는 같은 날 마지막 번호 + 1로 만든다. 동시에 주문이 들어와 번호가 겹치면(unique 충돌) 최대 3번 다시 시도한다.
  • Order · OrderItem · 판매수 갱신은 한 트랜잭션이다.

비회원 주문조회

주문번호와 주문자 연락처가 모두 맞아야 상세를 볼 수 있다.

  1. Server Action이 두 값을 DB와 대조한다. 틀리면 원인을 구분하지 않고 같은 메시지를 돌려준다.
  2. 맞으면 해당 주문 상세 경로에만 실리는 httpOnly 쿠키에 서명된 토큰(만료시각.서명)을 10분 동안 둔다.
  3. 상세 화면은 쿠키를 다시 검증한 뒤, 이름 · 연락처 · 주소를 서버에서 마스킹한 주문만 내려보낸다.

서명 키는 "주문번호 + DB에 저장된 연락처"로 만든다. 연락처를 모르면 토큰을 만들 수 없으므로, 주문번호만 알거나 쿠키 값을 지어내서는 들어갈 수 없다. 별도 비밀 환경 변수도 필요 없다.


파생 데이터(판매수) 저장 위치

인기순 정렬은 "많이 팔린 순"이다. 판매수는 주문 상품(OrderItem)의 수량 합계에서 나오는 값인데, 이를 어디에 둘지 세 가지를 비교했다.

방식 장점 단점
조회마다 집계 (OrderItem GROUP BY) 항상 정확, 컬럼 없음 목록을 열 때마다 주문 전체를 훑음. 주문이 쌓일수록 느려짐
Product에 salesCount 저장 (선택) 정렬이 컬럼 하나 읽기. 구현이 단순 파생값이라 원본과 어긋날 수 있음 → 다시 계산하는 길이 필요
별도 통계 테이블 (ProductStats) 상품 정보와 통계의 변경 주기를 분리 테이블 · 조인 추가. 지금 규모에는 과함

선택: Product에 저장하고, 원본은 OrderItem으로 유지

  • 원본은 OrderItem이다. salesCount는 정렬을 위한 복사본이라, 언제든 pnpm db:recount(차이만 보고) / pnpm db:recount --fix(바로잡기)로 주문 기준에서 다시 계산할 수 있다. 취소된 주문은 세지 않는다.
  • 갱신은 한 함수로만 한다(applySalesDelta). 주문 생성 트랜잭션 안에서 호출하므로 주문은 저장됐는데 판매수만 빠지는 일이 없다. 주문 취소 · 복원 때도 같은 함수를 부호만 바꿔 쓴다.
  • SQL 한 줄로 갱신한다 (SET "salesCount" = GREATEST("salesCount" + n, 0)). 두 가지 이유가 있다.
    • Prisma update는 @updatedAt을 같이 바꿔서, 상품이 팔릴 때마다 「상품 정보 수정일」이 바뀐다. 실제로 확인했다.
    • n = n + x는 DB가 원자적으로 처리해 동시 주문에도 값이 틀리지 않는다.
  • 상품 번호순으로 갱신한다. 여러 상품이 든 주문 둘이 동시에 들어올 때 서로의 행 잠금을 기다리다 교착(데드락)에 빠지지 않게 하기 위해서다.
  • 인덱스는 두지 않는다. 상품은 수십 개 규모라 전체를 읽어 정렬해도 비용이 작고, 인덱스는 판매될 때마다 갱신 비용만 늘린다. 상품이 수만 개가 되면 (salesCount DESC) 인덱스를 추가한다.
  • 정렬 기준: 인기순은 salesCount 내림차순 → 같으면 최신 등록순 → 마지막에 id로 고정해 결과 순서가 항상 같게 한다. 목록 캐시에는 product-sales 태그를 달고, 주문이 생기면 updateTag('product-sales')로 갱신한다.
  • 판매수를 정렬 외의 통계(기간별 · 일별)로도 쓰게 되면 ProductStats로 옮긴다.

💻 Code
📎 Reference

🖥️ FrontEnd

⚙️ BackEnd

📏 Project

Clone this wiki locally