간단히, 또 오랫동안, 고민해 왔던 생각 ..
그래서 어떻게 도메인을 왕으로 만드는데 ? — 좋은 객체 지향을 향하는 나름의 규칙
도메인을 왕으로 만드는 것이 OOP, 그것이 DDD.
이 글을 쓰는 이유
객체 지향을 공부하고, DDD를 흉내 내보고, 실제 프로젝트에 적용하면서 몇 가지 "그럴듯한 기준"을 세웠다. 처음에는 꽤 잘 먹혔다. 그런데 규모가 커지고, 코너 케이스를 만나고, 부하 테스트를 돌리면서 그 기준들이 하나씩 깨졌다.
이 글은 내가 믿었던 것들이 어디서 깨졌고, 지금은 어떤 기준으로 바뀌었는지를 정리한 회고다. 정답이 아니라 "지금의 기준선"이다. 앞으로도 계속 깨지고 다듬어질 것이다.
다루는 오해는 세 가지다 (더 많아질 수도 있음):
- 객체는 현실에 존재하는 것만 객체다
- Service는 전부 가짜 객체다
- Rich Domain이면 동시성도 해결된다
# 오해 1: "객체는 현실에 존재하는 것만 객체다"
객체 지향을 공부하면서 나름의 기준을 하나 세웠다.
객체는 현실 세계에서 정의할 수 있는 주체라는 것이다.
'학생', '강의', '수강 등록'은 객체다. 반면 LectureService는 현실에 존재하지 않으니 진짜 객체가 아니다.
인스턴스화가 된다고 다 객체인 것은 아니라고 생각했다.
이 기준은 상당히 유효했다. Lecture.enroll(student)처럼 엔티티가 자신의 상태와 규칙을 직접 관리하는 구조가 자연스럽게 나왔고, Service는 오케스트레이션만 하게 되었다.
# 깨진 건 Domain Service를 만들 때였다
수강 신청 대기 생성 시 "이미 같은 학생이 같은 강의에 신청했는가 ?"를 검증해야 했다. DB 조회가 필요하니 Entity 메서드로는 넣을 수 없었다.
그렇다고 Application Service에 두면 도메인 규칙이 유스케이스 흐름 사이에 묻힌다.
Entity에 넣을 수 없고, Application Service에 두기엔 규칙이 새어나가니, 남은 선택지는 Domain Service뿐이었다. 소거법이었다.
그런데 나중에 돌아보니 이 Domain Service는 다른 유스케이스에서 재사용된 적이 없었다.
그리고 해당 중복 방지 규칙은 테이블의 유니크 제약 하나로 해결 가능했다.
@UniqueConstraint(columnNames = {"lecture_id", "student_id"})
를 선언하면 사전 조회 자체가 불필요해진다.
중복 INSERT 시 DB가 예외를 던지고, 이걸 잡아서 도메인 예외로 변환하면 끝이다.
인프라가 풀 수 있는 문제를 도메인 레이어에서 중복으로 풀고 있었던 셈이다.
Value Object에서도 같은 문제가 나타났다.
Money나 Capacity 같은 개념은 현실에 "주체"로 존재한다고 보기 어려울 수 있다.
하지만 금융 도메인이라면 Money.add(other) 호출 시 통화 불일치를 검증해야 하고,
커뮤니티 서비스라면 number 하나로 충분하다.
같은 개념이라도 서비스의 맥락에 따라 객체로서의 무게가 완전히 달라진다.
# 지금의 기준
"현실에 존재하는가?"가 아니라 "이 도메인에서 규칙을 소유할 책임이 있는가 ?"로 판단한다.
그리고 한 단계 더 — 그 규칙의 최적 위치가 새로운 객체인지, 기존 인프라(DB 제약 등)로 충분한지도 함께 묻는다.
물론 인프라 제약에 의존하면 에러 메시지의 제어권이 DB 레이어로 넘어간다.
사용자에게 "이미 신청한 강의입니다"를 보여주려는 UX 요구사항이 있다면 사전 조회가 여전히 필요하다.
규칙의 최종 방어선은 인프라, 사전 조회는 UX를 위한 가드레일 — 이 구분이 지금의 기준선이다.
이 기준을 세우고 나니, 한동안 치워뒀던 질문이 다시 올라왔다.
Domain Service는 현실에 없는 존재인데도 도메인 규칙을 소유할 수 있었다.
그렇다면 내가 "현실에 없으니 가짜"라고 뭉뚱그렸던 Service라는 존재 전체를 다시 들여다볼 필요가 있다.
정말로 모든 Service가 "가짜 객체"인 걸까 ?
# 오해 2: "Service는 전부 가짜 객체다"
"현실에 존재하는 주체만 객체"라는 기준을 세운 뒤, 자연스럽게 다음 결론이 따라왔다.
MemberService, LectureEnrollmentService 같은 Service 클래스는 현실에 존재하지 않는다.
상태(필드)도 없고, DB에 저장되지도 않는다.
그러니 진짜 객체가 아니라 그냥 "함수 묶음"이다 — 도메인 객체들을 불러다가 시키는 일만 하는 가짜.
이 판단 덕분에 Service에 로직을 채우는 대신 Entity에 로직을 넣으려는 습관이 생겼다.
lecture.enroll(student), member.login(password) — Service가 아니라 도메인 객체가 자신의 규칙을 소유하는 구조를 만들 수 있었다.
"Service는 가짜"라는 거친 판단이 오히려 Rich Domain Model로 가는 좋은 가드레일이 되었다.
# 깨진 건 두 군데였다
첫 번째: Domain Service는 "가짜"가 아니었다.
오해 1에서 다뤘듯이, "중복 수강 신청 방지"처럼 DB 조회가 필요한 도메인 규칙은 Entity에 넣을 수 없었고, Domain Service가 그 규칙을 소유했다. 이 Domain Service는 현실에 없는 존재지만 도메인 규칙의 정당한 소유자였다.
두 번째: Application Service의 "순서"도 아무나 아는 게 아니었다.
수강 승인 처리를 구현할 때, "대기 상태를 승인 → 대기 기록 삭제 → 수강생으로 등록 → 알림 발행"이라는 순서를 코드로 옮겼다.
이 순서는 내가 기획한 서비스의 흐름에서 나온 것이었다.
처음에는 이것이 단순한 오케스트레이션이라고 생각했다. 그런데 곰곰이 보면, 이 순서가 뒤바뀌면 비즈니스적으로 문제가 생긴다.
수강생 등록을 먼저 하고 대기 승인을 나중에 하면 정원 검증 시점이 꼬인다. 알림을 등록보다 먼저 보내면 아직 수강생이 아닌 학생에게 "등록 완료" 알림이 간다.
그렇다면 "순서를 아는 것"도 일종의 규칙인가 ? 여기서 경계를 긋는 데 한참 고민했다.
# 지금의 기준: 두 종류의 Service
결론적으로, "순서를 아는 것"과 "규칙을 소유하는 것"은 다른 레벨의 책임이라고 정리했다.
- 도메인 규칙: "정원이 초과되면 등록을 거부한다", "통화가 다르면 합산할 수 없다"처럼 어떤 유스케이스에서 호출하든 항상 동일하게 적용되는 불변식(invariant)이다. Entity나 Value Object, 또는 Domain Service가 소유한다.
- 유스케이스 흐름: "대기 승인 → 삭제 → 등록 → 알림"처럼 특정 비즈니스 시나리오에서만 의미 있는 순서다. 같은 도메인 객체들이라도 다른 유스케이스(예: 관리자 강제 등록)에서는 순서가 달라질 수 있다. Application Service가 담당한다.
쉽게 말하면: "정원 초과면 거부"는 수강 등록이라는 행위 자체에 내장된 규칙이고, "승인 먼저, 등록 나중에"는 "수강 승인"이라는 특정 시나리오의 흐름이다.
"패키지 위치를 나누는 건 편하자고 합리화하는 것 아니야?"라고 생각할 수 있다.
하지만 핵심은 물리적 위치가 아니라 "이 규칙은 다른 유스케이스에서도 강제되어야 하는가 ?"라는 질문이다.
관리자 강제 등록에서도 정원 검증이 필요하다면 그것은 도메인 규칙이고,
관리자는 대기 단계를 건너뛸 수 있다면 "대기 → 승인" 순서는 유스케이스 흐름이다.
// Application Service — 유스케이스 흐름을 소유
@Transactional
public Long acceptEnrollment(command) {
PendingEnrollment pending = pendingRepository.getById(command.pendingId());
Lecture lecture = lectureRepository.getById(pending.getLectureId());
Student student = studentRepository.getById(pending.getStudentId());
pending.accept(); // 도메인 규칙
pendingRepository.delete(pending);
LectureEnrollment enrollment = lecture.enroll(student); // 도메인 규칙
enrollmentRepository.save(enrollment);
eventPublisher.publish(new EnrollmentAccepted(lecture, student));
return enrollment.getId();
}
이 코드에서 pending.accept()와 lecture.enroll(student)는 도메인 규칙이다.
어떤 유스케이스에서 호출하든 같은 검증이 적용된다.
반면 "accept → delete → enroll → publish"라는 순서는 이 유스케이스 전용이다.
| 구분 | Application Service | Domain Service |
| 소유하는 것 | 유스케이스 흐름 (순서, 트랜잭션 경계) | 도메인 규칙 (불변식, 교차 검증) |
| 상태 | 없음 (Stateless) | 없음 (Stateless) |
| 위치 | application 패키지 | domain 패키지 |
| 재사용성 | 특정 유스케이스 전용 | 여러 유스케이스에서 재사용 |
Application Service를 "가짜"로 취급하면 이 레이어에 대한 설계 투자를 안 하게 된다. "어차피 가짜니까 아무렇게나 짜도 돼"라는 태도가 생기면, 트랜잭션 경계가 흐려지고 에러 처리가 허술해진다.
Application Service는 도메인 객체가 아닐 뿐, 유스케이스의 정당한 소유자로서 존중받아야 한다.
여기까지 정리하고 나면, 자연스럽게 이런 질문이 따라온다. "그럼 도메인 규칙을 Entity에 잘 넣으면 모든 문제가 해결되나 ?" Lecture.enroll(student)가 정원 초과를 막아주니까, 동시에 두 명이 같은 강의에 등록해도 괜찮을까 ?
Rich Domain Model이 있으면 동시성까지 해결될까 ?
# 오해 3: "Rich Domain이면 동시성도 해결된다"
Lecture.enroll(student) 안에 정원 검증과 등록 처리를 캡슐화했다. 엔티티가 자신의 불변식을 직접 지키는 Rich Domain Model.
도메인이 왕이 되었으니, 정원 초과 같은 문제는 도메인이 알아서 막아 줄 거라고 생각했다.
enrolledCount >= capacity이면 예외를 던지는 코드가 엔티티 안에 있다. 어디서 enroll()을 호출하든 같은 검증이 적용된다.
규칙의 소유권이 명확하고, 테스트도 쉽다.
DB 없이 순수 유닛 테스트로 "정원 100인 강의에 101번째 등록을 시도하면 예외가 나는가?"를 검증할 수 있다.
# 1만 명이 동시에 수강 신청하는 특강에서 깨졌다
enrolledCount가 99인 상태에서 두 스레드가 동시에 엔티티를 조회하면, 둘 다 99 < 100으로 통과한다.
둘 다 enrolledCount++를 실행하고, 둘 다 DB에 저장한다.
결과: 정원 100인 강의에 101명이 등록된다. 도메인 규칙은 완벽했는데, 데이터 정합성이 깨졌다.
직접 k6로 부하 테스트를 해봤다. @Transactional만 있고 락이 없는 상태에서 1만 명이 동시 요청하면, 정원 100명인 강의에 199명이 등록되었다. 도메인 메서드의 검증 로직은 단일 스레드에서는 완벽하지만, 동시 접근에서는 무력했다.
# Redis로 옮기면 해결되지만, 새로운 문제가 생긴다
Redis의 DECR 명령은 원자적(atomic)이다.
Redis는 명령을 싱글 스레드로 순차 처리하기 때문에, 조회와 감소가 하나의 연산으로 실행된다.
"두 스레드가 동시에 같은 값을 읽는" 문제가 구조적으로 발생하지 않는다.
그래서 특강에서는 enrolledCount를 엔티티에서 빼고 Redis로 옮겼다. Redis가 DECR 후 0 미만이면 거부하는 구조.
동시성 문제는 해결되었다.
하지만 "정원 초과면 거부"라는 도메인 규칙이 엔티티가 아니라 Redis 클라이언트 안에 하드코딩된 상태가 되었다.
만약 나중에 "VIP 학생은 정원의 110%까지 허용"이라는 정책이 추가된다면, 이 변경은 SpecialLecture 엔티티가 아니라 Redis 클라이언트를 수정해야 한다. 도메인 규칙이 인프라에 결합된 것이다.
# "정의"와 "실행"을 분리할 수 있지 않을까?
한참을 고민하다가 이런 구조를 떠올렸다:
// 도메인이 규칙을 "정의"한다
public class SpecialLecture {
private int capacity;
public boolean canEnroll(int currentEnrolledCount, StudentGrade grade) {
int effectiveCapacity = grade == StudentGrade.VIP
? (int)(capacity * 1.1)
: capacity;
return currentEnrolledCount < effectiveCapacity;
}
}
// Application Service가 인프라에서 카운트를 가져와서 도메인에게 판단을 맡긴다
public void enroll(Long specialLectureId, Long studentId) {
SpecialLecture lecture = lectureRepository.getById(specialLectureId);
Student student = studentRepository.getById(studentId);
int currentCount = redisClient.getCurrentEnrolledCount(specialLectureId);
if (!lecture.canEnroll(currentCount, student.getGrade())) {
throw new CapacityExceededException();
}
redisClient.decrementCapacity(specialLectureId);
enrollmentRepository.save(new Enrollment(lecture, student));
}
규칙의 정의는 도메인이, 카운트의 관리는 인프라(Redis)가, 흐름의 조합은 Application Service가 담당한다.
VIP 정책이 바뀌면 엔티티의 canEnroll()만 수정하면 된다.
하지만 치명적 약점이 있다. canEnroll()로 검증한 시점과 decrementCapacity()를 실행하는 시점 사이에 다른 요청이 끼어들 수 있다.
검증할 때는 99명이었는데, 실행할 때는 이미 100명이 된 상태. 이것을 TOCTOU(Time of Check to Time of Use) 문제라고 한단다.
# 이 틈을 메울 수 있는가?
솔직히 말하면, 아직 완벽한 답을 찾지 못했다. 떠올린 방법은 두 가지다.
- 검증과 실행을 하나의 트랜잭션으로 묶는 것: Redis 검증 → DB 저장을 하나의 원자적 단위로 만들면 틈이 사라진다. 하지만 Redis와 DB를 하나의 트랜잭션으로 묶는 것은 분산 트랜잭션이라 복잡도와 지연이 크게 증가한다.
- Redis DECR을 먼저 실행하고, 도메인 검증은 그 뒤에 하는 것: Redis가 동시성의 최전방 방어선이 되고, 도메인은 추가 비즈니스 규칙을 사후 검증한다. 실패 시 Redis를 롤백(increment)한다. 하지만 그 사이에 다른 요청이 들어오면 정합성이 흔들릴 수 있다.
# 지금의 기준
"도메인 규칙의 소유권"과 "동시성 제어의 원자성"은 별개의 책임이다.
Rich Domain Model이 해결하는 것은 "규칙을 누가 알고 있는가 ?"이고,
동시성 제어가 해결하는 것은 "같은 데이터에 동시에 접근할 때 정합성을 어떻게 지키는가 ?"이다.
이 둘을 하나의 메커니즘으로 해결하려고 하면 어느 한쪽이 무너진다.
- 규칙의 정의는 도메인이 소유한다 — canEnroll(), isCapacityExceeded() 같은 검증 로직은 엔티티에
- 동시성 제어는 인프라가 담당한다 — 비관적 락, Redis 원자적 연산, 유니크 제약 등
- 규칙의 정의와 동시성 제어가 완벽히 분리되지 않는 지점이 존재한다 — 그리고 그 지점에서는 트레이드오프를 선택해야 한다
이 긴장은 해소되지 않는다. 선택지와 대가가 있을 뿐이다. 그리고 트래픽 규모에 따라 최적의 선택이 달라진다.
동시 접속이 적은 일반 강의에서는 도메인 메서드 + DB 비관적 락으로 충분하다.
1만 명 이상이 몰리는 특강에서는 Redis 원자적 연산이 필요하다.
같은 "수강 등록"이라는 도메인 행위라도, 트래픽 특성에 따라 인프라 전략이 달라지는 것은
도메인의 실패가 아니라 인프라의 책임 범위가 넓어지는 것이다.
# 여기까지의 결론
세 가지 오해를 거치면서 하나의 패턴이 보인다. 경계를 너무 좁게 그으면 깨진다.
- "현실에 존재하는 것만 객체" → Domain Service와 Value Object를 설명할 수 없었다
- "Service는 전부 가짜" → Application Service의 유스케이스 흐름이라는 정당한 책임을 무시했다
- "Rich Domain이면 동시성도 해결" → 도메인 규칙과 동시성 제어를 하나로 묶으려다 양쪽 다 무너졌다
지금의 기준선은 이렇다:
- 객체의 자격은 "규칙을 소유할 책임이 있는가 ?"로 판단한다
- Service에는 도메인 규칙을 소유하는 것과 유스케이스 흐름을 소유하는 것, 두 종류가 있다
- Rich Domain은 "규칙을 누가 아는가?"를 해결하고, 동시성은 인프라가 별도로 해결한다
이 기준들은 아직 완벽하지 않다.
다음에 다룰 주제
—
"좋은 ORM을 쓰면 자동으로 좋은 설계가 되는가 ?",
"Outbox 패턴을 잘 확장해서 쓰면 최종적인 exactly-once 실행이 보장되는가 ?"
— 에서 또 깨질 수 있다.
소프트웨어 설계 관점에서 의미 수준과 구현 수준 사이에는 언제나 간극이 존재할 수 있다. 모든 구현을 의미 수준까지 끌어올리면 당장 필요하지도 않은 작업들로 인해 리소스를 낭비할 수 있으며, 지나치게 구현 수준을 의미 수준으로부터 외면해버리면 추후에 시스템의 확장이 매우 어려워질 수 있다. 중요한 것은 왜 이러한 의사 결정과 가치 판단을 내렸는지에 대한 명확한 근거이다. 진정한 추상화는 발견하는 것이지 발명하는 것이 아니다.
— 출처: [MangKyu's Diary:티스토리]
다음 편에서는 "좋은 ORM, 좋은 설계 ?"와 "Outbox 패턴 exactly-once 보장 ?"을 다룰 예정이다.
'Design > OOP - 객체 지향 프로그래밍' 카테고리의 다른 글
| [OOP - 객체 지향 프로그래밍] 객체란 무엇일까 ? (0) | 2026.03.13 |
|---|