ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • MVC에서 헥사고날 아키텍처로의 여정 - 2편: 매핑 전략
    백엔드 : 서버공부/Spring 2025. 11. 2. 02:51
    728x90

    새벽3신데..
    잠이 오지않아 책을읽었는데 그래도 잠이 오지않아서 정리까지 하게되었다. 
    본격적으로 글 시작에 앞서서
     
     

    한로로의 정류장, 이 노래 가사가 참 좋은거같다.
    나도 이런 말을 해보고 싶다는 생각이 들기도..

    그나저나 갑자기 추워졌는데 감기조심
    https://youtu.be/zaQn86wvYJs?si=FL1O0vHEsPybXi_J

     
     
     

    들어가며

    만들면서 배우는 클린 아키텍처를 읽고 정리하는 글이다. 예시 코드는 지피티의 도움을 받아 작성했다.

    이 책은 웹, 애플리케이션, 도메인, 영속성 계층을 어떻게 나눠야 하고, 하나의 유스케이스가 이 네 계층을 통과하면서 어떤 역할을 수행하는지를 설명한다.
     
    실제로 우리가 서비스 코드를 작성할 때 많이 부딪히는 것은 “그럼 각 계층이 쓰는 모델은 어떻게 하지?” 라는 문제다.
    컨트롤러에서 받은 DTO를 그대로 비즈니스 로직을 구현하는 서비스 계층에 넘길지, 도메인 모델을 웹에 그대로 노출 시킬지, JPA 엔티티를 도메인 모델로 다시 감싸서 사용할지, 이런 것들이다.

    책에서도 "계층 사이의 모델을 어떻게 매핑할 것인지"를 가지고 몇 가지 전략을 제시한다.
    매핑에대한 논의는 보통 이렇게 시작된다.


    둘 다 틀린말은 아니다.
    그래서 이 책은 “어떤 매핑 전략이든 전역 규칙으로 두지 말고, 유스케이스마다 적절한 전략을 고를 수 있게 해야 한다”고 말해준다.


    '매핑하지않기' 전략

    첫 번째 전략은 '매핑하지 않기(NO Mapping)' 전략이다.
    이름 그대로 계층은 나눴지만 매핑을 하지않고 모두가 같은 클래스를 사용하는 것이다.
    웹 컨트롤러가 호출하는 유스케이스 인터페이스도, 그 유스케이스가 다루는 도메인도,
    반대편의 영속성 계층에서 JPA가 저장하는 엔티티도 전부 같은 클래스를 쓴다.
     
    예를 들어 아래처럼 작성한다.

    @Entity
    public class Account {
    
        @Id
        private Long id;
    
        private BigDecimal balance;
    
        protected Account() {}
    
        public Account(Long id, BigDecimal balance) {
            this.id = id;
            this.balance = balance;
        }
    
        public void withdraw(BigDecimal money) {
            this.balance = this.balance.subtract(money);
        }
    
        public void deposit(BigDecimal money) {
            this.balance = this.balance.add(money);
        }
    
        public Long getId() { return id; }
        public BigDecimal getBalance() { return balance; }
    }
    public interface SendMoneyUseCase {
        void sendMoney(Long sourceId, Long targetId, BigDecimal amount);
    }
    @Service
    public class SendMoneyService implements SendMoneyUseCase {
    
        private final AccountRepository accountRepository;
    
        public SendMoneyService(AccountRepository accountRepository) {
            this.accountRepository = accountRepository;
        }
    
        @Override
        public void sendMoney(Long sourceId, Long targetId, BigDecimal amount) {
            Account source = accountRepository.findById(sourceId).orElseThrow();
            Account target = accountRepository.findById(targetId).orElseThrow();
            source.withdraw(amount);
            target.deposit(amount);
            accountRepository.save(source);
            accountRepository.save(target);
        }
    }
    @RestController
    @RequestMapping("/accounts")
    public class AccountController {
    
        private final SendMoneyUseCase sendMoneyUseCase;
    
        public AccountController(SendMoneyUseCase sendMoneyUseCase) {
            this.sendMoneyUseCase = sendMoneyUseCase;
        }
    
        @PostMapping("/send")
        public void send(@RequestParam Long sourceId,
                         @RequestParam Long targetId,
                         @RequestParam BigDecimal amount) {
            sendMoneyUseCase.sendMoney(sourceId, targetId, amount);
        }
    }
    public interface AccountRepository extends JpaRepository<Account, Long> {
    }

    이렇게 하면 계층 사이에 매핑을 할 이유가 없다.
     
    하지만 이 구조의 한계는 책에서 말한 것처럼 금방 드러난다.
    웹 계층이 REST로 모델을 노출 시켰다면 모델을 JSON으로 직렬화를 위해 특정 필드에 애너테이션을 붙여야 할 수 도있고,
    영속성 계층이 ORM프레임 워크그르 사용한다면
    데이터 베이스 매핑을 위해 애너테이션을 붙여야 할 것이다.
     
    이렇게 되면 도메인과 애플리케이션 계층은 웹과 영속성 계층에 관심이 없음에도 불구하고 요구사항을 전부 다루게 된다.
    결국 도메인 모델 하나가 웹과 영속성을 동시에 만족시키기 위해 바뀌기 시작하고, 단일 책임 원칙을 깔끔하게 지키기 어렵다.
     
    그럼에도 이 전략을 아예 쓰지 말라는 뜻은 아니다.
    책도 말하듯이 모든 계층이 정확히 같은 구조를 필요로 한다면 '매핑하지 않기'전략은 완벽한 선택지이다.

     

    '양방향' 매핑전략

    조금 더 교과서적인 형태는 양방향(Two-way) 매핑이다. ( 아마 가장 익숙한 방식일 것이다.)
    여기서는 각 계층이 도메인 모델과는 완전히 다른 구조의 전용 모델을 가진다.
    웹은 웹대로 요청과 응답에 맞는 DTO를 쓰고, 도메인은 도메인 로직을 중심으로 한 순수한 모델을 쓰며,
    영속성은 DB 에 적절한 엔티티를 쓴다.
    그리고 웹 계층에서 웹 모델을 인커밍 포트에서 필요한 도메인 모델로 매핑하고, 인커밍 포트에 의해 반환된 도메인 객체를 다시 웹 모델로 매핑한다.
    영속성 계층은 애플리케이션 계층에서 아웃고잉 포트가 사용하는 도메인 모델과 영속성 모델 간의 매핑과 유사한 매핑을 담당한다.
    이와 같이 두 계층 모두 양방향으로 매핑하기 때문에 양방향 매핑이라고 부른다.

    public class Account {
        private final Long id;
        private BigDecimal balance;
    
        public Account(Long id, BigDecimal balance) {
            this.id = id;
            this.balance = balance;
        }
    
        public void withdraw(BigDecimal money) {
            this.balance = this.balance.subtract(money);
        }
    
        public void deposit(BigDecimal money) {
            this.balance = this.balance.add(money);
        }
    
        public Long getId() { return id; }
        public BigDecimal getBalance() { return balance; }
    }
    @Entity
    @Table(name = "accounts")
    public class AccountEntity {
    
        @Id
        private Long id;
    
        private BigDecimal balance;
    
        protected AccountEntity() {}
    
        public AccountEntity(Long id, BigDecimal balance) {
            this.id = id;
            this.balance = balance;
        }
    
        public Long getId() { return id; }
        public BigDecimal getBalance() { return balance; }
        public void setId(Long id) { this.id = id; }
        public void setBalance(BigDecimal balance) { this.balance = balance; }
    }
    public class SendMoneyRequest {
        public Long sourceAccountId;
        public Long targetAccountId;
        public BigDecimal amount;
    }
    @RestController
    @RequestMapping("/accounts")
    public class AccountController {
    
        private final SendMoneyUseCase sendMoneyUseCase;
    
        public AccountController(SendMoneyUseCase sendMoneyUseCase) {
            this.sendMoneyUseCase = sendMoneyUseCase;
        }
    
        @PostMapping("/send")
        public void send(@RequestBody SendMoneyRequest req) {
            sendMoneyUseCase.sendMoney(req.sourceAccountId,
                                       req.targetAccountId,
                                       req.amount);
        }
    }
    public interface SendMoneyUseCase {
        void sendMoney(Long sourceId, Long targetId, BigDecimal amount);
    }
    @Component
    public class JpaAccountPersistenceAdapter {
    
        private final AccountJpaRepository repo;
    
        public JpaAccountPersistenceAdapter(AccountJpaRepository repo) {
            this.repo = repo;
        }
    
        public Account load(Long id) {
            AccountEntity e = repo.findById(id).orElseThrow();
            return toDomain(e);
        }
    
        public void save(Account account) {
            repo.save(toEntity(account));
        }
    
        private Account toDomain(AccountEntity e) {
            return new Account(e.getId(), e.getBalance());
        }
    
        private AccountEntity toEntity(Account d) {
            return new AccountEntity(d.getId(), d.getBalance());
        }
    }
    public interface AccountJpaRepository extends JpaRepository<AccountEntity, Long> {
    }

    이 방식의 장점은 도메인 모델이 웹이나 영속성 관심사로 오염되지 않는다는 점이다. 즉 단일 책임 원칙을 만족하는 것이다.
    도메인은 비즈니스 규칙을 잘 표현하는 구조를 가질 수 있고, 웹은 응답 구조를 최적으로 표현하는 구조를 가질 수 있고, 영속성은 ORM이 요구하는 구조를 가질 수 있다. 또한 각 계층이 전용모델을 변경한다 하더라도, 내용이 변경되지 않는 한 다른 계층으로 그 변경이 전파되지 않는다. 
    그리고 바깥쪽 계층이 매핑 책임을 지기 때문에 안쪽 계층은 자기 모델만 알면 되고 매핑 대신 도메인 로직에 집중할 수 있다.
    단점은 분명하다. 매핑 코드가 많다. 매핑 프레임워크를 써도 두 모델 간 매핑을 한 번은 구현해야 하고, 그 프레임워크가 제네릭과 리플렉션 뒤로 내부를 숨기면 디버깅이 귀찮아진다.
    그래도 책이 이 전략을 “매핑하지 않기 다음으로 간단하다”고 표현한 이유는 책임이 분명하기 때문이다.
    바깥→안쪽, 안쪽→바깥 이라는 규칙만 따르면 된다.

    '완전' 매핑전략

    세 번째는 '완전(Full)' 매핑전략 전략이다.
    이 전략에서는 계층 경계를 넘을 때 도메인 모델을 그대로 쓰지 않는다. ( 양방향 매핑처럼 계층을 넘어 통신할때 도메인을 사용하지 않는다.)
    대신 포트에 입력 모델로 동작하는 각 작업에 특화된 모델을 사용한다.
    책에서 예로 든 것처럼 SendMoneyUseCase 라면 SendMoneyCommand 같은 걸 만든다.

    컨트롤러는 웹에서 들어온 요청을 이 커맨드로 매핑하는 책임을 가진다. 이때 커맨드 객체는 애플리케이션 계층의 인터페이스를 해석할 필요 없이 명확하게 만들어준다.
    커맨드 객체를 받기 때문에 어떤 값이 필수인지, 어떤 값은 비워도 되는지와 같은 고민을 할 필요가 없고,
    해당 유스케이스에서 필요 없는 필드에 대한 검증이 수행되는 일도 없다.

    public interface SendMoneyUseCase {
        void sendMoney(SendMoneyCommand command);
    }
    public class SendMoneyCommand {
    
        private final Long sourceAccountId;
        private final Long targetAccountId;
        private final BigDecimal amount;
    
        public SendMoneyCommand(Long sourceAccountId,
                                Long targetAccountId,
                                BigDecimal amount) {
    
            if (sourceAccountId == null || targetAccountId == null) {
                throw new IllegalArgumentException("account ids must be provided");
            }
            if (amount == null || amount.signum() <= 0) {
                throw new IllegalArgumentException("amount must be positive");
            }
            if (sourceAccountId.equals(targetAccountId)) {
                throw new IllegalArgumentException("cannot send to same account");
            }
    
            this.sourceAccountId = sourceAccountId;
            this.targetAccountId = targetAccountId;
            this.amount = amount;
        }
    
        public Long getSourceAccountId() { return sourceAccountId; }
        public Long getTargetAccountId() { return targetAccountId; }
        public BigDecimal getAmount() { return amount; }
    }
    @Service
    public class SendMoneyService implements SendMoneyUseCase {
    
        private final LoadAccountPort loadAccountPort;
        private final UpdateAccountStatePort updateAccountStatePort;
    
        public SendMoneyService(LoadAccountPort loadAccountPort,
                                UpdateAccountStatePort updateAccountStatePort) {
            this.loadAccountPort = loadAccountPort;
            this.updateAccountStatePort = updateAccountStatePort;
        }
    
        @Override
        public void sendMoney(SendMoneyCommand cmd) {
            Account source = loadAccountPort.loadAccount(cmd.getSourceAccountId());
            Account target = loadAccountPort.loadAccount(cmd.getTargetAccountId());
    
            source.withdraw(cmd.getAmount());
            target.deposit(cmd.getAmount());
    
            updateAccountStatePort.update(source);
            updateAccountStatePort.update(target);
        }
    }
    @RestController
    public class SendMoneyController {
    
        private final SendMoneyUseCase useCase;
    
        public SendMoneyController(SendMoneyUseCase useCase) {
            this.useCase = useCase;
        }
    
        @PostMapping("/send-money")
        public void send(@RequestBody SendMoneyRequest req) {
            SendMoneyCommand cmd = new SendMoneyCommand(
                    req.sourceId(),
                    req.targetId(),
                    req.amount()
            );
            useCase.sendMoney(cmd);
        }
    }

     
    이렇게 하면 API 스펙 변경 등의 이유로 웹 계층의 표현이 바뀌어도 바로 다른 계층으로 변경사항이 전파되지않는다.

    예를 들어 API에서 일부 필드 이름을 from, to, money로 바꿔도 웹 계층에서는 웹 모델인 DTO에서만 수정을 하고 커맨드로 매핑해서 애플리케이션 계층으로 넘기면 된다.

    애플리케이션 계층이 웹의 표현 문제를 다루지 않아도 되기 때문에 경계가 선명해진다.

    이 전략도 역시 단점이 존재하는데, 그것은 비용이 가장 크다는 점이다.
    웹 모델을 여러 개의 커맨드로 나눠서 매핑하기때문에 웹 모델 하나를 도메인 모델로 매핑하는 것보다 코드가 많아진다.

    그래서 책에서도 이 패턴을 전역 패턴으로 권하지 않는다.
    웹과 애플리케이션 사이에서 상태를 변경하는 유스케이스의 경계를 명확히 하고 싶을 때 가장 빛난다고만 말한다.
    또한 애플리케이션과 영속성 사이에서는 매핑 오버헤드 때문에 이걸 쓰지 말라고 한다. (103p)
    그리고 경우에 따라서는 입력에만 이 전략을 쓰고, 출력은 그냥 도메인 객체를 그대로 반환해도 된다고 한다.
    송금 유스케이스가 업데이트된 잔고를 가진 Account를 그대로 반환하는 식이다.
     
     

    단방향 매핑 전략

    마지막으로 단방향 매핑이 있다. 이 전략은 앞의 것들보다 조금 개념적이다. ( 잘 이해 못해서 후루루루룩 정리하겠다. )
    여기서는 모든 계층의 모델들이 같은 인터페이스를 구현하도록 한다. 이 인터페이스는 관련 있는 속성을 읽어올 수 있는 getter만 제공해서 도메인 모델의 상태를 캡슐화한다. 도메인 모델 자체는 풍부한 행동을 구현할 수 있고, 애플리케이션 계층은 그 행동에 접근할 수 있다. 도메인 객체를 바깥 계층으로 전달하고 싶으면 매핑 없이 전달할 수 있다.
    왜냐하면 도메인 객체가 인커밍/아웃고잉 포트가 기대하는 대로 상태 인터페이스를 구현하고 있기 때문이다.
    바깥 계층에서 애플리케이션으로 객체를 전달할 때도 이 상태 인터페이스를 구현하게 해서, 애플리케이션 계층에서 필요하면 실제 도메인 모델로 다시 매핑하게 한다. 이때 매핑은 팩터리와 잘 어울린다고 책은 설명한다. 어떤 특정한 상태로부터 도메인 객체를 재구성하는 책임을 팩터리가 가지는 것이다. 이렇게 하면 각 계층은 한 방향으로만 매핑한다.
    그래서 단방향 매핑이라고 부른다. 다만 매핑이 계층을 넘나들며 퍼져 있기 때문에 다른 전략에 비해 개념적으로 어렵고, 모델이 서로 비슷할 때 특히 읽기 전용 연산에서 효과가 크다.
     
     
    여기까지가 책이 말하는 네 가지 전략이다.
    책이 끝에서 다시 강조하는 건 이거다. 소프트웨어는 시간이 지나면서 요구사항이 바뀐다.
    오늘은 단순 CRUD라서 매핑하지 않기로 한 코드가, 내일은 도메인을 웹과 영속성 문제로부터 좀 떼어내야 하는 상황이 될 수 있다.
    그래서 처음부터 복잡한 전략을 전역으로 깔아두는 것보다, 빠르게 코드를 짤 수 있는 단순한 전략으로 시작해서 필요해지면 계층 간 결합을 떼어내는 복잡한 전략으로 갈아타는 게 좋다고 말한다.
     
    그리고 이걸 하려면 팀에서 어떤 상황에서 어떤 전략을 먼저 선택할지, 왜 그 전략을 우선으로 둬야 하는지에 대한 합의된 가이드라인이 있어야 한다고 덧붙인다.

    특정 작업에 최선의 패턴이 아님에도 매핑 깔끔해 보인다는 이유만으로 전역 규칙으로 만드는 것은 무책임한 처사라고 책은 표현하고있다.
     
    그러니까 지금 그 유스케이스에 맞는 것을 고르면 된다.
     
     
     

    마무리

    하하.. 거의 책을 옮겨 적은거같은데.. 프로젝트를 진행하며 해댱 패턴에 적응한다면 나만의 설명이 좀 더 잘 나오지 않을까 싶다.

Designed by Tistory.