
오프라인에서 대박을 친 스타트업의 온라인 쇼핑몰을 개발하는 일에 투입됐다고 가정해보자. 당연히 요구 사항은 어마어마하게 많을 것이다. 중요한 것은 모든 요구 사항을 일정 안에 한 번에 반영하는 것이 아니라, 지금 서비스에 당장 필요한 기능이 무엇인지에 대해 집중해야 한다. 단순히 기술 구현에만 매몰되지 않고, 비즈니스의 성공이라는 큰 그림을 봐야 한다.
이렇게 핵심 요구 사항을 정의하는 과정을 MVP(Minimum Viable Product)를 정의한다고 한다. 논의 끝에 아래와 같은 1차 오픈 스펙이 정해졌다고 가정해보자.
이제 위 기능 명세서에서 핵심 엔티티를 도출해야 한다. 엔티티(Entity)는 저장할 만한 가치를 지닌 정보를 여러 개 가지고 있으면서, 다른 것과는 명확히 구분되는 유무형의 모든 것이다. 특정 대상이 엔티티인지 아닌지 판단하기 위해서는 해당 대상에 “우리 시스템이 저장하고 관리해야 할 대상인가?” 라고 질문을 던져보자. 보통 명사를 뽑아내면 된다.
여기서 “주문” 과 “배송” 을 별도의 엔티티로 분리한 것에 주목해야 한다. “주문” 은 “결제” 까지 포함하는 비즈니스 트랜잭션의 단위이고, “배송” 은 “물류” 라는 별개의 프로세스다. 이렇게 역할을 분리하면 나중에 “주문은 완료되었지만 배송은 시작 전” 과 같은 다양한 상태를 명확하게 관리할 수 있고, 각자의 책임이 분명해져 시스템을 유지보수하기 쉬워진다.
이제 각 엔티티가 어떤 구체적인 데이터 항목들을 가져야 하는지, 엔티티 사이에 어떤 관계로 연결되어 있는지를 요구 사항을 기반으로 정의해야 한다.
여기서 눈에 띄는 관계가 있는데, 바로 주문과 상품이 다대다 관계로 연결되어 있다는 점이다. 상황에 따라 이렇게 다대다 관계로 연결되어 있는 엔티티들은 몇 가지 문제를 일으킬 수 있다.
“하나의 주문에는 여러 상품이 포함될 수 있고, 하나의 상품은 여러 주문에 포함될 수 있다.”
말만 들어보면, 아주 타당하다. 하지만 이걸 실제 데이터베이스의 테이블 구조로 옮길 수 없다. 관계형 DB는 테이블이라는 2차원 표 형태를 사용하며, 관계는 한 테이블의 기본 키(Primary Key)를 다른 테이블의 외래 키(Foreign Key)로 포함시켜 표현한다.
이 방식을 다대다 관계에 그대로 적용해보도록 하자. 네이트(회원 id: 1)가 주문 1001번으로 상품 101(키보드)과 상품 102(마우스)를 주문했다고 가정해보자.
| 주문 id (PK) | 회원 id | 주문 일시 | 상품 id (FK?) |
|---|---|---|---|
| 1001 | 1 | 2026-09-17 | 101 |
| ??? | 1 | 2026-09-17 | 102 |
보다시피 주문 id가 1001인 주문에 마우스를 담을 수가 없다. 주문 id를 1002로 해서 새로운 주문을 생성해버리면 그건 더 이상 같은 주문이 아니게 되므로 의미가 없다. 이 구조는 하나의 주문에 단 하나의 상품만 가질 수 있기 때문에 여러 상품을 주문한 경우를 표현할 수 없다.
| 주문 id (PK) | 회원 id | 주문 일시 | 상품1 id | 상품2 id | 상품3 id |
|---|---|---|---|---|---|
| 1001 | 1 | 2026-09-15 | 101 | 102 | NULL |
| 1002 | 3 | 2026-09-16 | 103 | NULL | NULL |
| 1003 | 1 | 2026-09-17 | 102 | 103 | 104 |
대충 봐도 상황이 더 심각해졌다. 만약 어떤 고객이 상품을 4개 이상 주문한다면 개발자가 일일이 DDL을 실행해야 한다. 그리고 대부분의 주문에 어마어마하게 많은 상품들이 포함되지는 않을 텐데, 컬럼을 미리 많이 만들어두면 저장 공간이 상당 부분 낭비될 것이다. 마지막으로 특정 상품이 포함된 주문들을 모두 찾고 싶은 경우에는 WHERE 절에 모든 상품 id 컬럼을 작성해야 한다.
| 주문 id (PK) | 회원 id | 주문 일시 | 상품 ids |
|---|---|---|---|
| 1001 | 1 | 2026-09-15 | “101, 102” |
| 1002 | 2 | 2026-09-17 | “101, 102, 103” |
결국 최악의 실수를 저질러버리고 말았다. 이 방식은 관계형 DB의 근간을 흔드는 최악의 설계다.
만약 마우스가 포함된 모든 주문을 찾기 위해서는 매번 복잡하고 비효율적인 텍스트 검색을 해야 하고, 데이터를 수정하거나 삭제해야 할 경우에는 문자열을 긁어와서 파싱한 후에 다시 저장해야 한다. 그리고 무엇보다 이 설계는 데이터베이스의 제 1정규형, “테이블의 모든 칸은 원자적인 값 하나만 가져야 한다” 는 대원칙을 위반한다.
주문과 상품 엔티티의 관계를 잘 들여다보면, 주문이 발생할 그 순간에만 의미를 갖는 데이터들이 존재한다. 현재 엔티티들로는 이 데이터들을 저장할 장소가 없다는 것이 문제다. 주문이 발생한 이후에 상품 정보가 일부 수정되었다고 가정해보자.
| 상품 id (PK) | 상품명 | 가격(원) | 재고 수량(개) |
|---|---|---|---|
| 101 | 기계식 키보드 | 120,000 | 50 |
| 102 | 무소음 마우스 | 45,000 | 100 |
| 상품 id (PK) | 상품명 | 가격(원) | 재고 수량(개) |
|---|---|---|---|
| 101 | 기계식 키보드 | 150,000 | 20 |
| 102 | 무소음 마우스 | 40,000 | 120 |
만약 고객이 기계식 키보드와 무소음 마우스를 각각 1개씩 구매했는데, 주문 이후에 가격이 변경되었다면 주문 당시의 주문 수량과 주문 가격은 어디에 저장해야 할까? 주문 테이블? 상품 테이블? 근데 생각해보면, 주문 테이블에는 변경 이전과 이후의 가격 중 어떤 가격을 컬럼에 넣어야 할지도 모르고, 상품 테이블에 넣자니 만약 상품이 없어졌다면…? 둘 다 말이 안 된다. 이렇게 주문 수량이나 주문 당시 가격은 주문이나 상품에 속한 속성이 아니라는 느낌이 강하게 온다. 이들은 주문과 상품이 관계를 맺음으로써 발생하는 속성이다.
해결책은 다대다 관계를 연관 엔티티(Associative Entity)로 바꾸는 것이다. 두 엔티티의 관계를 나타내는 새로운 엔티티를 만들어, 기존의 다대다 관계를 2개의 일대다 관계로 풀어내는 방식이다.

