시작하며
안녕하세요. 개발자 stark입니다.
테이블을 설계하다 보면 비슷한 개념을 가지는 데이터 모델을 타입 컬럼을 사용해서 분류하고 같은 테이블을 사용하도록 하는 경우가 흔합니다. DDD에서는 이런 식으로 설계된 테이블 모델(Entity)과 도메인 모델(Aggregate)의 개념을 동일하게 적용하면 도메인 모델 내부에 여러 타입의 비즈니스가 쌓이면서 점점 혼잡하고 복잡해지면서 'God Object' 문제가 발생합니다.
저는 실제로 이런 문제를 마주하며 '도메인 모델(Aggregate)을 테이블 모델과 동일시 하는 게 정답일까?'에 대해 매번 고민해 왔습니다. 그리고 이제 생각이 정리되어 이번 포스팅을 작성하게 되었습니다. 포스팅에서는 내용 이해를 위해 'documents(문서)'라는 가상의 테이블을 통해 설명드리며 환경은 SpringBoot + JPA + PostgreSQL기반입니다.
gpt로 한 컷 만화를 만들어봤습니다. 만화에는 오타가 상당히 적혀있지만 재미있게 봐주세요 ㅎㅎ
통합 테이블의 실무적 장점
실제 비즈니스에서는 '송장(Invoice), 영수증(Receipt), 계산서(CreditNote)' 등 서로 다른 '문서'들이 발행일, 총액, 작성자 같은 공통 속성을 공유하는 경우가 많습니다. 이때 각 문서별로 별도의 테이블을 만들면 테이블과 인덱스 수가 빠르게 늘어나 운영·백업·마이그레이션 시 관리 포인트가 크게 복잡해집니다. 반면, documents라는 하나의 테이블에 type 컬럼으로 문서 유형만 구분하고 공통 필드를 함께 모아두면 스키마 설계와 관리가 대폭 단순해집니다.
특히 신규 문서 유형을 추가할 때마다 DDL을 수정할 필요 없이 type 값 하나만 추가하면 되므로, 애플리케이션 배포 과정에서 스키마 롤아웃 부담이 크게 줄어들고 마이그레이션 작업도 최소화할 수 있습니다.
또한, 단일 테이블에 공통 컬럼을 모아두면 인덱스 설계와 데이터 조회·분석에서도 장점을 누릴 수 있습니다. 발행일이나 작성자 같은 자주 조회되는 컬럼에 대해 한 번만 인덱스를 생성하면 되고, 서로 다른 문서 유형 간의 비교·집계(예: 일별 송장 대비 영수증 건수, 월별 총액 합계)도 추가 조인 없이 단일 GROUP BY 쿼리만으로 처리할 수 있어 BI 리포트나 대시보드 구성 시 쿼리 복잡도가 크게 낮아집니다.
-- 1) enum 타입 정의
CREATE TYPE document_type AS ENUM (
'INVOICE',
'RECEIPT',
'CREDIT_NOTE'
);
-- 2) 테이블 생성 시 해당 enum 타입을 사용
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
type document_type NOT NULL,
date TIMESTAMP NOT NULL,
amount NUMERIC(12,2) NOT NULL,
invoice_number VARCHAR(50),
invoice_memo VARCHAR(255), -- Invoice 추가 설명
receipt_number VARCHAR(50),
credit_note_no VARCHAR(50),
credit_note_reason VARCHAR(255) -- CreditNote 사유
);
-- 3) 인덱스 생성
CREATE INDEX idx_documents_date ON documents(date);
CREATE INDEX idx_documents_type_date ON documents(type, date);
이처럼 DDL을 정의하면 테이블 구조가 단순해지고, 인덱스 운영과 스키마 변경이 훨씬 수월해집니다.
단일 엔티티 기반 Aggregate의 한계
이렇게 통합 테이블을 설계한 후에는 JPA 구성을 위해 DocumentEntity 클래스를 선언할 것이고 도메인 모델(Aggregate) POJO 객체를 선언할 것입니다. 이때 주의할 점은 비즈니스는 여러 유형(송장, 영수증, 계산서) 별로 다를 텐데 도메인 모델을 유형별로 각각 선언하지 않고 단 1개를 선언해서 공유해서 사용한다면 몇 가지 문제가 드러납니다.
1. 불필요한 필드와 NULL 이슈
단일 테이블에 문서별 전용 컬럼을 전부 선언하면, 해당 유형이 아닌 레코드에서는 대부분의 전용 컬럼이 NULL이 됩니다. JPA의 SINGLE_TABLE 상속 전략과 마찬가지로 서브타입 전용 필드에는 NOT NULL 제약을 걸 수 없어, 스키마가 비효율적인 NULL 마커로 가득 차게 됩니다. 이는 데이터 정합성 관리와 스키마 문서화, 그리고 쿼리 성능에까지 부정적인 영향을 줍니다.
2. 복잡한 로직 분기
하나의 Aggregate 루트에 if (type == INVOICE) { … } else if (type == RECEIPT) { … } 식의 조건문이 늘어나면, 새로운 문서 유형이 추가될 때마다 분기 구문을 수정해야 합니다. 코드가 점차 읽기 어려워지고, 테스트 커버리지를 확보하기도 까다로워지며, 팀 내 신규 개발자가 도메인 로직의 흐름을 파악하기가 매우 어렵습니다.
public class DocumentAggregate {
private Long id;
private DocumentType type;
private LocalDate date;
private BigDecimal amount;
private String invoiceNumber;
private String receiptNumber;
private String creditNoteNo;
// … 생성자, Getter/Setter 생략 …
public void process() {
if (type == DocumentType.INVOICE) {
// 송장 발행 로직
issueInvoice();
} else if (type == DocumentType.RECEIPT) {
// 영수증 발행 로직
issueReceipt();
} else if (type == DocumentType.CREDIT_NOTE) {
// 계산서(크레딧노트) 발행 로직
issueCreditNote();
} else {
throw new UnsupportedOperationException("지원하지 않는 문서 타입: " + type);
}
}
private void issueInvoice() {
// invoiceNumber 생성, 세금 계산 등
}
private void issueReceipt() {
// receiptNumber 생성, 결제 확인 등
}
private void issueCreditNote() {
// creditNoteNo 생성, 환불 금액 계산 등
}
}
3. 단일 책임 원칙(SRP) 위반
DDD에서는 Aggregate가 “자신이 책임질 수 있는 비즈니스 규칙만” 포함해야 합니다. 그러나 모든 문서 로직을 한 클래스에 몰아넣으면 해당 Aggregate는 송장 발행, 영수증 발급, 계산서 처리 등 서로 다른 도메인 규칙을 동시에 관리해야 합니다. 이로 인해 도메인 모델은 빈약해지거나 지나치게 비대해져 제어가 어려운 ‘God Object’가 됩니다.
public class DocumentAggregate {
private Long id;
private DocumentType type;
private LocalDate date;
private BigDecimal amount;
private String invoiceNumber;
private String receiptNumber;
private String creditNoteNo;
// … 생성자, Getter/Setter 생략 …
/**
* 이 하나의 메서드가 송장, 영수증, 계산서
* 세 가지 전혀 다른 책임을 모두 수행하고 있다.
*/
public void executeBusinessLogic() {
// 1) 송장 발행 관련 검증과 로직
if (type == DocumentType.INVOICE) {
validateInvoice();
generateInvoiceNumber();
calculateInvoiceTax();
}
// 2) 영수증 발급 관련 검증과 로직
else if (type == DocumentType.RECEIPT) {
validateReceipt();
generateReceiptNumber();
confirmPayment();
}
// 3) 계산서 처리 관련 검증과 로직
else if (type == DocumentType.CREDIT_NOTE) {
validateCreditNote();
generateCreditNoteNumber();
calculateAdjustment();
}
else {
throw new UnsupportedOperationException("알 수 없는 문서 유형입니다: " + type);
}
}
// --- 송장 전용 ---
private void validateInvoice() { /* … */ }
private void generateInvoiceNumber() { /* … */ }
private void calculateInvoiceTax() { /* … */ }
// --- 영수증 전용 ---
private void validateReceipt() { /* … */ }
private void generateReceiptNumber() { /* … */ }
private void confirmPayment() { /* … */ }
// --- 계산서 전용 ---
private void validateCreditNote() { /* … */ }
private void generateCreditNoteNumber() { /* … */ }
private void calculateAdjustment() { /* … */ }
}
위와 같은 이유로, 하나의 DocumentEntity를 개념적 분리 없이 그대로 도메인 모델로 사용하는 것은 아래와 같은 부작용을 초래합니다.
- 응집도 저하
- 유지보수성 악화
- 도메인 명세 모호화
따라서 물리적으로는 테이블 구조가 하나라 하더라도, 도메인 모델인 Aggregate는 문서 유형별로 분리해서 설계하는 것이 바람직하다고 생각합니다. 실제로 저는 God Object가 되어버린 도메인 모델을 다룬 적이 있는데 여러 가지 역할을 가지기 때문에 비즈니스가 굉장히 모호했으며 테스트하기 쉽지 않았습니다. 제일 불편했던 것은 모든 필드를 다 가졌기에 어떤 것이 쓰이는지 안 쓰이는지 구분할 수도 없을뿐더러 DDD의 보편 언어 개념이 파괴되어 비즈니스를 이해하려면 기획서 단계부터 하나하나 해석해나가야 했고 주석이 없다면 정확한 비즈니스 행위를 보장할 수 없어서 굉장히 답답하다는 생각이 들었습니다.
JPA 엔티티와 도메인 객체 분리하기
앞서 단일 테이블에 모든 문서 유형을 모아두었을 때 발생하는 응집도 저하와 복잡한 분기 문제를 살펴보았습니다. 이제 물리적으로는 하나의 documents 테이블만 유지하면서도, 도메인 레이어에서는 문서 유형별로 완전히 분리된 객체를 운영하는 방안을 구체적으로 살펴보겠습니다. 아래와 같은 구조로 모델링을 진행할 것입니다.
1. 공통 JPA 엔티티: DocumentEntity
- 가장 먼저, documents 테이블 전체 칼럼을 매핑하는 하나의 JPA 엔티티를 선언합니다. 이 엔티티는 순수히 데이터 저장소(persistence) 로서의 역할만 담당하며, 도메인 로직은 포함하지 않습니다.
@Getter
@Entity
@Table(name = "documents")
public class DocumentEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 문서 유형 구분: INVOICE, RECEIPT, CREDIT_NOTE
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false)
private DocumentType type;
// 모든 문서에 공통으로 들어가는 필드
private LocalDate date;
private BigDecimal amount;
// Invoice 전용 필드
@Column(name = "invoice_number")
private String invoiceNumber;
@Column(name = "invoice_memo")
private String invoiceMemo;
// Receipt 전용 필드
@Column(name = "receipt_number")
private String receiptNumber;
// CreditNote 전용 필드
@Column(name = "credit_note_no")
private String creditNoteNo;
@Column(name = "credit_note_reason")
private String creditNoteReason;
}
- type: 각 레코드가 어떤 도메인 모델(Aggregate)에 해당하는지 식별
- 공통 필드(date, amount)는 한 번만 선언하고
- 전용 필드(invoiceNumber 등)는 nullable로 두어, 해당 타입이 아닐 때 자동으로 NULL
이처럼 하나의 엔티티에 모든 컬럼을 선언해 두면, JPA 매핑 설정은 단순하지만 도메인 로직이 섞이지 않도록 주의해야 합니다.
2. 도메인 레이어에서의 POJO 분리
- JPA 엔티티와는 별도로, 도메인 모델(POJO)을 유형별로 선언합니다. 각 POJO는 자신이 담당하는 문서 유형의 필드와 비즈니스 로직만 담아 응집도를 높입니다.
@Getter
public class Invoice {
private Long id;
private LocalDate date;
private BigDecimal amount;
private String invoiceNumber;
private String memo;
// DocumentEntity에서 필요한 필드만 받는 생성자
public Invoice(Long id, LocalDate date, BigDecimal amount,
String invoiceNumber, String memo) {
this.id = id;
this.date = date;
this.amount = amount;
this.invoiceNumber = invoiceNumber;
this.memo = memo;
}
// 송장 전용 비즈니스 로직 예시
public BigDecimal calculateTax() {
// 예: 세금 = amount * 0.1
return amount.multiply(BigDecimal.valueOf(0.1));
}
}
- 생성자를 통한 필드 제한: DocumentEntity의 불필요한 칼럼은 제외
- 도메인 행위: calculateTax()처럼 송장 발행에 특화된 로직만 포함
@Getter
public class Receipt {
private Long id;
private LocalDate date;
private BigDecimal amount;
private String receiptNumber;
public Receipt(Long id, LocalDate date, BigDecimal amount, String receiptNumber) {
this.id = id;
this.date = date;
this.amount = amount;
this.receiptNumber = receiptNumber;
}
// 영수증 불변식(invariant) 검사 예시
public boolean isPaid() {
return amount.compareTo(BigDecimal.ZERO) > 0;
}
}
- isPaid() 같은 간단한 검사 메서드가 Receipt의 본질적 책임을 드러냄
@Getter
public class CreditNote {
private Long id;
private LocalDate date;
private BigDecimal amount;
private String creditNoteNo; // 엔티티의 credit_note_no
private Long originalInvoiceId;
private String reason; // 엔티티의 credit_note_reason
// DocumentEntity에서 필요한 필드만 받는 생성자
public CreditNote(Long id,
LocalDate date,
BigDecimal amount,
String creditNoteNo,
Long originalInvoiceId,
String reason) {
this.id = id;
this.date = date;
this.amount = amount;
this.creditNoteNo = creditNoteNo;
this.originalInvoiceId = originalInvoiceId;
this.reason = reason;
}
// 계산서(크레딧노트) 전용 비즈니스 로직 예시
public BigDecimal calculateAdjustment() {
// 예: 차액 = 원금액 - amount
// (실제로는 원송장 금액을 조회해 계산)
return BigDecimal.ZERO; // placeholder
}
}
- originalInvoiceId는 이 CreditNote(계산서)가 어떤 송장(Invoice)에 대해 발행된 것인지를 가리키는 식별자(ID)입니다. 실무에서 크레딧 노트는 “이미 발행된 송장 금액을 환불”하거나 “부분 차액을 조정”하기 위해 만들어지는데, 그 대상이 되는 송장의 PK를 가리켜야 하기 때문에 참조합니다.
- calculateAdjustment() 같은 메서드는 수정·환불·차액 계산 같은 크레딧노트 전용 로직을 담을 수 있습니다.
3. 타입별 Repository 구성
- 각 도메인 객체를 조회하기 위한 Repository는 공통 엔티티(DocumentEntity)를 기반으로, JPQL 생성자 표현식을 통해 필요한 필드만 선택해 POJO로 매핑합니다.
@Repository
public interface InvoiceRepository extends JpaRepository<DocumentEntity, Long> {
@Query("""
SELECT new com.example.domain.Invoice(
d.id,
d.date,
d.amount,
d.invoiceNumber,
d.invoiceMemo
)
FROM DocumentEntity d
WHERE d.type = 'INVOICE'
""")
List<Invoice> findAllInvoices();
}
@Repository
public interface ReceiptRepository extends JpaRepository<DocumentEntity, Long> {
@Query("""
SELECT new com.example.domain.Receipt(
d.id,
d.date,
d.amount,
d.receiptNumber
)
FROM DocumentEntity d
WHERE d.type = 'RECEIPT'
""")
List<Receipt> findAllReceipts();
}
@Repository
public interface CreditNoteRepository extends JpaRepository<DocumentEntity, Long> {
@Query("""
SELECT new com.example.domain.CreditNote(
d.id,
d.date,
d.amount,
d.creditNoteNo,
d.originalInvoiceId,
d.creditNoteReason
)
FROM DocumentEntity d
WHERE d.type = 'CREDIT_NOTE'
""")
List<CreditNote> findAllCreditNotes();
}
- SELECT NEW ... 구문: JPQL 생성자 표현식으로, 조회 결과를 도메인 모델의 생성자로 바로 매핑
- 필터 조건(WHERE d.type = '...'): 다른 타입의 레코드는 제외하여 단일 책임 원칙 유지
이렇게 하면 물리적 테이블은 하나지만 도메인 레이어에서는 타입별로 완전히 분리된 객체 집합을 운영할 수 있습니다. 공통 Entity는 단순히 데이터 저장소 역할을 하고, 각 도메인 객체는 자신의 책임(로직)만 담당하게 되어, DDD 관점에서도 이상적인 구조가 됩니다.
참고로 Invoice, Receipt, CreditNote 같은 POJO 클래스들은 도메인 모델(Domain Model)에 해당합니다. 도메인 모델이란 비즈니스 개념과 규칙을 코드로 옮긴 순수한 객체들의 집합입니다. 각 클래스는 “문서 관리”라는 서브 도메인 안에서 애그리거트 루트(Aggregate Root) 역할을 하며, 자신만의 비즈니스 행위(세금 계산, 유효성 검사 등)와 불변식(invariant)을 책임집니다. 반면 DocumentEntity는 영속성(Persistence) 모델로, 데이터베이스와 매핑되기 위한 기술적인 구조입니다.
엔티티에 대한 전체 도메인 모델링은 이렇게 될 것입니다.
Read / Write에 따른 리포지토리 분리
JPA를 사용하신다면 Repository에서 데이터를 조회할 때 Aggregate 객체를 바로 반환받는 것에 대해 의문점이 생기실 수 있습니다. 저는 도메인 모델(Aggregate)과 JPA 엔티티를 분리했다면, 조회(Read)할 때는 JPQL 생성자 프로젝션을 적극 활용하고, 쓰기(Write) 에는 영속화가 필요하니 매퍼+엔티티 방식을 통해 DocumentEntity를 직접 다루는 것이 바람직하다고 생각합니다.
예를 들어, 조회한 데이터(Read)를 바탕으로 순수 계산만 하고 그 결과를 반환한다면 영속화되지 않는 POJO 도메인 모델(Aggregate)로 처리해도 전혀 문제가 없습니다. 프로젝션으로 송장 정보(Invoice 도메인 모델)를 읽어와서 calculateTax() 같은 계산 메서드를 호출해 세금 금액만 구한 뒤 그 값을 API 응답으로 돌려주는 정도라면, Repository에서 굳이 DocumentEntity를 로드해서 매핑·저장할 필요 없이 Invoice POJO를 받아서 바로 처리할 수 있습니다.
데이터를 조회할 때 고려할 점은 “무엇을 반환하느냐” 와 “무엇을 영속화하려 하느냐”입니다.
1. 순수 조회 후 계산만 하는 경우
“이 송장(Invoice)에 대한 예상 세금액을 계산해서 돌려달라” -> 이때는 InvoiceRepository의 생성자 프로젝션으로 Invoice 도메인 모델을 받아와서 invoice.calculateTax() 메서드를 호출하고, 그 결과만 반환하면 됩니다. 이 경우 데이터베이스에는 아무 변경이 없으므로, POJO 객체만 조회해서 비즈니스를 호출하여 성능과 메모리 사용을 최적화할 수 있습니다.
2. 조회→도메인 행위(상태 변경)→영속화가 필요한 경우
“이 송장(Invoice)에 세금 계산 결과를 메모에 남겨 두고 업데이트하라” -> 이때는 반드시 DocumentEntity를 조회해서 엔티티를 영속화시키고 비즈니스를 수행하기 위해 Invoice 도메인 모델로 변환한 뒤 invoice.updateInvoiceMemo(...) 로직을 수행하여 세금 계산 결과를 메모 값에 넣은 뒤 도메인을 다시 DocumentEntity로 변환해서 트랜잭션 커밋 시 변경 감지에 의해 DB에 반영되도록 해야 합니다.
즉, 반환만 하고 끝낼 연산인 비즈니스 로직이라면 도메인 모델(POJO)로 충분하고, '변경을 DB에 기록해야 하는 로직'이라면 엔티티 기반 흐름(find → mapper -> domain/action → mapper -> merge/save)을 꼭 따라야 합니다. (도메인 모델을 따로 사용한 덕분에 변환 과정이 많다 보니 불필요한 작업이 진행된다는 느낌이 들 수는 있습니다.)
이제 직접적인 예시를 통해 살펴봅시다. 우선 조회 전용 리포지토리부터 살펴보겠습니다. 아래 예시는 DocumentEntity 대신 곧바로 순수 도메인 모델(Invoice 혹은 Receipt)을 반환하는 방식입니다.
@Repository
public interface InvoiceRepository extends JpaRepository<DocumentEntity, Long> {
@Query("""
SELECT new com.example.domain.Invoice(
d.id,
d.date,
d.amount,
d.invoiceNumber,
d.invoiceMemo
)
FROM DocumentEntity d
WHERE d.type = 'INVOICE'
""")
List<Invoice> findAllInvoices();
}
@Repository
public interface ReceiptRepository extends JpaRepository<DocumentEntity, Long> {
@Query("""
SELECT new com.example.domain.Receipt(
d.id,
d.date,
d.amount,
d.receiptNumber
)
FROM DocumentEntity d
WHERE d.type = 'RECEIPT'
""")
List<Receipt> findAllReceipts();
}
위 코드에서 SELECT new … 구문은 JPQL의 생성자 표현식을 사용해 곧바로 Invoice 또는 Receipt 객체를 생성합니다. 이 방식의 가장 큰 장점은 필요한 필드만 조회하므로 데이터베이스 I/O와 JVM 메모리 사용량을 최소화할 수 있다는 점입니다. 반환된 도메인 객체들은 JPA 영속성 컨텍스트에 등록되지 않는 읽기 전용 스냅샷이기 때문에, 단순히 화면에 보여주거나 API 응답용 DTO로 사용할 때 최적의 선택입니다.
그러나 이러한 조회 전용 프로젝션에는 한계도 분명히 존재합니다. 프로젝션으로 반환된 Invoice, Receipt 객체는 JPA Entity 객체가 아니므로 기술적으로 영속화되지 않으므로 이들 객체를 수정해도 JPA가 변경을 감지하거나 데이터베이스에 반영하지 않습니다. 또한, 트랜잭션 경계 내에서의 예외 처리나 롤백 범위에도 포함되지 않기 때문에, 단순 조회를 넘어 비즈니스 로직 실행과 영속화가 동시에 필요한 쓰기(Write) 작업에는 적합하지 않습니다.
따라서 쓰기 작업에서는 반드시 DocumentEntity를 직접 조회하여 값을 변경한 뒤, JPA의 변경 감지 기능을 활용해야 합니다. 가장 일반적인 흐름은 다음과 같습니다.
1. 엔티티 조회 : 트랜잭션 범위 내에서 documentEntityRepository.findById(id)로 DocumentEntity를 로드합니다.
2. 도메인 매퍼 사용(Optional) : 필요한 경우 매퍼(InvoiceMapper, ReceiptMapper 등)를 통해 엔티티를 도메인 객체로 변환하고, 도메인 모델에 비즈니스 로직을 위임할 수 있습니다.
3. 도메인 로직 실행 : invoice.issue(taxRate)처럼 순수 도메인 모델의 메서드를 호출하거나, 엔티티에 직접 구현된 비즈니스 메서드를 사용해 상태를 변경합니다.
4. 엔티티에 반영 : 도메인 모델이 수정된 경우 매퍼를 통해 다시 DocumentEntity로 변환해서 데이터를 저장할 수 있도록 합니다.
5. 트랜잭션 커밋 : 커밋 시점에 JPA가 변경 감지를 수행하여 자동으로 UPDATE 쿼리를 실행합니다.
예를 들어 송장 발행 로직을 도메인 매퍼와 함께 구현한다면, 다음과 같은 코드가 될 수 있습니다.
@Transactional
public void issueInvoice(Long id, BigDecimal taxRate) {
// 1) 엔티티 로드
DocumentEntity entity = documentEntityRepository.findById(id)
.orElseThrow(() -> new NotFoundException(id));
// 2) 도메인 모델로 매핑
Invoice invoice = InvoiceMapper.toDomain(entity);
// 3) 비즈니스 로직 수행
invoice.issue(taxRate);
// 4) 엔티티에 변경사항 반영
InvoiceMapper.applyToEntity(invoice, entity);
// 5) 트랜잭션 커밋 시 JPA가 변경 감지 후 UPDATE 실행
}
이처럼 조회 전용 프로젝션과 쓰기 전용 엔티티 처리를 분리하면, 조회 성능을 극대화하는 동시에 도메인 로직의 무결성과 응집도를 지킬 수 있습니다. 필요할 때만 DocumentEntity를 로드해 비즈니스 행위를 수행하고, 나머지 부분은 경량화된 도메인 객체로 깔끔하게 유지하는 것이 핵심입니다.
추가설명: Mapper 변환시 엔티티의 ID값이 매우 중요합니다.
- DocumentEntity entity = repo.findById(1L).orElseThrow(); [엔티티 조회]
- Invoice invoice = mapper.toDomain(entity); [도메인 변환]
- 도메인 로직 수행 (invoice.issue(...)) [비즈니스 로직 수행 -> 값 변경]
- DocumentEntity newEntity = mapper.toEntity(invoice); [다시 엔티티로 변환 -> id값이 중요]
- repo.save(newEntity); [JPA save 호출]
만약 4번에서 변환되어 만들어진 newEntity.getId()값이 null 이라면, JPA는 이를 “새로운 엔티티”로 보고 INSERT를 시도합니다. 처음 조회했던 id=1인 레코드를 업데이트하려면, newEntity.setId(1L) 같이 변환된 엔티티 객체가 기존의 ID값을 그대로 유지해야만 save()가 UPDATE … WHERE id=1 을 실행합니다. (매우 중요)
하나의 엔티티에서 도메인 모델(Aggregate)을 분리해서 얻은 장점
단일 DocumentEntity에 모든 비즈니스 로직을 몰아넣는 대신, 문서 유형별로 Invoice, Receipt, CreditNote 같은 별도 도메인 모델(Aggregate)을 분리하면 '코드의 응집도'가 크게 향상됩니다. 각 클래스는 자신이 담당하는 필드와 행위만 가짐으로써 불필요한 if–else 조건문이나 NULL 체크가 사라지고, 메서드 하나하나가 오롯이 한 가지 책임만 수행하게 됩니다. 그 결과 코드 베이스를 처음 접하는 개발자도 흐름을 파악하기 쉽고, 개별 Aggregate에 대한 단위 테스트를 작성할 때도 불필요한 목킹(mocking)이나 복잡한 시나리오 없이 핵심 로직 검증에만 집중할 수 있습니다.
또한, 도메인 모델(Aggregate)을 분리하면 변경 관리가 한결 수월해집니다. 예를 들어 새로운 문서 유형이 추가되거나 기존 비즈니스 규칙이 바뀔 때, 통합 모델에서는 거대한 클래스 내부를 모두 뒤져야 하지만, 분리된 Aggregate 구조에서는 관련 도메인 클래스와 리포지토리만 수정하면 됩니다. 이는 배포 시점의 위험 부담을 낮추고, 코드 충돌 가능성을 줄여 릴리즈 주기를 단축시켜 줍니다.
성능과 자원 사용 측면에서도 분리 설계의 이점이 분명합니다. 조회(Read) 시에는 JPQL 생성자 프로젝션을 이용해 필요한 필드만 읽어 들이므로 네트워크 I/O와 JVM 메모리를 효과적으로 절약할 수 있고, 쓰기(Write) 시에는 실제로 업데이트가 필요한 DocumentEntity를 로드해 JPA의 변경 감지(dirty-checking) 기능을 활용할 수 있습니다. 이처럼 각 작업에 맞춰 경량화된 도메인 모델과 무거운 영속 모델을 분리하면, 전체 시스템의 처리량(throughput)과 응답 속도도 향상됩니다.
마지막으로, 운영·모니터링 관점에서의 투명성(Observability) 도 강화됩니다. 도메인별로 로그와 메트릭을 독립적으로 수집·분석할 수 있어, 송장 발행 실패율이나 영수증 생성 지연 시간 같은 지표를 세분화해 모니터링할 수 있습니다. 이를 통해 장애 원인을 빠르게 파악하고 SLA 위반 위험을 줄일 수 있으며, 팀 내 책임 경계도 명확해져 협업 효율이 높아집니다.
참고로 SLA(Service Level Agreement) 위반이란, 시스템 운영자가 고객이나 내부 팀과 약속한 서비스 가용성(업타임), 응답 시간, 처리량 등의 성능·품질 지표를 만족시키지 못했을 때를 말합니다. 예를 들어 “월간 99.9% 이상 가용성 보장” 또는 “API 평균 응답 시간 200ms 이내”와 같은 목표를 정해 두면, 실제 운영 중 이 목표가 0.1% 이상 떨어지거나 응답 시간이 이를 초과할 경우 SLA 위반이 발생합니다. 서비스가 고객 기대에 미치지 못하면 금전적 벌칙, 신뢰도 하락, 계약 해지 등의 리스크로 이어질 수 있습니다.
따라서 도메인별로 로그와 메트릭을 세분화해 “송장 발행 지연”, “영수증 처리 실패율” 등을 실시간 모니터링하면, 이상 징후를 미리 감지해 원인을 빠르게 파악하고 대응할 수 있습니다. 이렇게 하면 SLA 목표를 안정적으로 지킬 수 있어 고객 신뢰를 유지하고, 계약 조건 위반에 따른 불이익을 예방할 수 있습니다.
결론: 도메인 응집도와 모델 분리의 중요성
지금까지의 내용을 정리해 보자면 DDD를 선택했을 때 도메인 응집도를 유지하도록 설계 방향을 이끌어가는 것은 매우 중요합니다. 물리적으로는 테이블을 통합하더라도 도메인 모델은 비즈니스 관점에 따라 별도로 설계할 수 있습니다. 즉, JPA 엔티티(DocumentEntity)는 단순히 데이터 저장소 역할만 하고, 도메인 객체(Invoice, Receipt, CreditNote 등)를 개념적으로 분리하여 전용 비즈니스를 담은 순수 객체로 다루는 것입니다.
이것을 위해서는 도메인 객체와 JPA 엔티티가 서로 다른 개념이라는 것을 인지해야 합니다. 도메인 객체(Aggregate)는 '비즈니스 개념'에 가깝고 풍부한 행위를 가질 수 있지만, JPA 엔티티는 '영속성을 위한 기술적 표현'일 뿐입니다. 위에서 살펴본 것처럼, JPQL에서 '생성자 표현식'을 이용하면 JPA 쿼리 결과를 도메인 객체에 매핑할 수 있습니다. 이를 통해 테이블을 단일화하면서도 도메인 레이어에서는 각 객체가 자기만의 책임과 로직을 갖는 설계가 가능합니다.
마지막으로, 영속계층과 도메인계층의 관심사 분리가 중요합니다. 영속 계층은 “데이터가 오래 보존되어야 한다”는 제약 아래에서 스키마가 변하지 않도록 설계되어야 하고, 도메인 계층은 “비즈니스 변화에 유연”하게 설계되어야 합니다. 두 계층이 같은 구조에 묶여있으면 이 제약조건이 충돌하게 됩니다. 따라서 하나의 통합 테이블로 설계하더라도 타입에 따라 비즈니스 개념이 분리될 수 있다면 도메인 모델을 분리함으로써, 도메인 로직의 응집도를 높이고 유지보수성을 확보할 수 있습니다.
결론적으로 도메인을 모델링할 때는 테이블 구조대로 도메인을 분리 설계하는 것이 아니라 비즈니스에 따라 분리하여 표현할 수 있도록 설계해야 합니다. 저는 엔티티 모델을 도메인에 그대로 가져가야 할지에 대한 명확한 기준을 가지지 못했었기에 처음에는 잘못 설계한 적도 있었지만 점점 개발하며 익숙해지고 지식이 늘어날수록 비즈니스 개념에 따라 분리된 도메인 모델이 정말 중요하다는 생각을 하게 되었습니다.
긴 글 읽어주셔서 감사합니다 :)
'DDD' 카테고리의 다른 글
[DDD] 왜 외부 애그리거트는 ID로 참조하는것이 좋을까? (0) | 2025.05.31 |
---|---|
[DDD] 값 객체(VO)를 활용한 유비쿼터스 언어 기반의 명확한 도메인 표현법 (0) | 2025.05.30 |
코드에 비즈니스의 가치를 담자 (0) | 2025.05.03 |
[DDD] Aggregate 당 하나의 Repository, 과연 최선인가? (0) | 2025.04.19 |
[DDD] 애그리게잇(Aggregate) 구성하기 (0) | 2025.03.12 |