Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정

단일 정수 sortOrder로 줄세운 선형 레벨을 노드/엣지 분리 DAG로 옮겼다. 셀프 참조 1:N vs 별도 엣지 테이블, PostgreSQL recursive CTE로 진행도 계산, 엣지 INSERT 시점 사이클 검출, 선형 i→i+1 자동 마이그레이션 4가지 결정과 zod invariants e2e로 회귀를 차단한 트러블슈팅.


💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • sortOrder 정수 한 필드로 줄세운 레벨 모델은 분기·합류·독립 노드를 표현하지 못한다 — 신규 요구가 들어오는 순간 unique 제약과 충돌해 즉시 깨진다
  • 해결: 노드(Level) + 엣지(LevelDependency) 분리 — 별도 테이블에 (fromId, toId) 행으로 간선 저장, FK는 양쪽 Level로 강제
  • 진행도 계산은 PostgreSQL WITH RECURSIVE + Prisma $queryRaw — N+1 없이 한 쿼리로 DAG descendants 전체 수집
  • 사이클 검출은 엣지 INSERT 시점 가드 + DB BEFORE INSERT 트리거 — 애플리케이션 가드 우회 경로까지 차단
  • 마이그레이션은 선형 sortOrder i → i+1 엣지 자동 생성 — 기존 30개 행을 한 트랜잭션 안에서 29개 엣지로 변환
  • 회귀 차단: zod superRefine으로 invariants 검증(상위·하위 ID 존재 + 사이클 부재) + e2e 3종 + CI 게이트

🔥 증상 — 두 번째 분기 노드를 시드에 넣자 unique 제약이 터졌다

기존 Level 모델은 단일 정수 sortOrder 한 필드로 줄세운 선형 트랙이었다. 한 컬럼 정렬만으로 “다음 레벨”이 결정되는 구조였고, 두 달 동안 잘 굴러갔다. 신규 요구로 한 노드에서 두 갈래로 갈라지는 분기 노드가 들어오면서 즉시 깨졌다.

$ pnpm prisma db seed

Invalid `prisma.level.create()` invocation:

Unique constraint failed on the fields: (`sortOrder`)
    at PrismaClient._executeRequest (node_modules/@prisma/client/runtime/library.js:128:7)

분기 노드의 두 하위 노드를 같은 sortOrder 값으로 넣으려 했더니 @@unique([sortOrder])가 막았다. unique 제약을 풀면 정렬 의미가 깨지고, 풀지 않으면 분기 자체가 표현 불가능. 진퇴양난이었다.

// ❌ Before — schema.prisma (단일 선형 가정)
model Level {
  id          Int      @id @default(autoincrement())
  name        String   @unique
  sortOrder   Int      @unique          // ← 분기 시 충돌
  isActive    Boolean  @default(true)
  createdAt   DateTime @default(now())
}

같은 시드 스크립트에 두 상위 노드를 가지는 합류 노드도 끼워 넣었더니 표현 불가능한 두 번째 케이스가 나왔다. sortOrder 한 필드로는 “이 노드의 직전 노드가 둘”이라는 정보를 저장할 위치 자체가 없다.

// ❌ Before — 합류 노드를 표현하려는 시도
await prisma.level.create({
  data: {
    name: 'Advanced-Merge',
    sortOrder: 12,             // 직전 노드가 둘인데 sortOrder는 하나
    // 직전 노드 정보를 어디에도 못 넣음
  },
});

📌 핵심: 선형 자료구조의 정렬 필드는 그래프를 표현하지 못한다. 분기·합류·독립 노드가 한 케이스라도 들어오면 sortOrder 한 컬럼은 즉시 무너진다. 정렬 필드 추가나 nullable 전환 같은 호환 패치는 근본 원인을 가리고, 다음 분기 노드가 들어올 때 같은 지점에서 또 터진다.


🔍 탐색 — 셀프 참조 1:N vs 별도 엣지 테이블

해법으로 검토한 자료구조는 셋이었다. 각 후보의 트레이드오프를 잡아 보고 결정해야 했다.

가설 1: Level에 배열 컬럼 두 개를 추가한다

PostgreSQL 배열 타입(Int[])을 Prisma Int[]로 매핑해 upperLevelIds·lowerLevelIds를 노드에 분산 저장하는 안이었다.

