본문 바로가기
DDD&MSA

[DDD] 응용 서비스 구현과 표현 계층

by 덩라 2023. 6. 7.

본 포스팅은 DDD 를 공부하면서 정리하기 위한 포스팅입니다.

출처: 도메인 주도 개발 시작하기 - DDD 핵심 개념 정리부터 구현까지 (저자. 최범균)

1. 응용 서비스 구현 방향

1-1. 도메인 로직 구현 안하기

DDD 를 공부하면서 가장 중요하게 생각했던 내용이라 해도 과언이 아닙니다.

저 같은 경우, 과거에는 비지니스 로직을 Application 계층(Service 계층) 에 개발하는 트랜잭션 스크립트 패턴을 많이 활용했다면, 

요즘 DDD 를 공부하며 비지니스 로직을 Domain 계층에 개발하는 도메인 모델 패턴을 많이 활용하고 있습니다.

 

DDD 는 도메인 로직을 도메인 계층에 구현하는 것을 대원칙으로 두고 있습니다.

주문 취소 로직을 작성한다 했을 때, 트랜잭션 스크립트 패턴으로 개발한다면 아래 처럼 개발될 것입니다.

public class OrderSerivce{
	
    public void cancel(Long orderId) {
    	Order order = orderRepository.findById(orderId);
        
        if(OrderStatus.PREPARED != order.getOrderStatus()) {
        	throw new CannotCancelOrderException("준비 중인 주문이 아니면 취소할 수 없습니다.");
        }
        order.setOrderStatus(OrderStatus.CANCEL);
    }
}

상품이 배송되기 전에 주문을 취소 할 수 있게 하는 비지니스 로직입니다.

서비스 계층에 이렇게 비지니스 로직이 포함되면, 해당 로직을 수행여부를 결정할 검증 또한 서비스계층에 노출되게 됩니다.

트랜잭션 스크립트 방식에선 어느정도 자연스러운 형태일 수 있으나, 도메인 모델 패턴에선 아래처럼 개발하는 것을 권장합니다.

public class OrderSerivce{
    public void cancel(Long orderId) {
    	Order order = orderRepository.findById(orderId);
        order.cancel();
    }
}

public class Order {
    private OrderStatus orderStatus;

    public void cancel() {
    	if(OrderStatus.PREPARED != this.orderStatus) {
        	throw new CannotCancelOrderException("준비 중인 주문이 아니면 취소할 수 없습니다.");
        }
        this.orderStatus = OrderStatus.CANCEL;
    }
}

 

이렇게 응용 서비스를 구현할 때, 도메인 로직이 서비스 계층에 노출되지 않도록 실제 비지니스 로직은 도메인에서 구현하고,

응용 서비스에선 도메인의 기능을 호출하여 로직의 실행을 도메인에 위임하도록 합니다.

 

1-2. 표현 계층에 의존하지 않기

애플리케이션을 개발할 때, 가장 보편적으로 사용하는 구조가 무엇인가 생각해보면 당연 Layered Architecture 일 것입니다.

Layered Architecture

위와 같은 구조로 개발을 하게 된다면, Presentation Layer 는 Application Layer 를 의존하고, Application Layer 는 Domain Layer 를 의존하게 됩니다. 즉, 응용 서비스가 포함되는 Application Layer 를 Domain Layer 에 속한 객체들에 의존해야지, Presentation Layer 에 속한 객체들에 의존하면 옳바른 구조가 아니라는 것을 의미합니다.



간략하게 회원 가입 로직을 예로 들어보겠습니다.

회원 가입 시, 1) 로그인 아이디, 2) 비밀번호, 3) 이름 정도가 필요하다고 가정해보겠습니다.

그렇다면 사용자는 로그인아이디, 비밀번호, 이름 을 입력해서 서비스에 회원가입을 시도하기 위해 요청을 보낼 것 입니다.

간단하게 구현해보면 아래처럼 Presentation Layer 를 구현할 수 있습니다.

public class UserController {
    private UserService userService;
    
    public ResponseEntity<SignUpResponse> signUp(HttpServletRequest request) {
		SignUpResponse response = userService.signUp(request);
        return ResponseEntity.ok(response);
    }
}

