도메인 영역을 잘 구현하는 것은 사용자의 요구를 충족하는, 제대로 된 소프트웨어를 만드는 데에 기본이 된다.
하지만 도메인 영역만 잘 만든다고 끝나는 것은 아니다!
도메인이 제 기능을 하려면 사용자와 도메인을 연결해주는 매개체가 필요한데, 이러한 매개체 역할을 하는 것이 응용 영역과 표현 영역이다.

표현 영역은 사용자의 요청을 해석하고, 응용 영역은 실제 사용자가 원하는 기능을 서비스로써 제공한다.
사용자가 웹 브라우저에서 폼에 데이터를 입력해 전송하면, 요청 파라미터를 포함한 HTTP 요청이 표현 영역에 전달된다.
표현 영역은 요청의 URL, 요청 파라미터, 쿠키, 헤더 등을 이용해 사용자가 실행하고자 하는 기능을 판별하고, 알맞은 응용 서비스를 호출한다.
응용 서비스는 기능을 실행하는 데 필요한 입력 값을 인자로 받아서 실행 결과를 반환한다.
이 때, 응용 영역의 메서드가 요구하는 파라미터와 표현 영역이 사용자로부터 전달받은 데이터의 형식이 일치하지 않는다.
때문에 표현 영역은 응용 서비스가 요구하는 형식으로 사용자 요청을 변환해서 전달해야 한다.
@PostMapping("/member/join")
public ModelAndView join(HttpServletRequest request) {
String email = request.getParameter("email");
String password = request.getParameter("password");
// 사용자 요청을 응용 서비스에 맞게 변환
JoinRequest joinReq = new JoinRequest(email, password);
// 변환한 객체를 이용해서 응용 서비스를 호출
joinService.join(joinReq);
...
}
응용 서비스에게 전달받은 실행 결과를 통해 표현 영역은 사용자에게 알맞은 형식으로 응답한다.
사용자의 요청에 맞게 HTML 혹은 JSON 형식으로 응답할 것이다.
사용자와의 상호작용은 표현 영역의 책임으로, 응용 서비스는 표현 영역에 의존하지 않으며, 의존해서도 안 된다.
응용 영역은 사용자가 어떤 기술을 사용하는지 알 필요가 없으며, 단지 기능을 실행하기 위해 필요한 입력 값을 받고 결과를 반환하면 된다.
응용 서비스는 클라이언트가 요청한 기능을 실행하며, 요청을 처리하기 위해 Repository 에서 도메인 객체를 가져온다.
응용 영역의 주요 역할은 도메인 객체를 사용해 사용자의 요청을 처리하는 것이므로, 표현 영역 관점에서 도메인 영역과 표현 영역을 연결해주는 창구 역할을 한다.
응용 영역의 서비스는 주로 도메인 객체 간의 흐름을 제어하는 형태로, 단순한 형태를 갖는다.
public Result doSomeFunc(SomeReq req) {
// 1. Repository에서 Aggregate 가져오기
SomeAgg agg = someAggRepository.findById(req.getId());
checkNull(agg);
// 2. Aggregate의 도메인 기능 호출
agg.doFunc(req.getValue());
// 3. 결과 리턴
return createSuccessResult(agg);
}
새로운 애그리거트를 생성하는 응용 서비스 역시 단순한 형태를 갖는다.
public Result doSomeCreation(CreateSomeReq req) {
// 1. 데이터 중복 등 유효성 검사
validate(req);
// 2. Aggregate 생성
SomeAgg newAgg = createSome(req);
// 3. Repository에 Aggregate 저장
someAggRepository.save(someAgg);
// 4. 결과 리턴
return createSuccessResult(newAgg);
}
응용 서비스가 복잡하다면 서비스에서 도메인 로직의 일부를 구현하고 있을 가능성이 높다. 서비스가 도메인 로직을 구현하면 코드 중복, 로직 분산 등 코드 품질에 안 좋은 영향을 줄 수 있다.
응용 서비스는 도메인의 상태 변경을 트랜잭션으로 처리함으로써 데이터의 일관성을 보장해야 한다.
데이터의 일관성을 보장하기 위해 응용 서비스는 트랜잭션 범위에서 실행해야 한다.
도메인 로직은 도메인 영역에 위치하고, 응용 서비스는 도메인 로직을 구현하지 않아야 한다.
도메인 로직이 도메인 영역과 응용 서비스에 분산되면 다음과 같은 문제가 발생하며, 이는 곧 코드 품질을 떨어뜨린다.
도메인 데이터와 데이터를 조작하는 로직이 한 영역에 위치하지 않고 서로 다른 영역에 위치한다는 것은 도메인 로직 파악을 위해 여러 영역을 분석해야 함을 의미한다.
코드 중복을 막기 위해 응용 서비스 영역에 별도의 보조 클래스를 만들 수 있지만, 이것도 좋은 방법이 아니다.
응용 서비스는 도메인 영역에 구현된 로직을 가져와 사용하기만 하면 된다!
응용 서비스에서 도메인이 제공하는 기능을 사용하면 자연스럽게 코드 중복 문제가 발생하지 않는다.
이러한 문제를 해결함으로써 코드 변경의 어려움을 해결할 수 있는데, 이러한 변경 용이성은 소프트웨어가 가져야 할 중요한 경쟁 요소이다.
소프트웨어의 가치를 높이기 위해 도메인 로직은 도메인 영역에 모아서 코드 중복을 줄이고, 응집도를 높여야 한다!
응용 서비스는 표현 영역과 도메인 영역을 연결하는 매개체 역할로, 디자인 패턴에서 파사드, facade 패턴에 빗댈 수 있다.
응용 서비스를 구현할 때 고려해야 하는 사항들을 다루겠다.
응용 서비스는 어느 정도의 크기로 구현해야 할까?
응용 서비스는 회원 가입, 탈퇴, 암호 변경 같은 기능을 구현하기 위해 도메인 모델을 사용하게 된다.
이 경우, 응용 서비스는 보통 다음의 두 가지 방법 중 한 가지 방식으로 구현한다.
회원 관련 기능을 하나의 클래스에 구현하게 되면, 관련 기능이 한 클래스에 위치하므로 각 기능에서 동일 로직에 대한 코드 중복을 제거할 수 있다는 장점이 있다.
그러나 한 서비스 클래스의 크기가 커진다는 것이 이 방식의 단점이다.
코드 크기가 커지면 연관성이 적은 코드가 한 클래스에 함께 위치할 가능성이 높아지게 되고, 결과적으로 관련 없는 코드가 뒤섞여 코드 이해에 방해가 된다.
한 클래스에 코드가 모이기 시작하면 엄연히 분리하는 것이 좋은 상황임에도 습관적으로 기존에 존재하는 클래스에 억지로 끼워 넣게 된다.
이것은 코드를 점점 얽히게 만들어 코드 품질을 낮추는 결과를 초래한다.
구분되는 기능별로 서비스 클래스를 구현하게 되면, 한 응용 서비스 클래스에서 2~3개의 기능을 구현한다.
이 방식은 클래스의 개수는 많아지지만 코드 품질을 일정 수준으로 유지하는 데 도움이 된다.
또한, 각 클래스별로 필요한 의존 객체만 포함하므로 다른 기능을 구현한 코드에 영향을 받지 않는다.
각 기능마다 동일한 로직을 구현할 경우, 여러 클래스에 중복 코드가 발생할 가능성이 있으므로, 별도 클래스에 로직을 구현해 중복을 방지할 수 있다.
// 각 응용 서비스에서 공통되는 로직을 별도 클래스로 구현
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;
}
}
// 공통 로직을 제공하는 메서드를 응용 서비스에서 사용
import static com.myshop.member.application.MemberServiceHelper.*;
public class ChangePasswordService {
private MemberRepository memberRepository;
public void changePassword(String memberId, String curPw, String newPw) {
Member member = findExistingMember(memberRepository, memberId);
member.changePassword(curPw, newPw);
}
...
}
응용 서비스를 구현할 떄 논쟁이 잦은 부분이 바로 인터페이스가 필요한가! 이다.
인터페이스를 만들고, 이를 상속한 클래스를 만드는 것이 필요할까?
인터페이스가 필요한 몇 가지 상황이 있는데, 그 중 하나는 구현 클래스가 여러 개인 경우다.
구현 클래스가 다수 존재하거나 런타임에 구현 객체를 교체해야 할 때 인터페이스를 유용하게 사용할 수 있다.
그러나 응용 서비스는 런타임에 교체하는 경우가 거의 없고, 구현 클래스가 두 개인 경우도 드물다.
이런 이유로 인터페이스와 클래스를 분리하면 소스 파일만 많아지고 구현 클래스에 대한 간접 참조만 증가해서 구조가 복잡해진다.
따라서 인터페이스가 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스 작성이 좋은 선택이라고 볼 수는 없다.
표현 영역에 대한 단위 테스트를 위해 클래스의 가짜 객체가 필요한데 이를 위해 인터페이스를 추가할 수도 있다. 하지만 Mockito와 같은 테스트 도구는 클래스에 대해서 테스트용 대역 객체를 만들 수 있기 때문에 인터페이스가 없어도 된다. 이는 결과적으로 응용 서비스에 대한 인터페이스 필요성을 약화시킨다.
응용 서비스가 제공하는 메서드는 도메인을 이용해 사용자가 요구하는 기능을 실행하는 데 필요한 값을 파라미터로 전달받아야 한다.
이 때, 각 값을 개별 파라미터로 전달받을 수도, 별도 데이터 클래스를 만들어 전달받을 수도 있다.
응용 서비스는 파라미터로 전달받은 데이터를 사용해 필요한 기능을 구현하기만 하면 된다!
스프링 MVC 같은 웹 프레임워크는 웹 요청 파라미터를 자바 객체로 변환하는 기능을 제공한다.
때문에 응용 서비스에 데이터로 전달할 요청 파라미터가 두 개 이상 존재하면 데이터 전달을 위한 별도 클래스를 사용하는 것도 편리한 방법이다.
응용 서비스의 결과를 표현 영역에서 사용해야 하면 서비스 메서드 결과로 필요한 데이터를 리턴한다.
결과 데이터가 필요한 대표적인 예가 바로 식별자다.
표현 영역은 응용 서비스가 리턴한 값을 사용해서 사용자에게 알맞은 결과를 보여줄 수 있게 된다.
public class OrderService {
@Transactional
public OrderNo placeOrder(OrderRequest request) {
OrderNo orderNo = orderRepository.nextId();
Order order = createOrder(orderNo, request);
orderRepository.save(order);
// 응용 서비스 실행 후 표현 영역에서 필요한 값 리턴
return orderNo;
}
}
응용 서비스는 애그리거트 객체를 그대로 리턴할 수도 있다.
응용 서비스에서 애그리거트 자체를 리턴하면 코딩은 편할 수 있지만 도메인의 로직 실행을 응용 영역과 표현 영역 두 곳에서 할 수 있게 된다.
이것은 기능 실행 로직을 분산시켜 코드의 응집도를 낮추는 원인이 된다.
응용 서비스는 애그리거트 자체를 리턴하는 것보다, 표현 영역에서 필요한 데이터만 리턴하도록 하여 기능 실행 로직의 응집도를 확실히 높이도록 하자!
응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 영역과 관련된 타입을 사용하면 안 된다는 점이다!
@Controller
@RequestMapping("/member/changePassword")
public class MemberPasswordController {
@PostMapping
public String submit(HttpServletRequest request) {
try {
// 응용 서비스가 표현 영역을 의존하면 안 됨!
changePasswordService.changePassword(request);
} catch (NoMemberException e) {
// do handle Exception
}
}
}
응용 서비스에서 표현 영역에 대한 의존이 발생하면 응용 서비스의 단독 테스트가 어려워진다.
게다가 표현 영역의 구현이 변경되면 응용 서비스의 구현도 함께 변경해야 하는 문제가 발생한다.
가장 심각한 것은 응용 서비스가 표현 영역의 역할까지 대신하는 상황이 벌어질 수 있다는 것이다.
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)));
}
}
}
HttpSession 이나 쿠기는 표현 영역의 상태에 해당하는데 이를 응용 서비스에서 변경하면 표현 영역 코드만으로 상태 변경 추적이 어렵다.
즉, 표현 영역의 응집도가 깨지는 것이다.
이는 결과적으로 코드 유지 보수 비용을 증가시키는 원인이 된다.
철저하게 응용 서비스는 표현 영역의 기술을 사용하지 않도록 해야 하며, 이를 지키기 위해 서비스 메서드 파라미터와 리턴 타입으로 표현 영역의 구현 기술을 사용하지 않도록 하자!
트랜잭션을 관리하는 것은 응용 서비스의 중요한 역할이다.
프레임워크가 제공하는 트랜잭션 기능을 적극 사용하는 것이 좋다.
프레임워크가 제공하는 규칙을 따라 코드를 작성하면 트랜잭션 처리 코드를 간결하게 유지할 수 있다.
표현 영역의 책임은 크게 다음과 같다.

