[OOP - 객체 지향 프로그래밍] ORM을 잘 골라라 ?? 그리고 이벤트 한 번만 소비 ??

2026. 3. 30. 12:10·Design/OOP - 객체 지향 프로그래밍

 

 

 

이전 글에서 세 가지 오해를 다뤘다.

  1. "객체는 현실에 존재하는 것만 객체다" → 규칙을 소유할 책임이 있는가로 판단한다
  2. "Service는 전부 가짜 객체다" → 도메인 규칙과 유스케이스 흐름이라는 서로 다른 책임이 있다
  3. "Rich Domain이면 동시성도 해결된다" → 도메인 규칙의 소유권과 동시성 제어의 원자성은 별개의 책임이다

세 가지를 관통하는 패턴은 "경계를 너무 좁게 그으면 깨진다"였다.

이번 글에서는 두 가지를 더 다룬다. 도구에 대한 오해와, 패턴에 대한 오해.

 

 

오해 4: "좋은 ORM, 자동으로 좋은 설계"

 

내가 처음 믿었던 생각

Prisma에서 도메인 로직을 Entity에 넣기 어렵다는 걸 느끼고,

"Data Mapper 패턴의 ORM을 쓰면 자연스럽게 Rich Domain이 되겠지"라고 생각했다.

Unit of Work가 있으면 em.flush()로 변경 감지가 되고, 엔티티가 클래스 인스턴스로 반환되니까

lecture.enroll(student) 같은 메서드를 자연스럽게 붙일 수 있을 거라고 ..

 

왜 그 생각이 매력적인가

Prisma가 plain object를 반환하는 것이 Fat Service의 구조적 원인이라면,

클래스 인스턴스를 반환하는 ORM으로 바꾸면 원인이 제거되는 거 아닌가?

MikroORM의 Unit of Work, Identity Map은 JPA와 같은 사상이고, JPA 위에서 DDD가 자연스럽다는 글은 넘쳐난다.

도구만 바꾸면 설계가 따라올 것 같았다.

 

어디서 깨졌는가

JPA를 쓰는 Spring 프로젝트를 돌아보면 답이 나온다.

JPA는 영속성 컨텍스트, dirty checking, Unit of Work를 전부 지원하는데도,

대부분의 Spring 프로젝트에서 Entity는 getter/setter만 있고 모든 로직이 Service에 있다.

Anemic Domain Model이 압도적 다수다.

처음에는 "개발자의 역량 문제"라고 생각했다. 빠른 구현을 우선하다 보니,

도메인 설계에 대한 고민 없이 Transaction Script로 끝내는 거라고. 그리고 그것도 틀린 말은 아니다.

하지만 그게 전부가 아니었다.

동료한테서 "프레임워크의 스캐폴딩 자체가 Anemic을 기본값으로 유도하는 구조적 원인이 있을 수도 있다"라고 들었다

'nest g resource'를 치면 Controller-Service-Module이 1:1:1로 나온다.

Spring Initializr도 마찬가지다. 이 시점에서 이미 "Entity는 데이터, Service는 로직"이라는 경로가 확정된다.

새 요구사항이 오면 기존 Service에 메서드를 추가하는 게 가장 저항이 적고,

Entity에 행위를 넣으려면 프레임워크의 기본 흐름을 의식적으로 거슬러야 한다.

이건 개발자 개인의 의지 문제만이 아니라, 도구가 유도하는 경로 의존성일 수 있다.

 

ORM을 바꿔도 응집도는 바뀌지 않는다

프레임워크의 Service가 가진 응집도를 Stevens-Myers-Constantine의 응집도 스펙트럼으로 보면,

Communicational Cohesion — "같은 데이터에 대해 작동하는 것들의 묶음"이다.

UserService에 create(), update(), delete(), changePassword(), verifyEmail()이 같이 있는 이유는

"전부 User에 대한 거니까"뿐이다.

ORM을 Prisma에서 MikroORM으로 바꿔도,

Service의 응집 단위가 명사(엔티티) 기반인 건 변하지 않는다.

Entity에 메서드를 붙일 수 있게 되었을 뿐, Service가 그 Entity의 모든 유스케이스를 담는 구조는 그대로다.

