던플 개발기 (6): TypeORM에서 Prisma로, 그리고 item·market 컨텍스트 분리

Dunplay

지난 편에서 디자인 시스템을 세우며 화면 쪽 이야기를 했습니다. 이번 편은 화면 아래, 데이터가 앉아 있는 계층으로 화제를 돌립니다.

던플은 처음에 아이템 catalog 스키마를 TypeORM으로 시작했습니다. 그런데 얼마 지나지 않아 두 번의 큰 수술을 했습니다. 하나는 영속성 라이브러리를 TypeORM에서 Prisma로 교체한 것이고, 다른 하나는 하나였던 스키마를 item 컨텍스트와 market 컨텍스트로 쪼갠 것입니다. 이 글의 결론을 먼저 말하면, 라이브러리 교체는 표면이었고 진짜 얻은 건 아이템 카탈로그와 시세 데이터의 소유권 경계였습니다. 그리고 그 경계를 세우기 위해 저는 익숙한 안전장치 하나를 일부러 버렸습니다. 바로 외래 키(FK)입니다.

한눈에 보면

  • TypeORM에서 Prisma로 옮긴 결정적 이유는 "TypeORM이 나빠서"가 아니라, 곧 만들 파티션 원본 수집 스키마와 migration-only 정책이 Prisma의 migrate + 생성 클라이언트에 더 맞았기 때문입니다.
  • 도메인이 라이브러리가 아니라 포트(인터페이스)에 의존한 덕분에, 교체는 data-access 한 겹만 건드리고 끝났습니다. web·bff·도메인은 손대지 않았습니다.
  • item과 market은 데이터의 변화 주기가 다릅니다. 카탈로그(느리게 변함)와 시세(초 단위로 쏟아짐)를 한 컨텍스트에 두면 서로의 마이그레이션에 발이 묶입니다.
  • 그래서 item_spec_revisionsitems를 참조할 때 FK를 걸지 않았습니다. cross-context FK는 상대 컨텍스트의 migrate reset에 내 테이블 수명을 묶어버립니다.
  • 마이그레이션은 하나의 PostgreSQL 인스턴스와 _prisma_migrations를 공유하되, 각자 폴더의 pending만 적용하도록 굴렸습니다. item 마이그레이션은 items를 절대 다시 만들지 않습니다.
  • 아직 남은 부채는 정직하게 적어뒀습니다. items의 물리적 소유권은 여전히 market이 쥐고 있고, item-api는 잠시 market-neople-client를 빌려 씁니다.

왜 갈아엎었나

시작은 TypeORM이었습니다. items catalog 스키마를 TypeORM 엔티티와 DataSource, 마이그레이션으로 잡아둔 상태였죠. 엔티티는 이렇게 생겼습니다.

// 이전 — libs/market/data-access/src/lib/entities/item.entity.ts (TypeORM)
@Entity({ name: 'items' })
export class ItemEntity {
  @PrimaryColumn({ name: 'item_id', type: 'varchar', length: 64 })
  itemId!: string;
 
  @Column({ name: 'name', type: 'varchar', length: 255 })
  name!: string;
 
  @Column({ name: 'rarity', type: 'varchar', length: 32, nullable: true })
  rarity!: string | null;
  // item_type, item_type_detail, image_url ...
 
  @CreateDateColumn({ name: 'created_at' })
  createdAt!: Date;
 
  @UpdateDateColumn({ name: 'updated_at' })
  updatedAt!: Date;
}

교체를 결심한 건 TypeORM이 못나서가 아니었습니다. 바로 다음 작업이 경매장 원본 수집 스키마였는데, 이 스키마에는 일 단위 PARTITION BY RANGE 파티션 테이블이 필요했습니다. 파티션은 어차피 마이그레이션 SQL에 손으로 써야 하고, 나머지 테이블은 타입이 붙은 클라이언트로 다루고 싶었습니다. 여기에 던플의 DB 규칙 — "모든 환경에서 prisma db push 같은 자동 동기화 금지, 마이그레이션만" — 이 겹치면서, Prisma의 migrate deploy 흐름과 생성 클라이언트 조합이 더 자연스럽게 들어맞았습니다.

