[NestJS] NestJS + Prisma ? MikroORM ?

2026. 3. 16. 11:18·Framework/NestJS

비대해진 Service의 한계 — 도메인 분리를 위한 ORM 패러다임 시프트

서론: GraphQL이 해결하지 못한 것

GraphQL이 가져다준 유연함 — 오버페칭/언더페칭 해소, 클라이언트 주도의 데이터 선언, AST 파싱을 통한 Resolver 라우팅 — 을 다뤘었다.

하지만 GraphQL은 백엔드 내부의 구조적 문제까지 해결해주지는 않는다.

오히려 GraphQL의 유연함 이면에서, 조용히 커지고 있던 문제가 하나 있었다. Service 계층의 비대화다.

이 글은 Prisma 중심의 스키마 기반 설계가 왜 "Fat Service"를 만들어내는지,

그것이 이벤트 기반 아키텍처(EDA) 전환에 어떤 발목을 잡는지,

그리고 Data Mapper 패러다임의 ORM으로 어떻게 돌파구를 찾으려 하는지를 정리하려는 글이다.

 

 


1. Prisma와 Fat Service: 도메인은 어디에 있는가?

스키마 중심 설계의 구조적 한계

Prisma는 데이터 모델을 schema.prisma 파일로 관리한다.

여기에 테이블 구조, 관계, 인덱스를 선언하면 Prisma Client가 자동 생성되고, 조회 결과는 plain JavaScript object(순수 데이터 객체)로 반환된다.

ORM은 객체-관계 매핑인데, 그러면 관계형 데이터베이스의 '관계'를 '객체'로 매핑한다는 것이다.

객체 지향 패러다임을 추구하는 것으로도 볼 수 있는데,

여기서 말하는 '객체'가 진짜 '객체'인지 되돌아 볼 필요가 있다 ..

과연 plain JavaScript object(순수 데이터 객체)에서의 '객체'는 정말 우리가 지향하는 그 '객체'일까 ?

인스턴스화가 가능하다고 다 같은 그 '객체'가 아니라고 생각한다 !

 

// schema.prisma — 데이터 구조만 정의
model Lecture {
    id            Int      @id @default(autoincrement())
    title         String
    capacity      Int
    enrolledCount Int      @default(0)
    tutorId       Int
}

이 모델에는 "수강 정원이 초과되면 등록을 거부한다"는 비즈니스 규칙이 들어갈 자리가 없다.

schema.prisma는 데이터의 형태만 정의하고, 행위(behavior)를 정의하는 것은 설계 범위 밖이다.

 

그 결과, 모든 비즈니스 로직은 Service 클래스로 흘러들어간다:

// lecture.service.ts — 모든 것이 여기에
@Injectable()
export class LectureService {
    async enroll(lectureId: number, studentId: number) {
        // 1. 쿼리 로직 (Repository 역할)
        const lecture = await this.prisma.lecture.findUniqueOrThrow({
            where: { id: lectureId },
        });

        // 2. 도메인 로직 (Entity가 해야 할 일)
        if (lecture.enrolledCount >= lecture.capacity) {
            throw new BadRequestException('수강 정원 초과');
        }

        // 3. 상태 변경 + 연관 데이터 생성 (트랜잭션 관리)
        await this.prisma.$transaction([
            this.prisma.lecture.update({
                where: { id: lectureId },
                data: { enrolledCount: { increment: 1 } },
            }),
            this.prisma.lectureEnrollment.create({
                data: { lectureId, studentId },
            }),
        ]);

        // 4. 후속 처리 (이벤트 발행, 알림 등)
        await this.notificationService.send(studentId, '수강 등록 완료');
    }
}

하나의 메서드 안에 쿼리 로직, 도메인 규칙, 트랜잭션 관리, 후속 처리가 전부 들어있다. 기능이 추가될수록 이 메서드는 계속 커진다.

장점은 분명히 있다