진짜 바뀌어야 하는 건 ORM이 아니라 코드의 조직 단위다.

명사 기반(UserService)에서 행위 기반(RequestEnrollment, AcceptEnrollment)으로.

하나의 Service가 하나의 엔티티의 모든 것을 아는 구조에서, 하나의 단위가 하나의 행위만 아는 구조로.

 

지금의 기준

ORM은 도메인 모델을 가능하게 하는 전제 조건이지, 충분 조건이 아니다.

좋은 ORM이 있어도 개발자가 의식적으로 Entity에 행위를 넣고, Service의 응집 단위를 행위 기반으로 바꾸지 않으면

Fat Service의 위치만 옮긴 셈이 된다.

구체적으로는 세 가지가 동시에 필요하다:

  1. Entity에 행위를 넣을 수 있는 ORM — Prisma의 plain object가 아니라 클래스 인스턴스 반환
  2. Service의 응집 단위를 행위 기반으로 바꾸는 의식적 설계 — UserService가 아니라 RequestEnrollment, AcceptEnrollment
  3. 팀의 합의 — "왜 UserService가 있는데 별도 클래스를 만드느냐?"에 대한 답을 팀이 공유해야 한다

 

트레이드오프

행위 기반으로 쪼개면 파일 수가 늘어나고, 프레임워크의 기본 스캐폴딩과 충돌한다. 새로 합류한 팀원이 "이 기능의 코드가 어디 있지?"를 찾는 데 시간이 더 걸릴 수 있다. 모듈화와 네이밍이 잘 되어 있다면 큰 문제는 아니지만, 그 "잘 되어 있다면"이 추가 비용이다.

그리고 다른 글에서 볼 수 있듯이, "진정한 추상화는 발견하는 것이지 발명하는 것이 아니다."

프로젝트 초기부터 행위 기반으로 완벽하게 쪼갤 필요는 없다.

서비스가 커지면서 "이 Service가 너무 많은 것을 알고 있다"는 고통이 체감될 때, 그때 분리해도 괜찮을 듯하다.

지금 우리 프로젝트가 그렇고.

 

한 줄 결론

ORM은 Rich Domain의 가능 조건이지, ORM을 바꾸는 것만으로 설계가 바뀌지는 않는다.

바뀌어야 하는 건 코드의 조직 단위와 팀의 설계 합의다.


오해 5: "Outbox면 exactly-once"

 

내가 처음 믿었던 생각

Transactional Outbox Pattern을 도입하면 이벤트 유실 없이 정확히 한 번만 처리할 수 있다고 생각했다.

DB 트랜잭션과 이벤트 발행을 같은 트랜잭션에 묶으니까, "메인 로직 성공 + 이벤트 저장 성공"이 원자적으로 보장되고,

그러면 이벤트가 정확히 한 번 발행되고 정확히 한 번 소비될 거라고.

 

왜 그 생각이 매력적인가

Outbox 이전의 문제는 명확했다. 비즈니스 로직이 성공하고 이벤트를 발행하려는데, 발행 직전에 서버가 죽으면 이벤트가 유실된다. Outbox는 이 문제를 "이벤트를 DB에 먼저 저장하고, 별도 워커가 나중에 발행한다"로 깔끔하게 해결한다.

DB 트랜잭션이 성공하면 이벤트도 반드시 저장되어 있으니까, 유실이 구조적으로 불가능하다.

 

어디서 깨졌는가

발행 쪽은 해결되었지만, 소비 쪽은 해결되지 않았다.

Outbox 워커가 PENDING 상태의 이벤트를 읽어서 브로커에 발행한 직후,

DB 상태를 PUBLISHED로 업데이트하기 전에 워커가 죽으면 어떻게 되는가 ?

다음번 워커 실행 때 같은 이벤트가 또 발행된다. 이것은 at-least-once(최소 한 번)이지 exactly-once(정확히 한 번)가 아니다.

그리고 소비자 쪽에서 UUID 기반으로 중복을 체크한다고 해도, 그 중복 체크 자체가 동시성 문제를 가질 수 있다.

두 워커가 같은 이벤트를 동시에 poll하면, 둘 다 "아직 처리 안 됨"으로 읽고 둘 다 실행할 수 있다.

