5장 : 웹 어댑터 구현하기

애플리케이션은 대부분 웹 인터페이스 같은 것을 제공한다. 웹 브라우저를 통해 상호작용할 수 있는 UI나 다른 시스템에서 우리 애플리케이션으로 호출하는 방식으로 상호작용하는 HTTP API가 어댑터 해당한다.

의존성 역전

  • 웹 어댑터는 '주도하는' 혹은 '인커밍' 어댑터다.

  • 외부로부터 요청을 받아 애플리케이션 코어를 호출하고 무슨 일을 해야 할지 알려준다.

  • 이때 제어 흐름은 웹 어댑터에 있는 컨트롤러에서 애플리케이션 계층에 있는 서비스로 흐른다.

  • 의존성 역전 원칙이 적용되었는데, 제어 흐름이 왼쪽에서 오른쪽으로 흐르기 때문에 웹 어댑터가 유스케이스를 직접 호출할 수 있다.

웹 어댑터와 유스케이스 사이에 또 다른 간접 계층을 넣어야 할까 ?

애플리케이션 코어가 외부 세계와 통신할 수 있는 곳에 대한 명세가 포트이기 때문이다.

포트를 적절한 곳에 위치시키면 외부와 어떤 통신이 일어나고 있는지 정확히 알 수 있다.

애플리케이션이 웹 어댑터에 능동적으로 알림을 줘야 할 경우 의존성을 올바른 방향으로 유지하기 위해 아웃고잉 포트를 통과해야 한다.

예를 들면

상호작용이 많이 일어나는 애플리케이션에서 웹소켓을 통해 실시간 데이터를 사용자의 브라우저로 보낸다고 가정했을때, 애플리케이션 코어에서는 이러한 실시간 데이터를 어떻게 웹 어댑터로 보내고, 웹 어댑터는 이 데이터를 어떻게 사용자의 브라우저로 전송하는 것일까 ?

이 경우에는 반드시 포트가 필요하다. 이 포트는 웹 어댑터에서 구현하고 애플리케이션 코어에서 호출해야 한다. 아웃고잉 포트이기 때문에 이제 웹 어댑터는 인커밍 어댑터인 동시에 아웃고잉 어댑터가 된다.

웹 어댑터의 책임

웹 어댑터의 책임

  1. HTTP 요청을 자바 객체로 매핑

    • 웹어댑터는 URL, 경로, HTTP 메서드, 콘텐츠 타입 등과 같은 특정 기준을 만족하는 HTTP 요청 수신한다.

    • HTTP 요청의 파라미터와 콘텐츠를 객체로 역직렬화해야한다.

  2. 권한 검사

    • 보통은 웹 어댑터가 인증과 권한 부여를 수행하고 실패할 경우 에러를 반환한다.

  3. 입력 유효성 검증

    ✔️ 유스케이스에서 입력 모델의 입력 유효성 했는데 왜 또 할까 ..?

    • 여기서는 웹 어댑터의 입력 모델에 대해 유효성을 검증한다. 유스케이스의 입력 모델과 웹어댑터의 입력 모델은 다를 수 있다. 여기서는 웹 어댑터의 입력 모델을 유스케이스의 입력 모델로 변환 가능 여부 검증한다.

  4. 입력을 유스케이스의 입력 모델로 매핑

    입력 모델 변환한다.

  5. 유스케이스 호출

    • 변환된 입력 모델로 특정한 유스케이스를 호출하는 것으로 연결된다.

  6. 유스케이스의 출력을 HTTP로 매핑

    • 어댑터는 유스케이스의 출력을 반환받는다.

  7. HTTP 응답을 반환

    • 반환 받은 출력을 HTTP 응답으로 직렬화해서 호출자에게 전달한다.

웹 어댑터 책임이 많은 이유

HTTP 관련한 작업과 같은 애플리케이션 계층이 신경 쓰면 안되는 것들을 웹어댑터에서 처리해야 한다.

좋은 아키텍처에서는 선택의 여지를 남겨둔다.

컨트롤러 나누기

컨트롤러의 개수는 너무 적은 것보다는 너무 많은 게 낫다. 각 컨트롤러가 가능한 한 좁고 다른 컨트롤러와 가능한 한 적게 공유하는 웹 어댑터 조각을 구현해야 한다.