교체 그 자체는 놀랍도록 조용했습니다. 이유는 하나입니다. 도메인이 TypeORM을 몰랐기 때문입니다. application 계층은 ItemRepository 포트만 알고, 구현체는 data-access 계층에 있으며, 둘을 잇는 바인딩은 market-api 조립 지점에서만 일어납니다.

// 이전 — TypeOrmItemRepository
export class TypeOrmItemRepository {
  private readonly repository: Repository<ItemEntity>;
  constructor(dataSource: DataSource) {
    this.repository = dataSource.getRepository(ItemEntity);
  }
  // save(): PK가 새로우면 insert, 아니면 in-place update
  async upsert(item: Item): Promise<void> {
    await this.repository.save(itemToEntity(item));
  }
  async findById(id: ItemId): Promise<Item | null> {
    const entity = await this.repository.findOne({ where: { itemId: id } });
    return entity === null ? null : itemEntityToDomain(entity);
  }
}
 
// 이후 — PrismaItemRepository (같은 포트를 구조적으로 만족)
export class PrismaItemRepository {
  constructor(private readonly prisma: MarketPrismaClient) {}
  async upsert(item: Item): Promise<void> {
    const data = itemToWriteData(item);
    await this.prisma.item.upsert({
      where: { itemId: item.id },
      create: { itemId: item.id, ...data },
      update: data,
    });
  }
  async findById(id: ItemId): Promise<Item | null> {
    const row = await this.prisma.item.findUnique({ where: { itemId: id } });
    return row === null ? null : itemRowToDomain(row);
  }
}

두 클래스의 주석에는 똑같은 문장이 붙어 있습니다. "포트를 import하지 않고 구조적으로 만족한다. Nx 모듈 경계가 layer:data-accesslayer:application에 의존하는 것을 금지하기 때문에, 구현체는 두 계층에 모두 의존할 수 있는 market-api 조립 지점에서 포트에 바인딩된다."

flowchart TB
  subgraph assembly["market-api · layer:app · 유일한 조립 지점"]
    B["DI 바인딩: 포트 ← 구현체"]
  end
  P["ItemRepository 포트<br/>market-application · layer:application"]
  I["PrismaItemRepository<br/>market-data-access · layer:data-access"]
  B --> P
  B --> I
  I -. "포트를 import하지 않고<br/>구조적으로 만족" .-> P

핵심은 이 그림에서 교체가 건드린 상자는 PrismaItemRepository 하나라는 점입니다. 포트가 계약을 고정하고 있으니, 엔티티·DataSource·TypeORM 마이그레이션을 걷어내고 Prisma 스키마·생성 클라이언트·손으로 쓴 마이그레이션을 넣는 동안에도 web·bff·도메인은 그대로였습니다. 커밋 메시지에도 "boundaries are unchanged: web and bff stay free of Prisma, DB, and the Neople client"라고 못 박아 뒀습니다.

Prisma로 옮기며 바뀐 데이터 접근

Prisma로 오면서 스키마의 소스 오브 트루스는 schema.prisma가 됐습니다. 위의 TypeORM 엔티티는 이렇게 바뀌었습니다.

// 이후 — libs/market/data-access/prisma/schema.prisma
model Item {
  itemId    String   @id @map("item_id") @db.VarChar(64)
  name      String   @db.VarChar(255)
  rarity    String?  @db.VarChar(32)
  imageUrl  String?  @map("image_url") @db.Text
  createdAt DateTime @default(now())   @map("created_at") @db.Timestamptz(6)
  updatedAt DateTime @updatedAt        @map("updated_at") @db.Timestamptz(6)
 
  trackedItem TrackedItem?
  @@map("items")
}