표현 영역의 첫 번째 책임은 사용자가 시스템을 사용할 수 있도록 알맞은 흐름을 제공하는 것이다.
웹 서비스의 표현 영역은 사용자가 요청한 내용을 응답으로 제공하고, 응답에는 다음 화면으로 이동할 수 있는 링크나 데이터 입력에 필요한 폼 등이 포함된다.
표현 영역은 응용 서비스를 이용해서 표현 영역의 요청을 처리하고 그 결과를 응답으로 전송한다.
표현 영역의 두 번째 책임은 사용자의 요청에 맞게 응용 서비스에 기능 실행을 요청하는 것이다.
화면을 보여주는데 필요한 데이터를 읽거나 도메인 상태 변경을 위해 응용 서비스를 사용한다.
이 과정에서 표현 영역은 요청 데이터를 응용 서비스가 욕하는 형식으로 변환하고, 서비스 결과를 사용자에게 응답할 수 있는 형식으로 변환한다.
응용 서비스의 실행 결과를 사용자에게 알맞은 형식으로 제공하는 것도 표현 영역의 몫이다.
응용 서비스에서 예외가 발생하면 에러 코드를 설정하는데, 표현 영역의 뷰는 에러 코드에 알맞은 처리를 하게 된다.
표현 영역의 다른 주된 역할은 사용자의 연결 상태인 세션을 관리하는 것이다.
웹은 쿠키나 서버 세션을 이용해서 사용자의 연결 상태를 관리한다.
값 검증은 표현과 응용 영역, 두 곳에서 모두 수행할 수 있다.
원칙적으로 모든 값에 대한 검증은 응용 서비스에서 처리한다.
예를 들어, 회원 가입을 처리하는 응용 서비스는 파라미터로 전달받은 값이 올바른지 검사해야 한다.
표현 영역은 잘못된 값이 존재하면 이를 사용자게 알려주고 값을 다시 입력받아야 한다.
응용 서비스에서 값의 유효성 검증을 위해 Exception을 사용하게 되면 사용자에게 좋지 않은 경험을 제공한다.
이런 사용자 불편을 해소하기 위해 응용 서비스에서 에러 코드를 모아 하나의 Exception으로 발생하는 방법도 있다.
표현 영역은 응용 서비스가 ValidationErrorException을 발생시키면 에러 목록을 가져와 표현 영역에서 사용할 형태로 변환 처리한다.
@Transactional
public OrderNo placeOrder(OrderRequest request) {
List<ValidationError> errors = new ArrayList<>();
if (request == null) {
errors.add(ValidationError.of("empty");
} else {
if (request.getOrdererMemberId() == null) {
errors.add(ValidationError.of("ordererMemerId", "empty"));
if (request.getORderProducts() == null) {
errors.add(ValidationError.of("ordererProducts", "empty"));
if (request.getOrderProducts().isEmpty())
errors.add(ValidationError.of("ordererProducts", "empty"));
}
// 응용 서비스가 입력 오류를 하나의 Exception으로 모아서 발생
if (!errors.isEmpty()) throw new ValidationErrorException(errors);
...
}
표현 영역에서 필수 값을 검증하는 방법도 있다.
스프링 프레임워크는 값 검증을 위한 Validator 인터페이스를 별도로 제공하므로 이를 구현한 검증기를 통해 간결하게 작성할 수 있다.
이렇게 표현 영역에서 필수 값과 값의 형식을 검사하면 실질적으로 응용 서비스는 ID 중복 여부와 같은 논리적 오류만 검사하면 된다.
즉, 표현 영역과 응용 서비스가 값 검사를 나눠서 수행하는 것이다!
응용 서비스를 사용하는 표현 영역 코드가 한 곳이면 구현의 편리함을 위해 다음처럼 역할을 나눠 검증을 수행할 수도 있다.
응용 서비스에서 얼마나 엄격하게 값을 검증해야 하는지에 대해서는 의견이 갈릴 수 있다.
새로운 프로젝트를 할 때 고민해봐야 할 부분으로, 권한 검사를 들 수 있겠다.
'사용자 U가 기능 F를 실행할 수 있는가'를 확인하는 것으로, 권한 검사 자체는 복잡한 개념이 아니다.
그러나 개발하는 시스템마다 권한의 복잡도가 다르다.
단순한 시스템은 인증 엽만 검사하면 되는데 반해, 어떤 시스템은 관리자 여부에 따라 사용할 수 있는 기능이 달라지기도 한다.
또 실행할 수 있는 기능이 역할마다 달라지기도 한다.
이런 다양항 상황을 충족하기 위해 스프링 시큐리티 같은 프레임워크는 유연하고 확장 가능한 구조를 갖고 있다.
유연한 만큼 복잡하다는 것을 의미하기에, 보안 프레임워크에 대한 이해가 부족하면 무턱대고 도입하는 것보다 개발할 시스템에 맞는 권한 검사 기능을 구현하는 것이 유지 보수에 유리할 수 있다.
보안 프레임워크의 복잡도를 떠나서 보통 다음 세 곳에서 권한 검사를 수행할 수 있다.
표현 영역에서 할 수 있는 기본적인 검사는 인증된 사용자인지 검사하는 것이다.
대표적인 예가 회원 정보 변경 기능이다.
회원 정보 변경을 처리하는 URL은 인증된 사용자만 접근해야 하므로, 표현 영역은 다음과 같은 접근 제어를 할 수 있다.
이런 접근 제어를 하기에 좋은 위치가 Servlet Filter이다.
서블릿 필터에서 사용자의 인증 정보를 생성하고 인증 여부를 검사한다.
인증된 사용자는 다음 과정을 진행하고 그렇지 않으면 로그인 화면, 혹은 에러 화면을 보여주면 된다.

인증 여부뿐만 아니라 권한에 대해서 동일한 방식으로 필터를 사용해 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, articleId);
article.markDeleted();
}
...
}
스프링 시큐리티 같은 보안 프레임워크를 확장해서 개별 도메인 객체 수준의 권한 검사 기능을 프레임워크에 통합할 수도 있다.
도메인 객체 수준의 권한 검사 로직은 도메인별로 다르므로 도메인에 맞게 확장하기 위해 프레임워크에 대한 높은 이해가 필요하다.
이해도가 높지 않아 확장을 원하는 숮ㄴ으로 할 수 없다면 도메인에 맞는 권한 검사 기능을 직접 구현하는 것도 좋은 방법이 될 것이다.
마지막으로 다룰 것은 조회 기능과 응용 서비스에 대한 것이다.
서비스에서 조회 전용 기능을 사용하면 서비스 코드는 단순히 조회 전용 기능을 호출하는 형태로 끝날 수 있다.
즉, 서비스에서 수행하는 추가적인 로직이 없을 뿐더러 단일 쿼리만 실행하는 조회 전용 기능에는 트랜잭션이 필요하지도 않다.
이 경우라면 굳이 서비스를 만들 필요 없이 표현 영역에서 조회 전용 기능을 사용해도 문제가 없다!
응용 서비스를 항상 만들었던 개발자는 컨트롤러와 같은 표현 영역에서 응용 서비스 없이 조회 전용 기능에 접근하느 것이 이상하게 느껴질 수있다.
하지만 응용 서비스가 사용자 요청 기능을 실행하는데 별다른 기여를 하지 못한다면 굳이 만들 필요가 없다.
