[인프런 김영한] 실전 데이터베이스 설계 1

Jong Hwan
|2026. 3. 29. 15:42

자연 키 vs 대리 키

  • 비즈니스 로직에서 자연스럽게 생성되는 의미가 있는 값인 자연 키변경 가능성이 있다.
  • PK가 변경된다는 것은, 이와 연관관계를 맺고 있는 모든 테이블의 FK가 변경되어야 함을 의미한다.
  • 이는 FK 제약 조건 위반, 데이터 역사성 왜곡, 외부 시스템 연동 문제 등의 심각한 문제를 야기한다.
  • 즉, PK는 절대 변하지 않는 값인 대리 키 (Auto Increment, UUID 등)로 설정해주어야 한다.
  • 따라서 비즈니스 로직과 무관한 대리 키를 PK로 설정함으로써 느슨한 결합을 해주는 것이다.
  • 대리 키는 PK자연 키는 UNIQUE

 

 

 

복합 키

  • 복합 키를 구성할 때 자연 키들로 구성한다면 비즈니스 변화에 취약해진다.
  • 복합 키를 구성하고 있는 모든 키를 FK로 가지고 있어야 하므로 쓰기/조회 성능 저하된다.
  • 또한 직관적이지 않으며, 조인 쿼리 조건이 길어져 유지보수에 어려움을 겪게 된다.
  • 따라서 마찬가지로, PK는 대리 키로 두고 자연 키 모음들은 UNIQUE 제약조건으로 무결성을 지켜주면 된다.

 

 

 

다대다 관계와 복합키

  • 다대다 관계를 해소하기 위해 연결 테이블을 만드는데, 이 때 두 테이블의 PK (subject_id, target_id) 를 FK로 받아서 관계를 맺어준다.
  • 이 때 {subject_id, target_id}로 복합 키로 구성하던가, permission_id의 별도 대리 키를 PK로 두고 subject_id, target_id는 UNIQUE 제약조건을 거는 두 가지 방법이 있다.
  • 복합 키 구성 방식은 PK의 크기 자체가 크기 때문에 다른 테이블에서 참조 관계를 맺기 위해서는 복합 키를 구성하고 있는 컬럼들을 모두 가져가야 하므로 확장성이 떨어진다.
  • 별도 대리 키 구성 + UNIQUE 제약조건 방식은, 참조를 할 때 단일 대리 키 PK만 컬럼으로 가지면 되기에 구조가 단순하고 명확하지만, 기본 키 인덱스와 UNIQUE 인덱스가 별도로 생성되므로 약간의 저장 공간 및 인덱스 관리 비용이 추가될 수 있지만 현재 하드웨어 환경에서는 거의 무시해도 되는 수준이다.

 

 

즉, 대리 키를 PK로 사용하는 것이 현대 개발 환경의 표준적인 선택이다.

 

 

 

 

1:N, M:N 관계

  • 컬럼 하나에 여러 개 값을 (1, 2, 3 ..) 저장하는 방식은 관계형 데이터베이스의 장점을 모두 포기하는 최악의 설계이므로 절대 사용해선 안된다. -> 컬럼은 원자적이어야 한다. / FK는 단일 행만 참조해야 한다.
  • FK를 가지고 있는 테이블 (N)을 자식 테이블, 자식 테이블에 의해 참조되는 테이블 (1)을 부모 테이블이라고 한다.
  • N:1 방향 (FK -> PK)으로 조인을 할 때는 데이터 뻥튀기가 없지만, 1:N 방향 (PK -> FK)으로 조인을 하면 데이터가 뻥튀기될 수 있다.

 

 

1:1 관계 설계

  • 보통 하나의 테이블에 합칠 수 있지만, 성능/구조 등의 트레이드오프를 고려해서 테이블을 분리 (1:1 관계)하면 좋다.
    • 관심사의 분리, 독립적인 확장성 (한 테이블의 컬럼이 몇 십개면 설계를 의심해봐야 한다.)
    • 1:1 관계에서는 FK의 위치를 고민하는 것이 중요하다.

 

  •  보조 테이블에 FK를 두는 설계
    • 보조 테이블이 추가되더라도 주 테이블은 수정하지 않아도 되기 때문
    • 주 테이블에 FK를 두면, 보조 테이블 정보가 필요 없을 때 해당 컬럼이 NULL로 들어가기 때문
    • 이후 1:N 관계로 확장될 때, 보조 테이블 FK의 UNIQUE 제약 조건만 해제하면 쉽게 확장할 수 있기 때문
    • 보조 테이블에 대한 정보를 알기 위해서는 JOIN을 해야 한다는 단점이 있다.
    • 즉, OCP를 지킬 수 있는 설계이기 때문에 보조 테이블에 FK를 두는 방식을 권장한다.
    • 하지만, 주 테이블만으로 보조 테이블에 대한 정보를 알 필요가 있다면 주 테이블에 FK를 둘 수도 있다.

 

  • 주 테이블에 FK를 두는 설계
    • 주 테이블에 데이터가 들어올 때, 보조 테이블에 대한 정보도 무조건 가지는 비즈니스 요구사항일 때
      -> 주 테이블의 보조 테이블 FK에 NOT NULL 제약조건 설정. 보조 테이블에 데이터를 먼저 삽입해야 함.
    • 조회 성능이 극도로 중요할 때.
      -> 섣부른 최적화는 금물이다. 대부분은 JOIN이 성능에 큰 영향을 미치지 않고, 인덱스 설계를 잘 하는 것만으로 충분히 빠르다. 성능 테스트로 명확한 병목 지점이 확인되고 다른 튜닝이 먹히지 않을 때 비로소 선택할 만하다. 처음부터 성능을 예측해서 이 방법을 선택하는 것은 나쁜 설계로 이어질 가능성이 높다.

 

 

 