바뀐 건 문법만이 아닙니다. 마이그레이션을 굴리는 방식이 바뀌었습니다. TypeORM 시절의 migrate.tsDataSource를 초기화해 runMigrations() / undoLastMigration()을 호출했습니다. Prisma로 옮기면서 이 CLI는 Prisma migrate를 감싸는 얇은 러너가 됐습니다.

// libs/market/data-access/src/cli/migrate.ts
// run   -> prisma migrate deploy : 모든 pending migration 적용. 멱등·프로덕션용.
// reset -> prisma migrate reset  : Prisma엔 단건 down이 없으므로 스키마를 drop 후
//                                  전부 재적용. 파괴적이라 로컬 개발 전용.
const prismaArgs =
  command === 'reset'
    ? ['exec', 'prisma', 'migrate', 'reset', '--force', '--skip-generate']
    : ['exec', 'prisma', 'migrate', 'deploy'];
 
execFileSync('pnpm', [...prismaArgs, '--schema', schemaPath], {
  stdio: 'inherit',
  env: { ...process.env, DATABASE_URL: buildDatabaseUrl(loadDatabaseConfig()) },
});

여기서 한 가지를 의식적으로 지켰습니다. 환경 변수 계약을 깨지 않는 것입니다. Prisma의 datasource는 단일 DATABASE_URL을 원하지만, 던플은 검증된 POSTGRES_* 변수들을 소스 오브 트루스로 유지하고 있었습니다. 그래서 DATABASE_URL을 손으로 세팅하게 만드는 대신, 이미 있던 값들로 조립했습니다.

// libs/market/data-access/src/lib/config/database-config.ts
export function buildDatabaseUrl(config = loadDatabaseConfig()): string {
  const user = encodeURIComponent(config.username);
  const password = encodeURIComponent(config.password);
  const database = encodeURIComponent(config.database);
  const base = `postgresql://${user}:${password}@${config.host}:${config.port}/${database}`;
  return config.ssl ? `${base}?sslmode=require` : base;
}

비밀번호에 URL 예약 문자가 들어갈 수 있어 각 조각을 percent-encoding합니다. 덕분에 TypeORM에서 Prisma로 갈아탔지만 운영자가 알던 .env 계약은 그대로 남았습니다. 라이브러리를 바꾸면서 팀의 습관까지 바꾸지 않아도 되게 만드는 것, 이런 게 조용한 교체의 조건이었습니다.

item과 market은 다른 주기로 산다

여기까지가 "라이브러리 교체"였습니다. 진짜 이야기는 다음입니다. 저는 하나였던 스키마를 item 컨텍스트market 컨텍스트로 쪼갰습니다.

왜 쪼갰을까요. 두 데이터의 변화 주기가 다르기 때문입니다. 아이템 카탈로그(items, spec revision)는 게임 패치가 있을 때나 바뀌는 느린 데이터입니다. 반면 시세 데이터(경매 매물, 판매 이력, 분·시·일 집계)는 수집 워커가 초 단위로 쏟아붓는 빠른 데이터입니다. 성격이 이렇게 다른 둘을 한 data-access에 두면, 카탈로그 스키마를 손볼 때마다 고빈도 시세 테이블과 같은 마이그레이션 히스토리에 발이 묶입니다. 소유권이 흐려지는 건 덤이고요.

그래서 item catalog와 spec 소유권을 market 밖으로 떼어 item bounded context를 새로 만들었습니다. item-apiitems, item_spec_revisions, item_catalog_refresh_runs의 소스 오브 트루스가 되고, dunplay.item.v1ItemQueryService(SearchItems, GetItem, GetItemRevision)와 ItemCommandService(RefreshCatalog)를 gRPC로 제공합니다. market-api는 기존 호출자를 위해 CatalogQueryService를 호환 계층으로 남겨뒀습니다.

