Skip to main content
Summary

설계는 답을 맞히는 일이 아니다

좋은 데이터베이스 설계는 정답을 외우는 일이 아니다. 요구사항, 트래픽, 장애 가능성, 팀의 운영 역량 사이에서 설명 가능한 결정을 내리는 일이다.

같은 회원 가입 기능이라도 초기 서비스와 대규모 서비스의 설계는 다를 수 있다. 중요한 것은 지금의 선택이 어떤 문제를 해결하고, 어떤 비용을 만들며, 나중에 어떻게 바꿀 수 있는지 알고 결정하는 것이다.

실무 설계는 보통 다음 순서로 진행하면 흔들림이 줄어든다.

  1. 먼저 지켜야 할 업무 규칙을 쓴다.

  2. 엔티티와 관계를 정한다.

  3. 정합성이 필요한 경계를 찾는다.

  4. 주요 조회 패턴에 맞춰 인덱스를 설계한다.

  5. 장애와 재시도 상황을 가정한다.

  6. 마이그레이션과 모니터링 계획을 함께 세운다.

이 순서를 실제 기능에 적용해 보자.

실전 설계 사례와 의사결정 체크리스트AI 생성 이미지

네 가지 기능으로 보는 설계 흐름

회원 가입

회원 가입에서 가장 중요한 규칙은 보통 이메일이나 휴대전화 번호의 중복 가입 방지다. 이 규칙은 애플리케이션 코드의 사전 조회만으로는 부족하다. 동시에 두 요청이 들어오면 둘 다 중복이 없다고 판단할 수 있기 때문이다. 따라서 데이터베이스의 유니크 제약이 필요하다.

기본 구조는 단순하게 시작할 수 있다.

  • users: id, email, password_hash, status, created_at

  • user_profiles: user_id, nickname, avatar_url

  • email_verification_tokens: user_id, token_hash, expires_at, used_at

users.email에는 유니크 인덱스를 둔다. 로그인 조회가 많다면 email 인덱스가 핵심 경로가 된다. 이때 이메일의 대소문자 처리, 공백 제거, 탈퇴 후 같은 이메일로 다시 가입할 수 있는지 같은 정책도 함께 정해야 한다. 예를 들어 탈퇴 계정을 소프트 삭제하면서 이메일 유니크 제약을 그대로 유지하면, 같은 이메일의 재가입이 막힐 수 있다.

비밀번호는 원문이나 복호화 가능한 값이 아니라 해시로 저장한다. 이메일 인증 토큰도 가능하면 원문 토큰이 아니라 해시를 저장하는 편이 안전하다. 이메일 인증 메일 발송은 데이터베이스 트랜잭션 안에서 직접 처리하지 않는 편이 좋다. 사용자 생성이 커밋된 뒤 아웃박스나 작업 큐를 통해 발송하면, 메일 서버 장애가 회원 데이터 정합성을 깨뜨리지 않는다.

상태값도 중요하다. 가입 직후, 인증 완료, 정지, 탈퇴 같은 상태를 status로 표현하면 물리 삭제보다 운영과 감사가 쉬워진다. 단, 탈퇴 사용자의 개인정보 보존 기간과 익명화 정책은 별도로 정해야 한다. 계정 상태는 로그인 가능 여부, 결제 가능 여부, 관리자 조치 이력과도 연결되므로 단순한 문자열 하나로 끝내기보다 상태 전이 규칙까지 같이 문서화하는 것이 좋다.

주문 결제

주문 결제는 정합성의 경계가 뚜렷하다. 결제가 성공했는데 주문이 없거나, 재고가 없는데 주문이 생성되면 안 된다. 이 기능에서는 테이블 구조보다 먼저 불변 조건을 정해야 한다.

예를 들어 다음 규칙이 있다.

  • 주문은 하나 이상의 주문 상품을 가진다.

  • 재고는 0보다 작아질 수 없다.

  • 같은 결제 요청은 한 번만 처리되어야 한다.

  • 결제 성공 후 주문 상태는 결제 완료로 전환되어야 한다.

기본 테이블은 orders, order_items, payments, inventory_movements로 나눌 수 있다. orders(user_id, created_at) 인덱스는 사용자의 주문 목록 조회에 필요하다. payments.idempotency_key에는 유니크 제약을 둔다. 같은 요청이 네트워크 재시도로 두 번 들어와도 결제가 중복 기록되지 않게 하기 위해서다.

재고 차감은 읽고 계산한 값을 다시 저장하는 방식보다 조건부 업데이트가 안전하다.

