06.응용 서비스와 표현 영역

도메인 주도 개발 시작하기 6장을 요약한 내용입니다.

표현 영역과 응용 영역

도메인 영역을 잘 구현하지 않으면 사용자의 요구를 충족하는 제대로 된 소프트웨어를 만들지 못한다.

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

응용 영역표현 영역이 사용자와 도메인을 연결해 주는 매개체 역할을 한다.

사용자 -> 표현 영역 -> 응용 영역 -> 도메인 영역

표현 영역사용자의 요청을 해석한다.

  • 사용자가 HTTP 요청을 표현 영역에 전달.

  • 요청을 받은 표현 영역은 URL, 요청 파라미터, 쿠키, 헤더 등을 이용해서 사용자가 실행하고 싶은 기능을 판별하고 그 기능을 제공하는 응용 서비스 실행

응용 영역에 위치한 서비스는 실제 사용자가 원하는 기능을 제공한다.

  • 응용 서비스는 기능을 실행하는 데 필요한 입력 값을 메서드 인자로 받고 실행 결과를 리턴

  • 응용 서비스의 메서드가 요구하는 파라미터와 표현 영역이 사용자로부터 전달받은 데이터는 형식이 일치하지 않으므로 표현 영역은 응용 서비스가 요구하는 형식으로 사용자 요청을 변환

  • 응용 서비스를 실행한 뒤 표현 영역은 실행 결과를 사용자에게 알맞은 형식(HTML, JSON)으로 응답

  • 사용자와 상호작용은 표현 영역이 처리하므로 응용 서비스는 표현 영역에 의존하지 않고, 단지 기능 실행에 필요한 입력 값을 받고 실행 결과만 리턴하면 된다.

응용 서비스의 역할

응용 서비스는 사용자(클라이언트)가 요청한 기능을 실행

  • 사용자의 요청을 처리하기 위해 리포지터리에서 도메인 객체를 가져와 사용

  • 도메인 객체를 사용해서 요청을 처리하므로 도메인 영역과 표현 영역을 연결해 주는 창구 역할

  • 주로 도메인 객체 간의 흐름을 제어

도메인 객체 간의 흐름을 제어하는 단순한 형태의 응용 서비스

public Result doSomeFunc(SomeReq req) {
    // 1. 리포지터리에서 애그리거트 조회
    SomeAgg agg = someAggRepository.findById(req.getId());
    checkNull(agg);

    // 2. 애그리거트의 도메인 기능을 실행
    agg.doFunc(req.getValue());

    // 3. 결과를 리턴
    return createSuccessResult(agg);
}

새로운 애그리거트를 생성하는 응용 서비스

public Result doSomeCreation(CreateSomeReq req) {
    // 1, 데이터 중복 등 데이터가 유효한지 검사
    validate(req);

    // 2. 애그리거트 생성
    SomeAgg newAgg = createSome(req);

    // 3. 리포지터리에 애그리거트 저장
    someAggRepository.save(newAgg);

    // 4. 결과를 리턴
    return createSuccessResult(newAgg);
}

응용 서비스가 복잡하다면 응용 서비스에서 도메인 로직의 일부를 구현하고 있을 가능성이 높다.

  • 코드 중복, 로직 분산 등 코드 품질에 안 좋은 영향을 줄 수 있다.

응용 서비스는 트랜잭션 처리도 담당한다.

  • 도메인 상태 변경을 트랜잭션으로 처리

  • 트랜잭션 범위에서 응용 서비스를 실행해서 데이터 일관성이 깨지는 이슈를 방지

그 외에도 응용 서비스는 접근 제어와 이벤트 처리도 담당

도메인 로직 넣지 않기

도메인 로직은 도메인 영역에 위치하고 응용 서비스는 도메인 로직을 구현하지 않아야 한다.

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

  • 첫 번째 문제: 코드의 응집성이 떨어진다.

    • 도메인 로직 파악을 위해 여러 영역을 분산해야 한다.

  • 두 번째 문제: 여러 응용 서비스에서 동일한 도메인 로직을 구현할 가능성이 높아진다.

    • 도메인 객체에 기능을 구현하고 응용 서비스에서 도메인이 제공하는 기능을 사용하면 코드 중복 문제는 방생하지 않는다.