flowchart LR
  subgraph before["전환 전 — 하나의 market 컨텍스트"]
    M0["market-data-access"]
    M0 --> T0["items · tracked_items"]
    M0 --> T1["auction_* · *_aggregates"]
    M0 --> T2["item spec / catalog refresh<br/>(들어갈 자리)"]
  end
  subgraph after["전환 후 — item · market 두 컨텍스트"]
    I1["item-data-access"] --> IT["items (호환) · item_spec_revisions<br/>item_catalog_refresh_runs"]
    M1["market-data-access"] --> MT["tracked_items · auction_*<br/>*_aggregates · provider_*"]
  end

정직하게 남길 부채도 있었습니다. 이 분리는 한 번에 완결되지 않았습니다.

  • 추출 단계에서는 item 테이블과 market 테이블이 하나의 PostgreSQL 인스턴스를 공유합니다. 별도 RDS 인스턴스 분리는 이 작업의 범위 밖이었습니다.
  • items 테이블은 논리적으로는 item 컨텍스트 소유지만, 물리적으로는 아직 market 마이그레이션이 만듭니다. item 스키마에도 Item 모델이 선언돼 있지만 "compatibility layer"라는 주석과 함께, item 마이그레이션은 이 테이블을 만들지 않습니다.
  • item-api는 아직 자기 전용 네오플 클라이언트가 없어서, item-neople-client를 뽑기 전까지 market-neople-client를 잠시 빌려 씁니다. scope:item 모듈 경계가 이 전환기 의존만 예외로 허용합니다.

마지막 항목은 뒤에 (16) 백엔드 아키텍처 감사에서 HIGH 부채로 다시 등장합니다. 경계를 세운다는 건 이렇게, 못을 다 박기 전에 "지금은 여기까지"라고 선을 긋고 그 선을 기록으로 남기는 일이기도 했습니다.

FK를 끊고 ID 참조로

컨텍스트를 나누자 곧바로 결정을 강요하는 지점이 나왔습니다. 새로 만든 item_spec_revisionsitem_id로 아이템을 가리킵니다. 관계형 DB에서 이런 컬럼에는 반사적으로 외래 키를 겁니다. 실제로 초기 추출(0801)에서는 FK가 있었습니다.

// 초기 추출 — item_spec_revisions 가 items 를 FK cascade 로 참조
model ItemSpecRevision {
  itemId   String   @map("item_id") @db.VarChar(64)
  revision Int
  // spec, spec_hash, captured_at ...
  item Item @relation(fields: [itemId], references: [itemId], onDelete: Cascade)
 
  @@id([itemId, revision])
  @@map("item_spec_revisions")
}

그리고 다음 커밋에서 이 FK를 일부러 끊었습니다.

// FK 제거 후 — 평범한 인덱스 컬럼
model ItemSpecRevision {
  itemId   String   @map("item_id") @db.VarChar(64)
  revision Int
  // spec, spec_hash, captured_at ...
 
  @@id([itemId, revision])
  @@index([itemId], map: "idx_item_spec_revisions_item")
  @@map("item_spec_revisions")
}

이유는 컨텍스트 분리가 아직 물리적으로 완결되지 않았다는 사실에서 나옵니다. items는 여전히 market 마이그레이션이 소유합니다. 만약 item_spec_revisionsitems에 FK를 걸면, cross-history FK가 생깁니다. 즉 item 컨텍스트의 테이블 수명이 market 스키마에 묶입니다. 개발 중 누군가 market 쪽에서 prisma migrate reset(파괴적 리셋)을 돌리면, FK 때문에 item 테이블까지 영향을 받거나 리셋이 막힙니다. 두 컨텍스트가 결국은 서로 다른 인스턴스로 갈라설 예정인데, 지금 FK로 묶어두는 건 나중에 풀어야 할 매듭을 미리 지어두는 셈입니다.

flowchart TB
  subgraph naive["순진하게 짰다면"]
    a_items[("items")]
    a_spec[("item_spec_revisions")]
    a_spec ==>|"FK · ON DELETE CASCADE"| a_items
  end
  subgraph real["실제 결정"]
    b_items[("items · market 물리 소유")]
    b_spec[("item_spec_revisions · item 소유")]
    b_spec -. "item_id 인덱스 참조만<br/>FK 없음" .-> b_items
  end