// 가설 1 — 배열 컬럼 (검토 후 기각)
model Level {
  id              Int    @id @default(autoincrement())
  name            String @unique
  upperLevelIds   Int[]  @default([])
  lowerLevelIds   Int[]  @default([])
}

DB 호출이 한 번에 끝나는 장점은 분명했다. 단점은 FK 무결성을 DB가 보장하지 않는다는 점이었다. upperLevelIds에 존재하지 않는 ID가 들어가도 PostgreSQL은 거절하지 않는다. 시드 종료 직후 검증을 매번 돌려야 했고, 운영 중 일관성 깨짐이 런타임 빈 배열로 silent하게 흘러갔다.

가설 2: Level 셀프 참조 1:N (parentId)

Level에 nullable parentId를 두는 트리 구조. 흔한 카테고리 트리 패턴이다.

// 가설 2 — 셀프 참조 1:N (검토 후 기각)
model Level {
  id        Int     @id @default(autoincrement())
  parentId  Int?
  parent    Level?  @relation("LevelTree", fields: [parentId], references: [id])
  children  Level[] @relation("LevelTree")
}

문제는 합류 노드였다. 한 노드가 두 부모를 가질 수 없다 — 1:N은 자식 입장에서 부모가 하나뿐이다. 도메인 요구에 합류 케이스가 한 건이라도 있으면 1:N은 즉시 깨진다.

🔍 단서: 트리DAG은 다른 자료구조다. 트리는 부모 1개 가정, DAG은 부모 N개 허용. 도메인이 합류 노드를 한 케이스라도 요구하면 트리는 표현 불가능하다. 셀프 참조 1:N은 카테고리 트리·조직도 같은 진짜 트리에서만 정답이다.

가설 3: 별도 엣지 테이블 (LevelDependency)

노드(Level)와 간선(LevelDependency)을 분리하는 정석. (fromId, toId) 한 행이 한 간선을 표현한다.

// 가설 3 — 노드/엣지 분리 (채택)
model Level {
  id          Int      @id @default(autoincrement())
  name        String   @unique
  description String?
  isActive    Boolean  @default(true)

  outgoing    LevelDependency[] @relation("FromLevel")
  incoming    LevelDependency[] @relation("ToLevel")
}

model LevelDependency {
  fromId    Int
  toId      Int
  from      Level @relation("FromLevel", fields: [fromId], references: [id], onDelete: Cascade)
  to        Level @relation("ToLevel",   fields: [toId],   references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())

  @@id([fromId, toId])              // 복합 PK = 간선 중복 방지
  @@index([toId])                   // 합류 노드 조회용
}

세 후보를 비교 표로 정리하면 결정 기준이 분명해진다.

후보합류 노드FK 무결성사이클 검출진행도 쿼리
❌ 배열 컬럼가능DB 미보장애플리케이션만배열 unnest 필요
❌ 셀프 참조 1:N불가능DB 보장부모 추적만트리 재귀
✅ 엣지 테이블가능DB 양쪽 FK트리거 가능WITH RECURSIVE

엣지 테이블 한 가지만 합류 · 무결성 · 사이클 · 재귀 쿼리를 모두 동시에 잡았다. Prisma 공식 문서의 “Relations — Many-to-many with explicit join table” 패턴이 그대로 적용되는 사례였다.

prisma.io

🔬 진짜 범인 — 선형 모델은 DAG 자료구조로 옮겨야만 풀린다

세 가설 비교가 가리킨 결론은 단순했다. 분기·합류·독립을 한 모델에 담으려면 노드와 간선을 분리한 *DAG(Directed Acyclic Graph)*가 유일한 정답이다. sortOrder 필드를 늘리거나 nullable로 바꾸는 호환 패치는 본질을 회피하는 우회였다.

[선형]   N1 → N2 → N3 → N4 → ... → Nn
         (sortOrder 1, 2, 3, 4, ..., n)

[DAG]    N1 → N2 → N3a ──┐
                └→ N3b → N4 (합류)
         N_iso  (독립 노드, 어디에도 연결 없음)

선형은 노드와 간선이 암묵적이다 — sortOrder 정수 하나가 위치직전 노드를 동시에 표현한다. DAG은 명시적이다 — 노드는 노드대로, 간선은 간선대로 행이 있다. 그 명시성이 합류 · 분기 · 독립을 한 모델에서 표현하는 표현력의 원천이었다.

