도메인 주도 개발 시작하기 - 6. 응용 서비스와 표현 영역

 Date: 2022-10-17

6.1 표현 영역과 응용 영역
6.2 응용 서비스의 역할
6.3 응용 서비스의 구현
6.4 표현 영역
6.5 값 검증
6.6 권한 검사
6.7 조회 전용 기능과 응용 서비스

6.1 표현 영역과 응용 영역

  • 응용 영역과 표현 영역이 사용자와 도메인을 연결해주는 매개체 역할을 한다.
  • 표현 영역
    • 표현 영역은 URL, 요청 파라미터, 쿠키, 헤더 등을 이용해서 사용자가 실행하고 싶은 기능을 제공하는 응용 서비스를 실행한다.
    • 응용 서비스를 실행한 뒤에 표현 영역은 실행 결과를 사용자에게 알맞은 형식으로 응답한다.
  • 응용 영역
    • 실제 사용자가 원하는 기능을 제공하는 것은 응용 영역에 위치한 서비스다.
    • 응용 서비스의 메서드가 요구하는 파라미터와 표현 영역이 사용자로부터 전달받은 데이터는 형식이 일치하지 않기 때문에 표현 영역은 응용 서비스가 요구하는 형식으로 사용자 요청을 변환한다.
    • 사용자와 상호작용은 표현 영역이 처리하기 때문에, 응용 서비스는 표현 영역에 의존하지 않는다.

6.2 응용 서비스의 역할

  • 응용 서비스의 주요 역할은 도메인 객체를 사용해서 사용자의 요청을 처리하는 것이다.
  • 응용 서비스는 주로 도메인 객체 간의 흐름을 제어하기 때문에 단순한 형태를 갖는다.
  • 응용 서비스가 복잡하다면 응용 서비스에서 도메인 로직 일부를 구현하고 있을 가능성이 높다.

    → 응용 서비스가 도메인 로직을 일부 구현하면 코드 중복, 로직 분산 등 코드 품질에 안 좋은 영향을 줄 수 있다.

  • 응용 서비스는 트랜잭션 처리도 담당한다.
    • 응용 서비스는 도메인의 상태 변경을 트랜잭션으로 처리해야 한다.
  • 접근 제어와 이벤트 처리도 응용 서비스의 역할이다.

6.2.1) 도메인 로직 넣지 않기

  • 도메인 로직은 도메인 영역에 위치하고 응용 서비스는 도메인 로직을 구현하지 않는다.
  • 암호 변경 기능을 위한 응용 서비스
    • Member 애그리거트와 관련 리포지터리를 이용해서 도메인 객체 간의 실행 흐름을 제어한다.
    • Member 애그리거트는 암호를 변경하기 전에 기존 암호를 올바르게 입력했는지 확인한다.

      → 암호를 올바르게 입력했는지 확인하는 것은 도메인의 핵심 로직이기 때문에 응용 서비스에서 이 로직을 구현하면 안된다.

  • 도메인 로직을 도메인 영역과 응용 서비스에 분산해서 구현하면 코드 품질에 문제가 발생한다.
    • 코드의 응집성이 떨어진다.
    • 여러 응용 서비스에서 동일한 도메인 로직을 구현할 가능성이 높아진다.

→ 소프트웨어의 가치를 높이려면 도메인 로직을 도메인 영역에 모아서 코드 중복을 줄이고 응집도를 높여야 한다.

6.3 응용 서비스의 구현

  • 용용 서비스는 표현 영영과 도메인 영역을 연결하는 매개체 역할을 하는데, 이는 디자인 패턴에서 파사드와 같은 역할을 한다.
  • 응용 서비스 자체는 복잡한 로직을 수행하지 않기 때문에 응용 서비스의 구현은 어렵지 않다.

6.3.1) 응용 서비스의 크기

  • 일반적으로 응용 서비스를 구현하는 방식
    1. 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기
      • 각 기능에서 동일한 로직을 위한 코드 중복을 제거하기 쉽다.
      • 한 서비스 클래스의 크기가 커진다.
    2. 구분되는 기능별로 응용 서비스 클래스를 따로 구현하기
      • 클래스 개수는 많아지지만 코드 품질을 일정 수준으로 유지하는데 도움이 된다.
      • 각 클래스별로 필요한 의존 객체만 포함하므로 다른 기능을 구현한 코드에 영향을 받지 않는다.

    → 한 도메인과 관련된 기능을 하나의 응용 서비스 클래스에서 모두 구현하는 방식보다 구분되는 기능을 별도의 서비스 클래스로 구현하는 방식을 추천한다.

6.3.2) 응용 서비스의 인터페이스와 클래스

  • 응용 서비스를 구현할 때 인터페이스가 필요한지에 대한 의문이 들 수 있다.
    • 구현 클래스가 다수 존재하거나 런타임에 구현 객체를 교체해야 할 때 인터페이스를 유용하게 사용할 수 있다.

      → 하지만 응용 서비스는 런타임에 교체하는 경우가 거의 없고, 한 응용 서비스의 구현 클래스가 여러 개인 경우도 드물다.

    • 인터페이스가 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스를 작성하는 것이 좋은 선택이라고 볼 수 없다.
    • 응용 서비스가 구현되지 않은 상황에서 테스트를 위해 인터페이스를 작성할 수도 있지만, Mokito와 같은 테스트 도구를 사용해서 인터페이스의 역할을 대체할 수 있다.

