1. 표현 영역과 응용 역역
1장부터 5장까지는 주로 도메인과 그 구현에 관한 것이었다. 도메인이 제 기능을 하려면 사용자와 도메인을 연결해주는 매개체가 필요한데, 이를 위해 응용 영역과 표현 영역이 있다.
표현 영역은 URL, 요청 파라미터, 쿠키, 헤더 등을 이용하여 사용자가 필요한 기능을 판별하고 그 기능을 제공하는 응용 서비스를 실행한다. 응용 서비스를 실행한 뒤 실행 결과를 사용자에 알맞은 형식(HTML, JSON, XML, …)으로 응답한다.
사용자와의 상호작용은 표현 영역이 처리하기 때문에 응용 서비스는 표현 영역(웹 브라우저 사용 여부, REST API 호출 여부, TCP 사용 여부 등)에 의존하지 않는다.
2. 응용 서비스의 역할
응용 서비스는 사용자가 요청한 기능을 수행하고, 이를 위해 리포지터리로부터 도메인 객체를 구하고, 도메인 객체를 사용하고 저장한다. 이를 사용자 입장에서 보았을 때 도메인 영역과 표현 영역을 연결해 주는 창구인 파사드(facade : 고수준 인터페이스) 역할을 한다.
응용서비스의 기본 형태
- 리포지터리에서 애그리거트를 구한다.
- 애그리거트의 도메인 기능을 실행한다.
- 결과를 리턴한다
애그리거트 생성시 기본 형태
- 데이터 중복 등 유효성 검사를 한다.
- 애그리거트를 생성한다.
- 리포지터리에 애그리거트를 저장한다.
- 결과를 리턴한다.
이 외에 주요 역할로서 트랜잭션 처리, 접근 제어, 이벤트 처리가 있는데 뒤에서 살펴본다.
도메인 로직 넣지 않기
도메인 로직은 도메인 영역에 위치하고 응용 서비스는 도메인 로직을 구현하지 않는다. 도메인 로직을 도메인 영역과 응용 서비스에 분산해서 구현하면 코드 품질에 문제가 생기는데 첫 번째 문제는 코드 응집성이 떨어진다. 즉, 해당 도메인 로직을 파악하기 위해 여러 영역을 분석해야 한다. 두 번째 문제는 여러 응용 서비스에서 동일한 도메인 로직을 구현할 가능성이 높아진다.
3. 응용 서비스의 구현
응용 서비스는 표현 영역과 도메인 여역을 연결하는 매개체 역할, 디자인 패턴에서의 파사트(facade) 역할을 한다. 따라서 복잡한 로직을 수행하지 않기에 구현은 어렵지 않으며, 몇 가지 고려사항이 있다.
응용 서비스의 크기
응용 서비스는 다음 두 가지의 방법 중 한 가지 방식으로 구현한다
-
한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기
장점
- 코드 중복 제거
단점
- 클래스 크기(코드 줄 수) 커짐
- 연관성 적으 코드가 한 클래스에 위치
- 이후에 분리해야되는 상황이되도 습관적으로 억지로 끼워넣게 되어 코드 품질 낮아짐
-
구분되는 기능별로 응용 서비스 클래스를 따로 구현하기
장점
- 코드 품질 일정 수준으로 유지 가능
- 필요한 의존 객체만 포함하므로 다른 기능 구현한 코드에 영향 덜 받음
단점
- 중복 코드 구현 가능성 있음 -> 별도 클래스에 로직 구현해서 방지할 수 있음
개인적으로 클래스를 나누어 구현하는 것을 추천한다.
응용 서비스의 인터페이스와 클래스
응용 서비스의 인터페이스가 과연 필요한지는 논쟁이 될 만한 대상이다.
인터페이스가 필요한 몇 가지 상황이 있을 수 있는데, 그 중 하나는 구현 클래스가 여러 개인 경우이다. 그러나 응용 서비스는 보통 런타임에 이를 교체하는 경우가 거의 없을 뿐더러 한 응용 서비스의 구현 클래스가 두 개인 경우도 매우 드물다.
또 다른 상황은 테스트 주도 개발(TDD)을 즐겨하고 표현 영역부터 개발을 시작하는 경우인데, 미리 응용 서비스를 구현하지 않아서 가짜 객체가 필요하다. 기존에는 이런 표현 영역의 단위 테스트를 위해 응용 서비스 클래스의 가짜 객체로서 인터페이스를 추가했어야 했지만, 이제는 Mockito와 같은 테스트 도구가 있어 인터페이스 없이 표현 영역을 테스트 할 수 있다.
따라서 응용 서비스에 대한 인터페이스의 필요성은 적다고 할 수 있다.
메서드 파라미터와 값 리턴
표현 영역에서 스프링 MVC와 같은 웹 프레임워크는 웹 요청 파라미터를 자바 객체로 변환해 주는 기능을 제공하므로 요청 파라미터를 별도 클래스로 만들어 사용하는 것이 편리하다. 이는 컴파일러가 못잡는 에러도 잡을 수 있어 요청 파라미터가 1~2개여도 사용하는 것이 좋다.
@Controller
@RequestMapping("/member/changePassword")
public class MemberPasswordController {
...
@RequestMapping(method = RequestMethod.POST)
public String submit(ChangePasswordRequest changeRequest) {
...
}
}
표현 영역 코드는 응용 서비스가 리턴한 결과를 모델에 담아 뷰 코드에서 링크를 생성하는데 사용한다.
이 때 응용 서비스는 필요한 데이터만 리턴해야하지 애그리거트 자체를 리턴하면 도메인 로직을 응용 서비스와 표현 영역 두 곳에서 사용할 수 있게 되어 코드 응집도를 낮추는 원인이 된다.
표현 영역에 의존하지 않기
응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 영역과 관련된 타입(HttpServletRequest
, HttpSession
등)을 사용하면 안된다는 것이다. 응용 서비스에서 표현 영역에 대한 의존이 발생하면 응용 서비스마 단독으로 테스트하기가 어려워 진다. 게다가 표현 영역의 구현이 변경되면 응용 서비스의 구현도 함께 변경해야 하는 문제도 발생한다.
위 두 문제보다 더 나쁜 문제는 응용 서비스가 표현 영역 역할까지 대신하는 상황이 벌어질 수도 있다. 예를 들어, 응용 서비스에서 파라미터로 HttpServletRequest
를 전달했는데 응용 서비스에서 HttpSession
을 생성하고 세션에 인증과 관련된 정보를 담는다고 해보자. HttpSession
이나 쿠키는 표현 영역의 상태에 해당하는데 이를 응용 서비스에서 변경하면 표현 영역의 코드만으로 상태가 어떻게 변경되는지 이해하기 어려워진다. 즉, 표현 영역의 응집도가 깨져 코드 유지보수 비용을 증가시키게 된다.
따라서 서비스 메서드의 파라미터와 리턴 타입으로 표현 영역의 구현기술을 사용하지 않아야 한다.
트랜잭션 처리
스프링과 같은 프레임워크를 사용하면 프레임워크에서 제공하는 트랜잭션 관리 기능을 이용해 쉽게 트랜잭션 처리를 할 수 있다.
public class ChangePasswordService {
// 트랜잭션 시작하고 커밋하고 익셉션이 발생하면 롤백할 수 있다.
@Transactional
public void changePassword(ChangePasswordRequest request) {
Member member = findExistingMember(request.getMemberId());
member.changePassword(request.getCurrentPassword(), request.getNewPassword());
}
}
도메인 이벤트 처리
응용 서비스의 역할 중 하나는 도메인 영역에서 발생시킨 이벤트를 처리하는 것이다. 여기서 이벤트란 도메인에서 발생한 상태 변경을 의미하며 ‘암호 변경됨’, ‘주문 취소함’과 같은 것이 될 수 있다. (자세한 도메인 이벤트 구현에 대한 내용은 10장에서 다룬다)
public class Member {
private Password password;
public void initializePassword() {
String newPassword = generateRandomPassword();
this.password = new Password(newPassword);
Events.raise(new PasswordChangedEvent(this.id, password));
}
}
// 도메인에서 발생한 이벤트는 응용 서비스에서 처리한다.
public class InitPasswordService {
@Transactional
public void initializePassword(String memberId) {
Events.handle((PasswordChangeEvent evt) -> {
// evt.getid()에 해당하는 회원에게 이메일 발송하는 기능 구현
});
Member member = memberRepository.findById(memberId);
checkMemberExists(member);
member.initializePassword();
}
// 위의 코드를 아래와 같이 짜게 되면 이벤트가 발생하지 않은 경우에 대해 결국엔 따로 처리해줘야한다.
@Transactional
public void initializePassword(String memberId) {
Member member = memberRepository.findById(memberId);
checkMemberExists(member);
member.initializePassword(); // 이벤트 발생하지 않음
sendNewpasswordMailToMember(member); // 실행 안돼야 함
}
}
이벤트를 사용하면 코드가 다소 복잡해지는 대신 도메인 간 의존성이나 외부 시스템에 대한 의존성을 낮추는 장점을 얻을 수 있으며, 시스템을 확장시에 핵심 역할을 수행하게 된다. 이에 대해 10장과 CQRS에 대해 다루는 11장에서 자세히 살펴보도록 한다.
4. 표현 영역
표현 영역의 책임
- 사용자가 시슽메을 사용할 수 있는 (화면)흐름을 제공하고 제어
- 사용자의 요청에 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공
- 사용자의 세션을 관리(웹의 경우 쿠키나 서버 세션을 이용해 사용자의 연결 상태를 관리)
5. 값 검증
보통은 아래의 경우로 나누어서 파라미터 검증을 진행한다.
-
표현 영역 : 필수 값, 값의 형식, 범위 등을 검증
응용 서비스 값 전달 전에 하되, 사용자 경험을 위해 표현 영역에서 여러 값들에 대한 에러를 순차적으로 전달하지 않고 취합하여 한 번에 전달하는 것이 좋다.
@Controller public class Controller { @RequestMapping public String join(JoinRequest req, Errors errors) { checkEmpty(req.getid(), "id", errors); checkEmpty(req.getName(), "name", errors); ... // 나머지 값 검증 // 모든 값의 형식을 검증한 뒤, 에러가 존재하면 다시 폼(로그인화면 등)을 보여줌 if (errors.hasErrors()) return formView; ... } }
스프링과 같은 프레임워크는 값 검증을 위해 Validator 인터페이스를 별도로 제공하여 이를 구현한 검증기를 따로 구현하면 더 간결하게 줄일 수 있다.
checkEmpty(req.getid(), "id", errors); checkEmpty(req.getName(), "name", errors); ... -> new JoinRequestValidator().validate(req, errors);
-
응용 서비스 : 데이터의 존재 유무와 같은 논리적 오류 검증
다만 위의 구분 기준은 응용 서비스에서 어디까지 검증할지 여부는 엄격함이 어느 수준까지 필요하냐에 따라 달라질 수 있다. 응용 서비스를 실행하는 주체가 다양하면 응용 서비스에서 반드시 파라미터로 전달받은 값의 유무 등 올바른지 검사를 해야 한다.
6. 권한 검사
권한 검사 자체는 복잡한 개념이 아니나 개발할 시스템의 복잡도에 따라 권한의 복잡도가 달라진다. 단순히 인증 여부에 대한 검사부터 관리자 여부, 기능을 사용할 수 있는 역할 등 달라질 수 있다. 이런 다양한 상황을 충족하기 위해 스프링 시큐리티나 아파치 Shiro 같은 프레임워크는 유연하고 확장 가능한 구조를 갖는다. 하지만 유연한만큼 복잡하다는 것을 의미하기 때문에, 시스템에 맞게 적용하는 것이 좋다.
보안 프레임워크를 떠나 다음 세 곳에서 권한 검사를 수행할 수 있다.
- 표현 영역
- 응용 서비스
- 도메인 영역
표현 영역에서는 기본적으로 인증된 사용자인지 아닌지 여부를 검사(예. 회원 정보 변경)이다. 회원 정보 변경과 관련된 URL은 인증된 사용자만 접근해야 한다. 이에 대해 표현영역에서 아래와 같이 접근 제어를 할 수 있다.
- 이 URL을 처리하는 컨트롤러에 웹 요청을 전달하기 전, 인증 여부를 검사해서 인증된 사용자의 요청만 컨트롤러에 전달
- 인증된 사용자가 아닐 경우 로그인 화면으로 리다이렉트
이런 접근 제어를 하기 좋은 위치가 서블릿 필터이다. 서블릿 필터에서 사용자의 인증 정보를 생성하고 인증 여부를 검사한다.
인증 여부 뿐만 아니라 권한에 대해 동일한 방식으로 피터를 사용해 URL별 권한 검사를 할 수도 있다. 스프링 시큐리티도 이와 유사한 방식으로 필터를 이용해 접근을 제어한다.
URL만으로 접근 제어를 할 수 없는 경우 응용 서비스의 메서드 단위로 권한 검사를 수행해야 한다. 이는 꼭 응용 서비스의 코드에서 직접 권한 검사를 해야 한다는 것의 의미는 아니다. 예를 들어 스프링 시큐리티는 AOP(Aspect-Oriented Programming)를 활용해 다음과 같이 애노테이션으로 서비스 메서드에 대한 검사를 할 수 있다.
public class BlockMemberService {
...
@PreAuthroize("hasRole('ADMIN')")
public void block(String memberId) {
...
}
}
개별 도메인 단위로 권한 검사를 해야 하는 경우는 다소 구현이 복잡해진다. 예를 들어, 게시글 삭제는 본인 또한 관리자만 삭제할 수 있을 때, 이를 위해 결국 게시글 애그리거트를 먼저 로딩해야 한다. 즉, 응용 서비스 메서드 수준에서 권한 검사를 할 수 없어 내부에서 구현해야 한다.
스프링 시큐리티와 같은 보안 프레임워크를 확장해서 개별 도메인 객체 수준의 권한 검사 기능을 프레임워크에 통합할 수도 있지만 이는 프레임워크에 대한 높은 이해가 필요하므로, 이 수준이 아니라면 직접 구현하는 것이 코드 유지보수에 유리할 수 있다.
7. 조회 전용 기능과 응용 서비스
응용 서비스가 사용자 요청을 실행하는 데 있어 아래와 같이 별다른 기여를 하지 못하고 단순히 리포지터리의 조회 전용 기능을 호출할 뿐이라면 굳이 서비스를 만들 필요없이 표현 영역에서 바로 조회전용 기능을 사용해도 된다.
public class OrderListService {
public List<OrderView> getOrderList(String ordererId) {
return orderViewDao.selectByOrderer(ordererId);
}
}
public class OrderController {
...
@RequestMapping("/myorders")
public String list(ModelMap model) {
...
List<OrderView> orders = orderViewDao.selectByOrderer(ordererId);
...
}
}