도메인 별로 상황이 다 다를 수 있다.
내 경험으로만 판단하지 말고 항상 열린 자세로 트레이드 오프를 고려하는 것이 좋은 자세다.

 

 

 

 

M:N 관계 설계

  • 두 테이블만 가지고는 M:N 관계를 나타낼 수 없다. 별도의 연결 테이블을 중간에 둬야 한다.
  • 두 테이블로 M:N 관계를 표현하려면, 한 row에 대해 FK를 {a,b,c}의 방식처럼 원자성이 깨지게 저장을 해야 한다.
  • 또는 동일한 PK를 가진 row를 여러 개를 insert하고 각기 다른 FK를 갖도록 하는데, 이는 제약조건상 불가능하다.
  • 즉, 관계 자체하나의 독립된 데이터로 보고, 그것을 테이블로 모델링하는 것이 M:N 관계를 표현하는 설계이다.
  • 즉, 두 테이블 사이에서 발생할 수 있는 관계의 경우의 수를 하나하나의 독립된 데이터로 저장하는 것. 
  • 또한 두 테이블 간 관계에서 발생하는 새로운 속성이 생길  있는데, 이러한 속성을 연결 테이블이 가질 수 있다.
  • 즉, 연결 테이블은 단순히 M:N 관계를 해소하기 위한 테이블이 아닌 비즈니스적인 의미가 있는 테이블이 될 수 있다.
  • 연결 테이블이 비즈니스적인 의미를 가진다면 테이블의 이름 또한 비즈니스 의미에 맞는 이름으로 설정해야 좋다.

 

 

 

 

식별, 비식별 관계

  • 부모 PK를 자신의 PK의 일부로 사용한다면 식별 관계, 그렇지 않고 그냥 일반 컬럼으로 사용하면 비식별 관계이다.
  • 즉, 자식의 식별자가 부모의 식별자를 포함해야만 완전해지면 (식별될 수 있는) 식별 관계.
  • 부모의 식별자 없이 자식의 식별자만으로 식별될 수 있다면 비식별 관계.
  • 강한 관계와 식별 관계 - 약한 엔티티 (부모가 있어야 존재할 수 있는 자식 엔티티)
  • 약한 관계와 비식별 관계 - 강한 엔티티 (부모 없이 존재할 수 없는 자식 엔티티)

 

1:N에서의 식별, 비식별 관계

  • 자식 테이블에서 부모의 PK와 자신의 PK로 복합키로 구성하면 식별 관계이다.
    • 부모가 무조건 존재해야 하므로 (부모 PK 필수) 데이터 정합성인덱스 효율이 향상될 수 있다.
    • 하지만 두 키를 모두 가지고 있어야 하므로 유연성이 없다. -> 참조하는 부모가 바뀔 수 없다. (참조하는 부모 id가 PK로 구성되어 있으므로)
    • 확장성이 떨어진다. -> 식별 관계인 자식 테이블을 부모로 두는 손자 테이블이 추가된다면 그 손자 테이블의 PK는 계속해서 길어져야 한다.
  • 위와 같은 이유 등으로, 현대 개발에서는 별도 독립적인 PK를 갖는 비식별 관계로 관계를 맺는 것을 권장한다.
  • 비즈니스 요구사항이 빠르게 변화하는 현대 개발 환경에서 유연성, 확장성이 떨어지기 것이 주 요인이다.
  • 비식별 관계를 사용한다면, 부모 FK에 별도로 인덱스를 생성해주어야 성능을 챙길 수 있고, 부모 테이블에서 자손 테이블을 조회하려면 부모 join 자식 join 자손 이런 식으로 조회가 필요하다는 트레이드 오프는 있다.

 

