728x90
도메인 주도 개발 시작하기 6장을 요약한 내용입니다.
6.1 표현 영역과 응용 영역
- 도메인이 제 기능을 하려면 사용자와 도메인을 연결해주는 매개체가 필요하다
- 표현 영역은 사용자의 요청을 해석
- 웹 브라우저에서 ID와 암호를 입력한 뒤에 전송 버튼을 클릭하면 요청 파라미터를 포함함 HTTP 요청을 표현 영역에 전달
- 사용자가 실행하고 싶은 기능을 판별하고 기능을 제공하는 응용 서비스를 실행
- 사용자가 원하는 기느을 제공하는 것은 응용 영역에 위치한 서비스
- 회원 가입을 요청했다면 실제 그 요청을 위한 기능을 제공하는 주체는 응용 서비스
- 응용 서비스의 메서드가 요구하는 파라미터와 표현 영역이 사용자로부터 전달받은 데이터는 형식이 일치하지 않음
- 표현 영역은 응용 서비스가 요구하는 현식으로 사용자 요청을 변환
@PostMapping("/member/join") public ModelAndView join(HttpServletRequest reqeust) { String email = request.getParameter("email"); String password = request.getParameter("password"); JoinRequest joinRequest = new JoinReqeust(email, password); joinService.join(joinReqeust); }
- 사용자와 상호작용은 표현 영역이 처리하기 때문에 응용 서비스는 표현 영역에 의존하지 않는다.
- 응용 영역은 사용자가 웹 브라우저를 사용하는지 REST API를 호출하는지, TCP 소켓을 사용하는지 알필요가 없다.
6.2 응용 서비스의 역활
- 응용 서비스는 사용자의 요청을 처리하기 위해 리포지터리에 도메인 객체를 가져와 사용
- 응용 서비스는 주로 도메인 객체 간의 흐름을 제어하기 때문에 단순한 형태를 갖는다.
- public Result doSomeFunc(SomeReq req) { SomeAgg agg = someAggRepository.findById(req.getId()); checkNull(agg); agg.doFunc(req.getValue()); return createSuccessResult(agg); }
- 새로운 애그리거트를 생성하는 응용 서비스 역시 간단하다.
- public Result doSomeCreation(CreateSomeReq req) { validate(req); SomeAgg newAgg = createSome(req); someAggRepository.save(newAgg); return createSuccessResult(newAgg); }
- 응용 서비스가 복잡하다면 응용 서비스에서 도메인 로직의 일부를 구현하고 있을 가능성이 높다
- 응용 서비스가 도메인 로직을 일부 구현하면 코드 중복, 로직 분산 등 코드 품질에 안좋은 영향
- 응용 서비스는 도메인의 상태 변경을 트랜잭션으로 처리해야 한다.
- Member 객체의 block 메서드를 실행해서 상태를 변경했는데 DB에 반영하는 도중 문제가 발생하면?
- 일부 Member만 차단 상태가 되어 데이터 일관성이 꺠지게 된다.
- 트랜잭션 범위에서 응용 서비스를 실행
public void blockMembers(String[] blockingIds) { if (blockingIds == null || blockingIds.length == 0) { return; } List<Member> members = memberRepository.findByIdIn(blockingIds); for (Member mem: members) { mem.block(); } }
- Member 객체의 block 메서드를 실행해서 상태를 변경했는데 DB에 반영하는 도중 문제가 발생하면?
6.2.1 도메인 로직 넣지 않기
- 도메인 로직은 도메인 영역에 위치하고 응용 서비스는 도메인 로직을 구현하지 않음
- 암호 변경 기능을 위한 응용 서비스
- public class ChangePasswordService { public void changePassword(String memberId, String oldPw, String newPw) { Member member = memberRepository.findById(memberId); checkMemberExists(member); member.changePassword(oldPw, newPw); } }
- 암호 변경을 하기 위한 Member 애그리거트
- public class Member { public void chagnePassword(String oldPw, String newPw) { if (!matchpassword(oldPw)) { throw new BasPasswordException(); } setPassword(newPw); } public boolean matchPassword(String pwd) { return passwordEncoder.matches(pwd); } private void setPassword(String newPw) { if (isEmpty(newPw)) { throw new IllegalArgumnetException("no new passsword"); } this.password = newPw; } }
- 응용 서비스에서 암호를 변경하는 로직
- 잘못된 예제
- 코드의 응집성이 떨어짐
- 동일한 로직을 여러곳에서 구현할 가능성이 높아짐
- 암호를 확인하는 로직이 있는곳에서는 중복 코드가 발생함
public class ChangePasswordService { public void changePassword(String memberId, String oldPw, String newPw) { Member member = memberRepository.findById(memberId); checkMemberExists(member); if (!passwordEncoder.matches(oldPw, member.getPassword()) { throw new BasPasswordException(); } member.changePassword(oldPw, newPw); } }
- 중복 코드가 발생 되는 예제
- public class DeactivationService { public void deactivate(String memberId, String pwd) { Member member = memberRepository.findById(memberId); checkMemberExists(member); if (!passwordEncoder.matches(oldPw, member.getPassword()) { throw new BasPasswordException(); } member.deactivate(); } }
- Member 애그리거트에서 암호확인 기능 구현
- public class DeactivationService { public void deactivate(String memberId, String pwd) { Member member = memberRepository.findById(memberId); checkMemberExists(member); if (!member.matchPassword(pwd)) { throw new BasPasswordException(); } member.deactivate(); } }
- 잘못된 예제
6.3 응용 서비스의 구현
- 응용 서비스는 표현 영역과 도메인 영역을 연결하는 매개체 역활
- 디자인 패턴의 파사드와 같은 역활을 한다.
- 응용 서비스를 구현할 때 몇가지 고려할 사항과 트랜잭션과 같은 구현 기술의 연동에 대해 살펴본다.
6.3.1 응용 서비스 크기
- 응용 서비스 크기는 어떻게 할 것인가?
- 회원 도메인 기능
- 회원가입
- 회원 탈퇴
- 암호 변경
- 비밀번호 초기화
- 회원 도메인 기능
- 응용 서비스는 보통 두가지 방법중 한가지 방식으로 구현
- 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현
- 구분된 기능별로 응용 서비스 클래스를 따로 구현
- 회원과 관련된 기능을 한 클래스에서 모두 구현
- 동일 로직에 대한 코드 중복을 제거 가능
- 회원이 존재하지 않으면 NoMemberException을 발생시 private 메서드로 구현해서 호출하면 중복 코드를 제거 할 수 있다.
- 한 서비스 클래스의 크기가 커진다는 것은 단점
public class MemberService { private MemberRepository memberRepository; private Notifier notifier; public void chagnePassword(String memberId, String currentPw, String newPw) { Member member = findExistingMember(memberId); member.changePassword(currentPw, newPw); } public void initalizePassword(String memberId) { Member member = findExistingMember(memberId); String newPassword = member.initalizePassword(); notifier.notifyNewPassword(member, newPassword); } public void leave(String memberId, String curPw) { Member member = findExistingMember(memberId); member.leave(); } private Member findExistingMember(String memberId) { Member member = memberRepository.findById(memberId); if (member == null) { throw new NoMemberException(memberId); } return member; } }
- 구분 되는 기능별로 서비스 클래스를 구현하는 방식은 한 응용 서비스 클래스에서 한 개 내지 2~3개의 기능을 구현
- 각 클래스별로 필요한 의존 객체만 포함하므로 다른 기능을 구현할 코드에 영향을 받지 않는다.
public class ChnagePasswordService { private MemberRepository memberRepository; public void changePassword(String memberId, String curPw, String newPw) { Member member = memberRepository.findById(memberId); if (member == null) { throw new NoMemberException(memberId); } member.chagePassword(curPw, newPw); } }
- 동일 로직에 대한 코드 중복을 제거 가능
- public class MemberSerice { private MemberRepository memberRepository; public void join(MemberJoinReqeust joinRequest) {...} public void changePassword(String memberId, String curPw, String newPw) {...} public void initializePassword(String memberId) {...} public void leave(String. memberId, String curPw) {...} }
- 각 기능마다 동일한 로직을 구현할 경우 여러 클래스에 중복해서 동일한 코드를 구현할 가능성이 있다.
- 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; } } public class ChnagePasswordService { private MemberRepository memberRepository; public void changePassword(String memberId, String curPw, String newPw) { Member member = findExistingMember(memberRepository, memberId); member.chagePassword(curPw, newPw); } }
6.3.2 응용 서비스의 인터페이스와 클래스
- 응용 서비스를 구현할 때 논쟁이 될 만한 것은 인터페이스가 필요한지?
- public interface ChangePasswordService { public void changePassword(String memberId, String curPw, String newPw); } public class ChangePasswordServiceImpl implements ChangePasswordService { }
- 인터페이스가 필요한 몇가지 상황
- 구현 클래스가 여러 개인 경우
- 응용 서비스는 런타임에 교체하는 경우가 거의 없고 구현 클래스가 두개인 경우도 드물다
- 구현 클래스가 여러 개인 경우
- 인터페이스와 클래스를 따로 구현하면 소스 파일만 많아지고 구현 클래스에 대한 간접 참조가 증가해 전체 구조가 복잡해짐
- 인터페이스가 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스를 작성하는 것은 좋은 선택이라고 볼 수는 없다.
- TDD로 개발한다면 사용할 응용 서비스 클래스의 구현은 존재하지 않으므로 인터페이스를 이용해서 컨트롤러의 구현을 완성해 나갈 수 있다.
- Mockito와 같은 테스트 도구를 사용하면 이런 부분도 해결이 가능
6.3.3 메서드 파라미터와 값 리턴
- 응용 서비스가 제공하는 메서드는 도메인을 이용해서 사용자가 요구한 기능을 실행하는데 필요한 값을 파라미터로 전달 받아야 한다.
- public class ChangePasswordService { public void changePassword(String memberId, String curPw, String newPw) { ... } }
- 별도 데이터 클래스를 만들어서 전달 받을 수 있다.
- public class ChangePasswordRequest { private String memberId; private String currentPassword; private String newpassword; ... }
- 응용 서비스는 파라미터로 전달받은 데이터를 사용해서 필요한 기능을 구현하면 된다.
- 전달할 요청 파라미터가 두 개 이상 존재하면 데이터 전달을 위한 별도 클래스를 사용하는 것이 편리
public class ChangePasswordService { public void changePassword(ChangePasswordRequest req) { Member member = findExistingMember(req.getMemberId()); member.changePassword(req.getCurrentPassword(), req.getNewPassword()); } }
- 응용 서비스의 결과를 표현 영역에서 사용해야 하면 응용 서비스 메서드의 결과로 필요한 데이터를 리턴
- 결과 데이터가 필요한 대표적인 예가 식별자
public class OrderService { @Transactional public OrderNo placeOrder(OrderRequest orderRequest) { OrderNo orderNo = orderRepository.nextId(); Order order = createOrder(orderNo, orderRquest); orderRepository.save(order); return orderNo; } }
- 표현 영역 코드는 응용 서비스가 리턴한 값을 사용해서 사용자에게 알맞은 결과를 보여줄수 있게 된다.
- @Controller public class OrderController { @PostMapping("/order/place") public String order(OrderRequest orderReq, ModelMap model) { setOrderer(orderReq); OrderNo orderNo = orderService.placeOrder(orderReq); modelMap.setAttribute("orderNo", orderNo.toString()); return "order/success"; } }
- 응용 서비스에서 애그리거트 자체를 리턴하면 코딩은 편할 수 있지만 도메인의 로직 실행을 응용 서비스와 표현 영역 두 곳에서 할수 있게 된다.
- 기능 실행 로직을 응용 서비스와 표현 영역에 분산시켜 코드의 응집도를 낮추는 원인
- 응용 서비스는 표현 영역에서 필요한 데이터만 리턴하는 것이 기능 실행 로직의 응집도를 높이는 확실한 방법
6.3.4 표현 영역에 의존하지 않기
- 응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 영역과 관련된 타입을 사용하면 안된다.
- HttpServletRequest 나 HttpSession을 응용 서비스에 파라미터로 전달하면 안된다.
- 응용 서비스에서 표현 영역에 대한 의존이 반영되면 응용 서비스만 단독으로 테스트가 어려움
- 표현 영역이 변경되면 응용 서비스 구현도 함께 변경해야 하는 문제 발생
@Controller @RequestMapping("/member/chagePassword") public class MemberPasswordController { @PostMapping public String submit(HttpServletReqeust request) { try { changePasswordService.changePassword(request); } catch(NoMemberException ex) { } } }
- 응용 서비스가 표현 영역의 역활까지 대신하는 상황이 벌어질 수도 있다.
- HttpSession 이나 쿠키는 표현 영역의 상태에 해당
- 응용 서비스에서 상태를 변경해버리면 표현영역의 코드만으로 표현영역의 상태가 어떻게 변경되는지 추적이 어려워짐
public class AuthenticationService { public void authenticate(HttpServletRequest reqeust) { String id = request.getParameter("id"); String password = reqeust.getParameter("password"); if (checkIdPasswordMatching(id, password)) { HttpSession session = request.getSession(); session.setAttribute("auth", new Authentication(id)); } } }
- 문제가 발생하지 않도록 하려면 철저하게 응용 서비스가 표현 영역의 기술을 사용하지 않도록 해야함
6.3.5 트랜잭션 처리
- 트랜잭션을 관리하는 것은 응용 서비스의 중요한 역활이다.
- 프레임워크가 제공하는 트랜잭션 기능을 적극 사용하는 것이 좋다.
- 간단한 설정만으로 트랜잭션을 시작하고 커밋하고 익셉션이 발생하면 롤백 할 수 있다.
public class ChangePasswordService { @Transactional public void changePassword(ChangePasswordRequest req) { Member member = findExistingMember(req.getMemberId()); member.changePassword(req.getCurrentPassword(), req.getNewPassword()); } }
6.4 표현 영역
- 표현 영역의 책임은 크게 다음과 같다
- 사용자가 시스템을 사용할 수 있는 흐름을 제공하고 제어한다.
- 사용자의 요청을 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공한다.
- 사용자의 세션을 관리한다.
- 표현 영역의 첫 번째 책임은 사용자가 시스템을 사용할 수 있도록 알맞은 흐름을 제공
- 표현 영역의 두 번째 책임은 사용자의 요청에 맞게 응용 서비스에 기능 실행을 요청하는 것
- 표현 영역은 사용자의 요청 데이터를 응용 서비스가 요구하는 형식으로 변환
- 응용 서비스 결과를 사용자에게 응답할 수 있는 현식으로 변환
@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 (BadPasswordException | NoMemberException ex) { errors.reject("idPaswordNotMatch"); 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 (BadPasswordException | NoMemberException ex) { errors.reject("idPaswordNotMatch"); return formView; } }
- 표현 영역의 다른 주된 역활은 사용자의 연결 상태인 세션을 관리하는 것
6.5 값 검증
- 값 검증은 표현 영역과 응용 서비스 두 곳에서 모두 수행 할 수 있다.
- 원칙적으로 모든 값에 대한 검증은 응용 서비스에서 처리
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 checkDuplicatdId(String id) { int count = memeberRepository.countsById(id); if (count > 0) { throw new DuplicateIdException(); } } }
- 표현 영역은 잘못된 값이 존재하면 사용자에게 알려주고 값을 다시 입력받아야 한다.
- 컨트롤러에서 응용 서비스에서 발생한 폼에 에러 메세지를 보여주기 위해서는 다소 번잡한 코드를 작성 해야함
@Controller public class Controller { @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 (DuplicateIdException ex) { errors.rejectValue(ex.getPropertyName(), "duplicate"); return formView; } } }
- 응용 서비스에서 각 값으 유효한지를 확인할 목적으로 익셉션을 사용할 때의 문제점은 사용자에게 좋지 않은 경험을 제공
- 값을 검사하는 시점에 첫 번째 값이 올바르지 않아 익셉션을 발생시키면 나머지 항목에 대한 값을 검사하지 않게 된다.
- 첫 번째 값에 대한 에러 메세지만 보게됨
- 사용자가 같은 폼에 여러번 입력하게 만든다.
- 응용 서비스에서 에러코드를 모아 하나의 익셉션으로 발생시키는 방법
- 표현 영역은 응용 서비스가 ValidationErrorException을 발생시키면 에러 목록을 가져와 표현 영역에서 사용할 형태로 변환
@PostMaaping("/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.geUsername())); try { OrderNo orderNo = placeORderService.placeOrder(orderRequest); modelMap.addAttribute("orderNo", orderNo.getNumber()); } catch (ValidationErrorException e) { e.getErrors().forEacth(err -> { if (err.hasName()) { bindingResult.rejectValue(err.getName(), err.getCode()); } else { bindingResult.reject(err.getCode()); } }); populateProductsModel(orderRequest, modelMap); return "order/confirm"; } }
- @Transactional public OrderNo placeOrder(OrderRequest orderRequest) { List<ValidationError> errors = new ArrayList<>(); if (orderReqeust == null) { errors.add(ValidationError.of("empty"); } else { if (orderRequest.getOrdererMemberId() == null) { errors.add(ValidationError.of("orderMemberId", "empty"); } if (orderRequest.getOrderProducts() == null) { errors.add(ValidationError.of("orderProducts", "empty"); } if (orderRequest.getOrderProducts().isEmpty() == null) { errors.add(ValidationError.of("orderProducts", "empty"); } } if (!errors.isEmpty()) { throw new ValidationErrorException(errors); } }
- 표현역역에서 필수 값을 검증하는 방법도 있다.
- @Controller public class Controller { @PostMapping("/member/join") public String join(JoinRequest joinRequest, Errors errors) { checkEmpty(joinRequest.getId(), "id", errors); checkEmpty(joinRequest.getName(), "name", errors); if (errors.hasErrors()) { return formView; } try { joinService.join(joinRequest); return successView; } catch (DuplicateIdExcepiton ex) { errors.rejectValue(ex.getPropertyName(), "duplicate"); return formView; } } private void checkEmpty(String value, String property, Errors errors) { if (isEmpty(value)) { errors.rejectValue(property, "empty"); } } }
- 스프링 같은 프레임워크는 값 검증을 위한 Validator 인터페이스를 별도로 제공하므로 인터페이스를 구현한 검증기를 따로 구현할 수 있다.
- @Controller public class Controller { @PostMapping("/member/join") public String join(JoinRequest joinRequest, Errors errors) { new JoinRequestValidator().validate(joinRequest, errors); if (errors.hasErrors()) { return formView; } try { joinService.join(joinRequest); return successView; } catch (DuplicateIdExcepiton ex) { errors.rejectValue(ex.getPropertyName(), "duplicate"); return formView; } } }
- 표현 영역에서 필수 값과 형식을 검사하면 응용 서비스는 ID 중복 여부와 같은 논리적 오류만 검사하면 된다.
- 표현 영역: 필수 값, 값의 형식, 범위 등을 검증
- 응용 서비스: 데이터의 존재 유무와 같은 논리적 오류를 검증
- 응용 서비스에서 필요한 값 검증을 모두 처리
- 프레임워크가 제공하는 검증 기능을 사용할 때보다 작성할 코드가 늘어나는 단점이 있음
- 응용 서비스이 완성도가 높아지는 이점도 있음
6.6 권한 검사
- 권한 검사 자체는 복잡한 개념은 아님
- 시스템마다 권한의 복잡도가 다름
- 보안 프레임워크의 복잡도를 떠나 보통 세곳에서 권한 검사를 수행할 수 있다.
- 표현 영역
- 응용 서비스
- 도메인
표현 영역
- 인증된 사용자인지 아닌지 검사
- 회원 정보 변경 기능에서 회원 정보 변경과 관련된 URL은 인증된 사용자만 접근해야 한다.
- 회원 정보 변경을 처리하는 URL에 대한 표현 영역에서 접근 제어
- URL을 처리하는 컨트롤러에 웹 요청을 전달하기 전에 인증 여부를 검사해서 인증된 사용자의 웹 요청만 컨트롤러에 전달
- 인증된 사용자가 아닐 경우 로그인 화면으로 리다이렉트
- 접근 제어를 하기 좋은 위치가 서블릿 필터(Servlet Filter)
- 사용자의 인증 정보를 생성하고 인증 여부를 검사
- 인증 여부 뿐만 아니라 권한에 대해서 동일한 방식으로 필터를 사용해서 URL별 권한 검사를 할 수 있다.
- 스프링 시큐리티는 이와 유사항 방식으로 필터를 인증해서 인증 정보를 생성하고 접근 제어 한다.
응용 서비스
- URL만으로 접근 제어를 할 수 없는 경우 응용 서비스의 메서드 단위로 권한 검사를 수행해야 한다.
- 응용 서비스의 코드에서 직접 권한 검사를 해야 한다는 것을 의미하는 것은 아님
- public class BlockMemberService { private MemberRepository memberRepository; @PreAuthorize("hasRole('ADMIN')") public void block(String memberId) { Member member = memberRepository.findById(memberId); if (member == null) { throw new NoMemberExcepiton(); } member.block(); } }
도메인
- 개별 도메인 객체 단위로 권한 검사를 해야하는 경우는 구현이 복잡해짐
- 게시글 삭제는 본인 또는 관리자 역활을 가진 사용자만 할 경우
- 게시글 작성자가 본인인지 확인하려면 게시글 애그리커트를 먼저 로딩
- 응용 서비스의 메서드 수준에서 권한 검사를 할 수 없음
- permissionService.checkDeletePermission에서 삭제 권한을 검사
public class DeleteAtricleService { public void delete(String userId, Long articleId) { Article article = articleRepository.findById(articleId); checkArticleExistence(article); permissionService.checkDeletePermission(userId, article); article.markDeleted(); } }
- 게시글 작성자가 본인인지 확인하려면 게시글 애그리커트를 먼저 로딩
- 스프링 시큐리티와 같은 보안 프레임워크를 확장해서 개별 도메인 객체 수준의 권한 검사 기능을 프레임워크에 통합 할수 있음
- 도메인에 맞게 프레임워크를 확정하려면 프레임워크에 대한 높은 이해가 필요
- 이해도가 높지 않아 프레임워크 확장을 원하는 수준으로 할 수 없다면 프레임워크를 사용하는 대신 도메인에 맞는 권한 검사 기능을 직접 구현하는 것이 코드 유지보수에 유리
6.7 조회 전용 기능과 응용 서비스
- 서비스에서 조회 전용 기능을 사용하면 서비스 코드가 단순히 조회 전용 기능을 호추하는 현태로 끝날 수 있다.
- 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"; } }
- 응용 서비스가 사용자 요청 실행하는데 별다른 기여를 하지 못한다면 굳이 서비스를 만들지 않아도 된다.
6.1 표현 영역과 응용 영역
- 도메인이 제 기능을 하려면 사용자와 도메인을 연결해주는 매개체가 필요하다
- 표현 영역은 사용자의 요청을 해석
- 웹 브라우저에서 ID와 암호를 입력한 뒤에 전송 버튼을 클릭하면 요청 파라미터를 포함함 HTTP 요청을 표현 영역에 전달
- 사용자가 실행하고 싶은 기능을 판별하고 기능을 제공하는 응용 서비스를 실행
- 사용자가 원하는 기느을 제공하는 것은 응용 영역에 위치한 서비스
- 회원 가입을 요청했다면 실제 그 요청을 위한 기능을 제공하는 주체는 응용 서비스
- 응용 서비스의 메서드가 요구하는 파라미터와 표현 영역이 사용자로부터 전달받은 데이터는 형식이 일치하지 않음
- 표현 영역은 응용 서비스가 요구하는 현식으로 사용자 요청을 변환
@PostMapping("/member/join") public ModelAndView join(HttpServletRequest reqeust) { String email = request.getParameter("email"); String password = request.getParameter("password"); JoinRequest joinRequest = new JoinReqeust(email, password); joinService.join(joinReqeust); }
- 사용자와 상호작용은 표현 영역이 처리하기 때문에 응용 서비스는 표현 영역에 의존하지 않는다.
- 응용 영역은 사용자가 웹 브라우저를 사용하는지 REST API를 호출하는지, TCP 소켓을 사용하는지 알필요가 없다.
6.2 응용 서비스의 역활
- 응용 서비스는 사용자의 요청을 처리하기 위해 리포지터리에 도메인 객체를 가져와 사용
- 응용 서비스는 주로 도메인 객체 간의 흐름을 제어하기 때문에 단순한 형태를 갖는다.
- public Result doSomeFunc(SomeReq req) { SomeAgg agg = someAggRepository.findById(req.getId()); checkNull(agg); agg.doFunc(req.getValue()); return createSuccessResult(agg); }
- 새로운 애그리거트를 생성하는 응용 서비스 역시 간단하다.
- public Result doSomeCreation(CreateSomeReq req) { validate(req); SomeAgg newAgg = createSome(req); someAggRepository.save(newAgg); return createSuccessResult(newAgg); }
- 응용 서비스가 복잡하다면 응용 서비스에서 도메인 로직의 일부를 구현하고 있을 가능성이 높다
- 응용 서비스가 도메인 로직을 일부 구현하면 코드 중복, 로직 분산 등 코드 품질에 안좋은 영향
- 응용 서비스는 도메인의 상태 변경을 트랜잭션으로 처리해야 한다.
- Member 객체의 block 메서드를 실행해서 상태를 변경했는데 DB에 반영하는 도중 문제가 발생하면?
- 일부 Member만 차단 상태가 되어 데이터 일관성이 꺠지게 된다.
- 트랜잭션 범위에서 응용 서비스를 실행
public void blockMembers(String[] blockingIds) { if (blockingIds == null || blockingIds.length == 0) { return; } List<Member> members = memberRepository.findByIdIn(blockingIds); for (Member mem: members) { mem.block(); } }
- Member 객체의 block 메서드를 실행해서 상태를 변경했는데 DB에 반영하는 도중 문제가 발생하면?
6.2.1 도메인 로직 넣지 않기
- 도메인 로직은 도메인 영역에 위치하고 응용 서비스는 도메인 로직을 구현하지 않음
- 암호 변경 기능을 위한 응용 서비스
- public class ChangePasswordService { public void changePassword(String memberId, String oldPw, String newPw) { Member member = memberRepository.findById(memberId); checkMemberExists(member); member.changePassword(oldPw, newPw); } }
- 암호 변경을 하기 위한 Member 애그리거트
- public class Member { public void chagnePassword(String oldPw, String newPw) { if (!matchpassword(oldPw)) { throw new BasPasswordException(); } setPassword(newPw); } public boolean matchPassword(String pwd) { return passwordEncoder.matches(pwd); } private void setPassword(String newPw) { if (isEmpty(newPw)) { throw new IllegalArgumnetException("no new passsword"); } this.password = newPw; } }
- 응용 서비스에서 암호를 변경하는 로직
- 잘못된 예제
- 코드의 응집성이 떨어짐
- 동일한 로직을 여러곳에서 구현할 가능성이 높아짐
- 암호를 확인하는 로직이 있는곳에서는 중복 코드가 발생함
public class ChangePasswordService { public void changePassword(String memberId, String oldPw, String newPw) { Member member = memberRepository.findById(memberId); checkMemberExists(member); if (!passwordEncoder.matches(oldPw, member.getPassword()) { throw new BasPasswordException(); } member.changePassword(oldPw, newPw); } }
- 중복 코드가 발생 되는 예제
- public class DeactivationService { public void deactivate(String memberId, String pwd) { Member member = memberRepository.findById(memberId); checkMemberExists(member); if (!passwordEncoder.matches(oldPw, member.getPassword()) { throw new BasPasswordException(); } member.deactivate(); } }
- Member 애그리거트에서 암호확인 기능 구현
- public class DeactivationService { public void deactivate(String memberId, String pwd) { Member member = memberRepository.findById(memberId); checkMemberExists(member); if (!member.matchPassword(pwd)) { throw new BasPasswordException(); } member.deactivate(); } }
- 잘못된 예제
6.3 응용 서비스의 구현
- 응용 서비스는 표현 영역과 도메인 영역을 연결하는 매개체 역활
- 디자인 패턴의 파사드와 같은 역활을 한다.
- 응용 서비스를 구현할 때 몇가지 고려할 사항과 트랜잭션과 같은 구현 기술의 연동에 대해 살펴본다.
6.3.1 응용 서비스 크기
- 응용 서비스 크기는 어떻게 할 것인가?
- 회원 도메인 기능
- 회원가입
- 회원 탈퇴
- 암호 변경
- 비밀번호 초기화
- 회원 도메인 기능
- 응용 서비스는 보통 두가지 방법중 한가지 방식으로 구현
- 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현
- 구분된 기능별로 응용 서비스 클래스를 따로 구현
- 회원과 관련된 기능을 한 클래스에서 모두 구현
- 동일 로직에 대한 코드 중복을 제거 가능
- 회원이 존재하지 않으면 NoMemberException을 발생시 private 메서드로 구현해서 호출하면 중복 코드를 제거 할 수 있다.
- 한 서비스 클래스의 크기가 커진다는 것은 단점
public class MemberService { private MemberRepository memberRepository; private Notifier notifier; public void chagnePassword(String memberId, String currentPw, String newPw) { Member member = findExistingMember(memberId); member.changePassword(currentPw, newPw); } public void initalizePassword(String memberId) { Member member = findExistingMember(memberId); String newPassword = member.initalizePassword(); notifier.notifyNewPassword(member, newPassword); } public void leave(String memberId, String curPw) { Member member = findExistingMember(memberId); member.leave(); } private Member findExistingMember(String memberId) { Member member = memberRepository.findById(memberId); if (member == null) { throw new NoMemberException(memberId); } return member; } }
- 구분 되는 기능별로 서비스 클래스를 구현하는 방식은 한 응용 서비스 클래스에서 한 개 내지 2~3개의 기능을 구현
- 각 클래스별로 필요한 의존 객체만 포함하므로 다른 기능을 구현할 코드에 영향을 받지 않는다.
public class ChnagePasswordService { private MemberRepository memberRepository; public void changePassword(String memberId, String curPw, String newPw) { Member member = memberRepository.findById(memberId); if (member == null) { throw new NoMemberException(memberId); } member.chagePassword(curPw, newPw); } }
- 동일 로직에 대한 코드 중복을 제거 가능
- public class MemberSerice { private MemberRepository memberRepository; public void join(MemberJoinReqeust joinRequest) {...} public void changePassword(String memberId, String curPw, String newPw) {...} public void initializePassword(String memberId) {...} public void leave(String. memberId, String curPw) {...} }
- 각 기능마다 동일한 로직을 구현할 경우 여러 클래스에 중복해서 동일한 코드를 구현할 가능성이 있다.
- 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; } } public class ChnagePasswordService { private MemberRepository memberRepository; public void changePassword(String memberId, String curPw, String newPw) { Member member = findExistingMember(memberRepository, memberId); member.chagePassword(curPw, newPw); } }
6.3.2 응용 서비스의 인터페이스와 클래스
- 응용 서비스를 구현할 때 논쟁이 될 만한 것은 인터페이스가 필요한지?
- public interface ChangePasswordService { public void changePassword(String memberId, String curPw, String newPw); } public class ChangePasswordServiceImpl implements ChangePasswordService { }
- 인터페이스가 필요한 몇가지 상황
- 구현 클래스가 여러 개인 경우
- 응용 서비스는 런타임에 교체하는 경우가 거의 없고 구현 클래스가 두개인 경우도 드물다
- 구현 클래스가 여러 개인 경우
- 인터페이스와 클래스를 따로 구현하면 소스 파일만 많아지고 구현 클래스에 대한 간접 참조가 증가해 전체 구조가 복잡해짐
- 인터페이스가 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스를 작성하는 것은 좋은 선택이라고 볼 수는 없다.
- TDD로 개발한다면 사용할 응용 서비스 클래스의 구현은 존재하지 않으므로 인터페이스를 이용해서 컨트롤러의 구현을 완성해 나갈 수 있다.
- Mockito와 같은 테스트 도구를 사용하면 이런 부분도 해결이 가능
6.3.3 메서드 파라미터와 값 리턴
- 응용 서비스가 제공하는 메서드는 도메인을 이용해서 사용자가 요구한 기능을 실행하는데 필요한 값을 파라미터로 전달 받아야 한다.
- public class ChangePasswordService { public void changePassword(String memberId, String curPw, String newPw) { ... } }
- 별도 데이터 클래스를 만들어서 전달 받을 수 있다.
- public class ChangePasswordRequest { private String memberId; private String currentPassword; private String newpassword; ... }
- 응용 서비스는 파라미터로 전달받은 데이터를 사용해서 필요한 기능을 구현하면 된다.
- 전달할 요청 파라미터가 두 개 이상 존재하면 데이터 전달을 위한 별도 클래스를 사용하는 것이 편리
public class ChangePasswordService { public void changePassword(ChangePasswordRequest req) { Member member = findExistingMember(req.getMemberId()); member.changePassword(req.getCurrentPassword(), req.getNewPassword()); } }
- 응용 서비스의 결과를 표현 영역에서 사용해야 하면 응용 서비스 메서드의 결과로 필요한 데이터를 리턴
- 결과 데이터가 필요한 대표적인 예가 식별자
public class OrderService { @Transactional public OrderNo placeOrder(OrderRequest orderRequest) { OrderNo orderNo = orderRepository.nextId(); Order order = createOrder(orderNo, orderRquest); orderRepository.save(order); return orderNo; } }
- 표현 영역 코드는 응용 서비스가 리턴한 값을 사용해서 사용자에게 알맞은 결과를 보여줄수 있게 된다.
- @Controller public class OrderController { @PostMapping("/order/place") public String order(OrderRequest orderReq, ModelMap model) { setOrderer(orderReq); OrderNo orderNo = orderService.placeOrder(orderReq); modelMap.setAttribute("orderNo", orderNo.toString()); return "order/success"; } }
- 응용 서비스에서 애그리거트 자체를 리턴하면 코딩은 편할 수 있지만 도메인의 로직 실행을 응용 서비스와 표현 영역 두 곳에서 할수 있게 된다.
- 기능 실행 로직을 응용 서비스와 표현 영역에 분산시켜 코드의 응집도를 낮추는 원인
- 응용 서비스는 표현 영역에서 필요한 데이터만 리턴하는 것이 기능 실행 로직의 응집도를 높이는 확실한 방법
6.3.4 표현 영역에 의존하지 않기
- 응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 영역과 관련된 타입을 사용하면 안된다.
- HttpServletRequest 나 HttpSession을 응용 서비스에 파라미터로 전달하면 안된다.
- 응용 서비스에서 표현 영역에 대한 의존이 반영되면 응용 서비스만 단독으로 테스트가 어려움
- 표현 영역이 변경되면 응용 서비스 구현도 함께 변경해야 하는 문제 발생
@Controller @RequestMapping("/member/chagePassword") public class MemberPasswordController { @PostMapping public String submit(HttpServletReqeust request) { try { changePasswordService.changePassword(request); } catch(NoMemberException ex) { } } }
- 응용 서비스가 표현 영역의 역활까지 대신하는 상황이 벌어질 수도 있다.
- HttpSession 이나 쿠키는 표현 영역의 상태에 해당
- 응용 서비스에서 상태를 변경해버리면 표현영역의 코드만으로 표현영역의 상태가 어떻게 변경되는지 추적이 어려워짐
public class AuthenticationService { public void authenticate(HttpServletRequest reqeust) { String id = request.getParameter("id"); String password = reqeust.getParameter("password"); if (checkIdPasswordMatching(id, password)) { HttpSession session = request.getSession(); session.setAttribute("auth", new Authentication(id)); } } }
- 문제가 발생하지 않도록 하려면 철저하게 응용 서비스가 표현 영역의 기술을 사용하지 않도록 해야함
6.3.5 트랜잭션 처리
- 트랜잭션을 관리하는 것은 응용 서비스의 중요한 역활이다.
- 프레임워크가 제공하는 트랜잭션 기능을 적극 사용하는 것이 좋다.
- 간단한 설정만으로 트랜잭션을 시작하고 커밋하고 익셉션이 발생하면 롤백 할 수 있다.
public class ChangePasswordService { @Transactional public void changePassword(ChangePasswordRequest req) { Member member = findExistingMember(req.getMemberId()); member.changePassword(req.getCurrentPassword(), req.getNewPassword()); } }
6.4 표현 영역
- 표현 영역의 책임은 크게 다음과 같다
- 사용자가 시스템을 사용할 수 있는 흐름을 제공하고 제어한다.
- 사용자의 요청을 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공한다.
- 사용자의 세션을 관리한다.
- 표현 영역의 첫 번째 책임은 사용자가 시스템을 사용할 수 있도록 알맞은 흐름을 제공
- 표현 영역의 두 번째 책임은 사용자의 요청에 맞게 응용 서비스에 기능 실행을 요청하는 것
- 표현 영역은 사용자의 요청 데이터를 응용 서비스가 요구하는 형식으로 변환
- 응용 서비스 결과를 사용자에게 응답할 수 있는 현식으로 변환
@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 (BadPasswordException | NoMemberException ex) { errors.reject("idPaswordNotMatch"); 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 (BadPasswordException | NoMemberException ex) { errors.reject("idPaswordNotMatch"); return formView; } }
- 표현 영역의 다른 주된 역활은 사용자의 연결 상태인 세션을 관리하는 것
6.5 값 검증
- 값 검증은 표현 영역과 응용 서비스 두 곳에서 모두 수행 할 수 있다.
- 원칙적으로 모든 값에 대한 검증은 응용 서비스에서 처리
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 checkDuplicatdId(String id) { int count = memeberRepository.countsById(id); if (count > 0) { throw new DuplicateIdException(); } } }
- 표현 영역은 잘못된 값이 존재하면 사용자에게 알려주고 값을 다시 입력받아야 한다.
- 컨트롤러에서 응용 서비스에서 발생한 폼에 에러 메세지를 보여주기 위해서는 다소 번잡한 코드를 작성 해야함
@Controller public class Controller { @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 (DuplicateIdException ex) { errors.rejectValue(ex.getPropertyName(), "duplicate"); return formView; } } }
- 응용 서비스에서 각 값으 유효한지를 확인할 목적으로 익셉션을 사용할 때의 문제점은 사용자에게 좋지 않은 경험을 제공
- 값을 검사하는 시점에 첫 번째 값이 올바르지 않아 익셉션을 발생시키면 나머지 항목에 대한 값을 검사하지 않게 된다.
- 첫 번째 값에 대한 에러 메세지만 보게됨
- 사용자가 같은 폼에 여러번 입력하게 만든다.
- 응용 서비스에서 에러코드를 모아 하나의 익셉션으로 발생시키는 방법
- 표현 영역은 응용 서비스가 ValidationErrorException을 발생시키면 에러 목록을 가져와 표현 영역에서 사용할 형태로 변환
@PostMaaping("/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.geUsername())); try { OrderNo orderNo = placeORderService.placeOrder(orderRequest); modelMap.addAttribute("orderNo", orderNo.getNumber()); } catch (ValidationErrorException e) { e.getErrors().forEacth(err -> { if (err.hasName()) { bindingResult.rejectValue(err.getName(), err.getCode()); } else { bindingResult.reject(err.getCode()); } }); populateProductsModel(orderRequest, modelMap); return "order/confirm"; } }
- @Transactional public OrderNo placeOrder(OrderRequest orderRequest) { List<ValidationError> errors = new ArrayList<>(); if (orderReqeust == null) { errors.add(ValidationError.of("empty"); } else { if (orderRequest.getOrdererMemberId() == null) { errors.add(ValidationError.of("orderMemberId", "empty"); } if (orderRequest.getOrderProducts() == null) { errors.add(ValidationError.of("orderProducts", "empty"); } if (orderRequest.getOrderProducts().isEmpty() == null) { errors.add(ValidationError.of("orderProducts", "empty"); } } if (!errors.isEmpty()) { throw new ValidationErrorException(errors); } }
- 표현역역에서 필수 값을 검증하는 방법도 있다.
- @Controller public class Controller { @PostMapping("/member/join") public String join(JoinRequest joinRequest, Errors errors) { checkEmpty(joinRequest.getId(), "id", errors); checkEmpty(joinRequest.getName(), "name", errors); if (errors.hasErrors()) { return formView; } try { joinService.join(joinRequest); return successView; } catch (DuplicateIdExcepiton ex) { errors.rejectValue(ex.getPropertyName(), "duplicate"); return formView; } } private void checkEmpty(String value, String property, Errors errors) { if (isEmpty(value)) { errors.rejectValue(property, "empty"); } } }
- 스프링 같은 프레임워크는 값 검증을 위한 Validator 인터페이스를 별도로 제공하므로 인터페이스를 구현한 검증기를 따로 구현할 수 있다.
- @Controller public class Controller { @PostMapping("/member/join") public String join(JoinRequest joinRequest, Errors errors) { new JoinRequestValidator().validate(joinRequest, errors); if (errors.hasErrors()) { return formView; } try { joinService.join(joinRequest); return successView; } catch (DuplicateIdExcepiton ex) { errors.rejectValue(ex.getPropertyName(), "duplicate"); return formView; } } }
- 표현 영역에서 필수 값과 형식을 검사하면 응용 서비스는 ID 중복 여부와 같은 논리적 오류만 검사하면 된다.
- 표현 영역: 필수 값, 값의 형식, 범위 등을 검증
- 응용 서비스: 데이터의 존재 유무와 같은 논리적 오류를 검증
- 응용 서비스에서 필요한 값 검증을 모두 처리
- 프레임워크가 제공하는 검증 기능을 사용할 때보다 작성할 코드가 늘어나는 단점이 있음
- 응용 서비스이 완성도가 높아지는 이점도 있음
6.6 권한 검사
- 권한 검사 자체는 복잡한 개념은 아님
- 시스템마다 권한의 복잡도가 다름
- 보안 프레임워크의 복잡도를 떠나 보통 세곳에서 권한 검사를 수행할 수 있다.
- 표현 영역
- 응용 서비스
- 도메인
표현 영역
- 인증된 사용자인지 아닌지 검사
- 회원 정보 변경 기능에서 회원 정보 변경과 관련된 URL은 인증된 사용자만 접근해야 한다.
- 회원 정보 변경을 처리하는 URL에 대한 표현 영역에서 접근 제어
- URL을 처리하는 컨트롤러에 웹 요청을 전달하기 전에 인증 여부를 검사해서 인증된 사용자의 웹 요청만 컨트롤러에 전달
- 인증된 사용자가 아닐 경우 로그인 화면으로 리다이렉트
- 접근 제어를 하기 좋은 위치가 서블릿 필터(Servlet Filter)
- 사용자의 인증 정보를 생성하고 인증 여부를 검사
- 인증 여부 뿐만 아니라 권한에 대해서 동일한 방식으로 필터를 사용해서 URL별 권한 검사를 할 수 있다.
- 스프링 시큐리티는 이와 유사항 방식으로 필터를 인증해서 인증 정보를 생성하고 접근 제어 한다.
응용 서비스
- URL만으로 접근 제어를 할 수 없는 경우 응용 서비스의 메서드 단위로 권한 검사를 수행해야 한다.
- 응용 서비스의 코드에서 직접 권한 검사를 해야 한다는 것을 의미하는 것은 아님
- public class BlockMemberService { private MemberRepository memberRepository; @PreAuthorize("hasRole('ADMIN')") public void block(String memberId) { Member member = memberRepository.findById(memberId); if (member == null) { throw new NoMemberExcepiton(); } member.block(); } }
도메인
- 개별 도메인 객체 단위로 권한 검사를 해야하는 경우는 구현이 복잡해짐
- 게시글 삭제는 본인 또는 관리자 역활을 가진 사용자만 할 경우
- 게시글 작성자가 본인인지 확인하려면 게시글 애그리커트를 먼저 로딩
- 응용 서비스의 메서드 수준에서 권한 검사를 할 수 없음
- permissionService.checkDeletePermission에서 삭제 권한을 검사
public class DeleteAtricleService { public void delete(String userId, Long articleId) { Article article = articleRepository.findById(articleId); checkArticleExistence(article); permissionService.checkDeletePermission(userId, article); article.markDeleted(); } }
- 게시글 작성자가 본인인지 확인하려면 게시글 애그리커트를 먼저 로딩
- 스프링 시큐리티와 같은 보안 프레임워크를 확장해서 개별 도메인 객체 수준의 권한 검사 기능을 프레임워크에 통합 할수 있음
- 도메인에 맞게 프레임워크를 확정하려면 프레임워크에 대한 높은 이해가 필요
- 이해도가 높지 않아 프레임워크 확장을 원하는 수준으로 할 수 없다면 프레임워크를 사용하는 대신 도메인에 맞는 권한 검사 기능을 직접 구현하는 것이 코드 유지보수에 유리
6.7 조회 전용 기능과 응용 서비스
- 서비스에서 조회 전용 기능을 사용하면 서비스 코드가 단순히 조회 전용 기능을 호추하는 현태로 끝날 수 있다.
- 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"; } }
- 응용 서비스가 사용자 요청 실행하는데 별다른 기여를 하지 못한다면 굳이 서비스를 만들지 않아도 된다.
6.1 표현 영역과 응용 영역
- 도메인이 제 기능을 하려면 사용자와 도메인을 연결해주는 매개체가 필요하다
- 표현 영역은 사용자의 요청을 해석
- 웹 브라우저에서 ID와 암호를 입력한 뒤에 전송 버튼을 클릭하면 요청 파라미터를 포함함 HTTP 요청을 표현 영역에 전달
- 사용자가 실행하고 싶은 기능을 판별하고 기능을 제공하는 응용 서비스를 실행
- 사용자가 원하는 기느을 제공하는 것은 응용 영역에 위치한 서비스
- 회원 가입을 요청했다면 실제 그 요청을 위한 기능을 제공하는 주체는 응용 서비스
- 응용 서비스의 메서드가 요구하는 파라미터와 표현 영역이 사용자로부터 전달받은 데이터는 형식이 일치하지 않음
- 표현 영역은 응용 서비스가 요구하는 현식으로 사용자 요청을 변환
@PostMapping("/member/join") public ModelAndView join(HttpServletRequest reqeust) { String email = request.getParameter("email"); String password = request.getParameter("password"); JoinRequest joinRequest = new JoinReqeust(email, password); joinService.join(joinReqeust); }
- 사용자와 상호작용은 표현 영역이 처리하기 때문에 응용 서비스는 표현 영역에 의존하지 않는다.
- 응용 영역은 사용자가 웹 브라우저를 사용하는지 REST API를 호출하는지, TCP 소켓을 사용하는지 알필요가 없다.
6.2 응용 서비스의 역활
- 응용 서비스는 사용자의 요청을 처리하기 위해 리포지터리에 도메인 객체를 가져와 사용
- 응용 서비스는 주로 도메인 객체 간의 흐름을 제어하기 때문에 단순한 형태를 갖는다.
- public Result doSomeFunc(SomeReq req) { SomeAgg agg = someAggRepository.findById(req.getId()); checkNull(agg); agg.doFunc(req.getValue()); return createSuccessResult(agg); }
- 새로운 애그리거트를 생성하는 응용 서비스 역시 간단하다.
- public Result doSomeCreation(CreateSomeReq req) { validate(req); SomeAgg newAgg = createSome(req); someAggRepository.save(newAgg); return createSuccessResult(newAgg); }
- 응용 서비스가 복잡하다면 응용 서비스에서 도메인 로직의 일부를 구현하고 있을 가능성이 높다
- 응용 서비스가 도메인 로직을 일부 구현하면 코드 중복, 로직 분산 등 코드 품질에 안좋은 영향
- 응용 서비스는 도메인의 상태 변경을 트랜잭션으로 처리해야 한다.
- Member 객체의 block 메서드를 실행해서 상태를 변경했는데 DB에 반영하는 도중 문제가 발생하면?
- 일부 Member만 차단 상태가 되어 데이터 일관성이 꺠지게 된다.
- 트랜잭션 범위에서 응용 서비스를 실행
public void blockMembers(String[] blockingIds) { if (blockingIds == null || blockingIds.length == 0) { return; } List<Member> members = memberRepository.findByIdIn(blockingIds); for (Member mem: members) { mem.block(); } }
- Member 객체의 block 메서드를 실행해서 상태를 변경했는데 DB에 반영하는 도중 문제가 발생하면?
6.2.1 도메인 로직 넣지 않기
- 도메인 로직은 도메인 영역에 위치하고 응용 서비스는 도메인 로직을 구현하지 않음
- 암호 변경 기능을 위한 응용 서비스
- public class ChangePasswordService { public void changePassword(String memberId, String oldPw, String newPw) { Member member = memberRepository.findById(memberId); checkMemberExists(member); member.changePassword(oldPw, newPw); } }
- 암호 변경을 하기 위한 Member 애그리거트
- public class Member { public void chagnePassword(String oldPw, String newPw) { if (!matchpassword(oldPw)) { throw new BasPasswordException(); } setPassword(newPw); } public boolean matchPassword(String pwd) { return passwordEncoder.matches(pwd); } private void setPassword(String newPw) { if (isEmpty(newPw)) { throw new IllegalArgumnetException("no new passsword"); } this.password = newPw; } }
- 응용 서비스에서 암호를 변경하는 로직
- 잘못된 예제
- 코드의 응집성이 떨어짐
- 동일한 로직을 여러곳에서 구현할 가능성이 높아짐
- 암호를 확인하는 로직이 있는곳에서는 중복 코드가 발생함
public class ChangePasswordService { public void changePassword(String memberId, String oldPw, String newPw) { Member member = memberRepository.findById(memberId); checkMemberExists(member); if (!passwordEncoder.matches(oldPw, member.getPassword()) { throw new BasPasswordException(); } member.changePassword(oldPw, newPw); } }
- 중복 코드가 발생 되는 예제
- public class DeactivationService { public void deactivate(String memberId, String pwd) { Member member = memberRepository.findById(memberId); checkMemberExists(member); if (!passwordEncoder.matches(oldPw, member.getPassword()) { throw new BasPasswordException(); } member.deactivate(); } }
- Member 애그리거트에서 암호확인 기능 구현
- public class DeactivationService { public void deactivate(String memberId, String pwd) { Member member = memberRepository.findById(memberId); checkMemberExists(member); if (!member.matchPassword(pwd)) { throw new BasPasswordException(); } member.deactivate(); } }
- 잘못된 예제
6.3 응용 서비스의 구현
- 응용 서비스는 표현 영역과 도메인 영역을 연결하는 매개체 역활
- 디자인 패턴의 파사드와 같은 역활을 한다.
- 응용 서비스를 구현할 때 몇가지 고려할 사항과 트랜잭션과 같은 구현 기술의 연동에 대해 살펴본다.
6.3.1 응용 서비스 크기
- 응용 서비스 크기는 어떻게 할 것인가?
- 회원 도메인 기능
- 회원가입
- 회원 탈퇴
- 암호 변경
- 비밀번호 초기화
- 회원 도메인 기능
- 응용 서비스는 보통 두가지 방법중 한가지 방식으로 구현
- 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현
- 구분된 기능별로 응용 서비스 클래스를 따로 구현
- 회원과 관련된 기능을 한 클래스에서 모두 구현
- 동일 로직에 대한 코드 중복을 제거 가능
- 회원이 존재하지 않으면 NoMemberException을 발생시 private 메서드로 구현해서 호출하면 중복 코드를 제거 할 수 있다.
- 한 서비스 클래스의 크기가 커진다는 것은 단점
public class MemberService { private MemberRepository memberRepository; private Notifier notifier; public void chagnePassword(String memberId, String currentPw, String newPw) { Member member = findExistingMember(memberId); member.changePassword(currentPw, newPw); } public void initalizePassword(String memberId) { Member member = findExistingMember(memberId); String newPassword = member.initalizePassword(); notifier.notifyNewPassword(member, newPassword); } public void leave(String memberId, String curPw) { Member member = findExistingMember(memberId); member.leave(); } private Member findExistingMember(String memberId) { Member member = memberRepository.findById(memberId); if (member == null) { throw new NoMemberException(memberId); } return member; } }
- 구분 되는 기능별로 서비스 클래스를 구현하는 방식은 한 응용 서비스 클래스에서 한 개 내지 2~3개의 기능을 구현
- 각 클래스별로 필요한 의존 객체만 포함하므로 다른 기능을 구현할 코드에 영향을 받지 않는다.
public class ChnagePasswordService { private MemberRepository memberRepository; public void changePassword(String memberId, String curPw, String newPw) { Member member = memberRepository.findById(memberId); if (member == null) { throw new NoMemberException(memberId); } member.chagePassword(curPw, newPw); } }
- 동일 로직에 대한 코드 중복을 제거 가능
- public class MemberSerice { private MemberRepository memberRepository; public void join(MemberJoinReqeust joinRequest) {...} public void changePassword(String memberId, String curPw, String newPw) {...} public void initializePassword(String memberId) {...} public void leave(String. memberId, String curPw) {...} }
- 각 기능마다 동일한 로직을 구현할 경우 여러 클래스에 중복해서 동일한 코드를 구현할 가능성이 있다.
- 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; } } public class ChnagePasswordService { private MemberRepository memberRepository; public void changePassword(String memberId, String curPw, String newPw) { Member member = findExistingMember(memberRepository, memberId); member.chagePassword(curPw, newPw); } }
6.3.2 응용 서비스의 인터페이스와 클래스
- 응용 서비스를 구현할 때 논쟁이 될 만한 것은 인터페이스가 필요한지?
- public interface ChangePasswordService { public void changePassword(String memberId, String curPw, String newPw); } public class ChangePasswordServiceImpl implements ChangePasswordService { }
- 인터페이스가 필요한 몇가지 상황
- 구현 클래스가 여러 개인 경우
- 응용 서비스는 런타임에 교체하는 경우가 거의 없고 구현 클래스가 두개인 경우도 드물다
- 구현 클래스가 여러 개인 경우
- 인터페이스와 클래스를 따로 구현하면 소스 파일만 많아지고 구현 클래스에 대한 간접 참조가 증가해 전체 구조가 복잡해짐
- 인터페이스가 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스를 작성하는 것은 좋은 선택이라고 볼 수는 없다.
- TDD로 개발한다면 사용할 응용 서비스 클래스의 구현은 존재하지 않으므로 인터페이스를 이용해서 컨트롤러의 구현을 완성해 나갈 수 있다.
- Mockito와 같은 테스트 도구를 사용하면 이런 부분도 해결이 가능
6.3.3 메서드 파라미터와 값 리턴
- 응용 서비스가 제공하는 메서드는 도메인을 이용해서 사용자가 요구한 기능을 실행하는데 필요한 값을 파라미터로 전달 받아야 한다.
- public class ChangePasswordService { public void changePassword(String memberId, String curPw, String newPw) { ... } }
- 별도 데이터 클래스를 만들어서 전달 받을 수 있다.
- public class ChangePasswordRequest { private String memberId; private String currentPassword; private String newpassword; ... }
- 응용 서비스는 파라미터로 전달받은 데이터를 사용해서 필요한 기능을 구현하면 된다.
- 전달할 요청 파라미터가 두 개 이상 존재하면 데이터 전달을 위한 별도 클래스를 사용하는 것이 편리
public class ChangePasswordService { public void changePassword(ChangePasswordRequest req) { Member member = findExistingMember(req.getMemberId()); member.changePassword(req.getCurrentPassword(), req.getNewPassword()); } }
- 응용 서비스의 결과를 표현 영역에서 사용해야 하면 응용 서비스 메서드의 결과로 필요한 데이터를 리턴
- 결과 데이터가 필요한 대표적인 예가 식별자
public class OrderService { @Transactional public OrderNo placeOrder(OrderRequest orderRequest) { OrderNo orderNo = orderRepository.nextId(); Order order = createOrder(orderNo, orderRquest); orderRepository.save(order); return orderNo; } }
- 표현 영역 코드는 응용 서비스가 리턴한 값을 사용해서 사용자에게 알맞은 결과를 보여줄수 있게 된다.
- @Controller public class OrderController { @PostMapping("/order/place") public String order(OrderRequest orderReq, ModelMap model) { setOrderer(orderReq); OrderNo orderNo = orderService.placeOrder(orderReq); modelMap.setAttribute("orderNo", orderNo.toString()); return "order/success"; } }
- 응용 서비스에서 애그리거트 자체를 리턴하면 코딩은 편할 수 있지만 도메인의 로직 실행을 응용 서비스와 표현 영역 두 곳에서 할수 있게 된다.
- 기능 실행 로직을 응용 서비스와 표현 영역에 분산시켜 코드의 응집도를 낮추는 원인
- 응용 서비스는 표현 영역에서 필요한 데이터만 리턴하는 것이 기능 실행 로직의 응집도를 높이는 확실한 방법
6.3.4 표현 영역에 의존하지 않기
- 응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 영역과 관련된 타입을 사용하면 안된다.
- HttpServletRequest 나 HttpSession을 응용 서비스에 파라미터로 전달하면 안된다.
- 응용 서비스에서 표현 영역에 대한 의존이 반영되면 응용 서비스만 단독으로 테스트가 어려움
- 표현 영역이 변경되면 응용 서비스 구현도 함께 변경해야 하는 문제 발생
@Controller @RequestMapping("/member/chagePassword") public class MemberPasswordController { @PostMapping public String submit(HttpServletReqeust request) { try { changePasswordService.changePassword(request); } catch(NoMemberException ex) { } } }
- 응용 서비스가 표현 영역의 역활까지 대신하는 상황이 벌어질 수도 있다.
- HttpSession 이나 쿠키는 표현 영역의 상태에 해당
- 응용 서비스에서 상태를 변경해버리면 표현영역의 코드만으로 표현영역의 상태가 어떻게 변경되는지 추적이 어려워짐
public class AuthenticationService { public void authenticate(HttpServletRequest reqeust) { String id = request.getParameter("id"); String password = reqeust.getParameter("password"); if (checkIdPasswordMatching(id, password)) { HttpSession session = request.getSession(); session.setAttribute("auth", new Authentication(id)); } } }
- 문제가 발생하지 않도록 하려면 철저하게 응용 서비스가 표현 영역의 기술을 사용하지 않도록 해야함
6.3.5 트랜잭션 처리
- 트랜잭션을 관리하는 것은 응용 서비스의 중요한 역활이다.
- 프레임워크가 제공하는 트랜잭션 기능을 적극 사용하는 것이 좋다.
- 간단한 설정만으로 트랜잭션을 시작하고 커밋하고 익셉션이 발생하면 롤백 할 수 있다.
public class ChangePasswordService { @Transactional public void changePassword(ChangePasswordRequest req) { Member member = findExistingMember(req.getMemberId()); member.changePassword(req.getCurrentPassword(), req.getNewPassword()); } }
6.4 표현 영역
- 표현 영역의 책임은 크게 다음과 같다
- 사용자가 시스템을 사용할 수 있는 흐름을 제공하고 제어한다.
- 사용자의 요청을 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공한다.
- 사용자의 세션을 관리한다.
- 표현 영역의 첫 번째 책임은 사용자가 시스템을 사용할 수 있도록 알맞은 흐름을 제공
- 표현 영역의 두 번째 책임은 사용자의 요청에 맞게 응용 서비스에 기능 실행을 요청하는 것
- 표현 영역은 사용자의 요청 데이터를 응용 서비스가 요구하는 형식으로 변환
- 응용 서비스 결과를 사용자에게 응답할 수 있는 현식으로 변환
@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 (BadPasswordException | NoMemberException ex) { errors.reject("idPaswordNotMatch"); 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 (BadPasswordException | NoMemberException ex) { errors.reject("idPaswordNotMatch"); return formView; } }
- 표현 영역의 다른 주된 역활은 사용자의 연결 상태인 세션을 관리하는 것
6.5 값 검증
- 값 검증은 표현 영역과 응용 서비스 두 곳에서 모두 수행 할 수 있다.
- 원칙적으로 모든 값에 대한 검증은 응용 서비스에서 처리
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 checkDuplicatdId(String id) { int count = memeberRepository.countsById(id); if (count > 0) { throw new DuplicateIdException(); } } }
- 표현 영역은 잘못된 값이 존재하면 사용자에게 알려주고 값을 다시 입력받아야 한다.
- 컨트롤러에서 응용 서비스에서 발생한 폼에 에러 메세지를 보여주기 위해서는 다소 번잡한 코드를 작성 해야함
@Controller public class Controller { @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 (DuplicateIdException ex) { errors.rejectValue(ex.getPropertyName(), "duplicate"); return formView; } } }
- 응용 서비스에서 각 값으 유효한지를 확인할 목적으로 익셉션을 사용할 때의 문제점은 사용자에게 좋지 않은 경험을 제공
- 값을 검사하는 시점에 첫 번째 값이 올바르지 않아 익셉션을 발생시키면 나머지 항목에 대한 값을 검사하지 않게 된다.
- 첫 번째 값에 대한 에러 메세지만 보게됨
- 사용자가 같은 폼에 여러번 입력하게 만든다.
- 응용 서비스에서 에러코드를 모아 하나의 익셉션으로 발생시키는 방법
- 표현 영역은 응용 서비스가 ValidationErrorException을 발생시키면 에러 목록을 가져와 표현 영역에서 사용할 형태로 변환
@PostMaaping("/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.geUsername())); try { OrderNo orderNo = placeORderService.placeOrder(orderRequest); modelMap.addAttribute("orderNo", orderNo.getNumber()); } catch (ValidationErrorException e) { e.getErrors().forEacth(err -> { if (err.hasName()) { bindingResult.rejectValue(err.getName(), err.getCode()); } else { bindingResult.reject(err.getCode()); } }); populateProductsModel(orderRequest, modelMap); return "order/confirm"; } }
- @Transactional public OrderNo placeOrder(OrderRequest orderRequest) { List<ValidationError> errors = new ArrayList<>(); if (orderReqeust == null) { errors.add(ValidationError.of("empty"); } else { if (orderRequest.getOrdererMemberId() == null) { errors.add(ValidationError.of("orderMemberId", "empty"); } if (orderRequest.getOrderProducts() == null) { errors.add(ValidationError.of("orderProducts", "empty"); } if (orderRequest.getOrderProducts().isEmpty() == null) { errors.add(ValidationError.of("orderProducts", "empty"); } } if (!errors.isEmpty()) { throw new ValidationErrorException(errors); } }
- 표현역역에서 필수 값을 검증하는 방법도 있다.
- @Controller public class Controller { @PostMapping("/member/join") public String join(JoinRequest joinRequest, Errors errors) { checkEmpty(joinRequest.getId(), "id", errors); checkEmpty(joinRequest.getName(), "name", errors); if (errors.hasErrors()) { return formView; } try { joinService.join(joinRequest); return successView; } catch (DuplicateIdExcepiton ex) { errors.rejectValue(ex.getPropertyName(), "duplicate"); return formView; } } private void checkEmpty(String value, String property, Errors errors) { if (isEmpty(value)) { errors.rejectValue(property, "empty"); } } }
- 스프링 같은 프레임워크는 값 검증을 위한 Validator 인터페이스를 별도로 제공하므로 인터페이스를 구현한 검증기를 따로 구현할 수 있다.
- @Controller public class Controller { @PostMapping("/member/join") public String join(JoinRequest joinRequest, Errors errors) { new JoinRequestValidator().validate(joinRequest, errors); if (errors.hasErrors()) { return formView; } try { joinService.join(joinRequest); return successView; } catch (DuplicateIdExcepiton ex) { errors.rejectValue(ex.getPropertyName(), "duplicate"); return formView; } } }
- 표현 영역에서 필수 값과 형식을 검사하면 응용 서비스는 ID 중복 여부와 같은 논리적 오류만 검사하면 된다.
- 표현 영역: 필수 값, 값의 형식, 범위 등을 검증
- 응용 서비스: 데이터의 존재 유무와 같은 논리적 오류를 검증
- 응용 서비스에서 필요한 값 검증을 모두 처리
- 프레임워크가 제공하는 검증 기능을 사용할 때보다 작성할 코드가 늘어나는 단점이 있음
- 응용 서비스이 완성도가 높아지는 이점도 있음
6.6 권한 검사
- 권한 검사 자체는 복잡한 개념은 아님
- 시스템마다 권한의 복잡도가 다름
- 보안 프레임워크의 복잡도를 떠나 보통 세곳에서 권한 검사를 수행할 수 있다.
- 표현 영역
- 응용 서비스
- 도메인
표현 영역
- 인증된 사용자인지 아닌지 검사
- 회원 정보 변경 기능에서 회원 정보 변경과 관련된 URL은 인증된 사용자만 접근해야 한다.
- 회원 정보 변경을 처리하는 URL에 대한 표현 영역에서 접근 제어
- URL을 처리하는 컨트롤러에 웹 요청을 전달하기 전에 인증 여부를 검사해서 인증된 사용자의 웹 요청만 컨트롤러에 전달
- 인증된 사용자가 아닐 경우 로그인 화면으로 리다이렉트
- 접근 제어를 하기 좋은 위치가 서블릿 필터(Servlet Filter)
- 사용자의 인증 정보를 생성하고 인증 여부를 검사
- 인증 여부 뿐만 아니라 권한에 대해서 동일한 방식으로 필터를 사용해서 URL별 권한 검사를 할 수 있다.
- 스프링 시큐리티는 이와 유사항 방식으로 필터를 인증해서 인증 정보를 생성하고 접근 제어 한다.
응용 서비스
- URL만으로 접근 제어를 할 수 없는 경우 응용 서비스의 메서드 단위로 권한 검사를 수행해야 한다.
- 응용 서비스의 코드에서 직접 권한 검사를 해야 한다는 것을 의미하는 것은 아님
- public class BlockMemberService { private MemberRepository memberRepository; @PreAuthorize("hasRole('ADMIN')") public void block(String memberId) { Member member = memberRepository.findById(memberId); if (member == null) { throw new NoMemberExcepiton(); } member.block(); } }
도메인
- 개별 도메인 객체 단위로 권한 검사를 해야하는 경우는 구현이 복잡해짐
- 게시글 삭제는 본인 또는 관리자 역활을 가진 사용자만 할 경우
- 게시글 작성자가 본인인지 확인하려면 게시글 애그리커트를 먼저 로딩
- 응용 서비스의 메서드 수준에서 권한 검사를 할 수 없음
- permissionService.checkDeletePermission에서 삭제 권한을 검사
public class DeleteAtricleService { public void delete(String userId, Long articleId) { Article article = articleRepository.findById(articleId); checkArticleExistence(article); permissionService.checkDeletePermission(userId, article); article.markDeleted(); } }
- 게시글 작성자가 본인인지 확인하려면 게시글 애그리커트를 먼저 로딩
- 스프링 시큐리티와 같은 보안 프레임워크를 확장해서 개별 도메인 객체 수준의 권한 검사 기능을 프레임워크에 통합 할수 있음
- 도메인에 맞게 프레임워크를 확정하려면 프레임워크에 대한 높은 이해가 필요
- 이해도가 높지 않아 프레임워크 확장을 원하는 수준으로 할 수 없다면 프레임워크를 사용하는 대신 도메인에 맞는 권한 검사 기능을 직접 구현하는 것이 코드 유지보수에 유리
6.7 조회 전용 기능과 응용 서비스
- 서비스에서 조회 전용 기능을 사용하면 서비스 코드가 단순히 조회 전용 기능을 호추하는 현태로 끝날 수 있다.
- 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"; } }
- 응용 서비스가 사용자 요청 실행하는데 별다른 기여를 하지 못한다면 굳이 서비스를 만들지 않아도 된다.
6.1 표현 영역과 응용 영역
- 도메인이 제 기능을 하려면 사용자와 도메인을 연결해주는 매개체가 필요하다
- 표현 영역은 사용자의 요청을 해석
- 웹 브라우저에서 ID와 암호를 입력한 뒤에 전송 버튼을 클릭하면 요청 파라미터를 포함함 HTTP 요청을 표현 영역에 전달
- 사용자가 실행하고 싶은 기능을 판별하고 기능을 제공하는 응용 서비스를 실행
- 사용자가 원하는 기느을 제공하는 것은 응용 영역에 위치한 서비스
- 회원 가입을 요청했다면 실제 그 요청을 위한 기능을 제공하는 주체는 응용 서비스
- 응용 서비스의 메서드가 요구하는 파라미터와 표현 영역이 사용자로부터 전달받은 데이터는 형식이 일치하지 않음
- 표현 영역은 응용 서비스가 요구하는 현식으로 사용자 요청을 변환
@PostMapping("/member/join") public ModelAndView join(HttpServletRequest reqeust) { String email = request.getParameter("email"); String password = request.getParameter("password"); JoinRequest joinRequest = new JoinReqeust(email, password); joinService.join(joinReqeust); }
- 사용자와 상호작용은 표현 영역이 처리하기 때문에 응용 서비스는 표현 영역에 의존하지 않는다.
- 응용 영역은 사용자가 웹 브라우저를 사용하는지 REST API를 호출하는지, TCP 소켓을 사용하는지 알필요가 없다.
6.2 응용 서비스의 역활
- 응용 서비스는 사용자의 요청을 처리하기 위해 리포지터리에 도메인 객체를 가져와 사용
- 응용 서비스는 주로 도메인 객체 간의 흐름을 제어하기 때문에 단순한 형태를 갖는다.
- public Result doSomeFunc(SomeReq req) { SomeAgg agg = someAggRepository.findById(req.getId()); checkNull(agg); agg.doFunc(req.getValue()); return createSuccessResult(agg); }
- 새로운 애그리거트를 생성하는 응용 서비스 역시 간단하다.
- public Result doSomeCreation(CreateSomeReq req) { validate(req); SomeAgg newAgg = createSome(req); someAggRepository.save(newAgg); return createSuccessResult(newAgg); }
- 응용 서비스가 복잡하다면 응용 서비스에서 도메인 로직의 일부를 구현하고 있을 가능성이 높다
- 응용 서비스가 도메인 로직을 일부 구현하면 코드 중복, 로직 분산 등 코드 품질에 안좋은 영향
- 응용 서비스는 도메인의 상태 변경을 트랜잭션으로 처리해야 한다.
- Member 객체의 block 메서드를 실행해서 상태를 변경했는데 DB에 반영하는 도중 문제가 발생하면?
- 일부 Member만 차단 상태가 되어 데이터 일관성이 꺠지게 된다.
- 트랜잭션 범위에서 응용 서비스를 실행
public void blockMembers(String[] blockingIds) { if (blockingIds == null || blockingIds.length == 0) { return; } List<Member> members = memberRepository.findByIdIn(blockingIds); for (Member mem: members) { mem.block(); } }
- Member 객체의 block 메서드를 실행해서 상태를 변경했는데 DB에 반영하는 도중 문제가 발생하면?
6.2.1 도메인 로직 넣지 않기
- 도메인 로직은 도메인 영역에 위치하고 응용 서비스는 도메인 로직을 구현하지 않음
- 암호 변경 기능을 위한 응용 서비스
- public class ChangePasswordService { public void changePassword(String memberId, String oldPw, String newPw) { Member member = memberRepository.findById(memberId); checkMemberExists(member); member.changePassword(oldPw, newPw); } }
- 암호 변경을 하기 위한 Member 애그리거트
- public class Member { public void chagnePassword(String oldPw, String newPw) { if (!matchpassword(oldPw)) { throw new BasPasswordException(); } setPassword(newPw); } public boolean matchPassword(String pwd) { return passwordEncoder.matches(pwd); } private void setPassword(String newPw) { if (isEmpty(newPw)) { throw new IllegalArgumnetException("no new passsword"); } this.password = newPw; } }
- 응용 서비스에서 암호를 변경하는 로직
- 잘못된 예제
- 코드의 응집성이 떨어짐
- 동일한 로직을 여러곳에서 구현할 가능성이 높아짐
- 암호를 확인하는 로직이 있는곳에서는 중복 코드가 발생함
public class ChangePasswordService { public void changePassword(String memberId, String oldPw, String newPw) { Member member = memberRepository.findById(memberId); checkMemberExists(member); if (!passwordEncoder.matches(oldPw, member.getPassword()) { throw new BasPasswordException(); } member.changePassword(oldPw, newPw); } }
- 중복 코드가 발생 되는 예제
- public class DeactivationService { public void deactivate(String memberId, String pwd) { Member member = memberRepository.findById(memberId); checkMemberExists(member); if (!passwordEncoder.matches(oldPw, member.getPassword()) { throw new BasPasswordException(); } member.deactivate(); } }
- Member 애그리거트에서 암호확인 기능 구현
- public class DeactivationService { public void deactivate(String memberId, String pwd) { Member member = memberRepository.findById(memberId); checkMemberExists(member); if (!member.matchPassword(pwd)) { throw new BasPasswordException(); } member.deactivate(); } }
- 잘못된 예제
6.3 응용 서비스의 구현
- 응용 서비스는 표현 영역과 도메인 영역을 연결하는 매개체 역활
- 디자인 패턴의 파사드와 같은 역활을 한다.
- 응용 서비스를 구현할 때 몇가지 고려할 사항과 트랜잭션과 같은 구현 기술의 연동에 대해 살펴본다.
6.3.1 응용 서비스 크기
- 응용 서비스 크기는 어떻게 할 것인가?
- 회원 도메인 기능
- 회원가입
- 회원 탈퇴
- 암호 변경
- 비밀번호 초기화
- 회원 도메인 기능
- 응용 서비스는 보통 두가지 방법중 한가지 방식으로 구현
- 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현
- 구분된 기능별로 응용 서비스 클래스를 따로 구현
- 회원과 관련된 기능을 한 클래스에서 모두 구현
- 동일 로직에 대한 코드 중복을 제거 가능
- 회원이 존재하지 않으면 NoMemberException을 발생시 private 메서드로 구현해서 호출하면 중복 코드를 제거 할 수 있다.
- 한 서비스 클래스의 크기가 커진다는 것은 단점
public class MemberService { private MemberRepository memberRepository; private Notifier notifier; public void chagnePassword(String memberId, String currentPw, String newPw) { Member member = findExistingMember(memberId); member.changePassword(currentPw, newPw); } public void initalizePassword(String memberId) { Member member = findExistingMember(memberId); String newPassword = member.initalizePassword(); notifier.notifyNewPassword(member, newPassword); } public void leave(String memberId, String curPw) { Member member = findExistingMember(memberId); member.leave(); } private Member findExistingMember(String memberId) { Member member = memberRepository.findById(memberId); if (member == null) { throw new NoMemberException(memberId); } return member; } }
- 구분 되는 기능별로 서비스 클래스를 구현하는 방식은 한 응용 서비스 클래스에서 한 개 내지 2~3개의 기능을 구현
- 각 클래스별로 필요한 의존 객체만 포함하므로 다른 기능을 구현할 코드에 영향을 받지 않는다.
public class ChnagePasswordService { private MemberRepository memberRepository; public void changePassword(String memberId, String curPw, String newPw) { Member member = memberRepository.findById(memberId); if (member == null) { throw new NoMemberException(memberId); } member.chagePassword(curPw, newPw); } }
- 동일 로직에 대한 코드 중복을 제거 가능
- public class MemberSerice { private MemberRepository memberRepository; public void join(MemberJoinReqeust joinRequest) {...} public void changePassword(String memberId, String curPw, String newPw) {...} public void initializePassword(String memberId) {...} public void leave(String. memberId, String curPw) {...} }
- 각 기능마다 동일한 로직을 구현할 경우 여러 클래스에 중복해서 동일한 코드를 구현할 가능성이 있다.
- 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; } } public class ChnagePasswordService { private MemberRepository memberRepository; public void changePassword(String memberId, String curPw, String newPw) { Member member = findExistingMember(memberRepository, memberId); member.chagePassword(curPw, newPw); } }
6.3.2 응용 서비스의 인터페이스와 클래스
- 응용 서비스를 구현할 때 논쟁이 될 만한 것은 인터페이스가 필요한지?
- public interface ChangePasswordService { public void changePassword(String memberId, String curPw, String newPw); } public class ChangePasswordServiceImpl implements ChangePasswordService { }
- 인터페이스가 필요한 몇가지 상황
- 구현 클래스가 여러 개인 경우
- 응용 서비스는 런타임에 교체하는 경우가 거의 없고 구현 클래스가 두개인 경우도 드물다
- 구현 클래스가 여러 개인 경우
- 인터페이스와 클래스를 따로 구현하면 소스 파일만 많아지고 구현 클래스에 대한 간접 참조가 증가해 전체 구조가 복잡해짐
- 인터페이스가 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스를 작성하는 것은 좋은 선택이라고 볼 수는 없다.
- TDD로 개발한다면 사용할 응용 서비스 클래스의 구현은 존재하지 않으므로 인터페이스를 이용해서 컨트롤러의 구현을 완성해 나갈 수 있다.
- Mockito와 같은 테스트 도구를 사용하면 이런 부분도 해결이 가능
6.3.3 메서드 파라미터와 값 리턴
- 응용 서비스가 제공하는 메서드는 도메인을 이용해서 사용자가 요구한 기능을 실행하는데 필요한 값을 파라미터로 전달 받아야 한다.
- public class ChangePasswordService { public void changePassword(String memberId, String curPw, String newPw) { ... } }
- 별도 데이터 클래스를 만들어서 전달 받을 수 있다.
- public class ChangePasswordRequest { private String memberId; private String currentPassword; private String newpassword; ... }
- 응용 서비스는 파라미터로 전달받은 데이터를 사용해서 필요한 기능을 구현하면 된다.
- 전달할 요청 파라미터가 두 개 이상 존재하면 데이터 전달을 위한 별도 클래스를 사용하는 것이 편리
public class ChangePasswordService { public void changePassword(ChangePasswordRequest req) { Member member = findExistingMember(req.getMemberId()); member.changePassword(req.getCurrentPassword(), req.getNewPassword()); } }
- 응용 서비스의 결과를 표현 영역에서 사용해야 하면 응용 서비스 메서드의 결과로 필요한 데이터를 리턴
- 결과 데이터가 필요한 대표적인 예가 식별자
public class OrderService { @Transactional public OrderNo placeOrder(OrderRequest orderRequest) { OrderNo orderNo = orderRepository.nextId(); Order order = createOrder(orderNo, orderRquest); orderRepository.save(order); return orderNo; } }
- 표현 영역 코드는 응용 서비스가 리턴한 값을 사용해서 사용자에게 알맞은 결과를 보여줄수 있게 된다.
- @Controller public class OrderController { @PostMapping("/order/place") public String order(OrderRequest orderReq, ModelMap model) { setOrderer(orderReq); OrderNo orderNo = orderService.placeOrder(orderReq); modelMap.setAttribute("orderNo", orderNo.toString()); return "order/success"; } }
- 응용 서비스에서 애그리거트 자체를 리턴하면 코딩은 편할 수 있지만 도메인의 로직 실행을 응용 서비스와 표현 영역 두 곳에서 할수 있게 된다.
- 기능 실행 로직을 응용 서비스와 표현 영역에 분산시켜 코드의 응집도를 낮추는 원인
- 응용 서비스는 표현 영역에서 필요한 데이터만 리턴하는 것이 기능 실행 로직의 응집도를 높이는 확실한 방법
6.3.4 표현 영역에 의존하지 않기
- 응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 영역과 관련된 타입을 사용하면 안된다.
- HttpServletRequest 나 HttpSession을 응용 서비스에 파라미터로 전달하면 안된다.
- 응용 서비스에서 표현 영역에 대한 의존이 반영되면 응용 서비스만 단독으로 테스트가 어려움
- 표현 영역이 변경되면 응용 서비스 구현도 함께 변경해야 하는 문제 발생
@Controller @RequestMapping("/member/chagePassword") public class MemberPasswordController { @PostMapping public String submit(HttpServletReqeust request) { try { changePasswordService.changePassword(request); } catch(NoMemberException ex) { } } }
- 응용 서비스가 표현 영역의 역활까지 대신하는 상황이 벌어질 수도 있다.
- HttpSession 이나 쿠키는 표현 영역의 상태에 해당
- 응용 서비스에서 상태를 변경해버리면 표현영역의 코드만으로 표현영역의 상태가 어떻게 변경되는지 추적이 어려워짐
public class AuthenticationService { public void authenticate(HttpServletRequest reqeust) { String id = request.getParameter("id"); String password = reqeust.getParameter("password"); if (checkIdPasswordMatching(id, password)) { HttpSession session = request.getSession(); session.setAttribute("auth", new Authentication(id)); } } }
- 문제가 발생하지 않도록 하려면 철저하게 응용 서비스가 표현 영역의 기술을 사용하지 않도록 해야함
6.3.5 트랜잭션 처리
- 트랜잭션을 관리하는 것은 응용 서비스의 중요한 역활이다.
- 프레임워크가 제공하는 트랜잭션 기능을 적극 사용하는 것이 좋다.
- 간단한 설정만으로 트랜잭션을 시작하고 커밋하고 익셉션이 발생하면 롤백 할 수 있다.
public class ChangePasswordService { @Transactional public void changePassword(ChangePasswordRequest req) { Member member = findExistingMember(req.getMemberId()); member.changePassword(req.getCurrentPassword(), req.getNewPassword()); } }
6.4 표현 영역
- 표현 영역의 책임은 크게 다음과 같다
- 사용자가 시스템을 사용할 수 있는 흐름을 제공하고 제어한다.
- 사용자의 요청을 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공한다.
- 사용자의 세션을 관리한다.
- 표현 영역의 첫 번째 책임은 사용자가 시스템을 사용할 수 있도록 알맞은 흐름을 제공
- 표현 영역의 두 번째 책임은 사용자의 요청에 맞게 응용 서비스에 기능 실행을 요청하는 것
- 표현 영역은 사용자의 요청 데이터를 응용 서비스가 요구하는 형식으로 변환
- 응용 서비스 결과를 사용자에게 응답할 수 있는 현식으로 변환
@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 (BadPasswordException | NoMemberException ex) { errors.reject("idPaswordNotMatch"); 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 (BadPasswordException | NoMemberException ex) { errors.reject("idPaswordNotMatch"); return formView; } }
- 표현 영역의 다른 주된 역활은 사용자의 연결 상태인 세션을 관리하는 것
6.5 값 검증
- 값 검증은 표현 영역과 응용 서비스 두 곳에서 모두 수행 할 수 있다.
- 원칙적으로 모든 값에 대한 검증은 응용 서비스에서 처리
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 checkDuplicatdId(String id) { int count = memeberRepository.countsById(id); if (count > 0) { throw new DuplicateIdException(); } } }
- 표현 영역은 잘못된 값이 존재하면 사용자에게 알려주고 값을 다시 입력받아야 한다.
- 컨트롤러에서 응용 서비스에서 발생한 폼에 에러 메세지를 보여주기 위해서는 다소 번잡한 코드를 작성 해야함
@Controller public class Controller { @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 (DuplicateIdException ex) { errors.rejectValue(ex.getPropertyName(), "duplicate"); return formView; } } }
- 응용 서비스에서 각 값으 유효한지를 확인할 목적으로 익셉션을 사용할 때의 문제점은 사용자에게 좋지 않은 경험을 제공
- 값을 검사하는 시점에 첫 번째 값이 올바르지 않아 익셉션을 발생시키면 나머지 항목에 대한 값을 검사하지 않게 된다.
- 첫 번째 값에 대한 에러 메세지만 보게됨
- 사용자가 같은 폼에 여러번 입력하게 만든다.
- 응용 서비스에서 에러코드를 모아 하나의 익셉션으로 발생시키는 방법
- 표현 영역은 응용 서비스가 ValidationErrorException을 발생시키면 에러 목록을 가져와 표현 영역에서 사용할 형태로 변환
@PostMaaping("/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.geUsername())); try { OrderNo orderNo = placeORderService.placeOrder(orderRequest); modelMap.addAttribute("orderNo", orderNo.getNumber()); } catch (ValidationErrorException e) { e.getErrors().forEacth(err -> { if (err.hasName()) { bindingResult.rejectValue(err.getName(), err.getCode()); } else { bindingResult.reject(err.getCode()); } }); populateProductsModel(orderRequest, modelMap); return "order/confirm"; } }
- @Transactional public OrderNo placeOrder(OrderRequest orderRequest) { List<ValidationError> errors = new ArrayList<>(); if (orderReqeust == null) { errors.add(ValidationError.of("empty"); } else { if (orderRequest.getOrdererMemberId() == null) { errors.add(ValidationError.of("orderMemberId", "empty"); } if (orderRequest.getOrderProducts() == null) { errors.add(ValidationError.of("orderProducts", "empty"); } if (orderRequest.getOrderProducts().isEmpty() == null) { errors.add(ValidationError.of("orderProducts", "empty"); } } if (!errors.isEmpty()) { throw new ValidationErrorException(errors); } }
- 표현역역에서 필수 값을 검증하는 방법도 있다.
- @Controller public class Controller { @PostMapping("/member/join") public String join(JoinRequest joinRequest, Errors errors) { checkEmpty(joinRequest.getId(), "id", errors); checkEmpty(joinRequest.getName(), "name", errors); if (errors.hasErrors()) { return formView; } try { joinService.join(joinRequest); return successView; } catch (DuplicateIdExcepiton ex) { errors.rejectValue(ex.getPropertyName(), "duplicate"); return formView; } } private void checkEmpty(String value, String property, Errors errors) { if (isEmpty(value)) { errors.rejectValue(property, "empty"); } } }
- 스프링 같은 프레임워크는 값 검증을 위한 Validator 인터페이스를 별도로 제공하므로 인터페이스를 구현한 검증기를 따로 구현할 수 있다.
- @Controller public class Controller { @PostMapping("/member/join") public String join(JoinRequest joinRequest, Errors errors) { new JoinRequestValidator().validate(joinRequest, errors); if (errors.hasErrors()) { return formView; } try { joinService.join(joinRequest); return successView; } catch (DuplicateIdExcepiton ex) { errors.rejectValue(ex.getPropertyName(), "duplicate"); return formView; } } }
- 표현 영역에서 필수 값과 형식을 검사하면 응용 서비스는 ID 중복 여부와 같은 논리적 오류만 검사하면 된다.
- 표현 영역: 필수 값, 값의 형식, 범위 등을 검증
- 응용 서비스: 데이터의 존재 유무와 같은 논리적 오류를 검증
- 응용 서비스에서 필요한 값 검증을 모두 처리
- 프레임워크가 제공하는 검증 기능을 사용할 때보다 작성할 코드가 늘어나는 단점이 있음
- 응용 서비스이 완성도가 높아지는 이점도 있음
6.6 권한 검사
- 권한 검사 자체는 복잡한 개념은 아님
- 시스템마다 권한의 복잡도가 다름
- 보안 프레임워크의 복잡도를 떠나 보통 세곳에서 권한 검사를 수행할 수 있다.
- 표현 영역
- 응용 서비스
- 도메인
표현 영역
- 인증된 사용자인지 아닌지 검사
- 회원 정보 변경 기능에서 회원 정보 변경과 관련된 URL은 인증된 사용자만 접근해야 한다.
- 회원 정보 변경을 처리하는 URL에 대한 표현 영역에서 접근 제어
- URL을 처리하는 컨트롤러에 웹 요청을 전달하기 전에 인증 여부를 검사해서 인증된 사용자의 웹 요청만 컨트롤러에 전달
- 인증된 사용자가 아닐 경우 로그인 화면으로 리다이렉트
- 접근 제어를 하기 좋은 위치가 서블릿 필터(Servlet Filter)
- 사용자의 인증 정보를 생성하고 인증 여부를 검사
- 인증 여부 뿐만 아니라 권한에 대해서 동일한 방식으로 필터를 사용해서 URL별 권한 검사를 할 수 있다.
- 스프링 시큐리티는 이와 유사항 방식으로 필터를 인증해서 인증 정보를 생성하고 접근 제어 한다.
응용 서비스
- URL만으로 접근 제어를 할 수 없는 경우 응용 서비스의 메서드 단위로 권한 검사를 수행해야 한다.
- 응용 서비스의 코드에서 직접 권한 검사를 해야 한다는 것을 의미하는 것은 아님
- public class BlockMemberService { private MemberRepository memberRepository; @PreAuthorize("hasRole('ADMIN')") public void block(String memberId) { Member member = memberRepository.findById(memberId); if (member == null) { throw new NoMemberExcepiton(); } member.block(); } }
도메인
- 개별 도메인 객체 단위로 권한 검사를 해야하는 경우는 구현이 복잡해짐
- 게시글 삭제는 본인 또는 관리자 역활을 가진 사용자만 할 경우
- 게시글 작성자가 본인인지 확인하려면 게시글 애그리커트를 먼저 로딩
- 응용 서비스의 메서드 수준에서 권한 검사를 할 수 없음
- permissionService.checkDeletePermission에서 삭제 권한을 검사
public class DeleteAtricleService { public void delete(String userId, Long articleId) { Article article = articleRepository.findById(articleId); checkArticleExistence(article); permissionService.checkDeletePermission(userId, article); article.markDeleted(); } }
- 게시글 작성자가 본인인지 확인하려면 게시글 애그리커트를 먼저 로딩
- 스프링 시큐리티와 같은 보안 프레임워크를 확장해서 개별 도메인 객체 수준의 권한 검사 기능을 프레임워크에 통합 할수 있음
- 도메인에 맞게 프레임워크를 확정하려면 프레임워크에 대한 높은 이해가 필요
- 이해도가 높지 않아 프레임워크 확장을 원하는 수준으로 할 수 없다면 프레임워크를 사용하는 대신 도메인에 맞는 권한 검사 기능을 직접 구현하는 것이 코드 유지보수에 유리
6.7 조회 전용 기능과 응용 서비스
- 서비스에서 조회 전용 기능을 사용하면 서비스 코드가 단순히 조회 전용 기능을 호추하는 현태로 끝날 수 있다.
- 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"; } }
- 응용 서비스가 사용자 요청 실행하는데 별다른 기여를 하지 못한다면 굳이 서비스를 만들지 않아도 된다.
728x90
'도메인 주도 개발 스터디' 카테고리의 다른 글
Chapter 8 애그리거트 트랜잭션 관리 (0) | 2023.11.08 |
---|---|
Chapter 7 도메인 서비스 (0) | 2023.11.08 |
Chapter 5 스프링 데이터 JPA를 이용한 조회 기능 (0) | 2023.11.08 |
Chapter 4 리포지터리와 모델 구현 (1) | 2023.11.08 |
Chapter 2 아키텍처 개요 (0) | 2023.11.08 |