UPDATE products SET stock = stock - 1 WHERE id = ? AND stock >= 1

영향받은 행이 1개면 성공, 0개면 품절로 처리한다. 여러 상품을 함께 주문한다면 주문 생성, 주문 상품 생성, 재고 예약 또는 차감, 결제 시도 기록처럼 데이터베이스 안에서 함께 일관되어야 하는 작업을 하나의 트랜잭션으로 묶어야 한다.

다만 외부 결제 API 호출을 긴 트랜잭션 안에 넣으면 잠금 시간이 길어지고, 결제사 응답 지연이 데이터베이스 전체의 병목으로 번질 수 있다. 실무에서는 주문을 먼저 결제 대기 상태로 만들고, 결제 승인 요청과 승인 결과 반영을 분리하는 식으로 흐름을 나누는 경우가 많다. 중요한 것은 각 단계가 재시도 가능해야 하고, 이미 처리된 요청을 다시 받았을 때도 같은 결과를 돌려줄 수 있어야 한다는 점이다. 이때 멱등성 키, 결제 상태 전이, 실패 보상 로직이 함께 설계되어야 한다.

게시글 피드

게시글 피드는 주문 결제와 성격이 다르다. 피드에 새 글이 몇 초 늦게 나타나는 것은 보통 허용된다. 대신 읽기 트래픽이 많고 정렬, 페이지네이션, 공개 범위가 성능을 좌우한다.

단순한 시작점은 다음과 같다.

  • posts: id, author_id, body, visibility, created_at

  • follows: follower_id, followee_id, created_at

  • 인덱스: posts(author_id, created_at), follows(follower_id, followee_id)

초기에는 사용자가 팔로우한 작성자 목록을 찾고, 해당 작성자의 글을 최신순으로 조회하는 방식으로 충분할 수 있다. follows(follower_id, followee_id)에는 중복 팔로우를 막기 위한 유니크 제약을 함께 둘 수 있다. 하지만 사용자가 팔로우한 사람이 많아지면 IN 조건과 정렬 비용이 커진다. 이때는 피드 전용 테이블인 feed_items(user_id, post_id, created_at)를 만들 수 있다.

피드 생성 방식에는 두 선택지가 있다. 읽을 때 계산하는 fan-out on read는 쓰기가 단순하지만 조회가 무거워진다. 쓸 때 팔로워들의 피드에 미리 넣는 fan-out on write는 조회가 빠르지만 작성자의 팔로워가 많을 때 쓰기 비용이 커진다. 대규모 서비스에서는 일반 사용자와 유명 사용자의 전략을 다르게 가져가기도 한다. 일반 사용자의 글은 미리 배포하고, 유명 사용자의 글은 읽는 시점에 합치는 방식이다.

여기서 핵심은 피드가 원본 데이터가 아니라 읽기 모델이라는 점이다. 원본은 posts이고, feed_items는 성능을 위한 파생 데이터다. 따라서 누락이나 중복을 탐지하고 재생성할 수 있어야 한다. 예를 들어 feed_items(user_id, post_id)에 유니크 제약을 두면 같은 글이 한 사용자의 피드에 여러 번 들어가는 일을 줄일 수 있다. 반대로 공개 범위 변경, 차단, 삭제처럼 원본 상태가 바뀌었을 때 파생 피드를 어떻게 정리할지도 함께 생각해야 한다.

알림 시스템

알림은 대부분 최종 일관성으로 충분하다. 댓글이 달렸을 때 알림이 1초 늦게 가는 것은 괜찮지만, 같은 알림이 수십 번 가는 것은 문제다. 따라서 알림 시스템에서는 즉시성보다 중복 방지와 재시도가 더 중요할 때가 많다.

기본 구조는 다음처럼 나눌 수 있다.

  • notifications: id, user_id, type, resource_id, read_at, created_at

  • notification_deliveries: notification_id, channel, status, attempt_count, last_error

  • 인덱스: notifications(user_id, created_at), notifications(user_id, read_at, created_at)

댓글 생성 트랜잭션 안에서 알림 행을 함께 만들 수도 있지만, 푸시 발송까지 같이 처리하면 외부 서비스 장애가 댓글 작성까지 방해한다. 더 안전한 방식은 댓글 생성 후 이벤트를 기록하고, 작업자가 알림 생성과 발송을 비동기로 처리하는 것이다. 이때 댓글 생성과 이벤트 기록이 서로 어긋나지 않게 하려면 아웃박스 패턴을 사용할 수 있다.

