MongoDB에 외래 키가 없음: 참조 무결성 재고

발행: (2025년 12월 26일 오전 07:29 GMT+9)
17 분 소요
원문: Dev.to

Source: Dev.to

해당 기사 본문을 제공해 주시면, 요청하신 대로 한국어로 번역해 드리겠습니다.

외래 키와 애플리케이션‑수준 검증

SQL 데이터베이스에서 foreign keys는 쓰기 작업을 허용하기 전에 테이블 간 관계의 정확성을 즉시 검증하는 제약 조건으로 작동합니다. 이는 최종 사용자가 데이터베이스에 임의의 쿼리를 직접 제출할 수 있는 상황을 염두에 두고 설계되었습니다. 따라서 데이터베이스는 정규화, 무결성 제약, 저장 프로시저, 트리거 등을 사용해 데이터 모델을 보호할 책임이 있으며, 애플리케이션이 데이터베이스와 상호 작용하기 전에 수행되는 검증에 의존하지 않습니다.

관계 무결성이 위배되면 오류가 발생하여 사용자가 변경을 수행하지 못하게 합니다. 애플리케이션은 트랜잭션을 롤백하고 예외를 발생시킵니다.

MongoDB의 NoSQL 접근 방식은 관계형 데이터베이스와 달리 애플리케이션 개발자를 위해 설계되었습니다. application code가 이러한 규칙을 강제합니다. 사용 사례가 명확히 정의되고 검증이 애플리케이션 수준에서 이루어지며, 비즈니스 로직이 foreign‑key 검증보다 우선합니다. 외래 키와 관련된 추가 직렬화 읽기 요구를 없애면 쓰기 성능과 확장성을 크게 향상시킬 수 있습니다.

비동기 참조 무결성

참조 무결성은 asynchronously 검증될 수 있습니다. 예외를 발생시키는 대신—애플리케이션이 대비하지 못한 예기치 않은 이벤트—MongoDB는 쓰기를 진행하도록 허용하고 다음과 같은 도구를 제공합니다.

이를 통해 오류를 감지하고 로그에 기록할 수 있습니다. 이 접근 방식은 오류 분석, 데이터 정정, 애플리케이션 수정 등을 without affecting the application’s availability하게 수행할 수 있게 하며, 여전히 비즈니스 로직을 포함합니다.

Example: Departments and Employees

우리는 모든 직원이 부서에 속해야 하는 고전적인 시나리오를 모델링합니다.

두 개의 컬렉션과 참조

강한 관계(일대다 포함)는 엔터티가 정확히 동일한 수명 주기를 공유하는 경우 반드시 여러 컬렉션과 참조가 필요하지는 않습니다. 도메인의 상황에 따라 다음과 같이 할 수 있습니다:

  • **각 부서 문서 안에 직원 목록을 임베드하여 참조 무결성을 보장하고 고아 데이터를 방지합니다.
  • 부서 업데이트가 드물 때(예: 부서 설명을 단순히 여러 문서에 걸쳐 변경) 혹은 부서 변경이 보통 대규모 조직 개편의 일환으로 발생할 때 **각 직원 문서 안에 부서 정보를 임베드합니다.

두 엔터티가 항상 함께 접근되지 않으며, 무한한 카디널리티를 가지고 있거나 독립적으로 업데이트되는 경우에는 참조를 사용하는 것이 보통 더 좋습니다. 예를 들어, 각 직원에 deptno를 저장하고 고유한 deptno를 가진 별도의 departments 컬렉션을 유지합니다.

아래는 컬렉션, 인덱스 및 샘플 데이터를 생성하는 스크립트입니다.

// -------------------------------------------------
// Reset collections
// -------------------------------------------------
db.departments.drop();
db.employees.drop();

// -------------------------------------------------
// Departments collection
// -------------------------------------------------
db.departments.createIndex(
  { deptno: 1 },               // deptno will be used as the referenced key
  { unique: true }             // must be unique for many‑to‑one relationships
);