1:1에서의 식별, 비식별 관계

  • 식별 관계에서는 자식 테이블의 PK가 곧 부모 테이블의 PK이다.
    • 강한 논리적 결합을 테이블 구조로 표현할 수 있고 UNIQUE 제약조건이 필요 없다.
    • 부모 테이블과 강하게 결합되어 유연성이 낮다.
  • 비식별 관계에서는 자식 테이블의 PK는 독립적으로 두고, 부모 테이블의 PK는 별도 컬럼 FK로 갖는다.
    • 독립성과 유연성이 뛰어나고, 다른 테이블 설계와 마찬가지로 독립적인 대리 키를 가지므로 일관성이 있다.
    • FK 컬럼에 UNIQUE 제약조건을 걸어줘야 한다.
  • 실무에서는 유연성과 확장성이 매우 중요한 가치이므로, 비식별 관계를 사용하는 것을 권장한다.
  • 특히 1:1 관계에서 1:N 으로 확장된다면 식별 관계인 경우 구현이 매우 까다롭고 복잡해진다.

 

M:N에서의 식별, 비식별 관계

  • 이제껏 바왔던 예시들과 동일하다. 결론부터, 확장성/유연성을 위해 M:N에서도 비식별 관계가 권장된다.
  • 부모 테이블의 FK로 복합키를 구성하는 식별 관계에서는 강력한 데이터 정합성을 보장하지만 확장성과 유연성이 떨어진다.
  • 독립적인 대리 키를 가지고 부모 테이블의 FK는 별도 컬럼으로 가지는 비식별 관계에서는 FK들에 UNIQUE 제약조건을 걸어줘야 하지만, 유연성과 확장성이 뛰어나다.

 

 

 

정규화

  • 제1 정규형
    • 모든 컬럼이 원자적인 것.
  • 제2 정규형
    • 제1 정규형을 만족하면서, 부분 함수 종속이 없는 것. (모든 컬럼이 기본 키 전체에 완전 함수 종속)
    • 복합 키를  사용하지 않고, 대리 키를 사용하면 자연스럽게 만족된다.
    • ex. 중복되는 컬럼들이 생성되는 부분들을 추려내서 별도 테이블로 분리
  • 제3 정규형
    • 제2 정규형을 만족하면서, 이행적 함수 종속이 없는 것.(기본 키가 아닌 다른 컬럼에 종속되는 컬럼이 없는 것)
    • ex. {pk, member_id, member_name} -> member_name이 pk에 종속되지 않고 member_id라는 컬럼에 종속. 즉, pk -> member_id -> member_name의 이행적 관계
  • BCNF 정규형
    • 기본 키가 아닌 컬럼이 다른 컬럼을 결정한다면, 그 컬럼은 일반적인 컬럼이어선 안되고 후보 키여야 한다.

 

 

 

물리적 설계

  • 테이블과 컬럼 변환
    • 각 테이블 PK는 조인 시 직관성을 위해, 구체적인 컬럼명 (member_id) 으로 지정해주는 것이 좋다.
    • 컬럼명을 축약한다면, 다른 동의어들과 혼동되지 않도록 설정해야 하고 용어 사전에 명시해주어야 한다.
    • 보편적인 축약어는 사용하는 것이 좋다. (average -> avg, number -> no 등)
    • 본질은 유지보수다. 미래의 누군가가 직관적으로 의미를 파악할 수 있을지를 염두해야 한다.
    • 의심스러우면, 축약하지 말고 전부 써라. 모호하지 않은 선에서 가장 간결한 이름을 사용해라.
  • 데이터 타입
    • 필요한 것 보다 데이터 타입의 크기가 더 크면 한 번에 읽어올 수 있는 데이터의 양이 줄어들면서 성능이 저하되며, 저장 공간이 낭비된다.
    • 문자열의 경우 웬만하면 가변 길이 문자열인, VARCHAR를 넉넉한 길이로 지정해서 사용하자.
    • 소수 타입의 경우, 부동 소수점 연산 오차가 발생해선 안되는 경우엔 DECIMAL을 사용해야 한다.
    • PK인 경우 웬만하면 BIGINT를 사용하는 것이 좋다. 데이터가 1억건 정도가 쌓여야 INT와 BIGINT의 메모리 공간 차지 차이가 380 MB 정도이다.
    • 즉, INT를 사용해서 얻는 약간의 성능상 이점보다, 나중에 INT -> BINGINT로 변환하는 비용이 훨씬 크다.
    • 또한 모든 테이블의 PK가 BIGINT를 사용한다는 일관성을 지킬 수 있다.
    • 생성일과 수정일은 기본으로 깔고 간다.

 

 

 