public class UserService {

    public SignUpResponse signUp(HttpServletRequest request) {
    	String loginId = request.getParameter("loginId");
        String password = request.getParameter("password");
        String name = request.getParameter("name");
        
        // ...회원가입 로직 실행
        
        return new SignUpResponse("SUCCESS", loginId, name);
    }
}

위 코드를 보면, Service 계층에서 HttpServletRequest 라는 표현 계층의 기술에 의존하고 있는 모습을 볼 수 있습니다.

즉, UserService 의 signUp 메서드를 다른 곳에서 사용하려면, 그 곳에도 반드시 HttpServletRequest 를 가져올 수 있어야 한다는 의미입니다. 이렇게 UserService 라는 서비스 계층이, HttpServletRequest 라는 표현계층에 의존하면 코드의 재사용성이 현저하게 떨어질 수 있습니다.

 

표현 계층은 HttpServletRequest 를 사용할 수 도 있고, 단순하게 Socket 를 사용할 수 도 있는데 응용 계층에서 표현계층은 HttpServletRequest 를 써야된다고 규정하는 것과 다름이 없으니 이것은 서비스 계층이 표현 계층의 구현에도 관여하는 좋지 않은 의존관계라고 할 수 있습니다.

 

이러한 문제를 없애기 위해서는 응용 서비스 계층은 표현 계층이 무슨 기술을 사용하던 상관없이 실행 할 수 있는 구조를 가져야 합니다. 

예를 들면 아래 코드와 같이 말이죠.

public class UserController {
    private UserService userService;
    
    public ResponseEntity<SignUpResponse> signUp(HttpServletRequest request) {
    	String loginId = request.getParameter("loginId");
        String password = request.getParameter("password");
        String name = request.getParameter("name");
        
        SignUpResponse response = userService.signUp(loginId, password, name);
        return ResponseEntity.ok(response);
    }
}

public class UserService {

    public SignUpResponse signUp(String loginId, String password, String name) {
        // ...회원가입 로직 실행
        
        return new SignUpResponse("SUCCESS", loginId, name);
    }
}

UserService 코드만 보면 SignUpResponse 라는 응답객체를 제외하곤, 파라미터로 쓰이는 타입은 모두 String 으로 표현 계층과는 무관한 기술을 사용하고 있음을 볼 수 있습니다.

 

이처럼 응용 서비스를 개발할 때, 해당 기능을 호출하는 표현 계층이 어떠한 기술을 통해 구현됐는지에 관계없이 기능을 수행할 수 있도록 구현해야 합니다.

 

1-3. 애그리거트를 이용한 응용 서비스 개발 패턴

응용 서비스에서 도메인 로직을 구현하지 않음으로써, 응용 서비스의 역할은 다소 간단해졌습니다.

애그리거트 루트를 조회하고, 로직을 호출하고, 결과를 리턴만 하면 됩니다.

아래 형식으로 비교적 간단해지는 것이죠.

public class OrderService {

    public Long cancel(Long orderId) {
        // 1. 애그리거트 조회
        Order order = orderRepository.findById(orderId);
        
        // 2. 애그리거트 내 로직 수행
        order.cancel();
        
        // 3. 결과 리턴
        return order.getId();
    }
}

 

애그리거트의 상태를 변경하는 것 뿐 아니라 애그리거트를 생성하는 로직도 비교적 간단해질 수 있습니다.

public class OrderService {

    public OrderDto cancel(OrderCreateDto createDto) {
        // 1. 애그리거트 생성 파라미터 검증
        validate(createDto);
        
        // 2. 애그리거트 생성
        Order order = Order.create(createDto.getItems(), ...생략);
        
        // 3. 애그리거트 저장
        Order savedOrder = orderRepository.save(order);
        
        // 4. 저장결과 리턴
        return new OrderDto(savedOrder);
    }
}

 

 

 

2. 표현 계층의 역할