db.departments.insertMany([
  { deptno: 10, dname: "ACCOUNTING",  loc: "NEW YORK" },
  { deptno: 20, dname: "RESEARCH",    loc: "DALLAS"   },
  { deptno: 30, dname: "SALES",       loc: "CHICAGO"  },
  { deptno: 40, dname: "OPERATIONS",  loc: "BOSTON"   }
]);

// -------------------------------------------------
// Employees collection
// -------------------------------------------------
db.employees.createIndex(
  { deptno: 1 }                // reference to departments (helps $lookup)
);

db.employees.insertMany([
  { empno: 7839, ename: "KING",   job: "PRESIDENT", deptno: 10 },
  { empno: 7698, ename: "BLAKE",  job: "MANAGER",   deptno: 30 },
  { empno: 7782, ename: "CLARK",  job: "MANAGER",   deptno: 10 },
  { empno: 7566, ename: "JONES",  job: "MANAGER",   deptno: 20 },
  { empno: 7788, ename: "SCOTT",  job: "ANALYST",   deptno: 20 },
  { empno: 7902, ename: "FORD",   job: "ANALYST",   deptno: 20 },
  { empno: 7844, ename: "TURNER", job: "SALESMAN",  deptno: 30 },
  { empno: 7900, ename: "JAMES",  job: "CLERK",     deptno: 30 },
  { empno: 7654, ename: "MARTIN", job: "SALESMAN",  deptno: 30 },
  { empno: 7499, ename: "ALLEN",  job: "SALESMAN",  deptno: 30 },
  { empno: 7521, ename: "WARD",   job: "SALESMAN",  deptno: 30 },
  { empno: 7934, ename: "MILLER", job: "CLERK",     deptno: 10 },
  { empno: 7369, ename: "SMITH",  job: "CLERK",     deptno: 20 },
  { empno: 7876, ename: "ADAMS",  job: "CLERK",     deptno: 20 }
]);

Note – I didn’t declare a schema up‑front; the documents arrive as‑is from the application. Indexes on both sides enable fast navigation between employees and departments, and vice‑versa.

Query Examples

이 스키마는 모든 카디널리티를 지원합니다. 부서당 수백만 명의 직원이 있을 수도 있기 때문에(임베드하기에 부적합합니다) 정규화된 형태이며, 업데이트는 단일 문서만 변경하면 되면서도 양방향 조회가 가능합니다.

1. Employees → Department

db.employees.aggregate([
  {
    $lookup: {
      from: "departments",
      localField: "deptno",
      foreignField: "deptno",   // fast access thanks to the unique index
      as: "department"
    }
  },
  {
    $set: {
      // Keep only the first (and only) matching department document
      department: { $arrayElemAt: ["$department", 0] }
    }
  }
]);

2. Departments → Employees

db.departments.aggreg

