이 글에서 얻는 것

  • 요구사항을 테이블/컬럼으로 옮길 때, “감”이 아니라 키/제약조건/조회 패턴으로 설계할 수 있습니다.
  • PK/UK/FK/NOT NULL 같은 제약을 언제 강하게 걸고, 언제 완화해야 하는지 트레이드오프가 생깁니다.
  • 정규화/비정규화를 “이론”이 아니라 변경 비용 vs 조회 비용 관점으로 선택할 수 있습니다.
  • 운영 중 스키마가 바뀔 때(마이그레이션) “서비스를 안 깨뜨리는 변경” 감각을 잡습니다.

0) 스키마 설계는 ‘성능’보다 ‘변경 비용’을 결정한다

스키마는 한 번 정하면 끝이 아니라, 기능이 늘고 트래픽이 커지면서 계속 변합니다. 문제는 변경이 어려운 스키마가 되면 다음이 연쇄적으로 터진다는 점입니다.

  • 컬럼 의미가 불명확해져 데이터 품질이 무너짐(정합성)
  • 인덱스를 붙여도 느림(조회 패턴과 안 맞음)
  • 마이그레이션이 위험해져 배포가 두려워짐

그래서 스키마 설계의 핵심 질문은 “정답 테이블”이 아니라, 이 데이터는 누가 소유하고, 어떻게 변하며, 어떻게 조회되는가입니다.

1) 요구사항을 데이터로 바꿀 때 먼저 정리할 것

1-1) 엔티티의 수명주기

  • 생성/수정/삭제(또는 만료) 흐름이 명확한가?
  • 삭제가 진짜 삭제인가? (보관/감사 로그가 필요한가?)
  • 상태(state)가 있다면 상태 전이 규칙이 명확한가?

1-2) 조회 패턴(가장 중요)

“조회 패턴”이 스키마와 인덱스를 결정합니다.

  • 목록 API는 어떤 필터/정렬로 조회하는가?
  • 상세 조회는 어떤 연관 데이터를 같이 보여주는가?
  • 집계가 필요한가? (일/주/월 통계, Top-K 등)

2) 키 설계: PK는 ‘정체성’이고, 인덱스의 출발점이다

2-1) 자연키 vs 대리키(surrogate)

  • 자연키(자연스러운 의미가 있는 키): 예) 이메일, 전화번호

    • 장점: 의미가 명확
    • 단점: 변경 가능성/길이/개인정보/정규화(대소문자, 공백) 같은 현실 문제가 많음
  • 대리키(의미 없는 식별자): 예) bigint auto increment, snowflake, UUID

    • 장점: 변경에 강하고 FK로 쓰기 편함
    • 단점: “자연스러운 유니크”는 별도 제약(UK)으로 관리해야 함

실무에서 흔한 선택:

  • PK는 대리키(bigint/snowflake)를 두고
  • 이메일 같은 자연키는 UNIQUE로 “중복 금지”를 따로 걸어줍니다.

2-2) UUID를 PK로 쓸 때 체크 포인트

UUID는 편하지만, (특히 B-Tree에서) 랜덤성이 커서 인덱스 단편화/쓰기 비용이 커질 수 있습니다.

  • 트래픽/쓰기량이 크면 “정렬 가능한 ID”(ULID/UUIDv7 등)나 snowflake 계열을 고려
  • 정말 UUID가 필요한지(외부 노출/통합 필요) 기준을 먼저 정합니다

3) 제약조건: DB가 ‘마지막 방어선’이 되어야 한다

애플리케이션 검증만 믿고 DB 제약을 약하게 두면, 결국 데이터 품질이 무너집니다.

3-1) NOT NULL

  • “없으면 의미가 없다”면 NOT NULL이 기본입니다.
  • NULL을 허용하면, 이후 모든 쿼리/코드가 “NULL 처리”를 떠안습니다.

3-2) UNIQUE