표현 계층(Presentation Layer)에서는 기본적으로 사용자의 요청(Request)를 받고, 비지니스 로직을 수행한 결과를 사용자에게 응답(Response)하는 역할을 담당합니다. 여기서 말한 "사용자"란, 어떠한 서비스(ex. 인터넷 웹페이지, 모바일 앱 등)를 통해 원하는 기능을 실행하고자 하는 사람일 수 도 있고, 우리의 서비스에서 어떠한 정보를 얻고자 하는 외부 시스템일 수 도 있습니다.

 

사용자로부터 요청을 받게되면, 해당 요청이 인증된 사용자로부터의 요청이 맞는지, 로직을 수행하기 위한 필요한 값들이 모두 전달되었는지 등을 확인하고, 모두 정상이라고 판단하면 다음 계층인 응용 계층(Application Layer)에게 비지니스 로직을 수행하라고 명령합니다.

표현 계층의 역할

실제로 표현계층을 구현하다 보면, 더 많은 역할을 가질 수 도 있고, 일부 역할을 응용 계층에 위임할 수 도 있습니다.
위에 제시된 역할과 흐름은 필자의 개인적인 생각이 일부 반영된 내용이니 참고만 해주시면 됩니다.

 

2-1. 인증된 사용자 여부 검증

표현 계층에선 사용자의 요청을 가장 먼저 확인하게 됩니다. 이 때, 서비스에 무언가를 요청한 사용자가 정상적인 사용자인지, 즉 악의적인 목적으로 접근한 사용자는 아닌지에 대한 검증이 필요합니다. 

이런 역할을 표현 계층에서 담당함으로써, 요청한 사용자가 정상적인 사용자가 아닌 경우, 응용 계층 이하로 접근하지 못하도록 사전에 차단해줘야 합니다. 이러한 기능을 일반적으로 "필터(Filter)" 라고 부르며, Java 에선 Servlet 에서 제공하는 Filter interface를 통해 해당 기능을 구현할 수 있습니다.

 

 

2-2. 사용자의 요청을 응용 서비스가 원하는 형태로 변환

인증이 완료되어 실제 비지니스 로직을 수행하고자 하면, 가장 먼저 요청 파라미터를 확인해야 합니다.

파라미터에 필수 값이 없거나, 잘못된 값이 넘어오는 것을 확인해 문제가 있다고 판단하면 에러를 응답할 수 있어야 합니다.

코드를 예로 들면 아래와 같은 형태가 될 수 있습니다.

public class OrderController {

    @ExceptionHandler
    public ResponseEntity<ErrorResponse> badRequestException(BadRequestException e) {
        return ResponseEntity.badRequest().body(new ErrorResponse(e.getMessage()));
    }

    @PostMapping("/orders")
    public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
        validateNull(request.getItems());
        validateNull(request.getDeliveryInfo());
        
        // ... service 호출 실행
    }
    
    private void validateNull(Object target) {
        if(target == null) {
            throw new BadRequestException("요청 값이 누락되었습니다.");
        }
    }
}

 

요청 값에 문제가 없을 경우, 응용 계층에서 원하는 형태로 데이터를 변환하여 응용 계층 로직을 호출합니다.

public class OrderController {

    private OrderService orderService;

    @PostMapping("/orders")
    public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
        // ... 요청 파라미터 검증
        
        // 표현 계층의 요청 value 를 응용 계층의 데이터 형태로 변환
        OrderCreateDto createDto = new OrderCreateDto(request);
        // 서비스 로직 호출
        OrderDto orderDto = orderService.create(createDto);
        
        // ... 사용자에게 결과 반환 
    }
}

 

사실, 표현 계층에서 응용 계층에서 원하는 데이터 형태로 변환하지 않고, 바로 응용 계층 로직을 호출해도 실행에 문제는 없습니다.

public class OrderController {

    private OrderService orderService;

    @PostMapping("/orders")
    public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
        // ... 요청 파라미터 검증
        
        // 표현 계층의 요청 value 를 응용 계층의 데이터 형태로 변환하지 않고 바로 호출
        OrderDto orderDto = orderService.create(request);
        
        // ... 사용자에게 결과 반환 
    }
}

하지만, 위와 같은 구조는 프로그램 전체의 구조에서 볼 때 문제가 있습니다.

 

위 처럼 사용된다면, 응용 서비스의 create 메서드는 아래와 같이 정의되어야 할 것입니다.

