도메인 주도 개발 스터디

Chapter 6 응용 서비스와 표현 영역

막이86 2023. 11. 8. 17:40
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();
    	}
    }
    

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();
    	}
    }
    

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();
    	}
    }
    

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();
    	}
    }
    

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