6장 응용 서비스와 표현 영역

최범균의 DDD START! 6장을 요약한 내용입니다.

표현 영역과 응용 영역

도메인이 제 기능을 하려면 사용자와 도메인을 연결해 주는 매개체가 필요하다.

그림 6.1 사용자에게 기능을 제공하려면 도메인과 사용자를 연결해 줄 표현 영역과 응용 영역이 필요하다

응용 서비스의 메서드가 요구하는 파라미터와 표현 영역이 사용자로부터 전달받은 데이터는 형식이 일치하지 않기 때문에 표현 영역은 응용 서비스가 요구하는 형식으로 사용자 요청을 변환한다. 응용 서비스를 실행한 뒤 표현 영역은 실행 결과를 사용자에 알맞은 형식으로 응답한다.

사용자와의 상호작용은 표현 영역이 처리하기 때문에 응용 서비스는 표현 영역에 의존하지 않는다. 응용 영역은 사용자가 웹 브라우저를 사용하는지, REST API를 호출하는지, TCP 소켓을 사용하는지 여부를 알 필요가 없다. 단지, 응용 영역은 기능 실행에 필요한 입력밧을 전달받고 실행 결과만 리턴하면 될 뿐이다.

응용 서비스의 역할

응용 서비스의 주요 역할은 도메인 객체를 사용해서 사용자의 요청을 처리하는 것이므로 표현(사용자)영역 입장에서 보았을 때 응용 서비스는 도메인 영역과 표현 영역을 연결해 주는 창구인 파사드(fasade) 역할을 한다.

도메인 객체 간의 실행 흐름을 제어하는 것과 더불어 응용 서비스의 주된 역할 중 하나는 트랜잭션 처리이다. 응용 서비스는 도메인의 상태 변경을 트랜잭션으로 처리해야 한다.

도메인 로직 넣지 않기

도메인 로직을 도메인 영역과 응용 서비스에 분산해서 구현하면 코드 품질에 문제가 발생한다.

  • 코드의 응집성이 떻어진다는 것이다.

  • 응용 서비스에서 동일한 도메인 로직을 구현할 가능성이 높아진다.

위 두 가지 문제점은 결과적으로 코드 변경을 어렵게 만든다. 변경의 용이성이 낮은 소프트웨어는 그 만큼 가치가 하락할 수 밖에 없다. 소프트웨어의 가치를 높이려면 도메인 로직을 도메인 영역에 모아서 코드 중복이 발생하지 않도록 하고 응집도를 높여야 한다.

응용 서비스의 구현

응용 서비스의 크기

응용 서비스는 보통 다음의 두 가지 방법 중 한 가지 방식으로 구현한다.

  • 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기

    • 중복 로직이 있을경우 private method를 사용하여 중복 로직을 제거할 수 있는 장점이 있다.

    • 코드 크기가 커진다는 것은 연관성이 적은 코드가 한 클래스에 함께 위치할 가능성이 높아짐을 의미하는데, 이는 결과적으로 관련 없는 코드가 뒤섞여서 코드를 이해하는 데 방해가 될 수 있다.

  • 구분되는 기능별로 응용 서비스 클래스를 따로 구현하기 (필자는 이 방법을 더 선호함)

    • 클래스 개수는 많아지지만 이전과 비교해서 코드 품질을 일정 수준으로 유지하는 데 도움이 된다.

    • 클래스의 기능이 분산되어 중복해서 동일한 코드를 구현할 가능성이 있다.

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

응용 서비스를 구현할 때 논쟁이 될 만한 것이 인터페이스가 필요한지 여부이다. 인터페이스가 필요한 상황은 어느 경우 일까?

  • 구현 클래스가 다수 존재하거나 런타임에 구현 객체를 교체해야 할 경우이다.

  • 표현 영역에서 단위 테스트를 위해 응용 서비스 클래스의 가짜 객체가 필요할 경우 (Mockito를 사용할 경우엔 필요없음)

보통 런타임에 이를 교체하는 경우가 거의 없을 뿐만 아니라 한 응용 서비스의 구현 클래스가 두 개인 경우도 매우 드물다. 이런 이유로 인터페이스와 클래스를 따로 구현하면 소스 파일만 많아지고 전체 구조만 복잡해지는 문제가 발생한다. 따라서, 인터페이스가 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스를 작성하는 것이 좋은 설계라고는 볼 수 없다.

표현 영역에 의존하지 않기

응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 영역과 관련된 타입을 사용하면 안 된다는 점이다.

  • 응용 서비스에서 표현 영역에 대한 의존이 발생하면 응용 서비스만 단독으로 테스트하기가 어려워진다.

  • 게다가 표현 영역의 구현이 변경되면 응용 서비스의 구현도 함께 변경해야 하는 문제도 발생한다.

  • 더 나쁜 문제는 응용 서비스가 표현 영역의 역할까지 대신하는 상황이 벌어질 수도 있다는 것이다.