여기엔 이미 있던 전례가 있었습니다. market 쪽 고빈도 원본 수집 테이블(auction_listing_history, auction_sales, 분 단위 집계)도 items에 FK를 두지 않습니다. 이쪽은 일 단위 파티션 테이블이라 파티션 컬럼을 PK에 포함해야 하고, 대량 쓰기 경로에 FK 검증 비용을 얹지 않으려는 이유였습니다. 원인은 달라도(한쪽은 컨텍스트 경계, 한쪽은 파티션·볼륨) "참조는 하되 FK로 강제하지 않는다"는 결론은 같았습니다. 그래서 스키마 주석에도 "matching how the market raw-collection tables avoid an FK to items"라고 서로를 가리키게 적어뒀습니다.

FK를 끊는다는 게 참조를 포기한다는 뜻은 아닙니다. item_id는 여전히 인덱스가 걸린 조회 키이고, "고아 revision을 만들지 않는다" 같은 무결성은 이제 DB 제약이 아니라 애플리케이션 use case가 책임집니다. 안전장치를 DB에서 코드로 옮긴 것이고, 그 대가로 컨텍스트가 각자의 수명을 갖게 됐습니다.

마이그레이션을 안전하게 굴린 순서

문제는 "그럼 item 테이블은 어떻게 만드느냐"였습니다. items는 이미 market이 만드는데, item 컨텍스트가 순진하게 prisma migrate를 돌리면 items를 다시 만들려다 충돌합니다. 반대로 아무 마이그레이션도 없으면 item_spec_revisions를 만들 방법 자체가 없습니다. 이 두 위험("would recreate items"와 "no creation path")을 동시에 피해야 했습니다.

해법은 items를 이미 가진 baseline에서 오프라인으로 diff를 떠서, item 소유 테이블만 만드는 init 마이그레이션을 생성하는 것이었습니다.

-- libs/item/data-access/prisma/migrations/20260623000000_init_item_catalog/migration.sql
-- 이 마이그레이션은 item 소유 테이블만 만든다. `items`는 만들지 않는다
-- (추출 단계에선 market 마이그레이션이 물리 소유하는 compatibility layer).
-- prisma migrate diff 를 `items` 포함 baseline에서 오프라인 생성했다.
-- item_spec_revisions.item_id 는 items 에 FK를 두지 않는다(schema.prisma 참조).
 
CREATE TYPE "catalog_refresh_trigger" AS ENUM ('MAINTENANCE_RECOVERY', 'MANUAL');
CREATE TYPE "catalog_refresh_status"  AS ENUM ('RUNNING', 'SUCCEEDED', 'FAILED');
 
CREATE TABLE "item_spec_revisions" (
  "item_id"     VARCHAR(64) NOT NULL,
  "revision"    INTEGER     NOT NULL,
  "spec_hash"   VARCHAR(64) NOT NULL,
  "spec"        JSONB       NOT NULL,
  "captured_at" TIMESTAMPTZ(6) NOT NULL,
  "created_at"  TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT "item_spec_revisions_pkey" PRIMARY KEY ("item_id","revision")
);
CREATE TABLE "item_catalog_refresh_runs" ( /* ... */ );
CREATE INDEX "idx_item_spec_revisions_item" ON "item_spec_revisions"("item_id");
-- items 는 여기 없다.

두 컨텍스트는 같은 인스턴스와 같은 _prisma_migrations 이력 테이블을 공유하지만, 각자의 migrate deploy자기 폴더의 pending 마이그레이션만 적용합니다. 그래서 상호 간섭이 없습니다.

flowchart TB
  RUN1["market-api:migration:run<br/>= prisma migrate deploy"] --> mm
  RUN2["item-api:migration:run<br/>= prisma migrate deploy"] --> im
  subgraph pg["하나의 PostgreSQL 인스턴스 · 공유 _prisma_migrations"]
    subgraph mm["market/prisma/migrations"]
      m1["create_catalog_tables<br/>→ items 생성"]
    end
    subgraph im["item/prisma/migrations"]
      i1["init_item_catalog<br/>→ item_spec_revisions·refresh_runs 생성<br/>items 는 만들지 않음"]
    end
  end