이걸 막으려면 소비자 측에서도 DB 유니크 제약이나 낙관적 락이 필요하다.

 

결국 문제가 한 층씩 밀려나는 구조다:

Outbox → at-least-once 보장
  → 소비자가 멱등성으로 중복 방어 (UUID 기반)
    → 멱등성 체크 자체의 동시성 제어 (유니크 제약, 락)
      → ...

 

지금의 기준

Outbox는 "이벤트 유실 방지(at-least-once)"를 해결하는 패턴이지,

"정확히 한 번 실행(exactly-once)"을 보장하는 패턴이 아니다.

exactly-once는 Outbox 하나로 끝나는 게 아니라, 각 레이어에서 각자의 문제를 풀어야 한다.

 

구체적으로:

  1. 발행 측(Producer): Outbox가 at-least-once를 보장한다
  2. 소비 측(Consumer): 멱등성 키(UUID)로 중복 실행을 방지한다
  3. 멱등성 체크 자체: DB 유니크 제약이나 낙관적 락으로 동시성을 제어한다

 

트레이드오프

각 레이어의 방어를 완벽하게 구현하면 코드 복잡도가 상당히 올라간다.

"최신 발행만 실행"이 필요하면 타임스탬프까지 관리해야 하고, UUID + 타임스탬프 조합으로 유니크를 보장하려면 파싱 로직이 추가된다.

현실적으로는 "어디까지의 보장이 비즈니스적으로 필요한가 ?"를 먼저 판단해야 한다.

알림 발송처럼 두 번 보내도 큰 문제가 없는 경우와,

결제 처리처럼 반드시 한 번만 실행되어야 하는 경우는 요구되는 보장 수준이 다르다.

모든 이벤트에 동일한 수준의 exactly-once를 적용하면 과잉 엔지니어링이 될 수 있다.

 

 

다섯 가지 오해를 거치면서 하나의 패턴이 반복되었다. "도구나 패턴 하나가 문제를 해결해줄 것이다"라는 기대는 매번 깨졌다.

  • Rich Domain이 동시성을 해결해주지 않았다
  • 좋은 ORM이 자동으로 좋은 설계를 만들어주지 않았다
  • Outbox가 exactly-once를 보장해주지 않았다

매번 깨지고 나서 남은 건 같은 교훈이다.

도구는 가능 조건을 만들어줄 뿐, 충분 조건은 개발자의 설계 판단에 있다. 기준선은 고정되는 게 아니라 갱신되는 것 같다 ..

 

 

한 줄 결론

Outbox는 "유실 없이 발행한다"를 해결하지, "한 번만 실행한다"는 해결하지 않는다.

exactly-once는 발행-소비-멱등성-동시성 제어를 각 레이어에서 쌓아야 근접할 수 있다.

 

 

 

 

 

 

 

 

 

 

'Design > OOP - 객체 지향 프로그래밍' 카테고리의 다른 글

[OOP - 객체 지향 프로그래밍] 좋은 객체지향을 향하는 나름의 규칙에 대한 고민 ..  (0) 2026.03.25
[OOP - 객체 지향 프로그래밍] 객체란 무엇일까 ?  (0) 2026.03.13
'Design/OOP - 객체 지향 프로그래밍' 카테고리의 다른 글
  • [OOP - 객체 지향 프로그래밍] 좋은 객체지향을 향하는 나름의 규칙에 대한 고민 ..
  • [OOP - 객체 지향 프로그래밍] 객체란 무엇일까 ?
하가네
하가네
  • 하가네
    하 렌
    하가네
  • 전체
    오늘
    어제
    • 분류 전체보기 (128) N
      • Computer Science (23)
        • 운영체제 (7)
        • 데이터통신 (6)
        • 자료구조 (4)
        • 논리회로 (0)
        • 확률 및 통계 (0)
        • 데이터베이스 (2)
        • AI소프트웨어 (3)
        • 컴퓨터네트워크 (1)
      • Design (5) N
        • OOP - 객체 지향 프로그래밍 (3) N
        • 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)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

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

  • 최근 글

  • hELLO· Designed By정상우.
하가네
[OOP - 객체 지향 프로그래밍] ORM을 잘 골라라 ?? 그리고 이벤트 한 번만 소비 ??
상단으로

티스토리툴바