6.3.3) 메서드 파라미터와 값 리턴

  • 응용 서비스가 제공하는 메서드는 도메인을 이용해서 사용자가 요구한 기능을 실행하는데 필요한 값을 파라미터로 전달 받는다.
    • 이때, 필요한 값을 개별 파라미터로 전달받을 수도 있고, 별도 데이터 클래스를 만들어 전달받을 수도 있다.
    • 요청 파라미터가 두 개 이상 존재하면 데이터 전달을 위한 별도 클래스를 사용하는 것이 편리하다.
  • 응용 서비스의 결과를 표현 영역에서 사용해야 하면, 응용 서비스 메서드의 결과로 필요한 데이터를 리턴한다.
    • 결과 데이터가 필요한 대표적인 예가 식별자다.
    • 응용 서비스에서 애그리커트 객체를 그대로 리턴할 수도 있다.

    애그리거트 자체를 리턴하면 코딩은 편할 수 있지만, 도메인 로직 실행을 응용 서비스와 표현 영역 두 곳에서 할 수 있게 된다.

6.3.4) 표현 영역에 의존하지 않기

  • 응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 영역과 관련된 타입을 사용하면 안된다는 점이다.
    • HttpServletRequest, HttpSession을 응용 서비스에 파라미터로 전달하면 안된다.
    • 응용 서비스에서 표현 영역에 대한 의존이 발생하면 테스트하기 어려워진다.
    • 표현 영역의 구현이 변경되면 응용 서비스의 구현도 함께 변경해야 한다.
    • 또한, 응용 서비스가 표현 영역의 역할까지 대신하는 상황이 생길 수 있다.

        public class AuthenticationService {
            public void authenticate(HttpServletRequest request) {
                String id = request.getParameter("id");
                String password = request.getParameter("password");
                if (checkIdPasswordMatching(id, password)) {
                    // 응용 서비스에서 표현 영역의 상태 처리
                    HttpSession session = request.getSession();
                    session.setAttribute("auth", new Authentication(id));
                }
            }
        }
      
      • HttpSession이나 쿠키는 표현 영역의 상태인데, 이 상태를 응용 서비스에서 변경해버리면 표현 영역의 상태가 어떻게 변경되는지 추적하기 어려워진다. → 표현 영역의 응집도가 깨진다.

6.3.5) 트랜잭션 처리

  • 트랜잭션을 관리하는 것은 응용 서비스의 중요한 역할이다.
  • 스프링은 @Transactional이 적용된 메서드가 RuntimeException을 발생시키면 트랜잭션을 롤백하고, 그렇지 않으면 커밋한다.

6.4 표현 영역

  • 표현 영역의 책임
    • 사용자 시스템을 사용할 수 있는 흐름(화면)을 제공하고 제어한다.
    • 사용자의 요청을 응용 서비스에 전달하고 실행 결과를 사용자에게 알맞은 형식으로 제공한다.
    • 사용자의 세션을 관리한다. 세션 관리는 권한 검사와도 연결된다.