변경이 용이한 소프트웨어의 가치를 높이려면 도메인 로직을 도메인 영역에 모아서 코드 중복을 줄이고 응집도를 높여야 한다.

응용 서비스의 구현

응용 서비스는 디자인 패턴으로 보면 파사드(facade)와 같은 역할을 한다.

응용 서비스의 크기

응용 서비스는 보통 다음의 두 가지 방법 중 한 가지 방식으로 구현한다. 한 응용 서비스 클래스에 도메인의 모든 기능 구현하기

  • 장점) 한 도메인과 관련된 기능을 구현한 코드가 한 클래스에 위치하므로 각 기능에서 동일 로직에 대한 코드 중복을 제거할 수 있다.

  • 단점) 한 서비스 클래스의 크기(코드 줄 수)가 커지고, 연관성이 적은 코드가 한 클래스에 함께 위치할 가능성이 높아진다.

    • 코드를 점점 얽히게 만들어 코드 품질을 낮추는 결과를 초래한다.

      public class MemberService {
          private MemberRepository memberRepository;
      
          public void join(MemberJoinRequest joinRequest) {}
          public void changePassword(String memberId, String curPw, String newPw) {}
          public void initializePassword(String memberId) {}
          ...
      }

구분되는 기능별로 응용 서비스 클래스를 따로 구현하기

  • 한 응용 서비스 클래스에서 한 개 내지 2-3개의 기능을 구현한다. 클래스 개수는 많아지지만 코드 품질을 일정 수준으로 유지하는 데 도움이 된다.

  • 각 클래스별로 필요한 의존 객체만 포함하므로 다른 기능을 구현한 코드에 영향을 받지 않는다.

  • 각 기능마다 동일한 로직을 구현할 경우 별도 클래스에 로직을 구현해서 코드가 중복되는 것을 방지할 수 있다.

    public class ChangePasswordService {
        private MemberRepository memberRepository;
    
        public void changePassword(String memberId, String curPw, String newPw) {
            Member member = findExistingMember(memberRepository, memberId);
            member.changePassword(curPw, newPw);
            ...
        }
    }
    
    public final class MemberServiceHelper {
        public static Member findExistingMember(MemberRepository repo, String memberId) {
            Member member = memberRepository.findById(memberId);
            if (member == null) throw new NoMemberException(memberId);
            return member;
        }
    }

한 도메인과 관련된 기능을 하나의 응용 서비스 클래스에서 모두 구현하는 방식보다 구분되는 기능을 별도의 서비스 클래스로 구현하는 방식을 사용하자.

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

응용 서비스를 구현할 때 논쟁이 될 만한 것이 "인터페이스가 필요한가?" 이다. 인터페이스가 필요한 몇 가지 상황이 있는데, 그 중 하나는 구현 클래스가 여러 개인 경우.

인터페이스와 클래스를 따로 구현하면 소스 파일이 많아지고, 구현 클래스에 대한 간접 참조가 증가해서 전체 구조가 복잡해 진다. 인터페이스가 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스를 작성하는 것이 좋은 선택이라고 볼 수는 없다.

TDD를 위해 인터페이스가 필요할 수 있지만 Mockito와 같은 테스트 도구는 클래스에 대해서도 테스트용 대역 객체를 만들 수 있으므로 응용 서비스에 대한 인터페이스가 없어도 표현 영역 테스트가 가능하다.

메서드 파라미터와 값 리턴

응용 서비스는 파라미터로 전달받은 데이터를 사용해서 필요한 기능을 구현하면 된다.

Spring MVC와 같은 웹 프레임워크는 웹 요청 파라미터를 자바 객체로 변환하는 기능을 제공하므로 응용 서비스에 데이터로 전달할 요청 파라미터가 두 개 이상 존재하면 데이터 전달을 위한 별도 클래스를 사용하는 것이 편리하다.

  • 클래스를 이용해서 데이터를 전달하면 프레임워크가 제공하는 기능을 활용하기에 좋다.

응용 서비스는 표현 영역에서 필요한 데이터만 리턴하여 기능 실행 로직의 응집도를 높이자.

표현 영역에서도 애그리거트의 상태를 변경할 수 있게 되면 응집도를 낮추는 원인이 된다.