마이그레이션 직전 단계에서 결정해야 할 네 가지가 한 번에 드러났다.

  1. 간선 저장 위치 — 노드 컬럼 vs 별도 테이블 (이미 결정: 별도 테이블)
  2. 진행도 계산 쿼리 — N+1 vs recursive CTE 한 쿼리
  3. 사이클 방지 — 애플리케이션 가드 vs DB 트리거
  4. 기존 선형 데이터 마이그레이션 — 수동 시드 vs 자동 변환

🛠️ 해결 — 4가지 결정으로 푼 DAG 스키마

Prisma 그래프 스키마 4가지 결정 — Level/LevelDependency 모델 분리, recursive CTE 진행도 쿼리, 엣지 INSERT 사이클 검출, 선형 sortOrder 자동 마이그레이션의 데이터 흐름

결정 1: 노드/엣지 분리 — Level + LevelDependency

엣지 테이블은 복합 PK가 핵심이다. (fromId, toId) 두 컬럼을 @@id로 묶으면 같은 방향의 중복 간선이 DB 레벨에서 차단된다. @@index([toId])는 합류 노드 조회(“이 노드의 직전 노드는?”)를 인덱스로 빠르게 만든다.

// ✅ schema.prisma — 신규 두 모델
model Level {
  id          Int      @id @default(autoincrement())
  name        String   @unique
  description String?
  isActive    Boolean  @default(true)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt

  outgoing    LevelDependency[] @relation("FromLevel")
  incoming    LevelDependency[] @relation("ToLevel")
}

model LevelDependency {
  fromId    Int
  toId      Int
  from      Level @relation("FromLevel", fields: [fromId], references: [id], onDelete: Cascade)
  to        Level @relation("ToLevel",   fields: [toId],   references: [id], onDelete: Cascade)
  createdAt DateTime @default(now())

  @@id([fromId, toId])
  @@index([toId])

  // 자기 자신 가리키는 간선은 CHECK 제약으로도 막는다
  @@map("level_dependencies")
}

자기 참조 간선(fromId == toId)은 DAG에서 의미가 없는 자명한 사이클이다. Prisma 스키마에는 CHECK 제약 문법이 없어 마이그레이션 SQL에 직접 추가했다.

-- prisma/migrations/20260114_dag_schema/migration.sql 끝에 직접 append
ALTER TABLE "level_dependencies"
  ADD CONSTRAINT "level_dependencies_no_self_loop"
  CHECK ("fromId" <> "toId");

onDelete: Cascade로 노드 삭제 시 간선이 따라 사라지게 했다. 노드만 삭제되고 간선이 남으면 FK가 깨지므로 양쪽에 Cascade가 정답이다.

결정 2: 진행도 계산 — WITH RECURSIVE + Prisma $queryRaw

“이 노드에서 도달 가능한 모든 하위 노드는?” 같은 재귀 쿼리가 DAG의 핵심 연산이다. ORM 레벨에서 풀면 N+1이다 — 1차 자식 N개 조회, 각 자식의 자식 다시 N개씩… 깊이만큼 쿼리가 증식한다.

PostgreSQL의 WITH RECURSIVE는 한 쿼리로 끝낸다. Prisma의 $queryRaw로 raw SQL을 그대로 실행하면 된다.

// ✅ apps/api/src/level/level.repository.ts
@Injectable()
export class LevelRepository {
  constructor(private readonly prisma: PrismaService) {}

  /**
   * 시작 노드에서 도달 가능한 모든 하위 노드 (BFS, 깊이 포함)
   * - depth=0: 시작 노드 자신
   * - depth=N: N홉 떨어진 노드
   */
  async findDescendants(startId: number): Promise<Array<{ id: number; depth: number }>> {
    return this.prisma.$queryRaw<Array<{ id: number; depth: number }>>`
      WITH RECURSIVE descendants AS (
        SELECT id, 0 AS depth
        FROM levels
        WHERE id = ${startId}

        UNION ALL

        SELECT l.id, d.depth + 1
        FROM descendants d
        JOIN level_dependencies dep ON dep."fromId" = d.id
        JOIN levels l               ON l.id        = dep."toId"
        WHERE d.depth < 50         -- 깊이 가드 (무한 재귀 방지)
      )
      SELECT id, depth FROM descendants ORDER BY depth, id;
    `;
  }