AccountController snippet

  • AccountController 에서 계좌와 관련된 모든 요청을 받는다.

  • 하나의 클래스에 계좌 리소스와 관련된 모든 것이 모여 있는 경우

    • 코드를 파악하는데 난이도가 높아진다.

    • 테스트 코드도 많아 지는데 마찬가지로 파악하기 어렵고, 특정 프로덕션 코드에 해당하는 테스트 코드를 찾기 쉽게 만들어야 한다.

    • 모든 연산을 단일 컨트롤러에 넣는 것이 데이터 구조의 재활용을 촉진한다.

예를 들면

앞의 예제 코드에서 많은 연산들이 AccountResource 모델 클래스를 공유한다. AccountResource가 모든 연산에서 필요한 모든 데이터를 담고 있는 큰 통인 것이다. 아마도 AccountResource에는 id 필드가 있을 것이다. 그렇지만 이 id는 create 연산에서는 필요없기 떄문에 도움이 되기보다는 헷갈릴 수 있다.

Account가 User 객체와 일대다 관계를 맺고 있다고 가정할때, 계좌를 생성하거나 업데이트할 때 User 객체도 필요할까 ? list연산에 사용자 정보도 같이 반환해야 할까 ? 아주 간단한 사례이긴 하지만 토이 프로젝트가 아닌 한 프로젝트에서 언젠가 맞닥뜨릴 질문이다.

@RestController
@RequiredArgsConstructor
class AccountController {

    private final GetAccountBalanceQuery getAccountBalanceQuery;
    private final ListAccountAuery listAccountQuery;
    private final LoadAccountQuery loadAccountQuery;
    
    private final SendMoneyUseCase sendMoneyUseCase;
    private final CreateAccountUserCase createAccountUseCase;
    
    @GetMapping("/accounts")
    List<AccountResource> listAccounts() {
        // ...
    }
    
    @GetMapping("/accounts/{accountId}")
    AccountResource getAccount(@PathVariable("accountId") Long accountId) {
        // ...
    }
    
    @GetMapping("/accounts/{accountId}/balance")
    long getAccountBalance(@PathVariable("accountId") Long accountId){
        // ...
    }

    @PostMapping("/accounts")
    AccountResource createAccount(@RequestBody AccountResource account) {
        // ...
    }   
    
    @PostMapping("/accounts/send/{sourceAccountId}/{targetAccountId}/{amount}")
    void sendMoney(
        @PathVariable("sourceAccountId") Long sourceAccountId,
        @PathVariable("targetAccountId") Long targetAccountId,
        @PathVariable("amount") Long amount) {
        
        // ...    
    }
}

가급적이면 별도의 패키지 안에 별도의 컨트롤러를 만드는 방식을 선호한다.

  • 각 컨트롤러가 CreateAccountResource나 UpdateAccountResource 같은 컨틑롤러 자체의 모델을 가지고 있거나, 앞의 예제 코드처럼 원시값을 받아도 된다.

  • 전용 모델 클래스들은 컨트롤러의 패키지에 대해 private으로 선언할 수 있기 때문에 실수로 다른 곳에서 재사용될 일이 없다.

  • 컨트롤러끼리는 모델을 공유할 수 있지만 다른 패키지에 있는 덕분에 공유해서 사용하기 전에 다시 한 번 생각해 볼 수 있다.

  • 컨트롤러명/서비스명에 대해서도 잘 생각해봐야 한다.

  • 이렇게 나누는 스타일은 서로 다른 연산에 대한 동시 작업이 쉬워진다.

@RestController
@RequiredArgsConstructor
public class SendMoneyController {
    
    private final SendMoneyUseCase;
    
    @PostMapping("/accounts/send/{sourceAccount}/{targetAccountId}/{amount}")
    void sendMoney(
        @PathVariable("sourceAccountId") Long sourceAccountId,
        @PathVariable("targetAccountId") Long targetAccountId,
        @pathVariable("amount") Long amount) {
    
        SendMoneyCommand command = new SendMoneyCommand(
            new AccountId(sourceAccountId),
            new AccountId(targetAccountId),
            Money.of(amount));
            
        sendMoneyUseCase.sendMoney(command);    
    }
}

DISCUSSION

Last updated