스키마는 애플리케이션의 약속이다
스키마는 테이블과 컬럼을 나열한 문서가 아니다. 애플리케이션이 데이터를 어떤 의미로 저장하고, 어떤 규칙을 지키며, 어떤 방식으로 조회할지를 정한 약속이다. 잘못된 스키마는 처음에는 작은 불편처럼 보이지만, 시간이 지나면 데이터 불일치, 복잡한 쿼리, 예외 처리 증가, 마이그레이션 부담으로 돌아온다.
정규화는 이 약속을 정리하는 대표적인 방법이다. 핵심은 단순하다. 같은 사실을 여러 곳에 반복해서 저장하지 않는 것이다. 같은 사실이 여러 곳에 있으면 언젠가는 서로 달라진다. 고객 이름이 users에도 있고 orders에도 있고 payments에도 있다면, 이름 변경 시 세 곳을 모두 정확히 수정해야 한다. 하나라도 빠지면 시스템은 고객의 이름을 여러 개로 기억하게 된다.
반대로 모든 중복을 제거하는 것만이 좋은 설계는 아니다. 매번 여러 테이블을 조인해야 해서 조회가 느려지고, 개발자가 이해하기 어려운 구조가 될 수 있다. 실무의 스키마 설계는 중복을 줄여 정합성을 지키는 일과, 필요한 조회를 빠르고 단순하게 만드는 일 사이의 균형이다.
AI 생성 이미지
이상 현상은 실제 서비스에서 조용히 발생한다
정규화를 이해하려면 삽입 이상, 갱신 이상, 삭제 이상을 먼저 봐야 한다. 이상 현상이란 테이블 구조가 좋지 않아 정상적인 데이터 변경이 어렵거나, 변경 후 데이터가 모순되는 상황을 말한다.
예를 들어 주문 정보를 한 테이블에 모두 저장한다고 하자.
order_rows 테이블에 주문 ID, 고객 ID, 고객 이메일, 고객 이름, 배송지, 상품 ID, 상품명, 상품 가격, 주문 수량을 모두 넣는 구조다. 처음에는 단순해 보인다. 주문 목록 화면을 만들 때 한 테이블만 조회하면 되기 때문이다. 하지만 문제가 생긴다.
삽입 이상은 아직 주문이 없는 상품을 등록할 수 없는 상황이다. 상품명과 가격을 저장하려면 주문 ID도 필요하다. 즉 상품이라는 독립적인 데이터를 주문이 발생하기 전에는 넣기 어렵다.
갱신 이상은 같은 사실을 여러 행에서 수정해야 하는 상황이다. 고객이 이름을 바꾸면 그 고객의 과거 주문 행 전체에 저장된 고객 이름도 바꿔야 한다. 일부 행만 수정되면 같은 고객 ID에 서로 다른 고객 이름이 남는다.
삭제 이상은 어떤 데이터를 지우다가 보존해야 할 다른 사실까지 사라지는 상황이다. 마지막 주문 행을 삭제했는데, 그 상품의 이름과 가격 정보까지 함께 사라질 수 있다. 주문 기록을 지운 것뿐인데 상품 정보도 잃어버린다.
이 문제는 테이블이 너무 크기 때문에 생기는 것이 아니다. 서로 다른 의미의 데이터를 한 테이블에 섞었기 때문에 생긴다. 고객, 상품, 주문, 주문 항목은 각각 변경 주기와 식별자가 다르다. 이런 대상은 분리해서 설계하는 편이 안전하다.
제1정규형부터 제3정규형까지: 암기보다 의존 관계
정규형은 외워야 할 공식처럼 보이지만, 실무에서는 중복의 원인을 찾는 도구로 이해하는 편이 낫다.
제1정규형은 한 칸에 하나의 값만 저장하라는 원칙이다. 예를 들어 users 테이블의 phone_numbers 컬럼에 010-1111-1111, 010-2222-2222처럼 쉼표로 여러 전화번호를 넣으면 검색과 수정이 어려워진다. 특정 전화번호를 가진 사용자를 찾으려면 문자열 검색을 해야 하고, 전화번호 하나만 삭제하는 일도 복잡해진다. 이런 경우 user_phone_numbers처럼 별도 테이블로 분리하는 것이 자연스럽다.
물론 모든 배열 형태 데이터가 잘못된 것은 아니다. PostgreSQL의 배열 타입이나 JSON 컬럼처럼 복합 값을 저장할 수 있는 기능도 있다. 하지만 자주 검색하거나, 개별 값에 제약 조건을 걸거나, 다른 테이블과 관계를 맺어야 한다면 별도 테이블이 더 적합하다.
제2정규형은 복합 키를 사용할 때 특히 중요하다. 주문 항목 테이블 order_items의 기본 키가 order_id와 product_id의 조합이라고 하자. 여기에 상품 마스터의 현재 이름인 product_name을 함께 저장하면 문제가 된다. 상품명은 주문 ID와 상품 ID의 조합이 아니라 상품 ID에만 의존한다. 같은 상품이 여러 주문에 등장할 때 상품명이 반복 저장된다. 이 경우 상품의 현재 정보는 products에 두고, 주문 항목은 product_id, 수량, 주문 시점 가격처럼 주문 항목 자체에 필요한 값만 가져야 한다.
단, 주문 당시 화면과 영수증에 표시해야 하는 상품명은 이야기가 다를 수 있다. 상품명이 나중에 바뀌어도 과거 주문의 상품명은 그대로 남아야 한다면, 주문 항목에 주문 시점 상품명을 스냅샷으로 저장하는 것이 맞다. 같은 product_name이라는 컬럼처럼 보여도, "상품의 현재 이름"인지 "주문 당시 이름"인지에 따라 설계 판단이 달라진다.
제3정규형은 기본 키가 아닌 컬럼끼리의 의존을 제거하는 원칙이다. 예를 들어 users 테이블에 membership_grade와 discount_rate를 함께 저장한다고 하자. 할인율이 회원 등급에 따라 정해진다면 discount_rate는 사용자 ID가 아니라 회원 등급에 의존한다. 할인 정책이 바뀔 때 모든 사용자의 할인율을 수정해야 한다. 이때는 membership_policies 같은 테이블로 등급과 할인율을 분리하는 편이 낫다.
정리하면 제1정규형은 반복 값 제거, 제2정규형은 복합 키 일부에만 의존하는 값 제거, 제3정규형은 키가 아닌 값에 의존하는 값 제거에 가깝다. 핵심 질문은 항상 같다. 이 컬럼은 무엇에 의해 결정되는가?
제약 조건은 데이터베이스가 지키는 마지막 방어선이다
애플리케이션 코드에서 검증을 잘해도 데이터베이스 제약 조건은 필요하다. 데이터는 하나의 API에서만 들어오지 않는다. 관리자 도구, 배치 작업, 마이그레이션 스크립트, 여러 서버 인스턴스, 외부 연동을 통해 들어올 수 있다. 데이터베이스 제약 조건은 모든 진입 경로에 동일하게 적용되는 마지막 방어선이다.
유니크 제약은 중복되면 안 되는 값을 막는다. 이메일, 사용자명, 외부 결제 ID, 주문 번호가 대표적이다. 애플리케이션에서 먼저 중복 검사를 하면 사용자에게 친절한 메시지를 줄 수 있다. 하지만 동시에 두 요청이 들어오면 둘 다 중복이 없다고 판단한 뒤 저장을 시도할 수 있다. 최종적으로는 데이터베이스의 유니크 제약이 중복을 막아야 한다.
체크 제약은 컬럼 값의 기본 범위를 보장한다. 가격은 0 이상이어야 하고, 수량은 양수여야 하며, 할인율은 0에서 100 사이여야 한다. 이런 규칙은 애플리케이션에서도 검증해야 하지만, 데이터베이스에도 남겨두는 것이 좋다. 단, 복잡한 업무 규칙을 모두 체크 제약으로 넣는 것은 위험할 수 있다. 예를 들어 특정 국가, 특정 회원 등급, 특정 기간에만 적용되는 할인 규칙은 코드나 정책 테이블로 관리하는 편이 낫다.
외래 키 제약은 존재하지 않는 대상을 참조하지 못하게 한다. orders.customer_id가 users.id를 참조한다면, 없는 고객의 주문은 저장될 수 없다. 외래 키는 관계의 정합성을 강하게 지켜준다. 다만 대량 적재, 서비스 간 데이터 소유권 분리, 매우 높은 쓰기 처리량이 필요한 시스템에서는 외래 키 적용 여부를 신중히 판단해야 한다. 외래 키를 사용하지 않는다면 애플리케이션과 배치 검증에서 그 책임을 대신 져야 한다.
좋은 기준은 이렇다. 변하지 않는 기본 규칙은 데이터베이스 제약으로 둔다. 사용자 경험을 위한 사전 검증은 애플리케이션에서 한다. 자주 바뀌는 업무 정책은 코드나 정책 테이블로 분리한다.
반정규화는 중복이 아니라 책임이다
정규화된 구조는 데이터 변경에 강하다. 하지만 조회 화면에서는 여러 테이블을 조인해야 할 수 있다. 주문 목록에서 주문 번호, 주문 상태, 고객 이름, 결제 금액, 최근 배송 상태를 한 번에 보여줘야 한다면 매 요청마다 많은 조인이 발생한다.
이때 반정규화를 고려할 수 있다. 반정규화는 조회 성능이나 개발 단순성을 위해 의도적으로 중복 컬럼이나 집계 테이블을 두는 설계다. 중요한 점은 중복 자체가 아니라, 그 중복을 어떻게 최신 상태로 유지할지 정하는 것이다.
예를 들어 주문 테이블에 total_amount를 저장하는 것은 흔한 반정규화다. 원칙적으로는 주문 항목의 가격과 수량을 합산하면 구할 수 있다. 하지만 주문 총액은 거의 모든 주문 조회에서 필요하고, 주문 생성 이후 자주 바뀌지 않는다. 이 경우 주문 생성 시 총액을 계산해 저장하는 편이 합리적이다.
배송지 스냅샷도 비슷하다. 사용자의 현재 주소는 users나 addresses에 있지만, 주문 당시 배송지는 주문에 별도로 저장해야 한다. 사용자가 나중에 주소를 바꿔도 과거 주문의 배송지는 바뀌면 안 된다. 이 중복은 성능 때문이 아니라 업무 의미 때문에 필요하다.
집계 테이블은 읽기 부하가 큰 서비스에서 유용하다. 상품별 일 매출, 게시글별 댓글 수, 사용자별 누적 포인트처럼 매번 원본 데이터를 세기 부담스러운 값은 별도 테이블에 저장할 수 있다. 대신 집계 지연, 재계산 방법, 장애 시 복구 절차를 함께 설계해야 한다.
반정규화를 도입할 때는 네 가지를 확인하자.
조회 패턴이 충분히 명확한가?
중복된 값의 원본은 무엇인가?
원본이 바뀔 때 중복 값은 언제, 누가 갱신하는가?
불일치가 발생했을 때 탐지하고 복구할 방법이 있는가?
이 질문에 답하지 못한 반정규화는 성능 최적화가 아니라 미래의 장애 원인이다.
이름, 타입, 상태값은 오래 남는다
스키마의 세부 규칙은 사소해 보이지만 오래 영향을 준다. 컬럼 이름 하나도 API, 로그, 분석 쿼리, 운영 도구에 퍼진다. 나중에 바꾸기 어렵다.
이름은 일관성이 중요하다. 테이블 이름을 복수형으로 쓸지 단수형으로 쓸지, 생성 시각을 created_at으로 쓸지 created_date로 쓸지 팀 기준을 정해야 한다. data, info, value처럼 의미가 넓은 이름은 피하는 편이 좋다. 시간이 지나면 무엇을 담는 컬럼인지 코드 없이는 알 수 없게 된다.
컬럼 타입은 저장 공간뿐 아니라 정합성과 성능에도 영향을 준다. 금액은 부동소수점 타입보다 정수의 최소 화폐 단위나 고정 소수점 타입을 사용하는 편이 안전하다. 시간은 타임존 정책을 명확히 해야 한다. 글로벌 서비스를 고려한다면 저장은 UTC 기준으로 하고, 사용자에게 보여줄 때 지역 시간으로 변환하는 방식이 일반적이다.
문자열 길이는 단순한 형식 검증 수단이 아니다. 이메일, 국가 코드, 전화번호처럼 도메인별 특성이 있는 값은 애플리케이션 검증과 데이터베이스 타입을 함께 고려해야 한다. JSON 컬럼은 유연하지만, 자주 검색하고 조인해야 하는 값까지 JSON 안에 넣으면 쿼리와 인덱스 설계가 어려워진다. 구조가 자주 바뀌고 조회 조건으로 거의 쓰지 않는 부가 정보에 더 적합하다.
상태값 설계도 중요하다. status 컬럼 하나로 주문 상태를 표현할 때는 가능한 값과 전이 규칙을 정해야 한다. pending, paid, shipped, cancelled처럼 상태를 두었다면, 결제 완료 주문이 다시 대기 상태로 돌아갈 수 있는지, 배송 완료 후 취소가 가능한지 같은 규칙이 필요하다. 상태값은 단순 문자열이 아니라 업무 흐름의 압축된 표현이다.
좋은 스키마는 변경 가능성을 관리한다
좋은 스키마는 처음부터 완벽한 구조가 아니다. 변경될 수 있는 것과 변경되면 안 되는 것을 구분한 구조다.
사용자의 현재 닉네임은 바뀔 수 있다. 하지만 결제 시점의 결제 금액, 주문 시점의 상품명, 계약 체결 시점의 약관 버전은 과거 사실로 남아야 한다. 이런 값은 원본 테이블을 참조하는 것만으로는 부족할 수 있다. 스냅샷 컬럼이나 이력 테이블이 필요하다.
반대로 현재 상태만 의미 있는 값도 있다. 사용자의 마지막 로그인 시각, 장바구니 수량, 임시 인증 코드처럼 과거 이력을 모두 남길 필요가 없는 데이터도 있다. 모든 변경을 이력화하면 저장 공간과 쿼리 복잡도가 늘어난다.
스키마를 설계할 때는 각 컬럼마다 세 가지를 물어보면 좋다.
이 값은 어떤 엔티티에 속하는가?
이 값은 무엇이 바뀔 때 함께 바뀌는가?
이 값은 현재 상태인가, 과거 시점의 기록인가?
이 질문에 답할 수 있으면 정규화와 반정규화의 판단이 쉬워진다. 정규화는 데이터를 깔끔하게 나누는 기술이 아니라, 변경의 영향을 좁히는 방법이다. 반정규화는 무작정 합치는 기술이 아니라, 읽기 비용을 줄이기 위해 관리 책임을 명시적으로 받아들이는 선택이다.
스키마 설계의 목표는 이론적으로 가장 아름다운 테이블 구조가 아니다. 서비스가 성장해도 데이터의 의미가 흔들리지 않고, 변경과 조회를 예측 가능한 비용으로 처리할 수 있는 구조를 만드는 것이다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.