public class OrderService {
    private OrderRepository orderRepository;
    
    public OrderDto create(OrderRequest request) {
        Order order = Order.create(request.getItems(), request.getDeliveryInfo(), ...생략);
        Order savedOrder = orderRepository.save(order);
        
        return new OrderDto(savedOrder);
    }
}

응용 서비스의 구현에서 위 형태는 고려해봐야할 문제가 존재합니다.

바로 create 메서드의 OrderRequest 파라미터를 사용한 점 입니다.

Java 에서 우린 class 를 만들기 위해 사전에 패키지(package)를 만듭니다. 앞서 다뤘던 Layered Architecture 를 적용하게 되면,

통상적으로 package 구조를 아래 처럼 구성하게 됩니다.(물론, 꼭 이렇게 해야되는 것은 아닙니다.)

주문 도메인의 표현계층과 응용 계층의 package 구조 예시

Layered Architecture 를 이야기할 때, "표현 계층은 응용 계층에 의존하고, 응용 계층은 도메인 계층에 의존한다." 라는 표현을 사용했습니다. 여기서 "의존한다." 라는 의미는 "수정이 미치는 영향의 유무 가능성" 을 의미하며, 위 문구를 풀어서 해석한다면 "표현 계층은 응용 계층이 수정될 때 같이 수정 될 가능성이 있고, 응용 계층은 도메인 계층이 수정될 때 같이 수정될 가능성 있다." 로 해석됩니다.

 

즉, "응용 계층에 속하는 OrderService 의 내용이 수정될 때, 표현 계층인 OrderController 도 영향을 받아 같이 수정이 필요할 수 도 있다." 라는 것이고 반대로 "표현 계층인 OrderController 가 수정되어도, 응용 계층인 OrderService 는 수정될 필요가 없어야 한다." 라는 의미입니다. 

 

하지만, OrderService 에서 메서드의 파라미터로 표현계층에 속하는 OrderRequest 를 의존하게 된다면, 표현 계층의 수정(OrderRequest의 수정) 이 응용 계층의 수정(OrderService의 수정)에 영향을 미칠 수 있으므로, 옳바른 구조가 아니라고 할 수 있습니다.

 

위와 같은 의존성에서 오는 문제를 예방하고자, 단방향 의존성(표현 계층은 응용 계층에 의존하면, 응용 계층은 표현 계층에 의존하지 않는다.)을 지향하기 위해, 응용 서비스에선 표현 계층에서 사용되는 request객체를 그대로 사용하는 것이 아니라, 응용 서비스 계층에 맞는 별도의 밸류 객체를 통해 로직을 수행할 수 있어야 합니다.

 

2-3. 응용 서비스의 결과를 사용자가 원하는 형태로 변환

응용 서비스에서 도메인 계층에 비지니스 로직을 위임하고, 도메인 계층에서 로직에 대한 결과가 반환됐다면, 응용 서비스에서는 자신을 호출한 표현 계층으로 결과를 반환할 것 입니다.

이 때, 응용 서비스에선 비지니스 로직의 결과를 데이터로 알려주기 때문에, 표현 계층에선 사용자에게 결과에 알맞는 메세지를 보여주도록 해야합니다.

 

예를 들어, 주문을 생성하는 흐름을 응용 서비스 관점에서 본다면 아래와 같을 것 입니다.

public class OrderService {
    private OrderRepository orderRepository;
    
    public OrderDto createOrder(OrderCreateDto createDto) {
        Order savedOrder = orderRepository.save(new Order(createDto.getItems(), ...생략));
        return new OrderDto(savedOrder);
    }
}

 

표현 계층에선 해당 반환을 받아, 사용자에게 주문이 잘 생성되었는지, 문제가 생겨서 생성에 실패했는지 적절한 메세지로 확인시켜 줘야 합니다.

public class OrderController {

    private OrderService orderService;

    @PostMapping("/orders")
    public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
        // ... 요청 파라미터 검증
        
        // 표현 계층의 요청 value 를 응용 계층의 데이터 형태로 변환하지 않고 바로 호출
        OrderDto orderDto = orderService.create(request);
        
