[DDD] Chap 06 응용 영역과 표현 영역

2021. 9. 21. 14:33DDD

이번 장에선 사용자와 도메인을 연결해주는 매개체인 표현 영역과 응용 영역을 다뤄보겠습니다.

 

표현 영역

HTTP 요청 해석 같은 사용자와 긴밀히 상호작용하는 영역입니다.

표현 영역의 주요 기능은 다음과 같습니다.

  1. URL, 파라미터, 쿠키, 헤더 정보로 사용자가 어떤 기능을 실행하고 싶어하는지 판별 후 응용 서비스를 실행
  2. 응용 서비스가 요구하는 형식에 맞게 데이터를 파싱 또는 사용자에 알맞은 형식(HTML, JSON, XML 등)으로 응답

 

응용 영역(응용 서비스)

실제 기능을 제공하고 실행 결과를 표현 영역에 전달하는 영역입니다.

응용 영역의 주요 기능은 다음과 같습니다.

  1. 표현 영역과 도메인 영역을 연결해주는 Facade 역할(흐름 제어)
  2. 트랜잭션으로 도메인 상태 변경 관리(일관성 유지)
  3. 접근 제어와 이벤트 처리

응용 영역은 DIP에 의해 표현 영역에 의존하지 않으므로, 사용자가 무슨 웹 브라우저를 쓰는지나 REST API를 호출하는지, TCP 소켓을 쓰는지 등 표현 영역의 값들을 알 필요가 없습니다.

또한 도메인 영역의 역할을 침해하지 않도록 응용 영역에 도메인 로직을 구현하지 않도록 주의해야 합니다.(의존성 ↓, 응집도 ↑)

 

Facade Pattern은 여러 서브 클래스들을 하나의 Facade로 묶어서 실행하는 방식을 말합니다.

Facade는 '건물 외관'을 의미하며, 여러 서브 클래스들의 인스턴스 생성과 메서드 흐름을 하나의 메서드로 감싸서 코드의 가독성과 유연성을 높여줍니다.

응용 서비스는 이런 퍼사드 규칙을 따르기 때문에 '복잡한 로직을 수행하지 않는다'라는 설계 원칙을 갖습니다.

 

응용 영역은 DB 반영 도중에 예상치 못한 에러로 인해 일관성이 깨지는 것을 방지하기 위해 트랜잭션을 관리합니다.

프레임워크가 제공하는 트랜잭션 기능을 적극 사용하는 것이 좋습니다.

@Transactional 어노테이션을 사용하면 Exception 발생시 롤백하고 정상 처리시 commit합니다.


응용 서비스의 크기

응용 서비스를 구현시 아래의 두 가지 방법을 고려해볼 수 있습니다.

  1. 한 응용 서비스 클래스에 한 도메인의 모든 기능 구현(도메인별)
  2. 구분되는 기능별로 응용 서비스 클래스를 따로 구현(기능별)

결론부터 말하자면, 2번 방식을 사용해야 합니다.

 

도메인별로 서비스 클래스를 구성할 경우 한 클래스안에 한 도메인에 관련된 모든 기능들이 위치하므로, 동일한 로직에 대한 코드 중복이 제거되는 장점이 있습니다.

다만, 한 서비스 클래스의 코드 줄 수가 너무 커지고 한 클래스에 너무 많은 책임이 부여될 수 있습니다.

또한 연관성 적은 코드가 한 클래스에 위치해서 가독성과 코드 품질을 떨어트립니다.

public class MemberService {
    private MemberRepository memberRepository;
    private Notifier notifier;
    
    public void changePassword(String memberId, String currentPw, String newPw) {
        Member member = findExistingMember(memberId);
        member.changePassword(currentPw, newPw);
    }
    
    public void initializePassword(String memberId) {
        Member member = findExistingMember(memberId);
        String newPassword = member.initializePassword();
        notifier.notifyNewPassword(member, newPassword);
    }
    
