도메인 주도 개발 시작하기 - 6. 응용 서비스와 표현 영역
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) 응용 서비스의 크기
- 일반적으로 응용 서비스를 구현하는 방식
- 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기
- 각 기능에서 동일한 로직을 위한 코드 중복을 제거하기 쉽다.
- 한 서비스 클래스의 크기가 커진다.
- 구분되는 기능별로 응용 서비스 클래스를 따로 구현하기
- 클래스 개수는 많아지지만 코드 품질을 일정 수준으로 유지하는데 도움이 된다.
- 각 클래스별로 필요한 의존 객체만 포함하므로 다른 기능을 구현한 코드에 영향을 받지 않는다.
→ 한 도메인과 관련된 기능을 하나의 응용 서비스 클래스에서 모두 구현하는 방식보다 구분되는 기능을 별도의 서비스 클래스로 구현하는 방식을 추천한다.
- 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기
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"; } ... }