-
6장 - 응용 서비스와 표현 영역📕 book/도메인 주도 개발 시작하기 2023. 1. 5. 23:41
📍 표현 영역과 응용 영역
도메인이 제 기능을 하기위해서는 사용자와 도메인을 연결해주는 매개체가 필요하다.
응용 영역과 표현 영역이 사용자와 도메인을 연결해주는 매개체 역할을 하게된다.
표현 영역
- 사용자의 요청을 해석
- URL, 요청 파라미터, 쿠키, 헤더 등을 이용해 사용자가 실행하고 싶은 기능을 판별하고 제공하는 응용 서비스를 실행
- 응용서비스가 요구하는 형식으로 사용자 요청을 변환한다.
응용 영역
- 실제 사용자가 원하는 기능을 제공
📍응용 서비스의 역할
사용자(클라이언트)가 요청한 기능을 실행한다.
- 사용자의 요청을 처리하기 위해 리포지터리에서 도메인 객체를 가져와 사용한다.
주요 역할
- 도메인 객체를 사용해 사용자의 요청을 처리
- 도메인 영역과 표현 영역을 연결해주는 창구 역할
⚠️ 응용 서비스는 단순한 형태이다, 만약 복잡하다면 응용 서비스에서 도메인 로직의 일부를 구현하고 있을 가능성이 높다.
트랜잭션 처리
응용 서비스는 트랜잭션 처리도 담당한다.
- 도메인의 상태 변경을 트랜잭션으로 처리해야 한다.
도메인 로직 넣지 않기
도메인 로직은 도메인 영역에 위치해야 하고 응용 서비스는 도메인 로직을 구현하지 않는다.
❗ 도메인 로직을 도메인 영역과 응용 서비스에 분산해 구현하면 코드 품질에 문제가 발생
1. 코드의 응집성이 떨어진다
- 도메인 로직이 한 영역에 위치하지 않고 서로 다른 영역에 위치
- 이는 도메인 로직을 파악하기 위해서는 여러 영역을 분석해야 한다는 것을 의미
2. 여러 응용 서비스에서 동일한 도메인 로직을 구현할 가능성이 높아진다.
📍 응용 서비스의 구현
응용 서비스는 표현 영역과 도메인 영역을 연결하는 매개체 역할을 한다
- 디자인 패턴의 파사드(facade)와 같은 역할
응용 서비스의 크기
응용 서비스는 보통 2가지 방법중 한 가지 방식으로 구현한다.
- 한 응용 서비스 클래스에 도메인 모든 기능 구현
- 구분되는 기능별로 응용 서비스 클래스를 따로 구현
한 응용 서비스 클래스에 도메인 모든 기능 구현
- 각 기능에서 동일 로직에 대한 코드 중복을 제거할 수 있다.
- 한 서비스 클래스의 크기가 커진다.
- 코드가 점점 얽히게 되고 코드 품질을 낮춘다.
구분되는 기능별로 응용 서비스 클래스를 따로 구현
- 클래스 개수는 많아지지만 코드 품질을 일정 수준으로 유지하는데 도움이 된다.
- 각 클래스별로 필요한 의존 객체만 포함 → 다른 기능을 구현한 코드에 영향을 받지 않는다.
응용 서비스의 인터페이스와 클래스
응용 서비스를 구현하는데 인터페이스가 필요한가❓
인터페이스가 필요한 몇 가지 상황
- 구현 클래스가 여러 개인 경우
- 구현 클래스가 다수 존재하거나 런타임에 구현 객체를 교체해야하는 경우 인터페이스를 유용하게 사용 가능
- 하지만 응용 서비스는 런타임에 교체하는 경우가 거의 없다 ❌
- 게다가 한 응용 서비스의 구현 클래스가 2개인 경우도 드물다.
- 테스트 주도 개발 / 표현 영역부터 개발
- TDD를 하고 표현 영역부터 개발을 한다면 응용 서비스를 미리 구현할 수 없어 인터페이스부터 사용하게 된다.
- 응용 영역의 개발을 먼저 시작한다면 응용 서비스 클래스가 먼저 만들어진다. 표현 영역의 단위 테스트를 위해 가짜 객체가 필요한데 이때 인터페이스를 추가할 수 도있다.
- 하지만 Mockito 같은 테스트 도구를 사용하면 인터페이스 없이도 테스트를 할 수 있다.
메서드 파라미터와 값 리턴
응용 서비스가 제공하는 메서드는 도메인을 이용해 사용자가 요구한 기능을 실행하는 데 필요한 값을 파라미터로 전달받아야 한다.
- 개별 파라미터로 전달 받을 수도 있고 별도 데이터 클래스를 만들어 전달받을 수도 있다.
스프링 MVC와 같은 웹 프레임워크는 웹 요청 파라미터를 자바 객체로 변환하는 기능을 제공한다.
→ 응용 서비스에 데이터로 전달할 요청 파라미터가 두 개 이상 존재하면 데이터 전달을 위한 별도 클래스를 사용하는 것이 편하다.
⚠️ 응용서비스에서 애그리거트 객체를 그대로 리턴할 수도 있다.
- 코딩은 편해지지만 도메인 로직 실행을 응용 서비스와 표현 영역 두 곳에서 할 수 있게 된다.
❗응용 서비스는 표현 영역에서 필요한 데이터만 리턴하는 것이 기능 실핼 로직의 응집도를 높이는 확실한 방법이다.
표현 영역에 의존하지 않기
응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 영역과 관련된 타입을 사용하면 안된다는 점이다.
- 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)); } } }
트랜잭션 처리
트랜잭션을 관리하는 것은 응용 서비스의 중요한 역할이다!!
@Service public class ChangeShippingService { private OrderRepository orderRepository; public ChangeShippingService(OrderRepository orderRepository) { this.orderRepository = orderRepository; } @Transactional public void changeShipping(ChangeShippingRequest changeReq) { Optional<Order> orderOpt = orderRepository.findById(new OrderNo(changeReq.getNumber())); Order order = orderOpt.orElseThrow(() -> new NoOrderException()); order.changeShippingInfo(changeReq.getShippingInfo()); } }
스프링은 @Transaction 이 적용된 메서드가 RuntimeException을 발생시키면 트랜잭션을 롤백시키고 그렇지 않다면 커밋한다.
→ 트랜잭션 처리 코드를 간결하게 유지할 수 있다.
📍표현 영역
표현 영역의 책임
- 사용자가 시스템을 사용할 수 있는 흐름(화면)을 제공하고 제어
- 사용자가 요청한 내용을 응답으로 제공
- 사용자의 요청을 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공
- 화면을 보여주는데 필요한 데이터를 읽거나 도메인의 상태를 변경해야 할 때 응용 서비스를 사용
- 사용자의 요청 데이터를 응용 서비스가 요구하는 형식으로 변환하고 응용 서비스의 결과를 응답할 수 있는 형식으로 변환
- 사용자의 세션을 관리
- 웹은 쿠키나 서버 세션을 이용해 사용자의 연결 상태를 관리
📍값 검증
값 검증은 표현 영역, 응용 서비스 두 곳에서 모두 수행할 수 있다.
일반적으로 모든 값에 대한 검증은 응용 서비스에서 처리한다.
ex) 회원 가입을 처리하는 응용 서비스 - 파라미터로 전달받은 값이 올바른지 검사
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("cofirmPassword"); // 로직 검사 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 DuplicateIdException(); }
응용서비스에서 각 값이 유요한지 확인할 목적으로 Exception을 사용할 때의 문제점
→ 사용자에게 좋지 않은 경험을 제공한다.
사용자는 입력 한 값이 잘못되어 다시 입력을 해야 하는 경우 입력한 모든 항목에 대해 잘못된 값이 존재하는지 알고 싶어한다.
- 그래야 한 번에 잘못된 값을 제대로 입력할 수 있기 때문
하지만 응용 서비스에서는 값을 검사하는 시점에 첫 번째 값이 올바르지 않으면 exception을 발생시켜 나머지 항목에 대해서는 값을 검사하지 않는다.
이런 사용자 불편을 해소하기 위해 응용 서비스에서 에러 코드를 하나로 모아 하나의 exception으로 발생시키는 방법이 존재한다.
- 응용 서비스 영역
@Transactional public OrderNo placeOrder(OrderRequest orderRequest) { List<ValidataionError> 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("orderproducts", "empty")); if(orderRequest.getOrderProducts().isEmpty()) errors.add(ValidationError.of("orderproducts", "empty")); } // 응용 서비스가 입력 오류를 하나의 exception으로 모아 발생 if(!errors.isEmpty()) throw new ValidationErrorException(errors); ... }
- 표현 영역
- 표현 영역은 응용서비스가 ValidationErrorException을 발생시키면 exception에서 에러 목록을 가져와 표현 영역에서 사용할 형태로 변환후 처리한다.
@PostMapping("/orders/order") public String order(@ModelAttribute("orderReq") OrderRequest orderRequest, BindingResult bindingResult, ModelMap modelMap) { User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); orderRequest.setOrdererMemberId(MemberId.of(user.getUsername())); try { OrderNo orderNo = placeOrderService.placeOrder(orderRequest); modelMap.addAttribute("orderNo", orderNo.getNumber()); return "order/orderComplete"; } catch (ValidationErrorException e) { e.getErrors().forEach(err -> { if (err.hasName()) { bindingResult.rejectValue(err.getName(), err.getCode()); } else { bindingResult.reject(err.getCode()); } }); populateProductsAndTotalAmountsModel(orderRequest, modelMap); return "order/confirm"; } }
응용 서비스를 사용하는 표현 영역이 한 곳이면 구현의 편리함을 위해 역할을 나누어 검증을 수행할 수 있다.
- 표현 영역 - 필수 값, 값의 형식, 범위 등 검증
- 응용 서비스 - 데이터의 존재 유무와 같은 논리적 오류 검증
📍권한 검사
개발하는 시스템마다 권한의 복잡도가 다르다.
- 이런 다양한 상황을 충족하기 위해 스프링 시큐리티 같은 프레임워크는 유연하고 확장 가능한 구조를 갖는다 - 그만큼 복잡하다..!
- 이런 보안 프레임워크에 대한 이해가 부족하면 개발할 시스템에 맞는 권한 검사 기능을 구현하는 것이 시스템 유지보수에 유리할 수 있다.
보통 3곳에서 권한 검사를 수행할 수 있다.
- 표현 영역
- 응용 서비스
- 도메인
표현 영역에서의 권한 검사
인증된 사용자인지 아닌지 검사
- ex) 회원 정보 변경과 관련된 URL은 인증된 사용자만 접근해야 한다.
이런 접근 제어를 하기에 좋은 위치가 서블릿 필터이다.
서블릿 필터에서 사용자의 인증 정보를 생성하고 인증 여부를 검사한다.
응용 서비스에서의 권한 검사
❗URL 만으로 접근 제어를 할 수 없는 경우 응용 서비스의 메서드 단위로 권한 검사를 수행해야 한다.
- 응용 서비스의 코드에서 직접 권한 검사를 해야 한다는 의미는 아니다.
ex) 스프링 시큐리티는 AOP를 활용해 애너테이션으로 서비스 메서드에 대한 권한 검사를 할 수 있는 기능을 제공한다.
@Service public class BlockMemberService { private MemberRepository memberRepository; **@PreAuthorize("hasRole('ADMIN')")** @Transactional public void block(String memberId) { Member member = memberRepository.findById(new MemberId(memberId)) .orElseThrow(() -> new NoMemberException()); member.block(); } }
- @PreAuthorize
- 해당 메서드가 호출되기 이전에 검사한다.
- 실제로 해당 메서드를 호출할 권한이 있는지를 확인
도메인 권한 검사
개별 도메인 객체 단위로 권한 검사를 해야하는 경우에는 구현이 복잡하다.
→ 직접 권한 검사 로직을 구현해야 한다.
📍 조회 전용 기능과 응용 서비스
서비스에서 조회 전용 기능을 사용하면 서비스 코드가 단순히 조회 전용 기능을 호출하는 형태로 끝난다.
서비스에서 수행하는 추가적인 로직이 없고 단일 쿼리만 실행하는 조회 전용 기능이라 트랜잭션도 필요하지 않다.
- 이 경우라면 서비스를 만들 필요 없이 표현 영역에서 바로 조회 전용 기능을 사용해도 문제가 없다.
❗응용 서비스가 사용자 요청 기능을 실행하는데 별다른 기여를 하지 못한다면 굳이 서비스를 만들지 않아도 된다.
참고자료
'📕 book > 도메인 주도 개발 시작하기' 카테고리의 다른 글
8장 - 애그리거트 트랜잭션 관리 (0) 2023.01.12 7장 - 도메인 서비스 (0) 2023.01.12 5장 - 스프링 데이터 JPA를 이용한 조회 기능 (0) 2023.01.05 4장 - 리포지터리와 모델 구현 (0) 2023.01.02 3장 - 애그리거트 (1) 2022.11.02