이 구조의 장점을 부정할 수는 없다. 서비스의 전체 흐름을 한 번에 파악하기 쉽다.

하나의 메서드를 위에서 아래로 읽으면 "무엇을 조회하고, 어떤 검증을 하고, 어떻게 저장하는지"가 한눈에 보인다.

프로젝트 초기에는 이 단순함이 생산성을 높여준다.

 

하지만 규모가 커지면 무너진다

문제는 서비스가 성장하면서 드러난다:

  • 함수가 비대해진다: 수강 등록 하나에 정원 검증, 중복 등록 방지, 결제 확인, 알림 발송, 통계 업데이트가 모두 붙으면 하나의 메서드가 200줄을 넘긴다
  • 도메인 규칙이 흩어진다: "정원 초과 검증"이 LectureService.enroll()에도 있고, AdminService.forceEnroll()에도 복사-붙여넣기 되어 있다면 규칙의 소유권이 불명확해진다
  • 단일 책임 원칙이 무너진다: 하나의 Service 클래스가 쿼리, 검증, 트랜잭션, 이벤트 발행을 모두 담당한다
  • 테스트가 어려워진다: 도메인 규칙만 테스트하고 싶어도 Prisma Client를 모킹해야 한다

💡 Architect's Insight: Prisma의 이 특성은 "결함"이 아니라 "설계 철학"이다. Prisma는 의도적으로 ORM의 복잡성(영속성 컨텍스트, 지연 로딩, 프록시 객체)을 제거하고 "타입 안전한 쿼리 빌더"로 포지셔닝했다. 문제는 이 도구를 도메인 모델링 도구로 오용할 때 발생한다. Prisma는 데이터 접근에는 탁월하지만, 도메인 행위의 캡슐화에는 관여하지 않는다.

 

 

 


2. HTTP 동기 통신의 한계와 EDA의 필요성

서비스가 비대해질수록 동기 통신은 위험해진다

현재 경험하고 있는 MSA 환경에서 각 서비스는 HTTP 동기 통신으로 연결되어 있다. 사용자가 수강 등록을 하면:

[클라이언트] → [수강 서비스] → HTTP 호출 → [결제 서비스]
                                          → HTTP 호출 → [알림 서비스]

이 구조에서 결제 서비스가 3초 동안 응답하지 않으면, 수강 서비스도 3초를 기다리고, 클라이언트도 3초를 기다린다. 결제 서비스가 완전히 다운되면 수강 서비스 전체가 장애로 이어진다. 하나의 서비스 장애가 전체 시스템으로 전파되는, 동기 통신의 고질적인 단일 장애점(SPOF) 문제다.

 

EDA(이벤트 기반 아키텍처)로 가려면 "도메인 경계"가 먼저다

이 문제를 해결하기 위해 이벤트 기반 아키텍처(EDA)로의 전환을 고민하고 있다. 수강 등록이 완료되면 LectureEnrolled 이벤트를 발행하고, 결제 서비스와 알림 서비스가 각자 이 이벤트를 구독해서 처리하는 비동기 구조다.

[수강 서비스] → 이벤트 발행: "LectureEnrolled"
                    ↓
            [메시지 브로커]
              ↓           ↓
    [결제 서비스]     [알림 서비스]
    (각자 독립적으로 처리)

하지만 EDA로 전환하려면 "무엇이 하나의 도메인인가?"가 명확해야 한다. "수강 등록"이라는 행위가 어떤 도메인의 책임인지, 이 행위가 발생했을 때 어떤 이벤트가 발행되어야 하는지, 그 이벤트의 페이로드에는 무엇이 들어가야 하는지 — 이 모든 것이 도메인 경계가 명확해야 정의할 수 있다.

 

Prisma + Fat Service가 EDA 전환을 막는 이유

지금의 구조에서는 도메인의 경계를 식별하기 어렵다. LectureService.enroll() 안에 쿼리, 검증, 트랜잭션, 알림이 뒤섞여 있으니, "어디까지가 수강 도메인의 책임이고 어디부터가 알림 도메인의 책임인지"를 분리하기가 어렵다.

 