중복이 비즈니스 버그가 되는 값은 DB에서 막는 편이 안전합니다.

  • 예: 이메일, 주문번호, 외부 결제 트랜잭션 키 등

3-3) Foreign Key(FK)

FK는 데이터 정합성을 강하게 만들지만, 쓰기 경합/락/운영 복잡도가 늘 수 있습니다.

실무 기준(요약):

  • 단일 DB, 변경 빈도가 낮고 정합성이 최우선 → FK를 적극적으로 사용
  • 고트래픽/대규모, 샤딩/멀티테넌시/서비스 분리 예정 → FK를 약하게(또는 애플리케이션 레벨) 가져가는 경우도 많음

중요: FK를 안 쓰더라도 “참조 무결성”은 반드시 다른 방식으로 보장해야 합니다(정리 배치, 소유권 규칙, 삭제 정책).

4) 정규화 vs 비정규화: ‘조회 비용’과 ‘변경 비용’의 교환

4-1) 정규화가 주는 것

  • 중복이 줄고, 업데이트가 단일 지점에서 일어납니다(변경 비용 ↓).
  • 대신 조회 시 조인이 늘고, “읽기 성능”이 문제로 보일 수 있습니다.

4-2) 비정규화가 필요한 순간

비정규화는 “조인이 싫어서”가 아니라, 읽기 경로가 병목이고, 일관성 요구가 허용 가능한 범위일 때 의미가 있습니다.

예시:

  • 목록 화면에 항상 필요한 요약 필드(예: 댓글 수, 좋아요 수)
  • 통계/랭킹 같은 파생 데이터(원본과 분리된 읽기 모델)

비정규화를 할 때 반드시 정해야 하는 것:

  • 원본(source of truth)은 어디인가?
  • 파생 데이터는 언제/어떻게 갱신되는가? (동기/비동기, 재계산 전략)
  • 갱신 실패 시 어떻게 복구하는가? (재처리, 배치 리빌드)

5) 인덱스는 스키마 설계의 ‘후속’이다

인덱스는 스키마를 “좋게” 만들지 않습니다.
조회 패턴과 조건이 명확할 때, 인덱스가 성능을 끌어올립니다.

  • 어떤 컬럼이 WHERE/ORDER BY에 자주 등장하는가?
  • 카디널리티(분포)가 어떤가?
  • 복합 인덱스의 컬럼 순서는 어떻게 잡을 것인가?

인덱스 자체는 다음 글에서 더 깊게 다룹니다.

6) 운영 중 스키마 변경(마이그레이션) 기본 원칙

스키마는 “한 번에 바꾸는 것”이 아니라, 보통 2~3단계로 나눕니다(확장 → 전환 → 정리).

예시(컬럼 이름 변경):

  1. 새 컬럼 추가(확장) + 쓰기 시 둘 다 채움
  2. 읽기를 새 컬럼으로 전환(배포)
  3. 안정화 후 구 컬럼 제거(정리)

핵심은 “구버전/신버전이 잠시 공존해도 동작”하도록 만드는 것입니다.

연습(추천)

  • “주문/결제/배송” 도메인을 가정하고, 테이블 3~5개를 잡아 PK/UK/FK/NOT NULL을 설계해보기
  • 목록 API 2개를 정하고(예: 주문 목록, 결제 내역), WHERE/ORDER BY를 기반으로 “필요한 인덱스 후보”를 적어보기
  • 컬럼 추가/삭제/이름 변경을 ‘확장→전환→정리’로 나눠 배포 시나리오를 써보기

연결해서 읽기

  • 인덱스 기본: /learning/deep-dive/deep-dive-database-indexing/
  • EXPLAIN 읽는 법: /learning/deep-dive/deep-dive-mysql-index-explain/
  • 트랜잭션 경계/JPA 감각: /learning/deep-dive/deep-dive-jpa-transaction-boundaries/