  /**
   * 노드의 직전(상위) 노드 전부 — 합류 노드용
   */
  async findDirectAncestors(nodeId: number): Promise<Array<{ id: number; name: string }>> {
    return this.prisma.$queryRaw<Array<{ id: number; name: string }>>`
      SELECT l.id, l.name
      FROM level_dependencies dep
      JOIN levels l ON l.id = dep."fromId"
      WHERE dep."toId" = ${nodeId}
      ORDER BY l.id;
    `;
  }
}

⚠️ 주의: WITH RECURSIVE종료 조건이 없으면 사이클이 있는 그래프에서 무한 루프에 빠진다. PostgreSQL은 자동으로 중지하지 않는다. WHERE d.depth < 50 같은 깊이 가드를 반드시 두거나, UNION (DISTINCT) 대신 UNION ALL을 쓸 때는 사이클 부재가 DB 제약으로 보장돼야 안전하다.

PostgreSQL 공식 문서 “WITH Queries — Recursive Self-Reference” 가 패턴을 직접 설명한다.

postgresql.org

결정 3: 사이클 검출 — INSERT 시점 가드 + DB 트리거

DAG은 Acyclic이 정의의 일부다. 사이클이 한 번이라도 들어가면 WITH RECURSIVE가 깊이 가드에 걸리고, 진행도 계산이 잘못된 값을 돌려준다. 사이클은 생성 시점에 막아야 한다 — 한번 들어간 뒤 검출하면 이미 늦었다.

애플리케이션 레이어 가드는 LevelDependency.create 직전에 한 번 검사한다. “새 간선 from → to를 추가하면 사이클이 생기는가?”to에서 from으로 도달 가능한가?” 와 동치다.

// ✅ apps/api/src/level/level.service.ts
async addDependency(fromId: number, toId: number) {
  if (fromId === toId) {
    throw new BadRequestException('Self-loop not allowed');
  }

  // to에서 from으로 도달 가능하면 사이클
  const reachable = await this.repo.findDescendants(toId);
  if (reachable.some((n) => n.id === fromId)) {
    throw new BadRequestException(
      `Cycle detected: ${toId} → ... → ${fromId}, cannot add ${fromId} → ${toId}`,
    );
  }

  return this.prisma.levelDependency.create({ data: { fromId, toId } });
}

애플리케이션 가드만으로는 Prisma를 우회한 raw SQL 삽입이나 DB 콘솔 직접 INSERT가 통과한다. DB 레벨의 트리거가 두 번째 안전망이다.

-- prisma/migrations/20260114_dag_schema/migration.sql
CREATE OR REPLACE FUNCTION check_no_cycle_on_insert()
RETURNS TRIGGER AS $$
DECLARE
  cycle_count INT;
BEGIN
  -- NEW.toId에서 NEW.fromId로 도달 가능하면 사이클
  WITH RECURSIVE reachable AS (
    SELECT "toId" AS node FROM level_dependencies WHERE "fromId" = NEW."toId"
    UNION
    SELECT dep."toId"
    FROM level_dependencies dep
    JOIN reachable r ON dep."fromId" = r.node
  )
  SELECT COUNT(*) INTO cycle_count
  FROM reachable
  WHERE node = NEW."fromId";

  IF cycle_count > 0 THEN
    RAISE EXCEPTION 'Cycle detected: cannot add edge % -> %', NEW."fromId", NEW."toId";
  END IF;

  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER no_cycle_before_insert
BEFORE INSERT ON level_dependencies
FOR EACH ROW EXECUTE FUNCTION check_no_cycle_on_insert();

📌 핵심: 애플리케이션 가드는 정상 경로를 막고, DB 트리거는 우회 경로를 막는다. 두 안전망 중 하나만 깔리면 어느 한 경로에서 사이클이 침투한다. 한 트랜잭션 안에서 둘 다 통과해야 간선이 들어가는 구조가 DAG 무결성의 최소 비용이다.

결정 4: 마이그레이션 — 선형 sortOrder i → i+1을 엣지로 자동 변환

기존 운영 데이터(30개 레벨, sortOrder 1~30)를 새 스키마로 옮기는 작업이 남았다. 손으로 29개 엣지를 INSERT하는 건 오타 가능성반복성 양쪽에서 부담이다. 자동 변환 스크립트가 정답이다.