중복 방지를 위해 user_id, type, resource_id 조합에 유니크 제약을 둘 수 있다. 예를 들어 같은 댓글에 대한 같은 사용자 알림은 하나만 존재해야 한다는 규칙이다. 반대로 좋아요 알림처럼 여러 이벤트를 묶어서 보여줄 수 있는 기능은 매번 알림을 새로 만들기보다 카운터나 요약 테이블을 별도로 둘 수 있다. 읽음 처리 역시 단순해 보이지만 동시 요청이 들어올 수 있으므로, 이미 읽은 알림을 다시 읽음 처리해도 문제가 없는 멱등한 업데이트로 설계하는 편이 좋다.

강한 정합성과 최종 일관성을 나누는 질문

모든 데이터를 같은 강도로 보호하면 시스템은 느리고 복잡해진다. 반대로 모든 것을 느슨하게 만들면 돈, 권한, 재고처럼 중요한 데이터가 깨진다. 다음 질문으로 경계를 나누자.

  • 몇 초 동안 오래된 값을 보여줘도 되는가?

  • 중복 처리되면 금전 손실이나 법적 문제가 생기는가?

  • 사용자가 방금 쓴 값을 즉시 다시 봐야 하는가?

  • 나중에 보상 작업으로 되돌릴 수 있는가?

  • 유니크 제약이나 외래 키로 반드시 막아야 하는 규칙인가?

  • 실패한 작업을 안전하게 재시도할 수 있는가?

  • 읽기 복제본, 캐시, 검색 인덱스에서 오래된 값이 보여도 되는가?

강한 정합성이 필요한 대표 영역은 결제, 포인트, 재고, 권한, 개인정보 동의, 계정 상태다. 최종 일관성으로 충분한 영역은 알림, 검색 색인, 추천, 조회수, 분석 이벤트, 피드 일부, 집계 통계다. 단, 최종 일관성을 선택했다면 지연, 중복, 누락을 감지하고 복구하는 절차가 있어야 한다. 최종 일관성은 "틀려도 된다"는 뜻이 아니라, 잠시 어긋날 수 있지만 결국 맞게 만드는 구조를 갖춘다는 뜻이다.

서비스 단계에 따라 설계는 달라진다

초기 스타트업에서는 단일 관계형 데이터베이스로 시작하는 것이 대체로 합리적이다. PostgreSQL이나 MySQL에 스키마, 제약 조건, 인덱스, 트랜잭션을 제대로 잡는 것만으로도 많은 문제를 해결할 수 있다. 이 단계에서 과도한 샤딩, 메시지 브로커, 여러 NoSQL 저장소를 먼저 도입하면 제품보다 운영 복잡도가 더 빨리 커진다.

성장 중인 서비스에서는 병목이 구체적으로 보이기 시작한다. 특정 목록 조회가 느려지고, 배치 작업이 운영 DB에 영향을 주며, 캐시와 읽기 복제본이 필요해진다. 이때는 모든 구조를 갈아엎기보다 읽기 모델, 작업 큐, 아웃박스, 점진적 마이그레이션을 이용해 기존 구조를 확장하는 편이 안전하다.

대규모 서비스에서는 데이터의 성격별로 저장소가 나뉜다. 주문 원장은 관계형 데이터베이스에 두고, 피드와 추천은 별도 저장소를 쓰며, 검색은 검색 엔진에 맡기고, 분석은 데이터 웨어하우스로 보낸다. 이 단계의 핵심은 기술 종류가 아니라 데이터 흐름의 통제다. 어떤 저장소가 원본인지, 파생 데이터는 어떻게 재생성하는지, 장애 시 어느 기능을 제한할지 명확해야 한다.

새 저장소 도입 전 체크리스트

새로운 데이터베이스나 캐시, 검색 엔진을 도입하기 전에는 성능 장점만 보지 말고 운영 비용을 함께 봐야 한다.

  • 쿼리 패턴: 어떤 키로 읽고 쓰는가? 범위 조회, 정렬, 집계, 조인이 필요한가?

  • 정합성 요구: 쓰기 직후 읽기가 항상 최신이어야 하는가? 중복과 지연을 허용할 수 있는가?

  • 장애 모드: 저장소가 느려지거나 내려가면 어떤 기능이 멈추는가? 우회 경로가 있는가?

  • 운영 역량: 백업, 복구, 모니터링, 버전 업그레이드, 용량 증설을 팀이 감당할 수 있는가?

  • 데이터 이동: 기존 데이터는 어떻게 옮기는가? 이중 쓰기 기간의 불일치는 어떻게 검증하는가?

  • 비용: 서버 비용뿐 아니라 개발, 장애 대응, 학습, 보안, 관측 도구 비용이 얼마인가?

  • 철수 가능성: 기대한 효과가 없을 때 원래 구조로 돌아가거나 다른 저장소로 옮길 수 있는가?