    public void leave(String memberId, String curPw) {
        Member member = findExistingMember(memberId);
        member.leave();
    }
    
    // 중복된 코드는 공통 메서드로 중복 제거
    private Member findExistingMember(String memberId) {
        Member member = memberRepository.findById(memberId);
        if(member == null){
        	throw new NoMemberException(memberId);
        }
        return member;
    }
}

 

기능별로 서비스 클래스를 구성할 경우 한 클래스안에 최소한의 객체만 포함하므로 다른 기능을 구현한 서비스에 영향을 주지 않습니다.

한 응용 서비스 클래스에선 2~3개의 기능만을 구현하도록 해야합니다.

클래스 개수가 많아지지만 코드 품질을 일정한 수준으로 유지할 수 있습니다.

중복되는 코드는 아래처럼 static 메서드를 이용해서 코드의 중복을 제거합니다.

public final class MemberSeriveHelper {
    // 중복된 코드는 static 메서드로 중복 제거
    public static Member findExistingMember(MemberRepository repo, String memberId) {
        Member member = memberRepository.findById(memberId);
        if(member == null){
        	throw new NoMemberException(memberId);
        }
        return member;
    }
}
import static com.myshop.member.application.MemberServiceHelper.*;

public class ChangePasswordService {
    private MemberRepository memberRepository;
    
    public void changePassword(String memberId, String currentPw, String newPw) {
        Member member = findExistingMember(memberRepositry, memberId);
        member.changePassword(currentPw, newPw);
    }
}

메서드 파라미터와 값 리턴

Spring MVC Framework는 웹 요청 파라미터를 자바 객체로 변환해주는 기능을 제공합니다.

따라서 응용서비스에 전달할 파라미터가 두 개 이상이라면 데이터 전달을 위한 별도의 DTO 클래스를 정의하는 것이 좋습니다.

@Controller
@RequestMapping("/member/changePassword")
public class MemberPasswordController{
    @Autowired
    private ChangePasswordService changePasswordService;
    
    @RequestMapping(method = RequestMethod.POST)
    public String submit(ChangePasswordRequest changePwdReq) {  //별도의 클래스로 입력받기
        Authentication auth = securityContext.getAuthentication();
        changePwdReq.setMemberId(auth.getId());
        try {
            changePasswordService.changePassword(changePwdReq);
        } catch(NoMemberException e) {
            //Exception 처리
        }
    }
}
public class ChangePasswordRequest {  //별도 클래스 정의
    private String memberId;
    private String curPw;
    private String newPw;
    
    //생성자 & getter
}

 

아래의 코드처럼 응용 서비스에서 필요한 값만 리턴하는게 아니라 애그리거트 자체를 리턴하면, 표현 영역에도 도메인 상태를 변경할 수 있는 여지를 주고 이는 곧 코드의 분산과 코드의 응집도를 낮추는 결과를 낳기 때문에 지양하는 것이 좋습니다.

@Controller
public class OrderController {
    
    @RequestMapping(value="/order/place", method=RequestMethod.POST)
    public String order(OrderRequest orderReq, ModelMap model) {
        setOrderer(orderReq);
        Order order = orderService.placeOrder(orderReq);
        modelMap.setAttribute("order", order);
        return "order/success";
    }
}


//View 코드
<a href="/orders/my/${order.number}">주문 내용 보기</a>

 

표현 영역에 의존하지 않기

응용 서비스에서 HttpServletRequest나 HttpSession 같은 표현 영역에 관련된 타입을 사용해선 안됩니다.

'A가 B에 의존한다'는 것은 A 코드 내부에서 파라미터나 리턴 타입으로 B 타입 객체를 사용하는 상황을 말합니다.

응용 영역이 표현 영역에 의존한다면 아래와 같은 문제들이 발생합니다.

  1. 응용 서비스만 단독으로 테스트하기 어려워짐
  2. 표현 영역 변경시 응용 서비스도 함께 변경해야함
  3. 응용 서비스가 표현 영역의 역할을 침범하게 됨

