객체란 무엇인가 — 그리고 TypeScript/NestJS에서 객체 지향을 실현할 수 있는가
들어가며
개발 공부를 하다 보면 "객체가 중요하다", "객체 지향 설계를 해야 한다"는 말을 정말 많이 듣게 됩니다. 오랜 시간 개발을 해 온 것은 아니지만, 현 시점에서 스스로 느낀 바를 정리하고 앞으로도 조금씩 생각을 다듬어 나갈 예정입니다.
글의 목적은 두 가지입니다. 첫째는 머릿속에만 있던 생각을 구체화하는 것, 둘째는 이 글을 보는 누군가와 의견을 주고받는 것입니다.
1. 객체는 "현실에서 정의할 수 있는 주체"다
객체는 현실 세계에서 정의할 수 있는 주체라고 생각합니다. 구체적으로 말하면 '학생', '강사', '수업' 같은 것입니다.
"'수업'은 실체가 없지 않나요? 그러면 객체가 아닌 것 아닌가요?"라는 질문이 나올 수 있습니다. 여기서 말하는 것은 물리적 실체의 유무가 아닙니다. 그렇게 따지면 "실체란 무엇인가"라는 철학적 고민만 남을 뿐입니다.
중요한 것은 "주체"가 "동작"하는가? 입니다.
'학생'이 '수업'에 '등록한다' — 이것이 바로 "주체가 동작하는 것"에 해당합니다.
여기서 또 의문이 생길 수 있습니다. '학생'이 수업에 '등록하는' 것인지, '등록되는' 것인지? 이건 서비스의 성격에 따라 달라집니다. 강사가 승인해야 학생이 수업에 들어올 수 있다면 "등록되는 것"이고, 학생이 스스로 수강 신청을 한다면 "등록하는 것"입니다. 어떤 쪽이든 핵심은 같습니다.
각 주체('학생', '수업')가 있고, 동작('등록한다')이 있다. 객체가 자신의 상태를 행위로 변경하는 것.
이것이 '객체 지향'이라고 생각합니다.
2. 그러면 Service는 객체인가?
Spring이든 NestJS든 MemberService, LectureService 같은 클래스를 만들고 인스턴스화합니다. new MemberService()를 해서 인스턴스가 만들어지니까 객체인 것 같지만, 제 생각은 다릅니다.
MemberService는 현실에 존재하지 않습니다. 증거로, 이 클래스는 상태(필드)를 가지지 않고 데이터베이스에 저장되지도 않습니다. 인스턴스화가 된다고 모두 "객체"인 것은 아닙니다.
진짜 객체는 Member, Lecture, LectureEnrollment 같은 것입니다. 현실(또는 논리적 세계)에서 정의할 수 있고, 자신의 상태와 행위를 가진 존재입니다.
그렇다면 Service는 무엇인가? Service는 오케스트레이터입니다. 실제 도메인 객체들을 불러와서 행위를 지시하고, 트랜잭션을 관리하고, 결과를 조합하는 역할입니다. Service 자체가 "어떻게 로그인하는지", "어떻게 수강 등록하는지"를 알면 안 됩니다. 그건 도메인 객체의 책임입니다.
코드로 보면 더 명확합니다
// Member 엔티티가 직접 로그인 검증을 수행
public void login(String plainPassword) {
boolean same = this.password.match(plainPassword);
if (!same) {
throw new ApplicationException(MemberExceptionCode.INVALID_USERNAME_PASSWORD);
}
}
// MemberService는 "누가 어떻게 로그인하는지" 모름
// 단지 Member를 찾아서 "로그인해"라고 시킬 뿐
public Long login(String username, String password) {
Member member = memberRepository.getByUsername(username);
member.login(password); // 도메인에게 위임
return member.getId();
}
MemberService는 비밀번호를 어떻게 검증하는지 모릅니다. 해시 알고리즘이 SHA-256인지 Argon2인지, 비교 로직이 어떻게 되는지 전혀 모릅니다. 그냥 "로그인해"라고 시킬 뿐입니다. 이것이 도메인 로직과 비즈니스(애플리케이션) 로직의 분리입니다.
수강 등록도 마찬가지입니다
// Lecture 엔티티가 직접 정원을 검증하고 등록 처리
public LectureEnrollment enroll(Student student) {
increaseEnrolledCount(); // 정원 초과 검증 포함
return new LectureEnrollment(this, student);
}
// Service는 흐름만 조합
LectureEnrollment enrollment = lecture.enroll(student);
enrollmentRepository.save(enrollment);
lecture.enroll(student) 대신 Service에서 직접 new LectureEnrollment(lecture, student)를 하고 enrolledCount++를 할 수도 있습니다. 하지만 그렇게 하면 "정원 초과 검증", "중복 등록 방지" 같은 규칙의 소유권이 Service로 빠져나갑니다. 규칙이 여기저기 흩어지면 유지보수가 어려워지고, 누군가 다른 곳에서 new LectureEnrollment()를 직접 호출하면 검증이 빠지게 됩니다.
한 줄로 요약하면: "도메인을 왕으로 만드는 것이 객체 지향. 그것이 DDD."
3. 이 철학을 TypeScript + NestJS + Prisma에서 실현할 수 있는가?
여기서 기술적 충돌이 발생합니다.
JPA(Hibernate)와 Prisma의 근본적 차이
Java/Spring 진영에서는 JPA(Hibernate)가 이런 Rich Domain Model을 자연스럽게 지원합니다. DB에서 조회한 결과가 클래스 인스턴스로 반환되기 때문에, member.login()처럼 엔티티에 메서드를 붙이는 것이 자연스럽습니다.
그런데 Prisma는 조회 결과를 순수 JavaScript 객체(plain object)로 반환합니다.
클래스 인스턴스가 아니라 { id: 1, username: 'john', ... } 같은 데이터 덩어리입니다.
여기에 .login() 메서드를 붙일 수 없습니다.
| 관점 | JPA/Hibernate | Prisma |
|---|---|---|
| 조회 결과 | 관리되는 엔티티 인스턴스 | plain object |
| 변경 반영 | dirty checking + 자동 flush | 명시적 update 호출 필요 |
| 영속성 컨텍스트 | 있음 (1차 캐시 + 변경 감지) | 없음 |
| Rich Domain 난이도 | 상대적으로 쉬움 | 직접 설계해야 함 |
이 차이 때문에 "Prisma를 쓰면 DDD를 못 한다"는 오해가 생깁니다. 하지만 정확히 말하면:
Prisma가 Anemic Domain Model을 강제하는 것이 아니라, "Prisma record = domain entity"라고 취급하는 순간 Anemic이 되는 것입니다.
가능하다 — 다만 방식이 다르다
JPA는 "ORM이 관리해주는 엔티티 인스턴스 위에 Rich Domain을 얹는" 방식이지만, Prisma 환경에서는 "도메인 모델을 직접 세우고, Prisma는 영속화 어댑터로만 쓰는" 방식으로 가야 합니다.
[JPA 방식]
DB → JPA가 엔티티 인스턴스 생성 → 엔티티에 메서드 호출 → JPA가 변경 감지 → DB 반영
[Prisma 방식]
DB → Prisma가 plain object 반환 → Mapper가 도메인 객체로 변환 → 도메인 메서드 호출 → Mapper가 다시 plain object로 변환 → Prisma로 DB 반영
즉 매핑 레이어가 하나 더 필요합니다. 이것이 보일러플레이트인지, 아키텍처적으로 올바른 분리인지는 관점에 따라 다릅니다.
오히려 Prisma는 JPA의 "편리하지만 경계가 모호한" 측면을 제거합니다. JPA에서는 엔티티가 DB 어노테이션(@Entity, @Column)과 도메인 로직을 동시에 가지면서 persistence와 domain이 섞이기 쉽습니다. Prisma는 애초에 plain object를 반환하니까, 도메인 모델과 영속성 모델의 분리가 강제됩니다.
4. ORM 선택지 분석 — 객체 지향 설계의 관점에서
현재 NestJS 생태계에서 선택할 수 있는 ORM들을 "Rich Domain Model을 얼마나 자연스럽게 지원하는가" 기준으로 비교합니다.
Prisma (현재 사용 중)
Prisma는 Query Builder에 가까운 도구입니다. 스키마 정의(schema.prisma)에서 모델을 선언하고, 생성된 클라이언트로 타입 안전한 쿼리를 실행합니다. select/include의 타입 추론이 뛰어나서 읽기(Query) 측면에서는 최강입니다.
하지만 Rich Domain Model과의 궁합은 좋지 않습니다. 조회 결과가 plain object이므로 도메인 로직을 붙이려면 별도의 도메인 클래스 + Mapper가 필요합니다. "엔티티에 메서드를 넣는다"는 개념 자체가 Prisma의 설계 철학과 맞지 않습니다.
적합한 용도: 읽기 전용 API, CQRS의 Query 측, 빠른 프로토타이핑, 관리자 페이지
TypeORM
NestJS 생태계에서 가장 많이 쓰이는 ORM입니다. Active Record와 Data Mapper 패턴을 모두 지원하고, 엔티티에 메서드를 붙일 수 있어서 Rich Domain Model이 가능합니다.
하지만 완전한 Unit of Work 패턴이 없습니다. 복잡한 객체 그래프(Aggregate)를 한 번에 영속화하고 변경을 추적하는 데 한계가 있습니다. 또한 v0.3 이후 메인테이너 교체와 이슈 대응 지연이 커뮤니티에서 지속적으로 지적되고 있습니다.
적합한 용도: 중규모 프로젝트, DDD까지는 아니지만 엔티티 중심 설계를 하고 싶을 때
Drizzle ORM
SQL 중심의 타입 추론이 뛰어난 경량 ORM입니다. "SQL을 잘 아는 개발자를 위한 ORM"이라는 포지셔닝이 명확합니다.
하지만 함수형/데이터 중심적 패러다임이라서, 객체에 상태와 행위를 캡슐화하는 DDD와는 방향이 다릅니다. 엔티티라는 개념 자체가 약합니다.
적합한 용도: SQL 중심 프로젝트, 서버리스/엣지 환경, 성능이 최우선일 때
Sequelize
링커리어 Xen에서 사용 중인 레거시 ORM입니다. Active Record 패턴 기반이고, 모델에 메서드를 추가할 수 있어서 Rich Domain이 이론적으로는 가능합니다.
하지만 TypeScript 지원이 불완전하고, 타입 안전성이 약합니다. 새로운 프로젝트에서 선택할 이유는 거의 없습니다.
적합한 용도: 기존 레거시 유지보수
MikroORM
Node.js 진영에서 JPA/Hibernate와 가장 유사한 방향성을 가진 ORM입니다. 핵심 특징:
- Unit of Work: 엔티티의 상태 변경을 메모리에서 추적하고,
em.flush()시점에 한 번에 DB에 반영. JPA의 dirty checking과 같은 원리 - Identity Map: 같은 트랜잭션 내에서 같은 PK의 엔티티를 두 번 조회하면 캐시에서 반환. N+1 방지에 효과적
- Rich Entity 지원: 조회 결과가 클래스 인스턴스로 반환되므로,
lecture.enroll(student)같은 패턴이 자연스러움 - v7 안정성: 2025년 출시된 v7은 제로 디펜던시, Kysely 쿼리 빌더 내장, 네이티브 ESM 지원
가장 큰 장점은 도메인 모델과 영속성 모델을 하나의 클래스에서 관리하면서도, Unit of Work가 변경을 추적해준다는 점입니다. Prisma처럼 별도의 Mapper를 만들 필요 없이, JPA와 비슷한 개발 경험을 얻을 수 있습니다.
단점은 Prisma 대비 러닝 커브가 있고, 커뮤니티 규모가 작다는 점입니다.
적합한 용도: DDD + Rich Domain Model, 이벤트 기반 아키텍처, 복잡한 도메인 로직
비교 요약
| 기준 | Prisma | TypeORM | Drizzle | MikroORM |
|---|---|---|---|---|
| Rich Domain 친화도 | 낮음 (plain object) | 중간 | 낮음 (함수형) | 높음 (UoW + Identity Map) |
| Unit of Work | 없음 | 부분적 | 없음 | 완전 지원 |
| 타입 안전성 | 매우 높음 | 중간 | 높음 | 높음 |
| 읽기 성능/편의성 | 최강 | 보통 | 높음 | 보통 |
| 커뮤니티 규모 | 매우 큼 | 큼 | 성장 중 | 작지만 활발 |
| NestJS 공식 지원 | 있음 | 있음 | 커뮤니티 | 있음 |
| 정식 버전 | ✅ | ⚠️ (v0.3) | ✅ | ✅ (v7) |
5. 현실적인 선택일까 ? — Write는 Rich Domain, Read는 Prisma
모든 것을 하나의 ORM으로 해결할 필요는 없습니다. 실무에서 가장 균형 잡힌 전략은 CQRS(Command Query Responsibility Segregation)입니다: (다만 어렵겠죠 ..)
- Write(상태 변경): Rich Domain Model + MikroORM (또는 Prisma + 수동 Mapper)
- Read(조회): Prisma 직행 (타입 추론 + select/include의 강점 활용)
왜냐하면:
- 모든 조회 API까지 Rich Domain으로 올리면 매핑 비용 + 객체 생성 비용만 늘어남 (그래도 다 올리는 게 통일성 있을 수 있음 !)
- 목록 조회, 검색, 통계 같은 읽기 작업은 도메인 규칙이 필요 없음
- Prisma의
select/include타입 추론은 읽기 전용 모델로서 최강
반면 "정원 초과 검증이 필요한 수강 등록", "비밀번호 검증이 필요한 로그인" 같은 규칙이 있는 상태 변경은 반드시 도메인 객체가 직접 수행해야 합니다.
6. 아직 답이 나지 않은 것들
이 글을 쓰면서도 확실하게 정리되지 않은 고민들이 있습니다:
- 보일러플레이트 비용: Prisma + 수동 Mapper 방식은 도메인 클래스, Mapper 클래스, Repository 인터페이스, Repository 구현체 등 파일 수가 크게 늘어남. 이게 "올바른 분리"인지 "과도한 추상화"인지의 경계는 어디인가?
- 팀 컨벤션: 혼자 하는 프로젝트라면 어떤 방식이든 일관성을 유지할 수 있지만, 팀 프로젝트에서는 팀원 전체가 이 패턴을 이해하고 따라야 함. 러닝 커브가 높은 패턴이 팀에 도입 가능한가?
- "객체 지향을 제대로 하고 싶다"는 욕구와 "지금 당장 서비스를 안정적으로 운영해야 한다"는 현실 사이의 균형: 이 균형점은 프로젝트마다, 팀마다 다를 것이다
이 고민들은 앞으로 하나씩 풀어 나갈 예정입니다. 그렇게 풀어 나가고 싶습니다 ..!
다음 글에서 다룰 것
- MikroORM v7의 Unit of Work가 JPA의 dirty checking과 어떻게 대응되는지
- "어디까지 Rich Domain으로 가져가고, 어디부터 DTO로 갈 것인가"에 대한 나름대로의 기준
'Design > OOP - 객체 지향 프로그래밍' 카테고리의 다른 글
| [OOP - 객체 지향 프로그래밍] ORM을 잘 골라라 ?? 그리고 이벤트 한 번만 소비 ?? (1) | 2026.03.30 |
|---|---|
| [OOP - 객체 지향 프로그래밍] 좋은 객체지향을 향하는 나름의 규칙에 대한 고민 .. (0) | 2026.03.25 |