// ✅ prisma/migrations/scripts/linearize-to-dag.ts
import { PrismaClient } from '@prisma/client';

async function main() {
  const prisma = new PrismaClient();

  // 1. 기존 선형 sortOrder 순서대로 노드 조회
  const levels = await prisma.$queryRaw<Array<{ id: number; sortOrder: number }>>`
    SELECT id, "sortOrder" FROM levels_legacy ORDER BY "sortOrder" ASC;
  `;

  console.log(`[migrate] 기존 선형 노드: ${levels.length}개`);

  // 2. i → i+1 엣지를 한 트랜잭션으로 생성
  const edges = levels.slice(0, -1).map((curr, i) => ({
    fromId: curr.id,
    toId:   levels[i + 1].id,
  }));

  await prisma.$transaction([
    prisma.levelDependency.createMany({ data: edges, skipDuplicates: true }),
  ]);

  console.log(`[migrate] 엣지 생성: ${edges.length}개`);

  // 3. 검증 — 모든 엣지가 실제로 들어갔는가?
  const inserted = await prisma.levelDependency.count();
  if (inserted !== edges.length) {
    throw new Error(`Edge count mismatch: expected ${edges.length}, got ${inserted}`);
  }

  console.log('[migrate] 검증 통과');
}

main().catch((e) => { console.error(e); process.exit(1); });

createMany + skipDuplicates: true재실행 멱등성을 확보한다. 스크립트를 두 번 돌려도 같은 엣지가 두 번 생기지 않는다. $transaction 묶음은 부분 실패 롤백을 보장한다 — 한 엣지가 사이클 트리거에 걸리면 모든 엣지가 함께 롤백된다.

$ pnpm tsx prisma/migrations/scripts/linearize-to-dag.ts
[migrate] 기존 선형 노드: 30개
[migrate] 엣지 생성: 29개
[migrate] 검증 통과

29개 엣지로 깔끔하게 변환됐다. 이후 분기 노드는 새 엣지 두 개(from → branchA, from → branchB)만 추가하면 표현된다.


✅ 검증 — 시드 종료 직후 invariants + e2e 3종

DAG 스키마는 invariants가 생명이다. FK 양쪽 존재, 사이클 부재, 고립 노드 없음(선택) — 세 조건이 매 시드 직후 검증돼야 한다.

zod superRefine으로 그래프 invariants 검증

// apps/api/src/level/level.invariants.ts
import { z } from 'zod';

export const GraphInvariantsSchema = z.object({
  nodes: z.array(z.object({ id: z.number().int(), name: z.string() })),
  edges: z.array(z.object({ fromId: z.number().int(), toId: z.number().int() })),
}).superRefine((data, ctx) => {
  const nodeIds = new Set(data.nodes.map((n) => n.id));

  // 1. 엣지의 양 끝이 존재하는 노드인가
  for (const e of data.edges) {
    if (!nodeIds.has(e.fromId)) {
      ctx.addIssue({ code: 'custom', path: ['edges'],
        message: `Edge fromId=${e.fromId} references non-existent node` });
    }
    if (!nodeIds.has(e.toId)) {
      ctx.addIssue({ code: 'custom', path: ['edges'],
        message: `Edge toId=${e.toId} references non-existent node` });
    }
  }

  // 2. 자기 자신 참조 금지
  for (const e of data.edges) {
    if (e.fromId === e.toId) {
      ctx.addIssue({ code: 'custom', path: ['edges'],
        message: `Self-loop on node ${e.fromId}` });
    }
  }

  // 3. 사이클 부재 (Kahn 위상 정렬로 검증)
  const inDegree = new Map<number, number>();
  for (const n of data.nodes) inDegree.set(n.id, 0);
  for (const e of data.edges) inDegree.set(e.toId, (inDegree.get(e.toId) ?? 0) + 1);

  const queue = [...inDegree.entries()].filter(([, d]) => d === 0).map(([id]) => id);
  let visited = 0;
  while (queue.length > 0) {
    const v = queue.shift()!;
    visited += 1;
    for (const e of data.edges) {
      if (e.fromId !== v) continue;
      const next = (inDegree.get(e.toId) ?? 0) - 1;
      inDegree.set(e.toId, next);
      if (next === 0) queue.push(e.toId);
    }
  }
  if (visited !== data.nodes.length) {
    ctx.addIssue({ code: 'custom', path: ['edges'],
      message: `Cycle detected: visited ${visited} / ${data.nodes.length}` });
  }
});