표현 영역에 의존하지 않기

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

  • HttpServletRequestHttpSession을 응용 서비스에 파라미터로 전달하면 안 된다.

  • 응용 서비스에서 표현 영역에 대한 의존이 발생

  • 응용 서비스가 표현 영역의 역할까지 대신하여 표현 영역의 쿠키 수정과 같은 상태 변경 추척의 어려움

응용 서비스 메서드의 파라미터와 리턴 타입으로 표현 영역의 구현 기술을 사용하지 말자.

트랜잭션 처리

트랜잭션을 관리하는 것은 응용 서비스의 중요한 역할이다.

스프링과 같은 프레임워크가 제공하는 트랜잭션 관리 기능을 저극 사용해 보자.

  • 프레임워크가 제공하는 규칙을 따르면 간단한 설정만으로 트랜잭션을 시작하여 커밋하고 익셉션이 발생하면 롤백할 수 있다.

  • 스프링은 @Transactional 적용 메서드가 RuntimeException을 발생시키면 트랜잭션을 롤백하고 그렇지 않으면 커밋한다.

public class ChangePasswordService {

    @Transactional
    public void changePAssword(ChangePasswordRequest req) {
        Member member = findExistingMember(req.getMemberId());
        member.changePassword(req.getCurrentPAssword(), req.getNewPassword());
    }
}

표현 영역

표현 영역의 책임은 크게 아래와 같다.

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

    • 사용자가 요청한 내용을 응용 서비스를 이용해서 처리하고, 그 결과를 링크나 데이터 입력에 필요한 폼과 같은 응답으로 제공

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

    • 사용자의 요청 데이터를 응용 서비스가 요구하는 형식으로 변환하고 응용 서비스의 결과를 사용자에게 응답할 수 있는 형식으로 변환

  • 사용자의 연결 상태인 세션을 관리

// 프레임워크가 제공하는 기능을 사용해서 HTTP 요청을 응용 서비스의 입력으로 쉽게 변경 처리
@PostMapping
public String changePAssword(ChangePasswordRequest chPwReq, Errors errors) {
    // 표현 영역은 사용자 요청을 응용 서비스가 요구하는 형식으로 변환
    String memberId = SecurityContext.getAuthentication().getId();
    chPwReq.setMemberId(memberId);

    try {
        // 응용 서비스 실행
        changePasswordService.changePassword(chPwReq);
        return successView;
    } catch(BadPasswordException | NoMemberException ex) {
        // 응용 서비스의 처리 결과를 알맞은 응답으로 변환
        error.reject("idPassworedNotMatch");
        return formView;
    }
}

값 검증

값 검증은 표현 영역과 응용 서비스 두 곳에서 모두 수행 가능

  • 원칙적으로 모든 값에 대한 검증은 응용 서비스에서 처리

표현 영역응용 서비스가 값 검사를 나눠서 수행하자.

  • 표현 영역에서 필수 값과 값의 형식을 검사하면 실질적으로 응용 서비스는 ID 중복 여부와 같은 논리적 오류만 검사하면 된다.

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

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

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

하지만, 가능하면 응용 서비스에서 필수 값 검증과 논리적인 검증을 모두 해보자.

  • 응용 서비스에서 필요한 값 검증을 모두 처리하면 프레임워크가 제공하는 검증 기능을 사용할 때보다 작성할 코드가 늘어나지만 응용 서비스의 완성도가 높아지는 이점이 있다.

응용 서비스에서 에러 코드를 모아 하나의 익셉션으로 발생시키는 방법

권한 검사

스프링 시큐리티 같은 프레임워크는 유연하고 확장 가능한 구조를 가지고 있다.

표현 영역, 응용 서비스, 도메인에서 권한 검사를 수행할 수있다.

표현 영역: 기본적으로 인증된 사용자인지 검사(ex. 회원 정보 변경 기능 요청자 인증 여부)

  • URL을 처리하는 컨트롤러에 웹 요청을 전달하기 전에 인증 여부를 검사해서 인증된 사용자의 웹 요청만 컨트롤러에 전달

  • 인증된 사용자가 아닐 경우 로그인 화면으로 리다이렉트

  • 이런 접근 제어를 위한 좋은 위치는 서블릿 필터이다.

응용 서비스: 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();
          }
          ...
      }

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

서비스에서 조회 전용 기능을 사용하면 서비스 코드가 단순히 조회 전용 기능을 호출하는 형태로 끝날 수 있다.

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