PHpullh
개발자 면접 준비/시스템 디자인

시스템 디자인

데이터 모델링·DB 선택 면접 가이드

관계형 DB와 비관계형 DB의 선택 근거, 인덱스, 트랜잭션, 마이그레이션을 면접에서 설명하는 방법입니다.

스키마는 저장소 선택보다 접근 패턴에서 나옵니다

데이터 모델링 질문에 “관계형으로 하겠습니다”나 “문서 DB가 유연해서 좋습니다”로 시작하면, 아직 아무것도 모르는 상태에서 결론부터 낸 것이 됩니다. 저장소 종류는 답이 아니라 결과입니다. 먼저 나와야 하는 것은 이 데이터를 누가, 어떤 조건으로, 얼마나 자주 읽고 쓰는가입니다. 접근 패턴을 대여섯 개 적고 나면 저장소는 대부분 스스로 결정됩니다.

그리고 스키마는 문서가 아니라 실행되는 규칙입니다. 코드로 지키기로 한 제약은 언젠가 새 코드 경로에서 뚫리지만, 데이터베이스 제약은 뚫리지 않습니다. 어떤 규칙을 스키마에 새기고 어떤 규칙을 애플리케이션에 둘지 구분해서 말하는 것이 이 면접의 큰 축입니다.

연습 문제: 주문과 결제를 저장하는 스키마를 설계하세요

온라인 스토어입니다. 사용자가 여러 상품을 담아 주문하고, 결제가 성공하면 배송이 시작됩니다. 주문 취소와 부분 환불이 있습니다. 이 데이터를 어떻게 저장하겠습니까.

먼저 접근 패턴을 적습니다

설계 전에 이 시스템이 실제로 던질 질의를 문장으로 적습니다. 한 사용자의 최근 주문 목록을 최신순으로 20건 가져옵니다. 주문 하나의 상세를 상품 줄 단위로 가져옵니다. 결제 상태가 대기인 주문을 배치로 훑습니다. 특정 상품이 지난달에 몇 개 팔렸는지 집계합니다. 환불 이력을 주문 기준으로 조회합니다. 이 다섯 문장을 적는 순간 필요한 인덱스와 테이블 경계가 거의 드러납니다.

동시에 불변 조건도 적습니다. 주문 총액은 항목 금액의 합과 일치해야 합니다. 환불 총액은 결제 총액을 넘을 수 없습니다. 같은 결제 시도는 두 번 기록되면 안 됩니다. 취소된 주문에는 배송이 붙을 수 없습니다. 이 문장들이 곧 제약 조건 후보입니다.

약한 답

“orders 테이블에 사용자 아이디, 상품 목록, 총액, 상태를 넣고 payments 테이블에 결제 정보를 넣겠습니다. 상품 목록은 JSON으로 저장하면 유연합니다. 인덱스는 필요한 곳에 걸면 됩니다.”

강한 답

“접근 패턴 다섯 개와 불변 조건 네 개를 먼저 적었습니다. 상품 줄 단위 조회와 상품별 판매량 집계가 있으므로 주문 항목은 JSON 덩어리가 아니라 별도 행이어야 합니다. JSON에 넣으면 그 두 질의가 전부 전체 스캔이 됩니다.

정합성 요구가 명확하고 조인이 필요하므로 관계형으로 가겠습니다. 주문·주문항목·결제·환불 네 테이블로 나눕니다. 주문 항목에는 상품 아이디뿐 아니라 주문 시점의 상품명과 단가를 함께 복사해 넣겠습니다. 이건 정규화 원칙을 어기는 것처럼 보이지만 의도된 것입니다. 상품 가격은 나중에 바뀌는데, 과거 주문서는 그 시점의 값을 보여 줘야 합니다. 참조로만 두면 가격을 바꾸는 순간 과거 영수증이 전부 조용히 변합니다.

금액은 부동소수가 아니라 정수 최소 단위나 십진 타입으로 저장하겠습니다. 통화 코드도 함께 둡니다. 상태는 문자열 자유 입력이 아니라 허용된 값으로 제한하고, 상태 전이는 애플리케이션에서 검사하되 최종 상태에서 되돌아가지 못하게 하는 부분은 조건부 갱신으로 막겠습니다.