6.5 값 검증

  • 값 검증은 표현 영역과 응용 서비스 두 곳에서 모두 수행할 수 있다.
  • 원칙적으로 모든 값에 대한 검증은 응용 서비스에서 처리한다.

      public class JoinService {
          @Transactional
          public void join(JoinRequest joinReq) {
              // 값의 형식 검사
              checkEmpty(joinReq.getId(), "id");
              checkEmpty(joinReq.getName(), "name");
              checkEmpty(joinReq.getPassword(), "password");
              if (joinReq.getPassword().equals(joinReq.getConfirmPassword())) {
                  throw new InvalidPropertyException("confirmPassword");        
              }
        
              // 로직 검사
              checkDuplicateId(joinReq.getId());
          }
        
          private void checkEmpty(String value, String propertyName) {
              if (value == null || value.isEmpty()) 
                  throw new EmptyPropertyException(propertyName);
          }
        
          private void checkDuplicateId(String id) {
              int count = memberRepository.countsById(id);
              if (count > 0) throw new DuplicatedIdException();
          }
      }
    
  • Spring MVC 에서 Errors나 BindingResult를 사용하면, 입력 값이 잘못된 경우를 처리할 수 있다.

      @Controller
      public class Controller {
        
          @PostMapping("/member/join")
          public String join(JoinRequest joinRequest, Errors, errors) {
              try {
                  joinService.join(joinRequest);
                  return successView;
              } catch(EmptyPropertyException ex) {
                  // 표현 영역은 잘못 입력한 값이 존재하면 잘못된 값을 다시 입력할 수 있도록 사용자에게 알려준다.
                  errors.rejectValue(ex.getPropertyName(), "empty");
                  return formView;
              } catch(InvalidPropertyException ex) {
                  errors.rejectValue(ex.getPropertyName(), "invalid");
                  return formView;
              } catch(DuplicateIdException ex) {
                  errors.rejectValue(ex.getPropertyName(), "duplicate");
                  return formView;
              }
          }
      }
    
    • 값이 유효한지를 확인할 목적으로 익셉션을 발생시키면 사용자에게 좋지 않은 경험을 제공한다.
    • 잘못 입력된 값이 여러 개인 경우, 한 번에 하나의 값만 검증되기 때문에 여러 번 같은 폼을 입력하게 만든다.
  • 사용자 입력 값에 대한 검증이 끝난 뒤에 errors 목록을 반환할 수도 있다.

      @Transactional
      public OrderNo placeOrder(OrderRequest orderRequest) {
          List<ValidationError> errors = new ArrayList<>();
          if (orderRequest == null) {
              errors.add(ValidationError.of("empty"));
          } else {
              if (orderRequest.getOrdererMemberId() == null)
                  errors.add(ValidationError.of("ordererMemberId", "empty"));
              if (orderRequest.getOrderProducts() == null)
                  errors.add(ValidationError.of("ordererProducts", "empty"));
              if (orderRequest.getOrderProducts().isEmpty())
                  errors.add(ValidationError.of("orderProducts", "empty"));
          }
          // 응용 서비스가 입력 오류를 하나의 익셉션으로 모아서 발생
          if (!errors.isEmpty()) throw new ValidationErrorException(errors);
          ...    
      }
    
      @PostMapping("/orders/order")
      public String order(@ModelAttribute("orderReq") OrderRequest orderRequest, 
                          BindingResult bs, ModelMap modelMap) {
          // 권한 검사
          try {
              OrderNo orderNo = placeOrderService.placeOrder(orderRequest);
              modelMap.addAttribute("orderNo", orderNo.getNumber());        
          } catch (ValidationErrorException e) {
              e.getErrors().forEach(err -> {
                  if (err.hasName()) {
                      bs.rejectValue(err.getName(), err.getCode());
                  } else {
                      bs.reject(err.getCode());
                  }
              });
              populateProductsModel(orderRequest, modelMap);
              return "order/confirm";
          }
      }
    
  • Validator 인터페이스를 구현한 검증기를 따로 만들어서 사용할 수도 있다.

      @Controller
      public class Controller {
        
          @PostMapping("/member/join")
          public String join(JoinRequest joinRequest, Errors, errors) {
              new JoinValidator().validate(joinRequest, errors);
              if (errors.hasErrors()) return formView;        
        
              try {
                  joinService.join(joinRequest);
                  return successView;
              } catch(DuplicateIdException ex) {
                  errors.rejectValue(ex.getPropertyName(), "duplicate");
                  return formView;
              }
          }
      }
    
    • 표현 영역에서 필수 값과 값의 형식을 검사하면, 응용 서비스는 ID 중복 여부와 같은 논리적인 오류만 검사하면 된다.
  • 응용 서비스에서 얼마나 엄격하게 값을 검증해야 하는지에 대해서는 의견이 갈릴 수 있다.
    • 요즘에는 가능하면 응용 서비스에서 필수 값 검증과 논리적인 검증을 모두 하는 편이다.
    • 응용 서비스에서 값 검증을 모두 처리하면, 작성할 코드가 늘어나는 불편함이 있지만 완성도가 높아지는 이점이 있다.

6.6 권한 검사

  • 권한 검사 기능은 시스템의 복잡도에 따라 직접 구현할 수도 있고, 스프링 시큐리티 같은 프레임워크를 사용할 수도 있다.
  • 권한 검사를 수행하는 영역
    • 표현 영역: 인증된 사용자인지 아닌지 검사 (서블릿 필터)
    • 응용 서비스: 메서드 단위의 권한 검사 (@PreAuthorize)
    • 도메인: 직접 권한 검사 로직 구현

      → 스프링 시큐리티와 같은 보안 프레임워크를 확장해서 개별 도메인 객체 수준의 권한 기능을 프레임워크에 통합할 수도 있지만, 프레임워크에 대한 높은 이해가 필요하다.

6.7 조회 전용 기능과 응용 서비스

  • 조회 화면을 위한 조회 전용 모델과 DAO (→ 5장)

      public class OrderListService {
          public List<OrderView> getOrderList(String orderId) {
              return orderViewDao.selectByOrderer(orderId);
          }
          ...
      }
    
  • 서비스에서 수행하는 추가적인 로직 없이 단일 쿼리만 실행하는 조회 전용 기능은 트랜잭션이 필요하지 않다.
  • 조회 전용 기능이라면 서비스를 만들 필요 없이 표현 영역에서 바로 조회 전용 기능을 사용해도 문제가 없다.

    public class OrderController {
        private OrderViewDao orderViewDao;
      
        @RequestMapping("/myorders")
        public String list(ModelMap model) {
            String orderId = SecurityContext.getAuthentication().getId();
            List<OrderView> orders = orderViewDao.selectByOrderer(ordererId);
            model.addAttribute("orders", orders);
            return "order/list";
        }
        ...
    }