트랜잭션 처리

도메인 이벤트 처리

도메인 영역은 상태가 변경되면 이를 외부에 알리기 위해 이벤트를 발생시킬 수 있다. 예를 들어, 암호 초기화 기능은 다음과 같이 암호 변경 후에 '암호 변경된' 이벤트를 발생시킬 수 있다.

public class Member {
	private Password password;
	public void initializePassword() {
		String newPassword = generateRandomPassword();
		this.password = new Password(newPassword);
		Events.raise(new PasswordChangedEvent(this.id, password);
	}
}

도메인에서 이벤트를 발생시키면 그 이벤트를 받아서 처리할 코드가 필요한데, 그 역할을 하는 것이 바로 응용 서비스이다. 응용 서비스는 이벤트를 받아서 이벤트에 알맞은 후처리를 할 수 있다. 이벤트를 사용하면 코드가 다소 복잡해지는 대신 도메인 간의 의존성이나 외부 시스템에 대한 의존을 낮춰주는ㄴ 장점을 얻을 수 있다. 또한 시스템을 확장하는 데에 이벤트가 핵심 역할을 수행하게 된다.

표현 영역

표현 영역의 책임은 크게 다음과 같다.

  • 사용자가 시스템을 사용할 수 있는 (화면)흐름을 제공하고 제어한다.

  • 사용자의 요청을 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공한다.

  • 사용자의 세션을 관리한다.

값 검증

값 검증은 표현 영역과 응용 서비스 두 곳에서 모두 수행할 수 있다. 원칙적으로 모든 값에 대한 검증은 응용 서비스에서 처리한다. 응용 서비스에서 각 값이 존재하는지 형식이 올바른지 확인할 목적으로 익셉션을 사용할 때의 문제점은 사용자에게 좋지 않은 경험을 제공한다는 것이다. (순차적으로 필드를 검사하기 때문에 사용자 입력 값에 대한 전반적인 형식 오류 결과를 내어줄 수 없다) 이를 해결하기 위해 표현 영역에서 값을 검사하면 된다.

@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;

		...
	}

	private void checkEmpty(String value, String property, Errors errors) {
		if(isEmpty(value)) erros.rejectValue(property, "empty");
}

따라서 응용 서비스를 사용하는 표현 영역 코드가 한 곳이면 구현의 편리함을 위해 다음과 같이 역할을 나누어 검증을 수행할 수도 있다.

  • 표현 영역: 필수 값, 값의 형식, 범위 등을 검증한다

  • 응용 서비스: 데이터의 존재 유무와 같은 논리적 오류를 검증한다.

엄격하게 두 영역에서 모두 값 검사를 하고 싶다면 동일한 검증기를 사용해서 중복 코드를 줄일 수도 있다.

권한 검사

개발할 시스템마다 권한의 복잡도가 달라진다. 보안 프레임워크에 대한 이해가 부족하면 프레임워크를 무턱대고 도입하는 것보다 개발할 시스템에 맞는 권한 검사 기능을 구현하는 것이 시스템 유지보수에 유리할 수 있다.

권한 검사는 다음의 영역에서 수행할 수 있다.

  • 표현 영역

    • 인증된 사용자 여부 검사

    • 접근 제어를 하기에 좋은 위치가 서블릿 필터이다.

  • 응용 서비스 영역

    • 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();
      		...
      	}
      }
  • 도메인 영역

    • 도메인 단위로 권한 검사를 해야 하는 경우는 다소 구현이 복잡해진다. 예를 들어, 게시글 삭제는 본인 또는 관리자 역할을 가진 사용자만 할 수 있다고 해보자. 이 경우 게시글 작성자가 본인인지 확인하려면 게시글 애그리거트를 먼저 로딩해야한다. 즉, 응용 서비스의 메서드 수준에서 권한 검사를 할 수 없기 때문에 다음과 같이 직접 권한 검사 로직을 구현해야 한다.

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

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

5장에서는 조회 화면을 위해 별도로 조회 전용 모델과 DAO를 만드는 내용을 다루었는데 서비스에서 이들 조회 전용 기능을 사용하게 되면 서비스 코드가 다음과 같이 단순히 조회 전용 기능을 호출하는 것으로 끝나는 경우가 많다.

public class OrderListService {
	public List<OrderView> getOrderList(String ordererId) {
		return orderViewDao.selectByOrderer(ordererId);
	}
}

서비스에서 수행하는 추가적인 로직이 없고 조회 전용이라 트랜잭션이 필요하지 않을 경우에는 표현 영역에서 바로 조회 전용 기능을 사용해도 된다.

public class OrderController {
	private OrderViewDao orderViewDao;

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

Last updated