위상 정렬 결과 방문 노드 수 < 전체 노드 수 면 사이클이 있다. Kahn 알고리즘의 표준 사이클 검출 패턴이다. 시드 스크립트의 마지막 단계에서 한 번 돌린다.

// prisma/seed.ts (발췌)
const nodes = await prisma.level.findMany({ select: { id: true, name: true } });
const edges = await prisma.levelDependency.findMany({ select: { fromId: true, toId: true } });

const result = GraphInvariantsSchema.safeParse({ nodes, edges });
if (!result.success) {
  console.error('[seed] Graph invariants violated:');
  for (const issue of result.error.issues) console.error('  - ' + issue.message);
  process.exit(1);
}
console.log(`[seed] Graph invariants OK (${nodes.length} nodes, ${edges.length} edges)`);

e2e — 사이클 INSERT 시도, 합류 노드 조회, 진행도 깊이

// apps/api/test/level/dag.e2e-spec.ts
describe('DAG schema', () => {
  it('사이클이 되는 엣지는 트리거가 차단한다', async () => {
    const [a, b, c] = await Promise.all([
      prisma.level.create({ data: { name: 'A' } }),
      prisma.level.create({ data: { name: 'B' } }),
      prisma.level.create({ data: { name: 'C' } }),
    ]);
    await prisma.levelDependency.create({ data: { fromId: a.id, toId: b.id } });
    await prisma.levelDependency.create({ data: { fromId: b.id, toId: c.id } });

    // C → A를 추가하면 A→B→C→A 사이클
    await expect(
      prisma.levelDependency.create({ data: { fromId: c.id, toId: a.id } }),
    ).rejects.toThrow(/Cycle detected/);
  });

  it('합류 노드 — 두 부모를 가지는 노드의 직전 노드 N개', async () => {
    const [a, b, merge] = await Promise.all([
      prisma.level.create({ data: { name: 'A' } }),
      prisma.level.create({ data: { name: 'B' } }),
      prisma.level.create({ data: { name: 'Merge' } }),
    ]);
    await prisma.levelDependency.createMany({
      data: [{ fromId: a.id, toId: merge.id }, { fromId: b.id, toId: merge.id }],
    });

    const ancestors = await repo.findDirectAncestors(merge.id);
    expect(ancestors.map((n) => n.name).sort()).toEqual(['A', 'B']);
  });

  it('진행도 — recursive CTE가 N홉 도달 가능 노드를 한 쿼리로 반환', async () => {
    // A → B → C → D 체인
    const chain = await Promise.all(['A', 'B', 'C', 'D'].map((n) => prisma.level.create({ data: { name: n } })));
    await prisma.levelDependency.createMany({
      data: chain.slice(0, -1).map((curr, i) => ({ fromId: curr.id, toId: chain[i + 1].id })),
    });

    const descendants = await repo.findDescendants(chain[0].id);
    expect(descendants).toHaveLength(4);                    // 자신 포함
    expect(descendants[3].depth).toBe(3);                   // D는 3홉
  });
});
$ pnpm --filter api test:e2e dag
 PASS  test/level/dag.e2e-spec.ts (2.314 s)
  DAG schema
 사이클이 되는 엣지는 트리거가 차단한다 (124 ms)
 합류 노드 부모를 가지는 노드의 직전 노드 N개 (98 ms)
 진행도 recursive CTE가 N홉 도달 가능 노드를 쿼리로 반환 (87 ms)

세 e2e가 사이클 차단 · 합류 조회 · 재귀 진행도를 각각 잠근다. 시드 마이그레이션 직후 한 번, PR 단계에서 한 번 — 두 위치에서 invariants가 강제된다.


🛡️ 예방 — CI 게이트와 invariants 정적 스캔

같은 누수(사이클 침투, FK 깨짐, 고립 노드)가 PR 단계에서 잡히도록 CI 워크플로를 추가했다.

# .github/workflows/dag-invariants.yml
name: dag-invariants
on:
  pull_request:
    paths:
      - 'apps/api/src/level/**'
      - 'prisma/schema.prisma'
      - 'prisma/seed.ts'
      - 'prisma/migrations/**'