여기서 주문 항목(order_item)이라는 새로운 연관 엔티티를 만드는 것이다.

이런 식으로 다대다 관계를 일대다(주문 & 주문 항목), 다대일(주문 항목 & 상품) 관계로 풀어내면 아래와 같이 깔끔하게 설명이 가능하다.
이처럼 다대다 관계를 발견하면 단순히 연관 엔티티를 설계하는 것을 넘어, 해당 관계가 만들어지는 순간에만 의미를 갖는 데이터는 무엇인지를 적극적으로 질문하고 찾아내야 한다.
지금까지 분석한 MVP 기능을 바탕으로 개념적 모델 ERD를 그려보면 아래와 같다.

프로젝트의 모든 이해 관계자들은 모두 같은 비즈니스 용어를 사용하는 것이 중요하다. 그렇지 않으면 시스템 구조를 파악하는 데 엄청난 시간과 비용이 발생하고, 유지보수하는 것도 힘들다. 이런 문제를 방지하기 위해 프로젝트 초기에 모든 구성원이 합의하여 비즈니스 용어와 실제 물리적인 데이터 이름을 매핑해두면, 모두가 일관된 용어를 사용하여 소통하고 개발할 수 있다. 이는 사소해 보이지만, 장기적으로는 개발 생산성과 시스템의 안정성을 극대화하는 매우 중요한 활동이다.
용어를 역할별로 분류하고, 약어와 전체 영문명을 명확히 구분하며, 실제 시스템에서 어떻게 사용되는지 구체적인 예시를 함께 기록하는 것이 좋다. 회원(member), 가격(price)처럼 가장 작은 단위의 단일어를 중심으로 정의하고, 이를 조합해서 작성해보도록 하자.
| 분류 | 명칭 | 전체 영문명 | 축약어 | 설명 | 관련 시스템 요소 |
|---|---|---|---|---|---|
| 엔티티 | 회원 | member | 서비스를 사용하는 고객 | member 테이블 | |
| 상품 | product | 판매하는 물건 또는 서비스 | product 테이블 | ||
| 주문 | order | 회원의 상품 구매 요청 행위 | orders 테이블 | ||
| 주문 항목 | order_item | 하나의 주문에 포함된 개별 상품 정보 (연관 엔티티) | order_item 테이블 | ||
| 결제 | payment | pay | 주문에 대한 지불 정보 | payment 테이블 | |
| 배송 | delivery | 주문된 상품의 물리적 이동 정보 | delivery 테이블 | ||
| 주요 속성 | 식별자 | identifier | id | 데이터를 고유하게 식별하는 번호 | member_id, product_id |
| 이름, 명 | name | 사람, 상품 등 대상을 지칭하는 명칭 | member_name, product_name | ||
| 가격 | price | 상품의 현재 판매가 또는 주문 시점의 가격 | order_price | ||
| 금액 | amount | 금액 | |||
| 재고 | stock | 상품의 재고 | stock_quantity | ||
| 수량 | quantity | 개수나 양 | stock_quantity, order_quantity | ||
| 개수, 횟수 | count | 개별 항목을 하나씩 세는 행위, 이벤트 발생 횟수 | visit_count, click_count, employee_count | ||
| 상태 | status | 주문, 배송 등 엔티티의 현재 상태를 나타내는 값 | order_status, delivery_status | ||
| 주소 | address | addr | 위치 정보 | member.addr, order.ship_addr | |
| 비밀번호 | password | 로그인 시 사용하는 비밀번호 | member.password | ||
| 배송 | shipping | ship | 배송과 관련된 속성을 나타낼 때 접두사로 사용 | ship_addr | |
| 로그인 | login | 시스템에 접속하는 행위 | member.login_id | ||
| 수단 | method | 결제 수단 | payment_method | ||
| 번호 | number | no | 번호 | tracking_no | |
| 운송장 번호 | tracking_no | 배송 추적 번호 | |||
| 행위 / 수식어 | 생성 | create | 데이터가 만들어진 시점을 나타낼 때 사용 | created_at | |
| 수정 | update | 데이터가 변경된 시점을 나타낼 때 사용 | updated_at |
용어 사전은 한 번 만들고 끝나는 문서가 아니다. 프로젝트가 진행되면서 새로운 용어가 추가되고 새로운 비즈니스 용어가 생겨날 때마다 지속적으로 업데이트되어야 한다.