결국 의존성을 분리하지 않으면, 코드 유지보수 비용이 증가하게 됩니다.


이벤트 주도 아키텍처(Event Driven Architecture)

이벤트 주도 아키텍처는 프로그램이 어떤 유저 액션(이벤트)에 대한 반응으로 동작하는 패턴을 말합니다.

이벤트 주도 아키텍처를 따르면, 다수의 사용자를 수용하는 대규모 프로젝트에서 사용자의 요청을 비동기적으로 처리할 수 있습니다.

자세한 내용은 10장에서 다루도록 하겠습니다.

 

도메인 이벤트 처리

도메인 영역에서 발생한 이벤트를 처리하는 것도 응용 서비스의 역할 중 하나입니다.

회원가입 성공시 환영 메일이나 환영 문자, 회원가입 포인트 지급 같은 기능들을 동기적으로 처리한다고 가정한다면,

만 명 이상의 유저가 동시에 회원가입할 시 시스템이 뻗어버리고말 것입니다.

따라서 회원가입이라는 핵심 도메인 로직과 별게로 당장 급하지 않은 부가적인 기능들은 이벤트를 이용해서 비동기로 처리하고 응용 서비스에서 해당 이벤트에 맞는 후처리를 합니다.


값 검증

값 검증은 개발자가 의도한대로 사용자가 입력값을 잘 작성했는지 확인하는 작업을 말합니다.

예를 들면 데이터 미입력, 유효하지 않은 형식, id 중복, 데이터 미존재 등의 작업들이 있습니다.

원칙적으로 값 검증은 서비스 영역에서 처리합니다.

@Controller
public class Controller {
    @RequestMapping
    public String join(JoinRequest joinRequest, Errors errors) {
        try{
            joinService.join(joinRequest);
            return successView;
        } catch(EmptyPropertyException e) {
            errors.rejectValue(e.getPropertyName(), "empty");
            return formView;
        } catch(InvalidPropertyException e) {
            errors.rejectValue(e.getPropertyName(), "invalid");
            return formView;
        } cathch(DuplicateIdException e) {
            errors.rejectValue(e.getPropertyName(), "duplicate");
            return formView;
        }
    }
}

 

서비스 영역에서 값 검증을 전적으로 위임하고 표현 영역에서 Exception 처리할 때 문제점은 사용자에게 좋지 않은 경험을 제공한다는 것입니다.

만약 폼 데이터중 첫 번째 값만 올바르지 않다면 유저는 첫 번째 값에 대한 에러 메시지만 보게 되고 나머지 항목에 대해서는 값이 올바른지 여부를 알 수 없게 됩니다. 따라서 사용자가 같은 폼을 여러 번 작성해야하는 번거로움이 발생합니다.

따라서 추천하는 방법은 표현 영역에선 필수 값, 형식, 범위 등을 검증하고 응용 영역에선 데이터 존재 유무나 id 중복 같은 논리적 오류만 검증하는 것입니다.

@Controller
public class Controller {

    @RequestMapping
	public String join(JoinRequest joinRequest, Errors errors) {
        //값 형식 검증
        checkEmpty(joinRequest.getId(), "id", errors);
        checkEmpty(joinRequest.getName(), "name", errors);
        
		if (errors.hasErrors()) {
            return formView;
        }
        
        try {
            joinService.join(joinRequest);
            return successView;
        } catch(DuplicateIdException ex) {  //논리적 오류값 검증
            errors.rejectValue(ex.getPropertyName(), "duplicate");
            return formView;
        }
    }
    
    private void checkEmpty(String value, String property, Errors errors) {
        if(isEmpty(value)) {
            errors.rejectValue(property, "empty");
        }
    }
}

 

Spring Validator를 사용하면 위 코드를 더 간결하게 보완할 수 있습니다.

@Controller
public class Controller {