        // ... 사용자에게 결과 반환 
        return ResponseEntity.ok().body(new OrderResponse("주문이 완료되었습니다.", orderDto));
    }
}

여기서도 마찬가지로 응용 서비스의 반환값을 그대로 사용하지 않고, 사용자 응답에 맞는 별도의 밸류 객체를 생성하여 반환합니다.

 

2-4. 세션 관리

웹 개발을 하다보면 빼놓을 수 없는 개념 중 하나가 바로 "세션" 입니다. 세션이란, "서버에서 관리하는 사용자의 인증 정보"를 의미인데,

쉽게 말해 "지금 사용자가 요청을 보낼 수 있는 상태가 맞아?" 에 대한 대답입니다. 흔히 웹 서비스를 운영하면 "세션 만료 시간" 이라는 개념을 심심치 않게 들을 수 있습니다. 사용자가 서비스를 이용하기 위해 로그인을 하게 되면, 서버에서 세션 이란 정보를 관리하게 되고, 이 세션 정보가 "유효한 경우" 사용자의 요청을 받아들여 로직을 실행하는 과정을 거칩니다.

 

예를 들어, 정상적으로 인증 받은 사용자가 서비스를 이용하다가 장시간 자리를 비우게 되는 경우, 타인이 와서 마음대로 서비스를 이용하면 문제가 발생할 수 있습니다. 실제 계정 주인은 아무것도 하지 않았는데, 해당 계정으로 무언가 작업이 되어있다면 어떤 의미에선 이는 해킹으로도 간주되기 때문이죠. 그래서 인증 받은 사용자가 일정 시간동안 아무런 요청을 서버에 보내지 않는 경우, 서버에선 이 인증 정보를 무효화 시켜서 위 같은 악용사례를 예방해야 합니다. 

 

이런 작업 또한 실제 비지니스 로직이 닿기 전에 표현 계층에서 해야할 역할입니다.

이 또한 앞에서 언급한 Filter 에서 처리하면 모든 Controller 에 공통으로 적용시킬 수 있습니다.

public class SessionFilter implements Filter{
    
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
            
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        HttpSession session = httpServletRequest.getSession();
        if(session == null || session.getAttribute("user") == null) {
            throw new IllegalStateException("세션이 만료되었습니다. 다시 로그인해주세요.");
        }
        
        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }
}

 

3. 요청 값 검증 역할에 대해

요청 값을 검증하는 과정은 크게 2가지가 존재하게 됩니다.

요청 파라미터 자체에 대한 유효성 검증 : null 인지, 특정 범위에 벗어난 값인지 등
논리적인 유효성 검증 : 로그인아이디 중복 여부, 인증번호 불일치 등

 

해당 검증을 어디서 처리할지에 대한 정답은 없습니다. 

어떤 개발자는 요청 파라미터 자체에 대한 검증은 표현 계층에서, 논리적인 유효성 검증은 응용 계층에서 처리할 수도 있는 것이고,

어떤 회사의 개발문화에선 두 검증 모두 응용 계층에서 처리하도록 가이드라인을 줄 수 도 있습니다.

 

각자의 장점을 생각해본다면....

 

요청 파라미터 자체에 대한 유효성 검증은 표현 계층에서, 논리적인 유효성 검증은 응용 계층에서 하게 되는 경우에는

각 계층에게 명확한 역할 분담을 줄 수 있고, 값 자체가 문제가 생기면 응용 계층으로 로직이 흘러가지 않는다는 점 이 장점일 수 있고, 

 

두 검증 모두 응용 계층에서 처리한다고 가정하면, 

파라미터의 검증을 한 곳에서 처리하니, 나중에 변경할 때도 한 곳만 수정하면 되고, 특정 경우의 검증 과정을 한 눈에 보기 쉽다는 장점이 있을 수 있습니다.

 

(단점은 각 장점이 반대로 작용한다고 생각하면 될 것 같습니다.)

 

무언가를 선택하면 그에 따른 장단점이 있는 만큼, 현재 상황을 잘 파악하고 최선의 방식을 채택하는 것이 중요하다고 생각합니다.

댓글