도메인 주도 개발 시작하기 책 정리
표현 영역과 응용 영역
✅ 표현 영역
표현 영역은 사용자의 요청을 해석한다. 그리고 응용 서비스를 실행한다.
✅ 응용 영역
실제 사용자가 원하는 기능을 제공한다.
✅ 표현 -> 응용
응용 서비스의 메서드가 요구하는 파라미터와 표현 영역이 사용자로부터 전달받은 데이터는 형식이 일치하지 않기 때문에,
표현 영역은 응용 서비스가 요구하는 형식으로 사용자 요청을 변환한다.
@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);
}
응용 영역은 사용자가 웹 브라우저를 사용하는지, REST API를 호출하는지 알 필요가 없다.
-> 단지 기능 실행에 필요한 입력 값을 받고 실행 결과만 리턴하면 될 뿐이다.
응용 서비스의 역할
✅ 도메인 객체를 사용해서 사용자의 요청을 처리한다
public Result doSomeFunc(SomeReq reqe) {
// 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);
}
✅ 트랜잭션 처리를 담당한다
응용 서비스는 도메인의 상태 변경을 트랜잭션으로 처리해야 하기 때문에,
트랜잭션 범위에서 응용 서비스를 실행해야 한다.
도메인 로직 넣지 않기
✅ 응용 서비스는 단순하다
이처럼 응용 서비스는 주로 도메인 객체 간의 흐름을 제어하기 때문에 단순한 형태를 갖는다.
만약 응용 서비스가 복잡하다면, 도메인 로직의 일부를 구현하고 있을 가능성이 높다.
이런 응용 서비스가 있다.
public class ChangePasswordService {
public void changePassword(String memberId, String oldPw, String newPw) {
Member member = memberRepository.findById(memberId);
checkMemberExists(member);
member.changePassword(oldPw, newPw);
}
}
Member 애그리거트는 암호를 변경하기 전에 기존 암호를 올바르게 입력했는지 확인하는 로직을 구현한다.
@Entity
@Table(name = "member")
public class Member {
@EmbeddedId
private MemberId id;
private String name;
@Embedded
private Password password;
...
public void changePassword(String oldPw, String newPw) {
if (!password.match(oldPw)) {
throw new IdPasswordNotMatchingException();
}
this.password = new Password(newPw);
}
}
✅ 응용 서비스에 도메인 로직을 포함하면?
이처럼 기존 암호를 올바르게 입력했는지를 확인하는 것은 도메인의 핵심 로직이기 때문에,
다음 코드처럼 응용 서비스에서 이 로직을 구현하면 안 된다.
public class ChangePasswordService {
public void changePassword(String memberId, String oldPw, String newPw) {
Member member = memberRepository.findById(memberId);
checkMemberExists(member);
if (!member.getPassword().match(oldPw)) {
throw new IdPasswordNotMatchingException();
}
member.setPassword(newPw);
}
}
응용 서비스에 도메인 로직을 포함했을 때 문제점은 다음과 같다.
1. 코드의 응집성
도메인 데이터와 그 데이터를 조작하는 로직이 서로 다른 영역에 위치한다는 것은,
도메인 로직을 파악하기 위해 여러 영역을 분석해야 한다.
2. 코드의 중복
여러 응용 서비스에서 동일한 도메인 로직을 구현할 가능성이 높아진다.
-> 중요한 것은 응용 서비스는 도메인의 기능을 '사용하기만' 해야 한다는 것이다. (도메인 로직을 작성하면 안된다)
응용 서비스의 구현
응용 서비스의 크기
✅ 한 응용 서비스 클래스에 한 도메인의 모든 기능 구현하기
ex) 회원과 관련된 기능을 한 클래스에서 모두 구현하기.
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) {
...
}
public void leave(String memberId, String curPw) {
...
}
}
1. 장점
동일 로직에 대한 코드 중복을 제거할 수 있다.
ex) MemberService의 각 메서드에서 회원이 DB에 존재하는지를 판단하는 메서드를 private 메서드로 공통화할 수 있다.
2. 단점
한 서비스 클래스의 크기가 커진다.
-> 연관성이 적은 코드가 한 클래스에 위치할 가능성이 높아진다.
-> 한 클래스에 코드가 모이기 시작하면 습관적으로 해당 클래스에 코드를 계속 끼워 넣게 된다.
✅ 구분되는 기능별로 응용 서비스 클래스를 따로 구현하기
한 응용 서비스 클래스에서 1~3개의 기능을 구현한다.
ex) 암호 변경 기능만을 위한 응용 서비스 클래스
public class ChangePasswordService {
public void changePassword(String memberId, String curPw, String newPw) {
Member member = memberRepository.findById(memberId);
if (member == null) {
throw new NoMemberException(memberId);
}
member.changePassword(curPw, newPw);
}
}
1. 장점
한 클래스에 관련 기능만 있기 때문에, 코드 품질을 일정 수준으로 유지하는데 도움이 된다.
2. 단점
클래스 개수가 많아진다.
+) 각 기능마다 동일한 로직을 구현할 경우 여러 클래스에 중복해서 동일한 코드를 구현하는 것보다는,
다음과 같이, 별도 클래스에 로직을 구현하면 좋다.
// 공통 로직을 구현한 클래스
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.playground.MemberServiceHelper.*;
public class ChangePasswordService {
public void changePassword(String memberId, String curPw, String newPw) {
Member member = findExistingMember(memberRepository, memberId);
member.changePassword(curPw, newPw);
}
}
-> 책의 저자는 각 클래스마다 구분되는 역할을 갖는 것을 선호한다고 한다.
나도 기능들을 나열한 애매한 클래스보다는 후자 방법이 유지보수 측면에서 좋다고 생각한다.
응용 서비스의 인터페이스와 클래스
✅ 서비스에 인터페이스가 필요한가?
public interface ChangePasswordService {
public void changePassword(String memberId, String curPw, String newPw);
}
public class ChangePasswordServiceImpl {
public void changePassword(String memberId, String curPw, String newPw) {
Member member = findExistingMember(memberRepository, memberId);
member.changePassword(curPw, newPw);
}
}
위 코드에 대한 의견은 분분할 것이다. 이에 대해 책에서는 인터페이스가 필요한 상황에 대해 다음과 같은 의견을 제시한다.
✅ 구현 클래스가 여러 개인 경우
이처럼 런타임에 구현 객체를 교체해야 할 때 인터페이스를 유용하게 사용할 수 있다.
-> 다만, 역할 별로 잘 나눠진 응용 서비스는 런타임에 교체되는 경우가 거의 없기 때문에 인터페이스가 굳이 필요하지 않다.
-> 인터페이스와 클래스를 습관적으로 따로 구현하면 소스 파일만 많아지고, 전체 구조가 복잡해진다.
-> 따라서 인터페이스가 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스를 작성하는 것이 좋은 선택이라고 볼 수 없다.
✅ TDD에서 테스트를 작성하기 위해 인터페이스가 필요한 경우
만약 표현 영역을 TDD로 먼저 개발한다면, 응용 서비스 구현이 존재하지 않으므로 인터페이스를 이용해서 테스트할 수 있다.
-> 하지만 Mockito와 같은 테스트 도구는 클래스에 대해서도 테스트용 대역 객체를 만들 수 있기 때문에,
-> 응용 서비스에 대한 인터페이스의 필요성을 약화시킨다.
✅ 나의 생각
결론적으로 책에서는 응용 서비스에 대한 인터페이스 도입은 '필요할 때 하는 것' 정도로 받아들여진다.
-> 이에 대한 나의 생각은,
서비스에 대한 인터페이싱은 프로젝트의 성격, 팀의 성격에 따라 달라지지만,
서비스 개발 팀에서 일하는 나로서는 응용 서비스를 유저 시나리오에 따라 잘게 쪼개려고 하고,
실제로 하나의 응용 서비스에 대해 여러 구현체가 있던 경험이 없다 보니,
막연한 인터페이싱은 오히려 코드의 복잡도를 높인다는 생각이 들었다.
: 결론적으로 인터페이스를 도입해야 하는 이유가 명확하지 않으면 도입하지 않으려 한다.
(확장성, 버저닝, 유지보수가 굉장히 중요한 아키텍처 관련 팀은 인터페이스를 굉장히 중요하게 생각하기도 한다 -> 즉 팀바팀인 것)
메서드 파라미터와 값 리턴
✅ 응용 서비스가 필요한 데이터를 클래스로 전달
앞서 changePassword() 메서드처럼 필요한 데이터를 각각 받을 수도 있겠지만,
데이터가 여러 개인 경우 클래스 형태로 데이터를 전달해도 좋다.
public class ChangePasswordService {
public void changePassword(ChangePasswordRequest req) {
Member member = findExistingMember(memberRepository, req.getMemberId());
member.changePassword(reg.getCurrentPassword(), req.getNewPassword());
}
}
✅ 표현 영역에 필요한 데이터를 리턴
애그리거트 식별자, 애그리거트, 표현을 위한 객체 등 다양한 형식으로 리턴할 수 있겠지만,
애그리거트 객체 자체를 리턴하는 것은 표현 영역에서도 도메인 로직 실행의 가능성을 열어두는 것이기 때문에,
가급적 응용 서비스에서 표현 영역에서 필요한 데이터만 리턴하는 것이 응집도를 높이는 좋은 방법이다.
표현 영역에 의존하지 않기
✅ 응용 서비스의 파라미터로 표현 영역과 관련된 타입을 사용하지 말자
응용 서비스에서 표현 영역에 대한 의존이 발생하면,
1. 응용 서비스만 단독으로 테스트하기 어려워지고
2. 표현 영역이 변경되면 응용 서비스도 함께 변경해야 한다.
ex) 응용 서비스가 표현 영역의 역할을 대신할 수 있는 상황
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));
}
}
}
HttpServletRequest, HttpSession은 표현 영역의 구현이기 때문에,
이를 응용 서비스에 두면 표현 영역의 응집도가 개져서 유지 보수가 어려워진다.
-> 이를 방지하기 위해 응용 서비스 메서드의 파라미터와 리턴 타입에 표현 영역의 구현 기술을 사용하지 않아야 한다.
트랜잭션 처리
✅ 트랜잭션을 관리하는 것은 응용 서비스의 책임
public class ChangePasswordService {
@Transactional
public void changePassword(ChangePasswordRequest req) {
Member member = findExistingMember(memberRepository, req.getMemberId());
member.changePassword(reg.getCurrentPassword(), req.getNewPassword());
}
}
이처럼 스프링이 제공하는 @Transactional 애노테이션을 사용하면 여러 상황에 대한 커밋과 롤백을 간단하게 보장받을 수 있다.
표현 영역
✅ 사용자가 시스템을 사용할 수 있는 흐름(화면)을 제공하고 제어한다
쉽게 말해서, 사용자가 요청한 내용을 응답으로 제공해준다.
✅ 사용자의 요청을 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공한다
아까와 비슷한 말이지만, 조금 더 코드 관점에서 생각할 수 있다.
표현 영역은 응용 서비스가 요구하는 형식으로 데이터를 전달하고 응용 서비스의 결과를 사용자에게 응답할 수 있는 형식으로 변환한다.
ex) 암호 변경을 처리하는 표현 영역은 HTTP 요청 파라미터로부터 필요한 값을 응용 서비스에 전달한다.
@PostMapping
public String changePassword(HttpServletRequest request, Errors errors) {
String curPw = request.getParameter("curPw");
String newPw = request.getParameter("newPw");
String memberId = SecurityContext.getAuthentication().getId();
ChangePasswordRequest chPwdReq = new ChangePasswordRequest(memberId, curPw, newPw);
try {
changePasswordService.changePassword(chPwdReq);
return successView;
} catch (IdPasswordNotMatchingException | NoMemberException ex) {
errors.reject("idPasswordNotMatch");
return formView;
}
}
추가로 MVC는 HTTP 요청 파라미터로부터 자바 객체를 생성하는 기능을 지원하므로,
다음 코드처럼 응용 서비스에 전달한 자바 객체를 보다 손쉽게 생성할 수 있다.
@PostMapping
public String changePassword(ChangePasswordRequest chPwdReq, Errors errors) {
String memberId = SecurityContext.getAuthentication().getId();
chPwdReq.setMemberId(memberId);
try {
changePasswordService.changePassword(chPwdReq);
return successView;
} catch (IdPasswordNotMatchingException | NoMemberException ex) {
errors.reject("idPasswordNotMatch");
return formView;
}
}
✅ 사용자의 세션을 관리한다
표현 영역은 사용자의 연결 상태를 관리하기 위해 세션을 관리한다.
값 검증
✅ 응용 서비스에서 값 검증
값 검증은 표현 영역과 응용 서비스 두 곳에서 모두 수행할 수 있는데,
원칙적으로는 모든 값에 대한 검증은 응용 서비스에서 처리한다.
public class JoinService {
@Transactional
public void join(JoinRequest joinReq) {
// 값의 형식 검사
checkEmpty(joinReq.getId(), "id");
checkEmpty(joinReq.getName(), "name");
checkEmpty(joinReq.getPassword(), "password");
if (joinReq.getPassword().equals(joinReq.getConfirmPassword())) {
throw new InvalidPropertyException("confirmPassword");
}
// 로직 검사
checkDuplicateId(joinReq.getId());
// 로직 수행
...
}
private void checkEmpty(String value, String propertyName) {
if (value == null || value.isEmpty()) {
throw new EmptyPropertyException(propertyName);
}
}
private void checkDuplicateId(String id) {
int count = memberRepository.countsById(id);
if (count > 0) {
throw new DuplicateIdException();
}
}
}
이런 경우 표현 영역은 다음과 같이 작성할 수 있다.
@PostMapping("/member/join")
public String join(JoinRequest joinRequest, Errors errors) {
try {
joinService.join(joinRequest);
return successView;
} catch (EmptyPropertyException ex) {
errors.rejectValue(ex.getPropertyName(), "empty");
return formView;
} catch (InvalidPropertyException ex) {
errors.rejectValue(ex.getPropertyName(), "invalid");
return formView;
} catch (DuplicationIdException ex) {
errors.rejectValue(ex.getPropertyName(), "duplicate");
return formView;
}
}
예를 들어 폼에 입력한 값이 잘못된 경우,
사용자에게 어떤 값이 잘못됐는지 알려주고 폼을 다시 작성할 수 있도록 한다.
이 방식의 문제점은 다음과 같다.
1. 표현 영역에서 에러 메시지를 보여주기 위해 다소 번잡한 코드를 작성해야 한다.
2. 사용자에게 좋지 않은 경험을 제공한다.
why? 사용자는 어떤 값이 잘못됐는지 모두 알고 싶은데,
응용 서비스에서 예외가 발생하는 시점에서 나머지 항목에 대해서는 검사를 하지 않기 때문에,
사용자가 같은 폼에 값을 여러 번 입력하게 만든다.
✅ 응용 서비스에서 값 검증 - 예외를 모아 한 번에 전달하기
이런 사용자 불편을 해소하기 위해 응용 서비스에서 에러 코드를 모아 하나의 예외로 발생시키는 방법도 있다.
@Transactional
public OrderNo placeOrder(OrderRequest orderRequest) {
List<ValidationError> errors = new ArrayList<>();
if (orderRequest == null) {
errors.add(ValidationError.of("required"));
} else {
if (orderRequest.getOrdererMemberId() == null)
errors.add(ValidationError.of("ordererMemberId", "required"));
if (orderRequest.getOrderProducts() == null)
errors.add(ValidationError.of("orderProducts", "required"));
if (orderRequest.getOrderProducts().isEmpty())
errors.add(ValidationError.of("orderProducts", "required"));
...
}
if (!errors.isEmpty()) throw new ValidationErrorException(errors);
// 로직 수행
...
}
ValidationError 목록을 통해 각 예외 상황을 목록에 추가하고,
값 검증이 끝난 뒤에 목록에 값이 존재한다면 ValidationErrorException을 발생시킨다.
표현 영역은 다음과 같이 작성할 수 있다.
@PostMapping("/orders/order")
public String order(@ModelAttribute("orderReq") OrderRequest orderRequest,
BindingResult bindingResult,
ModelMap modelMap) {
User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
orderRequest.setOrdererMemberId(MemberId.of(user.getUsername()));
try {
OrderNo orderNo = placeOrderService.placeOrder(orderRequest);
modelMap.addAttribute("orderNo", orderNo.getNumber());
return "order/orderComplete";
} catch (ValidationErrorException e) {
e.getErrors().forEach(err -> {
if (err.hasName()) {
bindingResult.rejectValue(err.getName(), err.getCode());
} else {
bindingResult.reject(err.getCode());
}
});
populateProductsAndTotalAmountsModel(orderRequest, modelMap);
return "order/confirm";
}
}
✅ 표현 영역에서 값 검증
다음과 같이 표현 영역에서도 값을 검증할 수 있다.
@PostMapping("/member/join")
public String join(JoinRequest joinRequest, Errors errors) {
checkEmpty(joinReq.getId(), "id", errors);
checkEmpty(joinReq.getName(), "name", errors);
...
// 모든 값의 형식을 검증한 뒤, 에러가 존재하면 다시 폼을 보여줌
if (errors.hasErrors()) {
return formView;
}
try {
joinService.join(joinRequest);
return successView;
} catch (DuplicationIdException ex) {
errors.rejectValue(ex.getPropertyName(), "duplicate");
return formView;
}
}
private void checkEmpty(String value, String property, Errors errors) {
if (value == null || value.isEmpty()) {
errors.rejectValue(property, "empty");
}
}
스프링은 값 검증을 위한 Validator 인터페이스를 별도로 제공하므로,
해당 인터페이스를 구현한 검증기를 통해 다음과 같이 코드를 간결하게 줄일 수 있다.
@PostMapping("/member/join")
public String join(JoinRequest joinRequest, Errors errors) {
new JoinRequestValiadator().validate(joinRequest, errors);
if (errors.hasErrors()) {
return formView;
}
try {
joinService.join(joinRequest);
return successView;
} catch (DuplicationIdException ex) {
errors.rejectValue(ex.getPropertyName(), "duplicate");
return formView;
}
}
✅ 그래서 값 검증을 어디서 해야할까?
방금처럼 표현 영역에서 필수 값과 값의 형식을 검사하면,
실질적으로 응용 서비스는 ID 중복 여부와 같은 논리적 오류만 검사하면 된다.
-> 즉, 표현 영역과 응용 서비스가 값 검사를 나눠서 수행한다.
위 예시가 아니더라도 응용 서비스에서 얼마나 엄격하게 값을 검증해야 하는지는 의견이 갈릴 수 있다.
만약 응용 서비스를 사용하는 표현 영역 코드가 한 곳으로 보장되어 있다면,
구현의 편리함을 위해 다음과 같이 역할늘 나누어 검증을 수행할 수 있다.
1. 표현 영역: 필수 값, 값의 형식, 범위 등을 검증.
2. 응용 서비스: 데이터의 존재 유무과 같은 논리적 오류를 검증.
그러나 응용 서비스는 여러 표현 영역에서 사용될 수 있는데,
이런 경우 어떤 표현 영역에서 호출해도 괜찮도록 응용 서비스의 완성도를 높이기 위해서는,
코드가 조금 늘어나더라도 응용 서비스에서 값 검증을 하는 것이 좋다.
권한 검사
✅ 표현 영역
표현 영역에서는 인증된 사용자인지 아닌지 검사한다.
만약 특정 URL에 대해 인증된 사용자만 접근할 수 있다면,
서블릿 필터에서 접근 제어를 하면 좋다. (스프링 시큐리티는 필터를 이용해서 인증 정보를 생성하고 웹 접근을 제어한다)
✅ 응용 서비스
URL 만으로 접근 제어를 할 수 없는 경우, 응용 서비스 메서드 단위로 권한 검사를 수행해야 한다.
ex) 스프링 시큐리티는 AOP를 활용하여 다음과 같이 애노테이션으로 메서드에 대한 권한 검사를 할 수 있다.
@PreAuthorize("hasRole('ADMIN')") // 해당 메서드를 호출하기 전에 ADMIN 권한이 있는지 검사한다.
@Transactional
public void block(String memberId) {
Member member = memberRepository.findById(new MemberId(memberId))
.orElseThrow(() -> new NoMemberException());
member.block();
}
✅ 도메인
메서드 만으로 접근 제어를 할 수 없는경우, 직접 도메인에 맞는 권한 검사 기능을 구현한다.
ex) 게시글 삭제는 본인 또는 관리자 역할을 가진 사용자만 할 수 있다.
-> 게시글 작성자가 본인인지 확인하기 위해 게시글 애그리거트를 먼저 로딩해야 한다.
-> 즉, 메서드 수준에서 권한 검사를 할 수 없기 때문에 직접 권한 검사 로직을 구현해야 한다.
public class DeleteArticleService {
public void delete(String userId, Long articleId) {
Article article = articleRepository.findById(articleId);
checkArticleExistence(artice);
permissionService.checkDeletePermission(userId, article); // 권한 검사
article.markDeleted();
}
}
조회 전용 기능과 응용 서비스
✅ 응용 서비스가 꼭 필요한건 아니야
만약 조회 화면을 위한 조회 전용 모델과 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";
}
}
'book > 도메인 주도 개발 시작하기' 카테고리의 다른 글
[DDD Start] 애그리거트 트랜잭션 관리 (0) | 2023.09.02 |
---|---|
[DDD Start] 도메인 서비스 (0) | 2023.08.18 |
[DDD Start] 스프링 데이터 JPA를 이용한 조회 기능 (0) | 2023.08.12 |
[DDD Start] 리포지터리와 모델 구현 (0) | 2023.07.28 |
[DDD Start] 애그리거트 (0) | 2023.07.18 |