핵심 한 줄

7편에서 외부호출을 Port/Adapter로 떼어냈다. 이번엔 도메인 전체를 프레임워크에서 떼어낸다 — @Entity를 지운 순수 도메인 + 매핑 전담 엔티티 + 그 사이 매퍼. 그런데 순수하게 만든 대가로, JPA의 dirty checking(자동 UPDATE)이 사라졌다. 상태를 바꿔도 DB에 반영이 안 됐다. 순수함은 공짜가 아니다 — 명시적 save를 돌려받았다. 헥사고날은 강력하지만 비용이 있고, 그 비용을 알아야 언제 쓸지 판단할 수 있다.

시리즈 — 커머스 백엔드를 카타로 쌓다 (계속 연재)


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엔 과하다. 오늘 한 번 제대로 해본 이유는, 이 비용과 이득을 몸으로 알아야 언제 지불할지 판단할 수 있기 때문이다. 도구를 아는 것과, 언제 쓸지 아는 것은 다르다.

카타는 계속된다. 날짜 경계, 할인 배분의 잔돈, 상태머신, 캐시의 분산 난제 — 각각이 또 하나의 “여기서 이렇게 틀렸다”가 될 것이다.

이 시리즈는 계속 이어진다.

16.설정 한 줄 바꿨더니 세 번 터졌다