용어 사전과 테이블 정의서는 유지보수를 위한 필수 문서이다.



역정규화

  • 읽기 속도를 위해 쓰기 속도와 데이터 일관성을 일부 희생하는 것.
  • 반드시 정규화된 모델을 기본으로 두고 역정규화를 고민해봐야 한다. 섣불리 적용해선 안된다. 추측만으로 역정규화를 진행해서는 안된다. 반드시 정규화를 한 상태에서 설계를 하고, 오픈 전 성능 테스트 등으로 도출된 병목 지점에 역정규화를 실시.

 

역정규화의 가장 큰 대가는 데이터 불일치 위험이다. 

  • 1:1 관계의 두 테이블을 합친다면 JOIN이 없어져서 조회 성능은 좋아지지만 두 테이블 데이터의 생명주기가 다르기에, 불필요한 NULL 값이 들어가서 저장 공간이 낭비되고 데이터의 의미를 불분명하게 만든다.
  • 또한 데이터의 변경 빈도가 다르며, 비즈니스가 다른 두 테이블을 하나의 테이블에 합치는 것이므로 SRP를 위반한다.
  • 그리고 무엇보다, 성능상 이점이 크지가 않다.

 

따라서 데이터 불일치를 보장하기 위해,

  • 데이터 무결성을 반드시 애플리케이션 레벨에서 보장해주거나,
  • 데이터베이스 트리거로 구현하거나,
  • 배치 작업 스케줄러로 보장해주어야 한다.

 

요즘 통신 기기들은 속도가 빠르므로, 조인 성능이 크게 문제가 되지 않는 경우가 많다.

보통 성능 향상 방법이 RDB에서만 있는 것이 아니고 Redis 등 캐시를 이용해서 최적화 하는 방식을 사용하기도 한다.

즉, 역정규화는 정규화된 모델에서 시작해서 실제 운영 환경에서 발생한 성능 문제를 해결하기 위한 최후의 수단으로 사용한다.

언제나 그렇듯, 효과적인 트레이드 오프를 고려할 수 있도록 좋은 고민을 할 수 있는 역량을 길러야 한다.

 

 

 

 

데이터의 생명주기와 변경 빈도에 따라 테이블을 설계하자.
함께 변경될 가능성이 높은 것들은 모으고, 그렇지 않은 것들은 분리한다.

 

 

 

 

 

인덱스 설계

  • 복합 인덱스로 설계하는 것은 쓰기 비용 및 저장 공간을 더 많이 차지하는 것을 의미한다.
  • 이미 충분히 빠른 쿼리에 추가 인덱스를 생성하는 것은, 얻는 이점에 비해 잃는 것이 더 큰, 과잉 최적화로 이어질 수 있다.
  • 조회와 쓰기의 빈도, 그리고 비즈니스에서 핵심 기능이 되는 쿼리라서 병목점이 될 때 복합 인덱스 추가를 고려할 수 있다.
  • 비즈니스에 맞게 최적화된 인덱스를 설계할 수 있어야 한다.

인덱스 추가를 결정하는 기준

  • 데이터 분포와 조회 효율성
    • 단일 컬럼 인덱스로 필터링 했을 때 남는 데이터의 양이 얼마나 되는지를 기준으로 판단한다.
    • 수백 건 이하라면 충분히 좋은 성능이다.
    • 수천 건 이상이라면 복합 인덱스가 효과를 발휘하기 시작한다.
    • 서비스 규모가 아주 큰 경우라면 복합 인덱스를 고려해야 한다. 또는 별도 캐싱으로 성능 최적화를 볼 수 있다.
  • 쓰기의 빈도와 중요도
    • 쓰기가 빈번하다면, 인덱스 추가는 성능 저하를 유발할 수 있다.
    • 따라서 읽기 성능의 개선 효과가 쓰기 성능 저하로 인한 손실보다 확실히 클 때만 인덱스를 추가 해야 한다.
  • 실제 쿼리 패턴과 성능 측정 (가장 중요)
    • 실제 DB의 느린 쿼리를 분석하고 시스템에 부하를 주고 있는지 확인한다.
    • EXPLAIN을 사용해 실제 쿼리가 해당 인덱스를 사용한 후에 얼마나 많은 행을 스캔하는지 확인한다.
    • 성능 테스트로 인덱스 추가 전/후의 조회 성능 및 쓰기 성능을 비교 측정한다.

핵심은, 예상 가능한 확실한 경우가 아니라면 미리 짐작해서 최적하하지 말고, 문제가 발생하기 전에 데이터를 기반으로 판단하고 개선한다는 것이다. 또한, 문제가 고객에게 전파되기 전에 개발자가 미리 발견해서 해결해야 한다.