이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 데이터 타입 선택은 왜 성능과 정합성에 영향을 주는가?
  • 금액, 시간, 상태값, 식별자는 어떤 기준으로 저장해야 하는가?
  • 제약 조건은 애플리케이션 검증과 어떻게 역할을 나눠야 하는가?

개요

데이터 타입은 저장 방식 이상의 의미를 가진다. 타입은 값의 범위, 정렬, 인덱스 크기, 연산 정확도, 검증 위치를 결정한다. 컬럼을 전부 문자열로 두면 초기 개발은 편하지만 운영 데이터 품질이 무너진다.

제약 조건은 DB가 지키는 마지막 계약이다.

원리

데이터 타입과 제약 선택의 원리는 컬럼이 “값을 담는 통”이 아니라 DB와 애플리케이션 사이의 계약이라는 점이다. 타입은 저장 범위, 정렬 순서, 인덱스 크기, 연산 정확도, driver 매핑, migration 난이도를 결정하고, 제약은 어떤 코드 경로로 들어와도 깨지면 안 되는 하한선을 정한다.

백엔드 개발자는 Java 타입, DTO 검증, JPA mapping, DB column type, constraint name을 한 세트로 봐야 한다. String으로 받는 API 파라미터가 DB에서는 bigint인지, 금액을 double로 계산하는지, timezone을 어디서 변환하는지 같은 작은 선택이 운영 데이터 품질을 좌우한다.

금액

금액은 부동소수점으로 저장하지 않는다.

-- 좋지 않다
price double precision

정수 최소 단위나 decimal 계열을 사용한다.

price_amount bigint NOT NULL, -- 원 단위

또는 소수 통화가 필요하면 numeric/decimal을 검토한다.

price_amount numeric(19, 4) NOT NULL

Java에서도 double보다 BigDecimal이나 long minor unit을 쓴다.

금액 컬럼에는 통화 단위도 함께 설계한다. 단일 원화 서비스라면 amount_krw처럼 의미를 드러낼 수 있고, 다중 통화라면 currency와 minor unit 정책이 필요하다. “숫자 하나”만 저장하면 나중에 환율, 반올림, 통화별 소수 자리에서 문제가 생긴다.

시간

시간은 timezone 정책을 정해야 한다.

created_at timestamp NOT NULL DEFAULT now()

PostgreSQL에서는 timestamp with time zonetimestamp without time zone의 의미를 이해해야 한다. 애플리케이션에서는 UTC 저장, 사용자 timezone 변환 같은 정책을 명확히 한다.

이름도 중요하다. created_at은 보통 시스템 기록 시각이고, scheduled_for, business_date, settled_at은 서로 다른 의미다. 날짜만 필요한 업무 기준일을 timestamp로 섞어 저장하면 timezone 변환에서 하루가 밀리는 장애가 생길 수 있다.

기간 검색은 함수보다 범위 조건을 사용한다.

WHERE created_at >= :from
  AND created_at < :to

상태값

상태값은 단순 문자열보다 가능한 값을 제한하는 방식이 좋다.

ALTER TABLE orders
ADD CONSTRAINT chk_orders_status
CHECK (status IN ('CREATED', 'PAID', 'CANCELED'));

상태 전이가 복잡하면 별도 상태 모델과 전이 검증이 필요하다. DB check는 가능한 값의 범위를 막고, 애플리케이션은 전이 규칙을 관리한다.

DB enum은 읽기 쉽지만 값 추가/삭제가 migration이 된다. 문자열 + check constraint는 변경 절차가 명시적이고, 코드 테이블은 운영에서 값을 관리하기 좋지만 join과 관리 UI가 필요하다. 상태값 선택은 변경 빈도와 운영 통제 수준을 기준으로 고른다.

식별자

PK는 내부 식별자와 외부 노출 식별자를 구분한다.

  • 내부 PK: join과 FK에 사용되는 안정적 식별자
  • 외부 ID: 사용자에게 노출되는 주문 번호, slug, uuid 등

자동 증가 ID를 외부에 그대로 노출하면 추측 가능성과 enumeration 문제가 생길 수 있다. 모든 서비스에서 문제가 되는 것은 아니지만, 보안과 UX 요구사항에 따라 공개 ID를 따로 둘 수 있다.

제약 조건

제약은 코드 검증의 대체가 아니라 마지막 방어선이다.

ALTER TABLE members
ADD CONSTRAINT uq_members_email UNIQUE (email);
 
ALTER TABLE orders
ADD CONSTRAINT chk_orders_total_price CHECK (total_price >= 0);

애플리케이션은 사용자 친화적 메시지와 도메인 흐름을 담당하고, DB는 어떤 경로로 들어와도 깨지면 안 되는 규칙을 막는다.

제약 추가는 기존 데이터 위에 올리는 작업이다. 운영 테이블에 NOT NULL이나 CHECK를 추가하기 전에는 위반 데이터 조회, backfill, lock 영향, 배포 순서를 준비한다.

실전 팁

  • 금액은 정수 최소 단위 또는 decimal로 저장한다.
  • 시간은 저장 timezone과 조회 범위 정책을 정한다.
  • 상태값은 가능한 값과 전이 규칙을 분리해 설계한다.
  • 외부 노출 ID와 내부 PK를 구분한다.
  • Not Null, Unique, Check, FK를 두려워하지 않는다.
  • Java/JPA 타입과 DB 타입을 맞춘다. Long 식별자를 문자열로 비교하면 암묵 변환과 인덱스 실패를 만들 수 있다.
  • 상태값은 변경 빈도에 따라 check, enum, 코드 테이블 중 선택한다.
  • 제약 이름은 예외 변환과 운영 로그에서 읽을 수 있게 도메인 의미를 담는다.

위험 신호!

  • 금액을 double로 저장한다.
  • 날짜 검색에 DATE(created_at)을 남발한다.
  • 상태값이 자유 문자열이다.
  • 모든 컬럼이 nullable이다.
  • 중복 방지를 애플리케이션 코드에만 둔다.
  • 금액 컬럼에 통화와 소수점 정책이 없다.
  • timestamp 컬럼 이름만 보고 시스템 시각인지 업무 기준일인지 알 수 없다.
  • 운영 테이블에 제약을 추가하면서 기존 데이터 검증 쿼리가 없다.

확인 질문

  • 금액을 부동소수점으로 저장하면 위험한 이유는 무엇인가?
    • 이진 부동소수점은 십진 금액을 정확히 표현하지 못해 반올림과 비교 오류가 생길 수 있기 때문이다.
  • 상태값에서 DB Check와 애플리케이션 로직은 어떻게 역할을 나누는가?
    • DB Check는 가능한 값의 범위를 막고, 애플리케이션은 상태 전이 규칙과 사용자 흐름을 관리한다.
  • 외부 ID와 내부 PK를 구분하는 이유는 무엇인가?
    • 내부 join 안정성과 외부 노출 보안/UX 요구사항이 다를 수 있기 때문이다.
  • 상태값을 설계할 때 check, enum, 코드 테이블 중 무엇을 기준으로 고르는가?
    • 값 변경 빈도, DB migration 부담, 운영에서 값을 관리해야 하는지, join 비용, 애플리케이션 타입 안정성을 기준으로 고른다.

참고 문서