jobs:
  invariants:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:14
        env: { POSTGRES_PASSWORD: postgres }
        ports: ['5432:5432']
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm prisma migrate deploy
      - name: seed + invariants
        run: pnpm prisma db seed
      - name: e2e
        run: pnpm --filter api test:e2e dag

prisma db seed 자체가 invariants 검증 단계를 포함하므로, 시드가 통과하면 그래프가 정합이라는 단일 게이트가 만들어졌다.

PR 체크리스트 (DAG 모듈)

  • 새 엣지 추가 라우트에 addDependency 서비스(사이클 가드 포함)를 거치는가?
  • Prisma 외 raw SQL로 직접 level_dependencies를 INSERT하는 경로가 없는가?
  • WITH RECURSIVE에 깊이 가드(WHERE depth < N)가 있는가?
  • 시드 종료 직후 GraphInvariantsSchema.safeParse가 호출되는가?
  • 마이그레이션 SQL에 CHECK ("fromId" <> "toId") 자기 참조 차단이 있는가?
  • onDelete: Cascade로 노드 삭제 시 간선이 따라 사라지는가?

💡 인사이트: DAG 스키마의 안전성은 DB · 애플리케이션 · CI 세 위치에 같은 규칙을 두는 3중 안전망에서 나온다. DB 제약(CHECK + 트리거 + FK)은 최후의 방어선, 애플리케이션 가드는 정상 경로의 즉시 피드백, CI invariants는 PR 단계의 회귀 차단. 한 위치만 깔리면 다른 두 경로에서 새고, 세 위치가 모두 깔리면 의도하지 않은 사이클이 어디서도 들어오지 못한다.


📋 정리 — 핵심 요약

항목❌ 안티패턴✅ 권장 패턴
자료구조 선택sortOrder 정수 한 필드로 분기 표현 시도노드(Level) + 엣지(LevelDependency) 분리
합류 노드 표현셀프 참조 1:N (parentId)별도 엣지 테이블의 @@index([toId])
간선 무결성노드 컬럼 Int[] 배열 (DB FK 없음)엣지 테이블 양쪽 FK + onDelete: Cascade
간선 중복 차단애플리케이션 검사만@@id([fromId, toId]) 복합 PK
자기 참조 차단애플리케이션 검사만CHECK ("fromId" <> "toId")
진행도 계산ORM 루프 (N+1)PostgreSQL WITH RECURSIVE + Prisma $queryRaw
재귀 무한 루프종료 조건 없음WHERE depth < 50 가드
사이클 검출애플리케이션 가드만가드 + BEFORE INSERT 트리거 (2중)
선형 → DAG 마이그레이션수동 SQL 29번i → i+1 자동 변환 스크립트 + $transaction
멱등성단순 createcreateMany + skipDuplicates: true
회귀 차단수동 화면 확인zod superRefine invariants + e2e 3종 + CI

숫자로 보는 삽질

  • 시드 첫 실패까지: 5분 (unique 제약 충돌)
  • 자료구조 후보 3건 비교: 약 2시간
  • 노드/엣지 분리 + recursive CTE + 트리거 구현: 약 6시간
  • 마이그레이션 자동 변환 + invariants + e2e: 약 4시간
  • 이후 사이클·FK 회귀: 0건

선형에서 DAG로의 전환은 자료구조 선택스키마 모양쿼리 형태마이그레이션 전략을 동시에 결정하는 일이었다. sortOrder 한 필드 추가 같은 호환 패치는 근본 원인을 미루는 우회였고, 노드/엣지 분리라는 단 한 가지 결정이 합류 · 무결성 · 사이클 · 재귀 쿼리 네 가지를 한 번에 풀었다. PostgreSQL WITH RECURSIVE, Prisma 엣지 테이블, DB 트리거, zod superRefine — 네 도구가 각자의 역할로 맞물려야 DAG 무결성이 PR 단계에서 닫힌다.

📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (46편)

  1. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
  24. 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
  25. 25. 멀티테넌트 누수 — tenantId 3계층 강제
  26. 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
  27. 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
  28. 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
  29. 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
  30. 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
  31. 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
  32. 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
  33. 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
  34. 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
  35. 35. Unity ↔ 웹 PostMessage 브릿지 설계기
  36. 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
  37. 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
  38. 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
  39. 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
  40. 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
  41. 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
  42. 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
  43. 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
  44. 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
  45. 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
  46. 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날