헥사고날 아키텍처(Ports & Adapters)를 이야기할 때, 종종 도메인 모델과 JPA(데이터) 모델을 분리해야 한다는 이야기를 들었다. 이는 주로 유명한 서적에서 매핑 전략의 하나로 소개된 내용이 마치 헥사고날 아키텍처의 절대적인 원칙처럼 오해되면서 비롯되었다고 한다. 이러한 오해가 있었는지도 몰랐다. 오늘도 하나를 더 배운다
Get Your Hands Dirty on Clean Architecture 서적 중에 Two-way나 Full Mapping 전략인 듯 하다
- Tow-way Mapping Strategy: 도메인 모델과 JPA 엔티티를 분리하고 양방향 매핑
- Full Mapping Strategy: 모든 레이어 간 완전한 분리와 매핑
도메인 모델과 JPA 모델의 차이 (그리고 주장된 분리론)
도메인 모델은 비즈니스 도메인의 핵심 개념, 로직, 행위를 담은 개념적인 모델이자 실제 구현 코드의 결과물이다. JPA 모델(데이터 모델)은 데이터베이스 접근에 필요한 자바 객체, 즉 @Entity 애노테이션 등이 붙어 데이터베이스 테이블과 매핑되는 엔티티를 의미한다
분리하자는 의견은 이 둘이 근본적으로 다르기 때문에 분리해야 한다고 주장을 한다. 예를 들어, 도메인 레이어의 Member 클래스와 별도로 데이터베이스 저장을 위한 MemberEntity 클래스를 만들고 MemberRepository 인터페이스를 구현한 MemberRepositoryJpaAdapter에서 이 둘을 매핑하는 방식을 제안한다
- domain 계층의 member는 순수한 도메인 로직(예: register())를 가진다.
- application 계층에서는 MemberService가 MemberRepository 인터페이스를 통해 도메인 객체를 다룬다
- MemberRepositoryJpaAdapter가 MemberRepository를 구현하며, 이 어댑터에서 Member 도메인 객체와 MemberEntity JPA 객체 간의 매핑을 담당한다
- 실제 데이터베이스와의 상호작용은 @Repository 애노테이션이 붙은 MemberJpaRepository (Spring Data JPA)를 통해 MemberEntity를 사용하여 이루어진다
도메인 모델과 JPA 모델 분리를 선호하는 이유
- 레거시 DB에 도메인 모델 적용: 기존의 복잡하고 비정규화된 레거시 데이터베이스 스키마와 클린한 도메인 모델을 매핑해야 할 때, 중간 변환 계층이 유용하다
- 복잡한 도메인 모델: 도메인 모델의 구조가 매우 복잡하여 데이터 모델과 1:1로 매핑되기 어려운 경우(예: 여러 클래스가 하나의 테이블에 매핑되거나 그 반대), 별도의 매핑 로직이 필요할 수 있다
- 데이터 저장 기술 변경: RDB에서 NoSQL 등으로 데이터 저장 기술이 완전히 바뀔 때, 도메인 코드를 변경하지 않고 어댑터 계층만 교체하여 유연성을 확보할 수 있다( 하지만 Spring Data 프로젝트가 이런 변경을 매우 쉽게 지원하기 때문에 이 주장의 설득력은 약하다)
- 기술 의존성 제거: 도메인 코드에 @Entity, @Id와 같은 JPA 애노테이션이 붙는 것이 특정 기술에 대한 의존성을 부여한다고 보아 불편함을 느낄 수 있다.
하지만 JPA 애노테이션은 코드 실행에 직접적인 영향을 주지 않는 ‘주석’과 같으며, 특정 프레임워크가 이를 참고할 뿐이라는 점을 기억해야 한다. JPA 라이브러리 없이도 컴파일은 가능하며, 런타임에 JPA를 사용하지 않으면 애노테이션은 무시된다. 또한 JPA는 자바 표준 기술이며, List를 사용하는 것처럼 표준 기술의 사용을 문제 삼을 수는 없다
JPA의 본질: ORM과 도메인 모델 매핑
JPA는 단순히 SQL 매핑 도구 (SQL Mapper, 예: MyBatis)가 아니다. JPA는 ORM(Object Relational Mapping) 기술의 표준이며, 객체지향 모델과 관계형 데이터베이스 간의 패러다임 불일치를 해결하여 자바 도메인 모델을 관계형 데이터베이스에 관리할 수 있도록 돕는 것이 목표이다. 즉, JPA는 원래부터 ‘도메인 모델’을 데이터베이스에 매핑하기 위해 설계된 기술이다
JPA(ORM)가 해결하려는 대표적인 패러다임 불일치 5가지
- 세분성 불일치(Granularity Mismatch): 객체 모델의 클래스 구조와 데이터베이스 테이블 구조가 1:1로 일치하지 않을 때 발생한다. 예를 들어 Member 객체 안에 Email 이라는 값 객체(Value Object)가 포함되어 있지만, 데이터베이스에는 member 테이블의 한 컬럼으로 저장될 때 JPA의 @Embedded 기능을 통해 해결할 수 있다
// Member 도메인 객체 (일부)
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
@Embedded // Email 객체를 Member 테이블의 컬럼으로 매핑
private Email email;
// ...
}
// Email 값 객체 (일부)
@Embeddable // 임베디드 타입임을 명시
public class Email {
private String address;
// ...
}
- 상속 불일치 (Inheritance Mismatch): 객체지향의 상속 구조를 관계형 데이터베이스는 직접 지원하지 않는다. JPA는 @Inheritance 전략을 통해 단일 테이블, 조인 테이블, 각 클래스별 테이블 전략 등으로 객체의 상속 관계를 데이터베이스에 매핑한다
- 정체성 불일치 (Identity Mismatch): 객체 동일성(메모리 주소)과 데이터베이스의 기본 키(Primary Key)에 의한 동일성을 일치시키는 문제이다. JPA는 영속성 컨텍스트를 통해 같은 ID를 가진 객체를 관리하여 동일한 엔티티가 중복 생성되지 않도록 하며, equals()와 hashCode() 메서드를 잘 정의하여 해결을 돕는다.
- 연관 불일치 (Association Mismatch): 객체는 다른 객체에 대한 참조(reference)로 관계를 표현하지만, 데이터베이스는 외래 키(Foreign Key)를 통해 관계를 표현한다. JPA는 @ManyToOne, @OneToMany, @JoinColumn 등의 애노테이션으로 이 둘을 자연스럽게 연결한다
- 데이터 탐색 불일치 (Navigation Mismatch): 객체 그래프 탐색(객체 참조를 따라가는 방식)과 데이터베이스의 조인(JOIN)을 통한 데이터 탐색 방식이 다르다. JPA는 객체 그래프 탐색을 통해 필요한 데이터를 자동으로 로딩해주는 기능을 제공한다(지연 로딩 등)
분리론에 대한 반박: 대부분의 경우 결합이 합리적
- 불필요한 복잡성 증가: 도메인 모델과 JPA 모델이 실제로 크게 다르지 않은 대부분의 경우, 굳이 두 쌍의 클래스를 만들고 매핑 로직을 추가하는 것은 불필요한 복잡성만 초래한다. 이는 새로운 프로퍼티가 추가될 때마다 여러 클래스를 수정해야 하는 유지보수 비용을 증가시키고 매핑 누락으로 인한 데이터 버그 발생 가능성을 높인다
- JPA의 유연성과 강력함: JPA (특히 Hibernate와 같은 구현체)는 20년 이상 발전해오면서 상상할 수 있는 거의 모든 복잡한 매핑 문제들을 해결할 수 있는 다양한 기술과 전략을 내포하고 있다. 어지간한 복잡한 도메인 모델은 JPA가 충분히 매핑할 수 있다
- Spring Data JPA의 지원: Spring Data JPA는 JPA의 표준 위에 강력한 추상화와 편의 기능을 제공하여 ‘도메인 중심 개발’을 놀라운 수준으로 지원한다. Repository<T, ID> 인터페이스의 T (도메인 타입)는 JPA 관점에서 ‘엔티티이자 애그리거트 루트’로 명확히 정의되어 있다. 이는 Spring Data JPA가 도메인 모델 자체를 데이터 접근의 핵심으로 보고 있음을 의미한다
- 기술 의존성 논란: JPA 애노테이션이 도메인 계층을 ‘침범’한다는 주장이다. 애노테이션은 메터데이터일 뿐이며, 도메인 로직의 동작 방식 자체를 변경시키지 않는다. 또한 JPA는 자바 표준 기술이므로, 다른 자바 표준 라이브러리 (예: java.util,List)를 사용하는 것을 기술 의존성이라 보지 않는다면 JPA 애노테이션도 마찬가지로 볼 수 있다
- 불가피한 결합: 도메인 객체는 영속성을 가져야 하므로, 도메인 계층과 데이터 계층 간의 어느 정도 결합은 불가피하다. 완전히 독립적일 수는 없다
결론적으로 도메인 모델과 JPA 모델은 반드시 분리해야 하는 것은 아니다. 특정하고 명확한 이유(예: 위에 언급된 레거시 DB 통합이나 극도로 이질적인 모델 구조)가 있으 때만 분리를 고려하는 것이 좋다. 처음부터 ‘순수성’이라는 명목으로 분리를 택한다면, 얻는 것보다 불필요한 복잡성이라는 대가를 치르게 될 가능성이 높다. JPA는 도메인 모델 중심의 개발을 충분히 잘 지원하며, 그것이 JPA의 존재 이유이다.
JPA 애노테이션의 불편함 해결: XML 매핑
하지만 도메일 모델 코드에 @Id, @Column(length=…) 등 JPA 관련 애노테이션이 덕지덕지 붙어 있어 코드를 읽을 때 도메인 로직에만 집중하기 어렵다는 불편함은 공감할 수 있다. 이는 코드의 가독성을 저해하고 컨텍스트 스위칭을 유발할 수 있다. 이러한 불편함을 해결하기 위한 방법 중 하나는 JPA 매핑 설정을 XML 파일로 분리하는 것이다
- 원래 JPA는 XML을 통해 모든 매핑 설정을 하도록 만들어졌다. 애노테이션 방식이 도입된 이후에도 XML 설정은 여전히 강력하게 지원되는 표준 방식이다
- XML은 애노테이션 설정를 오버라이드(override)한다. 즉, 애노테이션으로 기본 설정을 하고 XML에서 더 세밀한 설정을 추가하거나 기존설정을 덮어 쓸 수 있다
- XML 설정은 코드 외부에 존재하므로, 매핑 정보 변경 시 소스 코드를 다시 컴파일할 필요가 없어 유연성이 높아진다
- resources/META-INF/ORM.xml 파일에 매핑 정보를 정의하여 도메인 엔티티 클래스에서 JPA 애노테이션을 제거하고 순수한 도메인 코드만을 남길 수 있다. 이는 도메인 코드의 가독성을 높이고 개발자가 도메인 로직에만 집중할 수 있도록 돕는 실용적인 해결책이 될 수 있다
JPA 애노테이션 제거 (중요한 의미를 지닌 애노테이션은 남긴다)
AbstractEntity
@MappedSuperclass
@ToString
public abstract class AbstractEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Getter(onMethod_ = {@Nullable})
private Long id;
@Override
public final boolean equals(Object object) {
if (this == object) return true;
if (object == null) return false;
Class<?> oEffectiveClass = object instanceof HibernateProxy ? ((HibernateProxy) object).getHibernateLazyInitializer().getPersistentClass() : object.getClass();
Class<?> thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass();
if (thisEffectiveClass != oEffectiveClass) return false;
AbstractEntity that = (AbstractEntity) object;
return getId() != null && Objects.equals(getId(), that.getId());
}
@Override
public final int hashCode() {
return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode();
}
}
Member
@Entity
@Getter
@ToString(callSuper = true, exclude = "detail")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends AbstractEntity {
@NaturalId
private Email email;
private String nickname;
private String passwordHash;
private MemberStatus status;
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private MemberDetail detail;
}
public record Email(String address) {
public Email { ... }
}
MemberDetail
@Entity
@Getter
@ToString(callSuper = true)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MemberDetail extends AbstractEntity {
private Profile profile;
private String introduction;
private LocalDateTime registeredAt;
private LocalDateTime activatedAt;
private LocalDateTime deactivatedAt;
}
Profile
public record Profile(String address) {
public Profile { ... }
}
ORM
<?xml version="1.0" encoding="UTF-8"?>
<entity-mappings xmlns="http://www.hibernate.org/xsd/orm/mapping"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.hibernate.org/xsd/orm/mapping
https://hibernate.org/xsd/orm/mapping/mapping-3.1.0.xsd">
<access>FIELD</access>
<mapped-superclass class="kimspring.splearn.domain.AbstractEntity">
<attributes>
<id name="id">
<column name="id"/>
<generated-value strategy="IDENTITY"/>
</id>
</attributes>
</mapped-superclass>
<entity class="kimspring.splearn.domain.member.Member">
<table name="member">
<unique-constraint name="UK_MEMBER_EMAIL_ADDRESS">
<column-name>email_address</column-name>
</unique-constraint>
<unique-constraint name="UK_MEMBER_DETAIL_ID">
<column-name>detail_id</column-name>
</unique-constraint>
</table>
<attributes>
<basic name="nickname">
<column name="nickname" nullable="false" length="100"/>
</basic>
<basic name="passwordHash">
<column name="password_hash" nullable="false" length="200"/>
</basic>
<basic name="status">
<column name="status" nullable="false" length="50"/>
<enumerated>STRING</enumerated>
</basic>
<one-to-one name="detail" fetch="LAZY">
<cascade>
<cascade-all />
</cascade>
</one-to-one>
<embedded name="email">
</embedded>
</attributes>
</entity>
<entity class="kimspring.splearn.domain.member.MemberDetail">
<table name="member_detail">
<unique-constraint name="UK_MEMBER_DETAIL_PROFILE_ADDRESS">
<column-name>profile_address</column-name>
</unique-constraint>
</table>
<attributes>
<basic name="introduction">
<column name="introduction" column-definition="TEXT"/>
</basic>
<basic name="registeredAt">
<column name="registeredAt" nullable="false"/>
</basic>
<basic name="activatedAt">
<column name="activatedAt"/>
</basic>
<basic name="deactivatedAt">
<column name="deactivatedAt"/>
</basic>
<embedded name="profile"/>
</attributes>
</entity>
<embeddable class="kimspring.splearn.domain.member.Profile">
<attributes>
<basic name="address">
<column name="profile_address" length="20"/>
</basic>
</attributes>
</embeddable>
<embeddable class="kimspring.splearn.domain.shared.Email">
<attributes>
<basic name="address">
<column name="email_address" nullable="false" length="150"/>
</basic>
</attributes>
</embeddable>
</entity-mappings>