도메인 로직이 엔티티 안에 캡슐화되어 있다면:

// 이상적인 구조
const enrollment = lecture.enroll(student);  // ← 도메인 규칙은 여기에
eventBus.publish(new LectureEnrolled(enrollment)); // ← 이벤트 발행은 여기에

이렇게 "도메인 행위"와 "이벤트 발행"이 깔끔하게 분리된다. 하지만 Prisma가 plain object를 반환하는 이상, lecture.enroll(student) 같은 메서드를 붙이기 어렵고, 결국 모든 것이 Service 안에서 절차적으로 처리된다.

💡 Architect's Insight: EDA 전환의 첫 번째 단계는 메시지 브로커 도입이 아니다. 도메인 경계를 식별하고, 각 도메인이 독자적으로 상태를 관리하고 이벤트를 발행할 수 있는 구조를 만드는 것이 선행되어야 한다. ORM 패러다임의 전환은 이 선행 작업의 핵심이다.

 

 

 


3. 올바른 분리를 위한 ORM 기술 검토 — Data Mapper를 찾아서

왜 Data Mapper인가?

Active Record 패턴(Prisma, Sequelize의 기본 모드)에서는 모델 객체가 "데이터 접근"과 "비즈니스 로직"을 모두 담당하거나, Prisma처럼 데이터 접근만 담당하고 로직은 Service로 밀어낸다. 어느 쪽이든 도메인 모델과 영속성 모델이 강하게 결합된다.

Data Mapper 패턴에서는 이 둘이 분리된다:

[Active Record / Prisma 방식]
Service → Prisma Client → DB
(도메인 로직이 Service에 흩어짐)

[Data Mapper 방식]
Service → Domain Entity → Repository(Mapper) → DB
(도메인 로직이 Entity에 캡슐화됨)

Domain Entity는 DB를 모른다. Repository가 DB에서 읽어온 데이터를 Entity로 변환(rehydrate)하고, Entity의 상태 변경을 다시 DB에 반영(persist)한다. 도메인 모델이 인프라(DB/ORM)에 종속되지 않는다.

ORM 선택지 비교

Node.js/NestJS 생태계에서 선택 가능한 ORM을 "Rich Domain Model을 얼마나 자연스럽게 지원하는가" 기준으로 비교한다.

조금 편향되어 있는 느낌도 있긴 하지만 ,,

기준 TypeORM Sequelize Prisma MikroORM
패턴 Active Record + Data Mapper Active Record Schema-first Query Builder Data Mapper (기본)
Unit of Work 부분적 없음 없음 ✅ 완전 지원
Identity Map 없음 없음 없음 ✅ 지원
Entity에 메서드 부착 가능 가능 ❌ (plain object 반환) ✅ 자연스러움
타입 안전성 중간 낮음 매우 높음 높음
NestJS 공식 지원 ✅ ✅ ✅ Recipes 섹션 ✅
정식 버전 출시 ❌ (v0.3, 1.0 미출시) ✅ ✅ ✅ (v7, 2025)
유지보수 현황 (2026) ⚠️ 정체 안정적 활발 활발
조회 결과 클래스 인스턴스 클래스 인스턴스 plain object 클래스 인스턴스

TypeORM: 생태계 1위지만 정체된 현실

TypeORM은 NestJS 생태계에서 가장 많이 사용되는 ORM이고, Data Mapper와 Active Record를 모두 지원한다. 하지만 두 가지 심각한 문제가 있다.

첫째, 정식 버전(1.0)이 출시되지 않았다. 2018년부터 0.x 버전으로 운영되고 있다.

둘째, 유지보수가 정체되어 있다. GitHub 이슈 #3267 "Future of TypeORM"에서 메인테이너 본인이 프로젝트의 지속 가능성에 대한 고민을 밝힌 바 있고, 이후 기능 확장과 버그 수정이 더뎌진 상태다. 프로덕션에서 장기적으로 의존하기에는 리스크가 있다.