```javascript
ate([
  {
    $lookup: {
      from: "employees",
      localField: "deptno",
      foreignField: "deptno",
      as: "employees"
    }
  },
  {
    $project: {
      _id: 0,
      deptno: 1,
      dname: 1,
      loc: 1,
      employees: {
        empno: 1,
        ename: 1,
        job:   1,
        deptno: 1
      }
    }
  }
]);

이 파이프라인은 읽기 시점에 simulate joins를 수행하여, 쓰기 성능이나 데이터 무결성을 희생하지 않으면서 임베디드 모델의 유연성을 제공합니다.

요약

  • SQL: 외래 키는 참조 무결성을 동기적으로 강제하며, 위반 시 즉시 오류를 발생시킵니다.
  • MongoDB: 참조 무결성은 일반적으로 애플리케이션 수준에서 강제되거나 집계 파이프라인 또는 변경 스트림을 통해 비동기적으로 검증됩니다.
  • 모델링 선택:
    • 임베드: 관계가 밀접하고, 저카디널리티이며, 데이터가 항상 함께 접근될 때.
    • 레퍼런스: 무제한 카디널리티, 독립적인 업데이트, 또는 별도 접근 패턴이 필요할 때.

이러한 트레이드오프를 이해함으로써 데이터 일관성을 유지하면서 성능과 확장성을 극대화하는 MongoDB 스키마를 설계할 수 있습니다.

집계 예시

db.departments.aggregate([
  {
    $lookup: {
      from: "employees",
      localField: "deptno",
      foreignField: "deptno", // fast access by index on employees
      as: "employees"
    }
  }
]);

성능 고려사항

성능 관점에서 $lookup을 수행하는 것이 단일 내장 컬렉션을 읽는 것보다 비용이 더 많이 듭니다. 그러나 이 오버헤드는 수십에서 수백 개의 문서를 탐색할 때는 크게 중요하지 않습니다.

이 모델을 선택할 때, 부서당 직원이 백만 명일 수 있기 때문에 모든 데이터를 한 번에 가져오지는 않습니다. 대신:

  • 첫 번째 쿼리에서는 $match$lookup 이전에 문서를 필터링하고, 또는
  • 두 번째 쿼리에서는 $lookup 파이프라인 내부에 필터가 적용됩니다.

이러한 변형에 대해서는 이전 게시물에서 다루었습니다.

참조 무결성

직원이 deptno를 가지고 삽입되었는데 departments에 해당 deptno가 존재하지 않으면 $lookup은 일치하는 항목을 찾지 못합니다:

  • 첫 번째 쿼리는 부서 정보를 생략합니다.
  • 두 번째 쿼리는 알려진 부서만 나열하므로 새 직원을 표시하지 않습니다.

이는 해당 부서를 삽입하지 않은 애플리케이션에 대한 예상 동작입니다.

관계형 DBA들은 이를 과장해서 데이터 손상이라고까지 부르곤 합니다. SQL은 기본적으로 내부 조인을 사용하므로 첫 번째 쿼리 결과에서 해당 직원이 누락됩니다. MongoDB의 $lookup과 같은 외부 조인에서는 이런 일이 발생하지 않습니다—SQL의 NULL과 비슷하게, 정보가 아직 알려지지 않았으므로 표시되지 않을 뿐입니다. 나중에 부서를 추가하면 쿼리는 해당 정보를 반영합니다.

여전히 일정 시간 후에(예: 버그로 인해) 참조된 항목이 삽입되지 않은 경우를 감지하고 싶을 수 있습니다.

$lookup 단계로 외래키 정의

두 단계, $lookup 단계와 $match 단계를 사용하여 참조 무결성을 정의하고, 참조된 문서가 존재하는지 확인합니다.

// $lookup stage
const lookupStage = {
  $lookup: {
    from: "departments",
    localField: "deptno",
    foreignField: "deptno",
    as: "dept"
  }
};

// $match stage – keep docs where the lookup returned an empty array
const matchStage = { $match: { dept: { $size: 0 } } };

정의는 간단하며 SQL 외래키와 유사합니다. 실제 상황에서는 더 복잡하고 정밀해질 수 있습니다. 문서형 데이터베이스는 정적인 외래키로는 표현하기 어려운 비즈니스 로직이 확장되는 시나리오에서 뛰어난 장점을 가집니다. 예를 들어:

  • 일부 직원은 일시적으로 부서가 없을 수 있습니다 (예: 신규 채용).
  • 전환 기간 동안 두 부서에 속할 수도 있습니다.

MongoDB의 유연한 스키마는 이러한 경우를 지원하며, 이에 맞게 참조 무결성 규칙을 정의합니다. 이번 예시에서는 간단히 설명하겠습니다.

One‑Time Validation with an Aggregation Pipeline

새로운 직원 Eliot을 아직 존재하지 않는 부서 42에 삽입합니다:

db.employees.insertOne({
  empno: 9002,
  ename: "Eliot",
  job: "CTO",
  deptno: 42 // Missing department
});

오류가 발생하지 않습니다. 모든 쿼리에서 직원은 부서 번호로만 표시되며, 다른 부서 정보는 없습니다.

이러한 상황을 감지하고 싶다면, 다음 집계 파이프라인을 실행하여 위반 사항을 나열합니다:

db.employees.aggregate([lookupStage, matchStage]);

결과:

[
  {
    "_id": { "$oid": "694d8b6cd0e5c67212d4b14f" },
    "empno": 9002,
    "ename": "Eliot",
    "job": "CTO",
    "deptno": 42,
    "dept": []
  }
]

위반을 비동기적으로 포착했으며, deptno를 수정하거나 누락된 부서를 삽입하거나 비즈니스 규칙을 조정하는 등 이후 조치를 결정할 수 있습니다.

When to Run This Validation?

  • 데이터베이스 규모와 무결성 문제 위험도에 따라 다릅니다.
  • 대규모 데이터 리팩터링 후, 추가 검증 단계로 실행합니다.
  • 운영에 영향을 주지 않으려면 읽기 전용 복제본에서 실행합니다(비동기 검증의 장점).
  • 높은 격리 수준이 필요하지 않으며, 최악의 경우 동시 트랜잭션이 잘못된 경고를 발생시킬 수 있는데 이는 나중에 검토하면 됩니다.
  • 재해 복구 테스트를 위해 백업을 복원할 때, 복원된 복사본에서 검증을 실행하여 복원 과정과 데이터 무결성을 모두 확인합니다.

실시간 워처와 변경 스트림

실시간에 가깝게 검증을 수행하고, 변경이 발생한 직후에 확인하고 싶을 수도 있습니다.

const cs = db.employees.watch([
  { $match: { operationType: { $in: ["insert", "update", "replace"] } } }
]);

print("👀 Watching employees for referential integrity violations...");

while (cs.hasNext()) {
  const change = cs.next(); // Get the next change event

  if (["insert", "update", "replace"].includes(change.operationType)) {
    const result = db.employees.aggregate([
      { $match: { _id: change.documentKey._id } }, // check the new document
      lookupStage, // lookup dept info by deptno
      matchStage   // keep only docs with NO matching dept
    ]).toArray();

    if (result.length > 0) {
      // Handle the violation (e.g., log, alert, corrective action)
      printjson(result[0]);
    }
  }
}

변경 스트림은 employees 컬렉션에 대해 삽입, 업데이트, 교체를 감시합니다. 변경된 각 문서에 대해 다음을 수행합니다.

  1. _id 로 문서를 조회합니다.
  2. $lookup 을 사용해 해당 부서 정보를 가져옵니다.
  3. $match 를 적용해 조회 결과가 빈 배열인 경우(즉, 매칭되는 부서가 없는 경우)만 남깁니다.

위반이 발견되면 로그를 남기거나 알림을 발생시키고, 교정 작업을 트리거할 수 있습니다.

요약

  • $lookup 은 MongoDB에서 관계를 모델링하는 유연한 방법을 제공합니다.
  • 참조 무결성은 비동기적으로(주기적 집계) 혹은 실시간에 가깝게(변경 스트림) 강제할 수 있습니다.
  • MongoDB의 스키마 유연성을 활용하면 강제적인 외래키 모델이 아니라 실제 비즈니스 요구에 맞게 무결성 규칙을 정의할 수 있습니다.
print("\n⚠ Real-time Referential Integrity Violation Detected:");
printjson(result[0]);

누락된 부서 42에 새로운 직원(Dwight) 삽입

db.employees.insertOne({
  empno: 9001,
  ename: "Dwight",
  job: "CEO",
  deptno: 42 // missing department
});
Back to Blog

관련 글

더 보기 »

Academic Suite 데이터베이스 설계

데이터베이스는 Academic Suite의 기본 기반입니다. 온라인 시험 시스템에서 부적절한 데이터베이스 스키마 설계는 심각한 병목 현상을 초래할 수 있으며, 특히...