새 저장소는 문제를 없애기보다 문제의 위치를 바꾼다. 관계형 데이터베이스의 조인 비용을 줄이려고 문서형 저장소를 도입하면, 이번에는 문서 중복과 갱신 정합성이 문제가 될 수 있다. 캐시로 읽기 부하를 줄이면, 캐시 무효화와 오래된 데이터 문제가 생긴다. 선택의 대가를 알고 도입해야 한다.

설계 리뷰 체크리스트

설계 리뷰는 취향을 겨루는 시간이 아니다. 빠진 위험을 찾고, 선택의 근거를 공유하는 과정이다. 다음 항목을 기준으로 검토하면 실무적인 누락을 줄일 수 있다.

데이터 모델과 제약 조건

  • 엔티티와 관계가 업무 의미를 제대로 표현하는가?

  • 기본 키와 외래 키가 안정적인가?

  • 유니크, NOT NULL, CHECK 제약으로 막아야 할 규칙이 빠지지 않았는가?

  • 상태값의 전이가 정의되어 있는가?

  • 삭제는 물리 삭제, 소프트 삭제, 익명화 중 무엇인가?

쿼리와 인덱스

  • 가장 자주 실행되는 조회는 무엇인가?

  • 목록 조회의 정렬 기준과 페이지네이션 방식은 무엇인가?

  • 복합 인덱스의 컬럼 순서가 WHERE, ORDER BY와 맞는가?

  • 쓰기 빈도에 비해 인덱스가 과도하지 않은가?

  • 느려졌을 때 실행 계획으로 확인할 수 있는가?

트랜잭션과 동시성

  • 하나의 업무 단위로 반드시 함께 성공해야 하는 작업은 무엇인가?

  • 트랜잭션 안에 외부 API 호출이나 오래 걸리는 작업이 들어가지 않았는가?

  • 재고, 포인트, 쿠폰처럼 경쟁이 생기는 데이터에 조건부 업데이트나 잠금 전략이 있는가?

  • 중복 요청을 막기 위한 멱등성 키가 필요한가?

  • 실패와 재시도 시 같은 결과가 보장되는가?

마이그레이션과 운영

  • 기존 데이터가 있는 상태에서 무중단으로 적용 가능한가?

  • 확장 후 축소 방식으로 배포 순서를 나눌 수 있는가?

  • 백필은 작은 배치로 재시도 가능하게 설계되었는가?

  • 롤백보다 롤포워드 계획이 현실적인가?

  • 배포 중 확인할 지표와 중단 기준이 정해졌는가?

모니터링과 보안

  • 지연 시간, 오류율, 락 대기, 커넥션 수, 복제 지연을 볼 수 있는가?

  • 핵심 쿼리의 호출량과 p95, p99 지연을 추적하는가?

  • 계정 권한은 최소 권한으로 분리되어 있는가?

  • 개인정보가 로그, 백업, 분석 환경에 과도하게 노출되지 않는가?

  • 백업에서 실제 복구까지 검증한 적이 있는가?

설명할 수 있는 설계가 좋은 설계다

좋은 설계 문서에는 사용한 기술 이름보다 판단의 이유가 더 많이 담겨야 한다. 왜 이 데이터는 관계형 데이터베이스에 두는지, 왜 이 필드는 중복 저장하는지, 왜 이 기능은 최종 일관성을 허용하는지, 장애가 나면 어떤 순서로 복구할지 설명할 수 있어야 한다.

데이터베이스는 애플리케이션의 상태를 보존하는 중심이다. 스키마, 인덱스, 트랜잭션, 캐시, 복제, 백업, 보안은 서로 따로 떨어진 주제가 아니다. 하나의 기능을 안정적으로 만들기 위해 반복해서 함께 고려해야 하는 기준이다.

처음부터 완벽한 설계는 없다. 그러나 좋은 질문을 던지는 설계는 오래 버틴다. 요구사항을 데이터 규칙으로 바꾸고, 규칙을 제약 조건과 트랜잭션으로 보호하며, 성능과 장애를 지표로 확인하는 습관이 개발자의 데이터베이스 역량을 만든다.

책 목록

개발자가 알아야 할 데이터베이스 지식

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기