    @RequestMapping
	public String join(JoinRequest joinRequest, Errors errors) {
        new JoinRequestValidator().validate(joinRequest, errors);
        
		if (errors.hasErrors()) {
            return formView;
        }
        
        try {
            joinService.join(joinRequest);
            return successView;
        } catch(DuplicateIdException ex) {  //논리적 오류값 검증
            errors.rejectValue(ex.getPropertyName(), "duplicate");
            return formView;
        }
    }
}

 

권한 검사

권한 검사는 현재 유저가 보유한 권한에 따라 실행할 수 있는 기능이 다른 경우 처리합니다.

비즈니스 특성에 따라 어디서든 권한 검사 처리가 가능합니다.

가능한 위치는 다음과 같습니다.

  1. 표현 영역
  2. 응용 서비스
  3. 도메인 영역

 

1. 표현 영역

표현 영역에서 권한 검사하는 경우는 보통 사용자 인증입니다.

인증된 사용자만 접근 가능하도록하고 이런 접근 제어를 하기 좋은 위치가 서블릿 필터입니다.

스프링 시큐리티는 필터를 이용해서 URL별로 인증 정보를 생성하고 웹 접근을 제어합니다.

 

2. 응용 서비스

URL만으로 접근 제어를 할 수 없는 경우 응용 메서드별로 권한 검사를 수행해야합니다.

스프링 시큐리티의 AOP를 이용하여 다음과 같이 어노테이션으로 서비스단의 메서드에 대한 권한 검사를 할 수 있습니다.

public class BlockMemberService {
    private MemberRepository memberRepository;
    
    @PreAuthorize("hasRole('ADMIN')")
    public void block(String memberId) {
        Member member = memberRepository.findById(memberId);
        if (member == null) throw new NoMemberException();
        member.block();
    }
}

 

3. 도메인 영역

도메인 단위로 권한 검사를 해야하는 경우, 다소 구현이 복잡해집니다.

public class DeleteArticleService {
    public void delete(String userId, Long articleId) {
        Article article = articleRepository.findById(articleId);
        checkArticleExistence(article);
        permissionService.checkDeletePermission(userId, article);
        article.markDeleted();
    }
}

스프링 시큐리티와 같은 보안 프레임워크를 확장하여 객체 수준의 권한 검사 기능을 통합할 수 있지만, 이는 프레임워크에 대한 깊은 이해도를 요구하므로 위처럼 직접 구현하는 것이 코드 유지보수에 유리할 수 있습니다.


정리

- 핵심은 계층간 책임 분리로 의존성을 낮추고 유지보수성을 높이는 것

- 표현 영역의 주요 기능: 사용자의 요청을 알맞은 형식으로 파싱하여 응용 영역에 전달 및 사용자에게 알맞은 값을 리턴

- 응용 영역의 주요 기능: 

  1. 흐름 제어
  2. 트랜잭션 처리
  3. 이벤트 처리

- 값 검증

  • 표현 영역: 필수 값, 형식, 범위 등을 검증
  • 응용 영역: 논리적 오류 검증

- 권한 검사

  • 표현 영역: 사용자 인증
  • 응용 영역: 기능별 권한 검사
  • 도메인 영역: 도메인별 권한 검사

 

 

Ref.

- 'DDD START! 도메인 주도 설계 구현과 핵심 개념 익히기' - 최범균

https://ko.wikipedia.org/wiki/%ED%8D%BC%EC%82%AC%EB%93%9C_%ED%8C%A8%ED%84%B4

https://velog.io/@limprove89/%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EA%B8%B0%EB%B0%98-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%EC%93%B0%EB%A0%88%EB%93%9C

 

'DDD' 카테고리의 다른 글

[DDD] Chap 04 리포지터리와 JPA중심 모델 구현  (0) 2021.08.21
[DDD] Chap 03 Aggregate  (0) 2021.08.15
[DDD] Chap 02 아키텍처 개요  (0) 2021.08.14
[DDD] Chap 01 도메인 모델 시작  (0) 2021.08.14