Sequelize: 전통의 강자, 하지만 TypeScript 시대에는

Sequelize는 Node.js 생태계에서 가장 오래된 SQL ORM이다. 객체 지향적 접근이 가능하고 안정적이지만, TypeScript 지원이 완벽하지 않다. 타입 추론이 약하고, 모던 TypeScript 프로젝트에서 기대하는 수준의 개발 경험을 제공하지 못한다. 레거시 유지보수 용도로는 충분하지만, 신규 도입에는 적합하지 않다.

Prisma: 타입 안전성은 최강이지만

Prisma의 select/include 타입 추론은 현존하는 ORM 중 최고 수준이다. 읽기 전용 API에서는 이보다 좋은 도구를 찾기 어렵다. 하지만 앞서 분석한 대로, plain object 반환이라는 설계 철학 때문에 Rich Domain Model과의 궁합이 좋지 않다.

Prisma를 버릴 필요는 없다. 조회(Query) 측면에서는 Prisma가 여전히 강력하다.

CQRS(Command Query Responsibility Segregation) 관점에서

쓰기는 Domain Entity + Data Mapper ORM으로, 읽기는 Prisma로 나누는 것도 현실적인 전략이 될 수 있다.

MikroORM: Unit of Work와 Identity Map을 지원하는 대안

MikroORM은 Node.js 진영에서 JPA/Hibernate와 가장 유사한 사상을 가진 ORM이다.

괜히 그냥 "Node.js에서 굳이 JPA/Hibernate 같은 걸 쓰려는 거 아니야 ?"

그렇지만 곰곰이 생각해 보자,

 

어디선가 그런 얘기를 들은 적이 있다,

 

미국의 B-2 폭격기나 F-35 전투기의 스텔스 기능이 월등하다고,

그런 기술을 독자적으로 구축하기 매우 어렵고, 미국 또한 그런 기술을 절대 유출하지 않으려고 애쓴다고,

그러나 다른 국가에서도 막강한 기술과 기능을 탑재하기 위해서 부단히 노력한다,

아무리 동맹국이고 미국이라고 한들 그런 기술을 그냥 제공하겠는가 ?

 

그런데 흥미로운 것은, 각국이 독자적으로 "최고의 스텔스 성능"을 추구하며 설계를 거듭하다 보면,

의도하지 않았어도 결국 미국의 그것과 외형이 닮아간다는 것이다.

 

레이더파를 피하기 위한 최적의 형상이라는 물리 법칙이 같기 때문이다.

모방한 것이 아니라, 같은 문제를 풀다 보니 같은 해답에 도달한 것이다.

 

생물학에서는 이것을 수렴 진화(Convergent Evolution)라고 부른다. 박쥐와 새는 전혀 다른 계통이지만, "하늘을 날아야 한다"는 동일한 선택 압력 아래에서 독립적으로 날개를 발전시켰다. 형태가 닮은 것은 모방이 아니라 목적이 같았기 때문이다.

 

하고 싶은 말은, JPA/Hibernate를 쓰려고 노력하지 않고 굳이 따라하려고 하지 않았더라도,

OOP, DDD를 추구하다 보면 필시 그런 형태를 띠게 될 것이라고.

 

MikroORM도 마찬가지다. Node.js 진영에서 JPA를 흉내 내려고 한 것이 아니다.

"복잡한 비즈니스 로직을 객체 지향적으로 제어하고 싶다"라는 동일한 목적을 추구하다 보니,

필연적으로 Unit of Work, Identity Map, 변경 감지(dirty checking) 같은 형태로 수렴한 것이다.

 

형태가 JPA와 닮은 것은 우연이 아니라, 같은 문제(도메인 모델의 영속화)를 같은 철학(OOP + DDD)으로 풀었기 때문이다.

 

 

