핵심 한 줄
7편에서 외부호출을 Port/Adapter로 떼어냈다. 이번엔 도메인 전체를 프레임워크에서 떼어낸다 —
@Entity를 지운 순수 도메인 + 매핑 전담 엔티티 + 그 사이 매퍼. 그런데 순수하게 만든 대가로, JPA의dirty checking(자동 UPDATE)이 사라졌다. 상태를 바꿔도 DB에 반영이 안 됐다. 순수함은 공짜가 아니다 — 명시적save를 돌려받았다. 헥사고날은 강력하지만 비용이 있고, 그 비용을 알아야 언제 쓸지 판단할 수 있다.
시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)
00.카타 서문 · 01.돈을 primitive로 두면 언젠가 샌다 · 02.불변식은 누가 지키나 · 03.동시에 사면 초과판매된다 · 04.두 번 취소해도 한 번만 · 05.save가 INSERT가 아니었다 · 06.기다리게 하지 말고 거절하라 · 07.트랜잭션 안에서 HTTP를 부르지 마라 · 08.응답을 못 받았다는 실패가 아니다 · 09.요청 하나에 쿼리 1+N · 10.5만 행을 1행으로 · 11.같은 걸 두 번 묻지 않기 · 12.가짜는 내가 시킨 답만 한다 · 13.분명히 잡았는데 터졌다 · 14.5개 주세요 했는데 5만 개를 읽었다 · 15.순수하게 만들었더니 저장이 사라졌다 · 16.설정 한 줄 바꿨더니 세 번 터졌다 · … 그리고 계속
1. 한 클래스가 두 가지 일을 하고 있었다
환불 도메인 객체는 이렇게 생겼었다.
@Entity @Table(name = "refund") // ← JPA
public class Refund {
@Id @GeneratedValue(...) // ← JPA
private Long id;
@Column(unique = true) // ← JPA
private String cancelId;
protected Refund(){} // ← JPA가 요구하는 빈 생성자
public void changeStatus(...) {...} // ← 순수 도메인 로직
}비즈니스 규칙(changeStatus)과 DB 매핑(@Entity·@Column)이 한 클래스에 뒤엉켜 있다. 도메인이 JPA를 알고 있는 것이다. 7편에서 외부 PG를 Port/Adapter로 떼어내 “도메인은 기술을 모른다”를 배웠는데, 정작 도메인 객체 자신은 프레임워크에 절어 있었다.
2. 떼어내기 — 순수 도메인 + 엔티티 + 매퍼
셋으로 가른다.
① 순수 도메인 Refund — 애노테이션을 전부 걷어낸다. 남는 건 필드와 로직뿐, JPA·Spring을 하나도 모른다.
여기서 작은 문제가 튀어나온다. create()는 새 환불(status=READY, id 없음)을 만든다. 그런데 DB에서 이미 COMPLETED인 환불을 읽어올 땐? create로 만들면 상태가 증발한다. 그래서 도메인엔 생성 경로가 둘 필요하다.
public static Refund create(String cancelId, Money refund, String pgKey) {...} // 새로
public static Refund reconstitute(Long id, RefundStatus status, String cancelId,
Money refund, String pgKey, String ref) {...} // 저장소에서 복원② 매핑 전담 RefundJpaEntity — @Entity는 이제 이쪽에만 산다. 순수 데이터 홀더.
③ 매퍼 — 두 세계의 국경. toDomain()은 엔티티의 날 필드를 도메인으로, fromDomain()은 그 반대로. 상태를 하나도 흘리지 않고 왕복하는 게 핵심이다(8편의 translate처럼, 경계에서 번역한다).
3. 포트는 도메인 언어로만 말한다
저장은 인터페이스 뒤로 숨긴다 — outbound port.
public interface RefundRepositoryPort {
Refund save(Refund refund); // 도메인을 주고받는다
Optional<Refund> findByCancelId(String cancelId);
}핵심은 이 포트가 RefundJpaEntity가 아니라 **도메인 Refund**를 주고받는다는 것. Spring Data의 JpaRepository<RefundJpaEntity>는 이 포트를 구현하는 어댑터 안에 숨는다. 서비스는 JPA 엔티티가 존재하는지조차 모른다.
4. 대가 — 자동 저장이 사라졌다
여기서 이 리팩터의 진짜 교훈이 나온다. 예전엔 상태만 바꾸면 끝이었다.
Refund refund = repository.findByCancelId(cancelId).orElseThrow(...);
refund.changeStatus(...);
// 끝. @Transactional 커밋 때 JPA가 알아서 UPDATE.이게 됐던 이유는, findByCancelId가 준 게 JPA가 관리하는 엔티티였고, 하이버네이트의 **변경 감지(dirty checking)**가 커밋 시점에 바뀐 필드를 찾아 UPDATE를 만들어줬기 때문이다(11편의 1차 캐시가 그 토대였다).
그런데 이제 findByCancelId(포트)가 주는 건 toDomain()을 거친 순수 도메인 객체다. JPA가 관리하지 않는다. 그래서 상태를 바꿔도 — 아무 일도 일어나지 않는다. 커밋해도 DB는 그대로다. 순수하게 만든 대가로 자동 저장을 잃은 것이다.
해법은 잃어버린 것을 명시적으로 되돌려받는 것뿐이다.
Refund refund = repositoryPort.findByCancelId(cancelId).orElseThrow(...);
refund.changeStatus(translate(pgResult.status()), pgResult.ref());
repositoryPort.save(refund); // ← 명시적으로 저장해야 반영된다그리고 이 save는, 도메인이 이미 id를 가졌으니 merge(SELECT+UPDATE)로 돈다 — 5편에서 만난 그 save=merge다. 마법이 사라진 자리를, 명시적인 코드가 채운다.
5. 입구도 인터페이스로 — inbound port와 Command
바깥에서 도메인을 부르는 입구도 인터페이스로 세운다 — inbound port.
public interface RefundUseCase {
PGResult refund(RefundCommand command);
}
public class RefundService implements RefundUseCase { ... }컨트롤러(있다면)는 구체 클래스 RefundService가 아니라 RefundUseCase에 의존한다 — 의존이 안쪽으로 뒤집힌다. 입력도 파라미터를 흩뿌리는 대신 값 객체 RefundCommand 하나로 묶어, 경계에서 이름·순서·검증을 한곳에 모은다.
public record RefundCommand(String pgKey, String cancelId, Money refund) {
public RefundCommand {
if (cancelId == null || refund == null) throw new IllegalArgumentException(...);
}
}6. 순수함은 공짜가 아니다
이 대수술이 끝난 뒤, 7편에서 짠 테스트 세 개(정상·멱등·재시도)를 그대로 돌렸다. 전부 통과. 구조를 뿌리부터 갈아엎었는데 동작은 한 치도 안 변했다 — 테스트가 그걸 증명했다.
그런데 정직하게 값을 매겨야 한다. 이 분리는 비쌌다. 클래스 하나였던 게 도메인+엔티티+매퍼+포트+어댑터가 됐고, 공짜였던 저장이 명시적 save가 됐고, 생성자 하나가 create+reconstitute가 됐다. 얻은 것: 도메인이 프레임워크에서 자유롭고, JPA 없이 테스트 가능하고, 영속성을 통째로 갈아끼울 수 있다.
그래서 결론은 “항상 이렇게 하라”가 아니다. 복잡한 핵심 도메인엔 이 값이 아깝지 않고, 단순 CRUD엔 과하다. 오늘 한 번 제대로 해본 이유는, 이 비용과 이득을 몸으로 알아야 언제 지불할지 판단할 수 있기 때문이다. 도구를 아는 것과, 언제 쓸지 아는 것은 다르다.
카타는 계속된다. 날짜 경계, 할인 배분의 잔돈, 상태머신, 캐시의 분산 난제 — 각각이 또 하나의 “여기서 이렇게 틀렸다”가 될 것이다.
이 시리즈는 계속 이어진다.