머릿속 설계가 실제로 그렇게 도는지는 결국 살아 있는 DB에서 확인해야 합니다. 단위 테스트는 in-memory fake만 쓰기 때문에, item 영속성 계층과 오프라인 생성 마이그레이션을 진짜 PostgreSQL에 대고 돌리는 통합 테스트(item-api:test-integration)를 붙였습니다.

// apps/item-api/src/database/item-catalog.integration-spec.ts
it('upserts an item, reads it back, and lists its id', async () => {
  const item: Item = {
    id: toItemId('itest-item-1'),
    name: '테스트 아이템',
    rarity: '레어',
    availableLevel: 100,
  };
  await items.upsert(item);
  expect(await items.findById(item.id)).toEqual(item);
});
 
it('runs RefreshItemCatalog end to end and persists a run + revision', async () => {
  // ... 첫 refresh 는 revision 1 을 만든다 ...
  const second = await useCase.execute({ trigger: 'MANUAL', itemIds: ['itest-item-3'] });
  // spec 이 같으면 두 번째 refresh 는 새 revision 을 만들지 않는다
  expect(second.revisionsCreated).toBe(0);
});

이 테스트로 확인한 것은 세 가지였습니다. market·item 마이그레이션이 하나의 인스턴스에서 공존하고, item 마이그레이션이 items를 다시 만들지 않고 적용되며, 리포지토리가 round-trip으로 왕복한다는 것. 그리고 spec이 그대로면 새 revision을 만들지 않는 멱등성까지요. 순서를 정리하면 이렇습니다. ① 라이브러리를 포트 뒤에서 조용히 교체하고 → ② 컨텍스트를 논리적으로 분리하고 → ③ FK를 끊어 수명을 갈라놓고 → ④ 충돌 없는 마이그레이션 경로를 만든 뒤 → ⑤ 살아 있는 DB에서 공존을 검증했습니다. 물리적 인스턴스 분리와 items 소유권 이전은 그다음 단계로 남겨뒀습니다.

정리하면

이 편의 전제는 이것이었습니다. 영속성 라이브러리 교체는 표면이고, 진짜 얻은 건 소유권 경계였다.

  • TypeORM에서 Prisma로 옮긴 이유는 라이브러리의 우열이 아니라, 다가올 파티션 수집 스키마와 migration-only 정책에 대한 적합성이었다.
  • 도메인이 포트에 의존한 덕에 교체는 data-access 한 겹으로 끝났고, DATABASE_URL은 기존 POSTGRES_*에서 조립해 환경 계약을 지켰다.
  • item과 market은 변화 주기가 달라 컨텍스트를 분리했고, items의 물리 소유권·전용 네오플 클라이언트는 부채로 남겨 기록했다.
  • cross-context FK는 상대의 migrate reset에 내 수명을 묶으므로, item_spec_revisions는 FK 대신 인덱스 컬럼으로 두고 무결성은 use case가 책임지게 했다.
  • 마이그레이션은 하나의 인스턴스와 _prisma_migrations를 공유하되 각자 pending만 적용하도록 굴리고, 통합 테스트로 공존을 검증했다.

(1)편의 "경계를 먼저 세우고 그 안에서 움직인다"는 태도가 데이터 계층에서도 똑같이 이어집니다. FK를 끊는다는 건 무결성을 포기하는 게 아니라, 그 책임을 어디에 둘지 다시 정하는 일이었습니다.

다음 편에서는 이렇게 나눈 컨텍스트들이 서로 어떻게 대화하는지 — 명령은 RabbitMQ로, 이벤트는 Kafka로, 그 사이를 transactional outbox로 잇는 메시징 백본 이야기로 넘어가겠습니다.

같이 보면 좋은 글