핵심 특성:

  • Unit of Work: 엔티티의 상태 변경을 메모리에서 추적하고, em.flush() 시점에 변경된 것만 한 번에 DB에 반영. JPA의 dirty checking과 같은 원리
  • Identity Map: 같은 트랜잭션 내에서 같은 PK의 엔티티를 두 번 조회하면 캐시에서 반환. 불필요한 중복 쿼리 제거
  • Rich Entity 지원: 조회 결과가 클래스 인스턴스로 반환되므로, lecture.enroll(student) 같은 패턴이 자연스러움
  • v7 (2025): 제로 디펜던시, Kysely 쿼리 빌더 내장, 네이티브 ESM, Oracle 지원 추가
// MikroORM 기반의 Rich Domain Entity
@Entity()
export class Lecture {
    @PrimaryKey()
    id!: number;

    @Property()
    capacity!: number;

    @Property()
    enrolledCount: number = 0;

    // 도메인 로직이 엔티티에 캡슐화됨
    enroll(studentId: number): LectureEnrollment {
        if (this.enrolledCount >= this.capacity) {
            throw new CapacityExceededException();
        }
        this.enrolledCount++;
        return new LectureEnrollment(this, studentId);
    }
}

// Service는 오케스트레이션만
@Injectable()
export class LectureService {
    async enroll(lectureId: number, studentId: number): Promise<number> {
        const lecture = await this.em.findOneOrFail(Lecture, lectureId);
        const enrollment = lecture.enroll(studentId);  // 도메인에게 위임
        this.em.persist(enrollment);
        await this.em.flush();  // Unit of Work가 변경 사항을 한 번에 커밋
        return enrollment.id;
    }
}

이 구조에서는 정원 검증, 등록 처리라는 도메인 규칙이 Lecture 엔티티 안에 있다. Service는 엔티티를 찾아서 "등록해"라고 시키고, 결과를 영속화할 뿐이다. 어디서 enroll()을 호출하든 같은 규칙이 적용된다.

💡 Architect's Insight: MikroORM의 Unit of Work는 "명시적 update() 호출 없이 엔티티 상태 변경을 추적한다"는 점에서 JPA의 영속성 컨텍스트와 같은 역할을 한다. Spring/JPA 경험이 있는 개발자라면 사고 모델의 전환 없이 자연스럽게 적응할 수 있다. 반면 Prisma만 써본 개발자에게는 "왜 update()를 안 호출해도 DB에 반영되지?"라는 혼란이 생길 수 있다. 팀의 러닝 커브를 반드시 고려해야 한다.

 

 

 


4. 결론: 아키텍처 개편의 방향성

Before → After

[Before: Prisma + Fat Service]
모든 로직이 Service에 집중 → 도메인 경계 불명확 → EDA 전환 불가

[After: MikroORM + Rich Domain + EDA]
도메인 규칙이 Entity에 캡슐화 → 도메인 경계 명확 → 이벤트 기반 분리 가능

점진적 전환 전략

전면적인 ORM 교체는 리스크가 너무 크다. 현실적인 전략은 Strangler Fig Pattern — 가장 독립적인 도메인 하나를 선정해서 새로운 구조의 레퍼런스를 먼저 만들고, 검증된 후에 점진적으로 확장하는 것이다.

  1. 1단계: 독립적인 도메인(예: 게시판) 하나를 선정해서 MikroORM 기반 Rich Domain Model 구현
  2. 2단계: 해당 도메인에서 도메인 이벤트 발행(Transactional Outbox Pattern) 적용
  3. 3단계: 검증된 패턴을 다른 도메인으로 확산
  4. 병행 운영: 읽기(Query)는 기존 Prisma를 그대로 활용 (CQRS)

기술 선택의 근거