결제 중복은 애플리케이션 검사만으로는 동시 요청에서 뚫리므로, 결제 요청 식별자에 유니크 제약을 두는 것이 마지막 방어선입니다. 환불 총액 제한은 애플리케이션에서 확인하고 잠금 아래에서 갱신하겠습니다. 인덱스는 적은 질의를 근거로 사용자별 최신 주문 조회용 복합 인덱스와 상태별 배치 조회용 인덱스를 두고, 나머지는 실행 계획을 보고 추가하겠습니다.”

SQL · 스키마 스케치와 근거

CREATE TABLE orders (
  id            BIGSERIAL PRIMARY KEY,
  buyer_id      BIGINT      NOT NULL REFERENCES users(id),
  status        TEXT        NOT NULL
                CHECK (status IN ('CREATED','PAID','CANCELLED','SHIPPED')),
  currency      CHAR(3)     NOT NULL,
  total_amount  BIGINT      NOT NULL CHECK (total_amount >= 0), -- 최소 단위 정수
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- "한 사용자의 최근 주문 20건"을 정렬 없이 인덱스만으로 처리
CREATE INDEX idx_orders_buyer_recent ON orders (buyer_id, created_at DESC);
-- "결제 대기 주문 배치 조회" — 상태 분포가 치우쳐도 부분 인덱스로 작게 유지
CREATE INDEX idx_orders_pending ON orders (created_at) WHERE status = 'CREATED';

CREATE TABLE order_items (
  order_id      BIGINT      NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
  line_no       INT         NOT NULL,
  product_id    BIGINT      NOT NULL REFERENCES products(id),
  product_name  TEXT        NOT NULL,   -- 주문 시점 값을 복사 (스냅숏)
  unit_price    BIGINT      NOT NULL,   -- 이후 가격 변경에 영향받지 않음
  quantity      INT         NOT NULL CHECK (quantity > 0),
  PRIMARY KEY (order_id, line_no)
);

CREATE INDEX idx_items_product ON order_items (product_id);  -- 상품별 판매 집계

CREATE TABLE payments (
  id            BIGSERIAL PRIMARY KEY,
  order_id      BIGINT      NOT NULL REFERENCES orders(id),
  request_key   TEXT        NOT NULL,   -- 클라이언트가 만든 결제 시도 식별자
  amount        BIGINT      NOT NULL CHECK (amount > 0),
  status        TEXT        NOT NULL
                CHECK (status IN ('PENDING','APPROVED','FAILED')),
  approved_at   TIMESTAMPTZ,
  -- 재시도로 인한 이중 결제를 DB가 막는다. 앱 레벨 검사는 동시 요청에서 뚫린다.
  UNIQUE (order_id, request_key)
);

CREATE TABLE refunds (
  id            BIGSERIAL PRIMARY KEY,
  payment_id    BIGINT      NOT NULL REFERENCES payments(id),
  amount        BIGINT      NOT NULL CHECK (amount > 0),
  reason        TEXT,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

두 답의 차이가 어디서 생겼는가

약한 답이 부족한 지점은 세 군데입니다. 첫째, 질의를 적지 않았습니다. 그러니 상품 목록을 JSON으로 넣는 결정이 “유연하다”는 막연한 이유로 내려졌고, 상품별 집계 질의를 불가능에 가깝게 만든다는 사실을 보지 못했습니다. 둘째, 시간에 따라 변하는 값과 고정되어야 하는 값을 구분하지 않았습니다. 가격을 참조로만 두면 과거 주문이 바뀌는데, 이건 성능 문제가 아니라 정확성 문제입니다. 셋째, 규칙을 어디서 지킬지 정하지 않았습니다. “인덱스는 필요한 곳에 걸면 된다”는 계획이 아니라 미룸입니다.

강한 답은 모든 결정에 그 결정을 강제한 질의나 불변 조건이 하나씩 붙어 있습니다. 면접관이 “왜 항목을 분리했나요”라고 물으면 “상품별 집계 질의 때문입니다”라고 즉답할 수 있고, “왜 상품명을 중복 저장했나요”라고 물으면 “과거 주문서의 불변성 때문입니다”라고 답할 수 있습니다. 이 대응 관계가 있으면 조건이 바뀌어도 다시 계산할 수 있습니다.

TIP

중복 저장을 제안할 때는 반드시 “왜 이 값은 스냅숏이어야 하는가”를 함께 말하십시오. 근거 없는 중복은 정합성 사고를 부르고, 근거 있는 중복은 정확성을 지킵니다. 둘의 차이는 컬럼 개수가 아니라 그 값이 시간에 따라 변해도 되는지에 있습니다.

인덱스를 말하는 방식

인덱스는 공짜가 아닙니다. 읽기를 빠르게 하는 대신 쓰기마다 갱신 비용이 붙고 저장 공간을 씁니다. 그래서 “필요하면 걸겠습니다”가 아니라 “이 질의 때문에 이 인덱스를 겁니다”로 말해야 합니다.

복합 인덱스에서는 컬럼 순서가 곧 사용 가능 여부를 결정합니다. 앞쪽 컬럼부터 순서대로 조건이 걸려야 인덱스를 탈 수 있으므로, 등호 조건으로 좁히는 컬럼을 앞에, 범위나 정렬에 쓰는 컬럼을 뒤에 두는 것이 기본입니다. 위 예제의 사용자별 최신 주문 인덱스가 정확히 그 형태입니다.

선택도가 낮은 컬럼에 단독 인덱스를 거는 것은 대개 낭비입니다. 상태 컬럼처럼 값이 서너 개뿐인 컬럼은 인덱스를 타도 대부분의 행을 읽게 됩니다. 다만 특정 값의 비중이 아주 작다면 그 값만 대상으로 하는 부분 인덱스가 효과적입니다. 이 구분을 말할 수 있으면 인덱스를 외운 것이 아니라 이해한 것으로 읽힙니다.

스키마를 바꾸는 절차

운영 중인 서비스에서 스키마 변경은 코드 배포보다 위험합니다. 코드는 되돌리면 되지만 데이터는 되돌리기 어렵기 때문입니다. 그래서 순서를 고정해 두는 것이 좋습니다.

  1. 확장. 새 컬럼이나 테이블을 추가하되 기존 코드가 모르는 상태로 둡니다. 널 허용이거나 기본값이 있어야 하고, 이 단계는 언제든 되돌릴 수 있습니다.
  2. 이중 쓰기. 애플리케이션이 옛 위치와 새 위치에 모두 씁니다. 읽기는 아직 옛 위치에서 합니다.
  3. 백필. 과거 데이터를 새 위치로 옮깁니다. 한 번에 하지 말고 작은 배치로 나눠 진행 상황을 기록하며, 중단 후 재개가 가능해야 합니다.
  4. 읽기 전환. 읽기를 새 위치로 옮깁니다. 여기서 문제가 보이면 읽기만 되돌리면 되므로 회수 비용이 낮습니다.
  5. 정리. 이중 쓰기를 끄고, 충분한 관측 기간 뒤에 옛 컬럼을 제거합니다.

이 절차가 중요한 이유는 각 단계가 독립적으로 되돌릴 수 있기 때문입니다. 컬럼 이름 변경 하나를 한 번의 배포로 처리하려 하면, 배포 순간에 옛 코드와 새 코드가 잠시 공존하는 구간에서 반드시 실패합니다. 데이터베이스 기초를 다시 정리하고 싶다면 초보자가 데이터베이스를 일찍 배워야 하는 이유가 출발점으로 적당합니다.

WARNING

큰 테이블에 인덱스를 만들거나 컬럼 타입을 바꾸는 작업은 잠금을 오래 잡을 수 있습니다. 어떤 작업이 테이블을 잠그는지 사용하는 데이터베이스 기준으로 확인하고, 잠금 없이 진행하는 옵션이 있는지 먼저 보십시오. 개발 장비의 작은 테이블에서 1초 만에 끝난 작업이 운영에서 30분간 서비스를 세우는 경우가 실제로 있습니다.

후속 질문과 답하는 방향

주문 데이터가 아주 많아지면 어떻게 하시겠습니까?

샤딩부터 꺼내지 말고 순서를 말합니다. 먼저 오래된 주문을 별도 테이블이나 저비용 저장소로 분리해 활성 테이블을 작게 유지합니다. 그다음 읽기 부하는 복제본으로 분산합니다. 그래도 단일 쓰기 노드가 포화되면 그때 나눕니다. 샤드 키는 대부분의 질의가 사용자 기준이므로 사용자 아이디가 후보이고, 그 대가로 상품별 전역 집계가 어려워진다는 점을 함께 말합니다.

주문 상태를 컬럼 하나로 두는 것이 맞나요?

현재 상태만 필요하면 컬럼 하나로 충분합니다. 다만 “언제 결제됐고 언제 취소됐는지” 같은 질문이 나오기 시작하면 상태 변경 이력을 별도 테이블에 append 방식으로 쌓고 현재 상태는 그 결과를 반영한 값으로 두는 편이 낫습니다. 이력이 필요한지 여부는 제품 요구이므로 되물어야 하는 항목입니다.

정규화를 어디까지 하시겠습니까?

기본은 정규화이고, 비정규화는 근거가 있을 때만 합니다. 근거는 두 종류뿐입니다. 하나는 시점 스냅숏이 필요한 경우이고, 다른 하나는 측정된 성능 문제입니다. “조인이 느릴 것 같아서”는 근거가 아닙니다. 실행 계획과 실제 지연을 확인하고 나서 결정합니다.

삭제는 어떻게 처리하나요?

주문처럼 회계·분쟁 대응에 필요한 데이터는 물리 삭제 대신 삭제 표시를 두는 편이 일반적입니다. 다만 삭제 플래그를 두면 모든 질의에 조건이 붙고 유니크 제약이 예상과 다르게 동작하므로 그 비용도 같이 말해야 합니다. 개인정보와 관련된 항목은 보존과 파기 요구가 별도로 있을 수 있으므로 정책 확인이 필요하다고 답하는 것이 안전합니다.

NoSQL을 썼다면 무엇이 달라집니까?

질의를 나중에 만드는 자유가 줄고, 대신 정해진 접근 패턴에 대해 확장이 쉬워집니다. 조인이 없으므로 주문과 항목을 한 문서에 묶게 되고, 그러면 주문 상세 조회는 아주 빨라지지만 상품별 집계는 별도 파이프라인이 필요합니다. 즉 저장소 선택이 아니라 “집계를 어디서 할 것인가”를 옮기는 결정입니다.

이 영역에서 흔한 실수

첫째, 금액을 부동소수로 저장하는 것입니다. 반올림 오차가 누적되어 합계가 어긋나고, 이 버그는 회계 대사 시점에야 발견됩니다.

둘째, 시각을 타임존 없이 저장하는 것입니다. 서버 지역이 바뀌거나 인스턴스가 여러 지역에 있으면 값의 의미가 흔들립니다. 저장은 절대 시각으로 하고 표시할 때만 지역 시간으로 변환하는 것이 원칙입니다.

셋째, 자연 키를 기본 키로 쓰는 것입니다. 이메일이나 사업자번호처럼 현실에서 바뀔 수 있는 값을 키로 삼으면 변경이 모든 참조로 번집니다.

넷째, 제약 조건을 전부 애플리케이션에만 두는 것입니다. 배치 스크립트나 수동 조작, 다른 서비스의 직접 접근은 애플리케이션 코드를 거치지 않습니다. 데이터의 마지막 방어선은 스키마입니다.

다섯째, 인덱스를 성능 문제의 만능 해법으로 제안하는 것입니다. 인덱스가 늘면 쓰기가 느려지고 쓰지 않는 인덱스도 계속 비용을 냅니다. 실행 계획을 보고 결정한다고 답하는 편이 언제나 안전합니다.

답하기 전 확인할 것

  • 주요 질의를 문장으로 다섯 개 적었는가
  • 불변 조건을 문장으로 적고 어디서 지킬지 정했는가
  • 시간에 따라 변하는 값과 스냅숏이어야 하는 값을 구분했는가
  • 금액·시각·식별자의 타입 선택에 근거가 있는가
  • 각 인덱스에 그것을 요구한 질의가 하나씩 붙어 있는가
  • 동시 요청에서 애플리케이션 검사가 뚫리는 지점에 DB 제약을 두었는가
  • 스키마 변경을 되돌릴 수 있는 단계로 나눴는가
  • 비정규화를 제안했다면 그 근거가 스냅숏이거나 측정된 수치인가

연습 방법

익숙한 서비스 하나를 골라 화면을 보면서 질의를 역산해 보십시오. 이 화면을 그리려면 어떤 조건으로 무엇을 읽어야 하는가를 적고, 그 질의를 만족하는 최소 스키마를 그린 뒤, 각 컬럼이 왜 필요한지 한 줄씩 붙입니다. 이유를 못 붙이는 컬럼은 대개 필요 없는 컬럼입니다.

그다음에는 요구를 하나씩 추가하며 스키마가 어떻게 변하는지 봅니다. 쿠폰이 붙으면, 부분 배송이 생기면, 판매자가 여러 명이면 무엇이 바뀌는지 직접 답해 보십시오. 이어지는 주제는 API 설계와 확장 가능한 서비스 설계이고, 전체 경로는 백엔드 개발 로드맵에서 확인할 수 있습니다.

관련 언어 학습으로 복습하기