확장이 정체된 TypeORM 대신, 구조적 분리가 가능하고 활발히 유지보수되는 MikroORM을 도입해서 도메인 분리와 EDA 전환의 초석을 다지려 한다. 이 결정의 핵심 근거는:

  • Unit of Work + Identity Map으로 도메인 모델과 영속성 모델의 분리가 ORM 레벨에서 지원됨
  • 정식 버전(v7)이 출시되었고 활발히 유지보수 중
  • JPA/Hibernate와 유사한 사상으로, Spring 경험이 있는 팀원의 적응이 수월
  • NestJS 공식 연동 모듈 제공

이것은 단순한 ORM 교체가 아니다. "비즈니스 로직을 어디에 둘 것인가?"라는 아키텍처적 질문에 대한 답을 바꾸는 것이다. Service에서 Entity로, 절차적 코드에서 도메인 중심 코드로 — 이 전환이 EDA와 MSA로 나아가는 첫 번째 걸음이 된다.

 

 


다음 글에서는 MikroORM v7의 Unit of Work가 JPA의 dirty checking과 어떻게 대응되는지, 그리고 Transactional Outbox Pattern으로 도메인 이벤트를 안전하게 발행하는 구체적인 구현을 다룰 예정이다.

 

 


 

 

 

 


참고 자료

  • TypeORM — Future of TypeORM (GitHub Issue #3267)
  • 인프랩 기술블로그 — 레거시 코드 개편기
  • MikroORM v7 릴리스 노트

 

 

 

 

'Framework > NestJS' 카테고리의 다른 글

[NestJS] Prisma vs TypeORM  (0) 2026.02.02
[NestJS] NestJS를 공부해 보자 ..  (0) 2026.01.26
'Framework/NestJS' 카테고리의 다른 글
  • [NestJS] Prisma vs TypeORM
  • [NestJS] NestJS를 공부해 보자 ..
하가네
하가네
  • 하가네
    하 렌
    하가네
  • 전체
    오늘
    어제
    • 분류 전체보기 (127)
      • Computer Science (23)
        • 운영체제 (7)
        • 데이터통신 (6)
        • 자료구조 (4)
        • 논리회로 (0)
        • 확률 및 통계 (0)
        • 데이터베이스 (2)
        • AI소프트웨어 (3)
        • 컴퓨터네트워크 (1)
      • Design (4)
        • OOP - 객체 지향 프로그래밍 (2)
        • DDD - 도메인 주도 개발 (데이터베이스 주도 .. (0)
        • EDA - 이벤트 기반 아키텍처 (1)
        • MSA - 마이크로서비스 아키텍처 (0)
        • ADD - AI 주도 개발 (1)
      • Language (2)
        • Java (0)
        • TypeScript (2)
      • Framework (12)
        • Spring (9)
        • NestJS (3)
      • Engine (3)
        • Elasticsearch (1)
        • GraphQL + Apollo Federation (2)
      • Plugin - Extension (1)
        • VS Code (1)
        • IntelliJ (0)
      • Tips (2)
        • 터미널 명령어 (1)
        • 우분투 명령어 에러 (1)
      • SSA (26)
        • Front (1)
        • Back (23)
        • DB (1)
        • 기획 (1)
      • CNU SW 아카데미 (42)
        • 1주차 (5)
        • 2주차 (5)
        • 3주차 (2)
        • 4주차 (1)
        • 5주차 (3)
        • 6주차 (2)
        • 7주차 (0)
        • 8주차 (1)
        • 9주차 (14)
        • 10주차 (0)
        • 11주차 (1)
        • 12주차 (0)
        • 13주차 (2)
        • 14주차 (2)
        • 15주차(최종 프로젝트) (3)
        • 최종 프로젝트 이후 (1)
      • 모각코 (6)
        • 2023 동계 (6)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Typescript
    ESLint
    DX(DeveloperExperience)
    생산성
    프론트엔드/백엔드
    아키텍처
    Husky
    ci/cd
    릴리스엔지니어링
    lint-staged
    개발자경험(DX)
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.
하가네
[NestJS] NestJS + Prisma ? MikroORM ?
상단으로

티스토리툴바