도메인 주도 설계 (6) - 응용 서비스와 표현 영역

gentledot·2021년 10월 24일
0

응용 서비스와 표현 영역

표현 영역과 응용 영역

  • 도메인 영역을 잘 구현하지 않으면 사용자의 요구를 충족하는 제대로 된 소프트웨어를 만들지 못한다.
  • 도메인이 제 기능을 하려면 사용자와 도메인을 연결해 주는 매개체가 필요하고, 응용 영역과 표현 영역이 사용자와 도메인을 연결해 주는 매개체 역할을 한다.
  • 표현 영역은 사용자의 요청을 해석한다.
    • 시용자가 웹 브라우저에서 폼에 아이디와 암호를 입력한 뒤에 전송 버튼을 클릭하면 요청 파라미터를 포함한 HTTP 요청을 표현 영역에 전달한다.
    • 요청을 받은 표현 영역은 URL, 요청 파라미터, 쿠키, 헤더 등 을 이용해서 사용자가 어떤 기능을 실행하고 싶어 하는지 판별하고 그 기능을 제공하는 응용 서비스를 실행한다.
  • 실제 사용자가 원하는 기능을 제공하는 것은 응용 영역에 위치한 서비스이다.
    • 사용자가 회원 가입을 요청 했다면 실제 그 요청을 위한 기능을 제공하는 주체는 응용 서비스에 위치한다.
    • 응용 서비스는 기능을 실행하는 데 필요한 입력값을 파라미터로 전달받고 실행 결과를 리턴한다.
  • 응용 서비스의 메서드가 요구하는 파라미터와 표현 영역이 사용자로부터 전달받은 데이터는 형식이 일치하지 않기 때문에 표현 영역은 응용 서비스가 요구하는 형식으로 사용자 요청을 변환한다.
    • 표현 영역의 코드는 폼에 입력한 요청 파라미터 값을 사용해서 응용 서비스가 요구하는 객체를 생성한 뒤, 응용 서비스의 메서드를 호출한다.

      @RequestMapping(value = "/member/join")
      public ModeMndView join(HttpServletRequest request) {
          String email = request.getParameter("email");
          String password = request.getParameter("password"); 
      		// 사용자 요청을 응용 서비스에 맞게 변환 
          JoinRequest joinReq = new JoinRequest(email, password); 
      		// 변환한 객체(데이터)를 이용해서 응용 서비스 실행 
          joinService.join(joinReq);
      }
  • 응용 서비스를 실행한 뒤에 표현 영역은 실행 결과를 사용자에 알맞은 형식으로 응답한다. 웹 브라우저인 경우 실행 결과를 HTML 형식으로 전송할 수 있다.
    • REST 클라이언트라면 JSON 이나 XML 같은 형식으로 응답한다.
  • 사용자와의 상호작용은 표현 영역이 처리하기 때문에 응용 서비스는 표현 영역에 의존하지 않는다.
    • 응용 영역은 사용자가 웹 브라우저를 사용하는지, REST API를 호출하는지, TCP 소켓을 시용하는지 여부를 알 필요가 없다.
    • 단지, 응용 영역은 기능 실행에 필요한 입력값을 전달받고 실행 결과만 리턴하면 될 뿐이다.

응용 서비스의 역할

  • 응용 서비스는 사용자(클라이언트)가 요청한 기능을 실행한다. 응용 서비스는 사용자의 요청을 처리하기 위해 리포지터리로부터 도메인 객체를 구하고, 도메인 객체를 사용한다.
  • 응용 서비스의 주요 역할은 도메인 객체를 사용해서 사용자의 요청을 처리하는 것
    • 표현(사용자) 영역 입장에서 보았을 때 응용 서비스는 도메인 영역과 표현 영역을 연결해 주는 창구 역할을 한다.
  • 응용 서비스는 주로 도메인 객체 간의 흐름을 제어하기 때문에 다음과 같이 단순한 형태를 갖는다.
    // 도메인 객체 간 흐름 제어 역할
    public Result doSomeFunc(SomeReq req) {
        // 1. 리포지터리에서 애그리거트를 구한다.
        SomeAgg agg = someAggRepository.findById(req.getId());
        checkNull(agg);
        
        // 2. 애그리거트의 도메인 기능을 실행한다. 
         agg.doFunc(req.getValue());
    
        // 3. 결과를 리턴한다.
        return createSuccessResult(agg);
    }
    
    // 새로운 애그리거트 생성 시 동작
    public Result doSomeCreation(CreateSomeReq req) {
    
        // 1 데이터 중복 둥 데이터가 유효한지 검사한다. 
        checkValid(req);
    
        // 2. 애그리거트를 생성 한다.
        SomeAgg newAgg = createSome(req);
    
        // 3. 리포지터리에 애그리거트를 저장한다. 
        someAggRepository.save(newAgg);
    
        // 4. 결과를 리턴한다.
        return createSuccessResult(newAgg);
    }
  • 응용 서비스가 이것보다 복잡하다면 응용 서비스에서 도메인 로직의 일부를 구현하고 있을 가능성이 높다.
    • 응용 서비스가 도메인 로직을 일부 구현하면 코드 품질에 안 좋은 영향을 준다.
  • 도메인 객체 간의 실행 흐름을 제어하는 것과 더불어 응용 서비스의 주된 역할 중 하나는 트랜잭션 처리이다.
    • 응용 서비스는 도메인의 상태 변경을 트랜잭션으로 처리해야 한다.
    • 변경 상태를 DB에 반영하는 도중 문제가 발생하면면 일부 처리만 차단 상태가 되어 데이터 일관성이 깨지게 된다. 이런 상황이 발생하지 않으려면 트랜잭선 범위에서 응용 서비스를 실행해야 한다.

도메인 로직 넣지 않기

  • 도메인 로직은 도메인 영역에 위치하고 응용 서비스는 도메인 로직을 구현하지 않는다.
  • 도메인 로직을 도메인 영역과 응용 서비스에 분산해서 구현하면 코드 품질에 문제가 발생한다.
    • 첫 번째 문제는 코드의 응집성이 떨어진다는 것이다.
      • 도메인 데이터와 그 데이터를 조작하는 도메인 로직이 한 영역에 위치하지 않고 서로 다른 영역에 위치한다는 것은 도메인 로직을 파악하기 위해 여러 영역을 분석해야 한다는 것을 뜻한다.
    • 두 번째 문제는 여러 응용 서비스에서 동일한 도메인 로직을 구현할 가능성이 높아진다는 것이다.
      • 서비스 영역에서의 로직을 구현하는 경우 코드 중복을 막기 위해 응용 서비스 영역에 별도의 보조 클래스를 만들 수 있지만
      • 애초에 도메인 영역에 암호 확인 기능을 구현했으면 응용 서비스는 그 기능을 사용하기만 하면 된다.
  • 일부 도메인 로직이 응용 서비스에 출현하면서 발생하는 두 가지 문제(응집도가 떨어지고 코드 중복이 발생) 는 결과적으로 코드 변경을 어렵게 만든다.
    • 소프트웨 어의 중요한 경쟁 요소 중 하나는 변경의 용이성인데, 변경이 어렵게 된다는 것은 그만큼 소프트웨어의 가치가 떨어진다는 것을 뜻한다.
    • 소프트웨어의 가치를 높이려면 도메인 로직을 도메인 영역에 모아서 코드 중복이 발생하지 않도록 하고 응집도를 높여야 한다.

응용 서비스의 구현

  • 응용 서비스는 표현 영역과 도메인 영역을 연결하는 매개체 역할을 하는데 이는 디자인 패턴에서 파사드(facade)와 같은 역할을 한다.

응용 서비스의 크기

  • 회원 도메인을 생각해볼 때 응용 서비스는 회원 가입하기, 회원 탈퇴하기, 회원 암호 변경하기, 비밀번호 초기화하기와 같은 기능을 구현 하기 위해 도메인 모델을 사용하게 된다.
    • 이 경우, 응용 서비스는 보통 다음의 두 가 지 방법 중 한 가지 방식으로 구현한다.
      • 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기
      • 구분되는 기능별로 응용 서비스 클래스를 따로 구현하기
  • 한 클래스에서 모두 구현하는 경우 : 각 메서드를 구현하는 데 필요한 리포지터리나 도메인 서비스는 필드로 추가
    public class MemberService {
    
        // 각 기능을 구현하는 데 필요한 리포지터리, 도메인 서비스 필드 추가
        private MemberRepository memberRepository;
    
        public void join(MemberJoinRequest joinRequest) { ... }
        public void changePassword (String menberId, String currentPw, String newPw) { .... } 
        public void initializePassword(String memberId) { ... } 
        public void leave(String memberId, String curPw) { ... }
    
        ...
    		// 회원이 존재하지 않는 경우 Exception 발생시키는 로직
    		private Member findExistingMember(String memberId) {...}
    }
    • 한 도메인과 관련된 기능을 구현한 코드가 한 클래스에 위치하므로 각 기능에서 동일 로직에 대한 코드 중복을 제거할 수 있다는 장점이 있다.
    • 예를 들어, changePassword, initializePassword, leave 는 회원이 존재하지 않으면 NoMemberException을 발생시켜야 한다고 해보자. 이 경우, 다음과 같이 중복된 로직을 구현한 private 메서드를 구현하고 이를 호출하는 방법으로 중복 로직을 쉽게 제거할 수 있다.
  • 각 기능에서 동일한 로직을 위한 코드 중복을 제거하는 것이 쉽다는 것이 장점이라면 한 서비스 클래스의 크기(코드 줄 수)가 커진다는 것은 이 방식의 단점이 된다.
    • 코드 크기가 커진다는 것은 연관성이 적은 코드가 한 클래스에 함께 위치할 가능성이 높아짐을 의미하는데, 이는 결과적으로 관련 없는 코드가 뒤섞여서 코드를 이해하는 데 방해가 될 수 있다.
    • 필드로 존재하지 않는 서비스 클래스에 대해서 어떤 기능 때문에 필요한지 확인하려면 각 기능을 구현한 코드를 뒤져야만 한다.
    • 게다가 한 클래스에 코드가 모이기 시작하면 엄연히 분리하는 것이 좋은 상황임에도 습관적으로 기존에 존재하는 클래스에 억지로 끼워 넣게 된다. 이는 코드를 점점 얽히게 만들어 코드 품질을 낮추는 결과를 초래한다.
  • 구분되는 기능별로 서비스 클래스를 구현하는 방식: 한 응용 서비스 클래스에서 1~3개의 기능을 구현/
    // 각 응용 서비스에서 공동되는 로직을 별도 클래스로 구현
    public final class MemberServiceHelper {
        public static Member findExistingMember(MemberRepository repo, String memberId) {
            Member member = memberRepository.findById(memberId);
            if (member == null) throw new NoMemberException(memberId);
    
            return member;
        }
    }
    
    // 공통 메서드 import 하고 응용 서비스에서 사용.
    import static MemberServiceHelper.*;
    
    public class ChangePasswordService {
        private MemberRepository memberRepository;
    
        public void changePassword(String memberId, String curPw, String newPw) {
    				Member member = findExistingMember(memberRepository, menberId);
            member.changePassword(curPw, newPw);
        }
    }
    • 이 방식을 시용하면 클래스 개수는 많아지지만 한 클래스에 관련 기능을 모두 구현 하는 것과 비교해서 코드 품질을 일정 수준으로 유지하는 데 도움이 된다.
    • 또한, 각 클래스별로 필요한 의존 객체만 포함하므로 다른 기능을 구현한 코드에 영향을 받지 않는다.
    • 각 기능마다 동일한 로직을 구현할 경우 여러 클래스에 중복해서 동일한 코드를 구현할 가능성 이 있다. 이런 경우에는 별도 클래스에 로직을 구현해서 코드가 중복되는 것을 방지할 수 있다.

응용 서비스의 인터페이스와 클래스

  • 응용 서비스를 구현할 때 논쟁이 될 만한 것이 인터페이스가 필요한지 여부이다.
  • 인터페이스가 필요한 몇 가지 상황이 있는데 그중 하나는 구현 클래스가 여러 개인 경우이다.
    • 구현 클래스가 다수 존재하거나 런타임에 구현 객체를 교체해야 할 경우 인터페이스를 유용하게 사용할 수 있다.
    • 그런데, 응용 서비스는 보통 런타임에 이를 교체하는 경우가 거의 없을 뿐만 아니라 한 응용 서비스의 구현 클래스가 두 개인 경우도 매우 드물다.
  • 이런 이유로 인터페이스와 클래스를 따로 구현하면 소스 파일만 많아지고 구현 클래스에 대한 간접 참조가 증가해서 전체 구조만 복잡해지는 문제가 발생한다.
  • 따라서, 인터페이스가 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스를 작성하는 것이 좋은 설계라고는 볼 수 없다.
  • 테스트 주도 개발(TDD)을 즐겨하고 표현 영역부터 개발을 시작한다면 미리 응용 서비스를 구현할 수 없으므로 응용 서비스의 인터페이스부터 작성하게 될 것이다.
    • 컨트롤러에서 사용할 응용 서비스 클래스의 구현은 존재하지 않으므로 응용 서비스의 인터페이스를 이용해서 컨트롤러의 구현을 완성해 나가게 된다.
  • 표현 영역이 아닌 도메인 영역이나 응용 영역의 개발을 먼저 시작하면 응용 서비스 클래스가 먼저 만들어진다. 이렇게 되면 표현 영역의 단위 테스트를 위해 응용 서비스 클래스의 가짜 객체가 필요한데 이를 위해 인터페이스를 추가할 수도 있다.
    • 또는, Mockito 와 같은 테스트 도구는 클래스에 대해서도 테스트용 가짜 객체를 만들기 때문에 응용 서비스에 대한 인터페이스가 없어도 표현 영역을 테스트할 수 있다. 이는 결과적으로 응용 서비스에 대한 인터페이스 필요성을 약화시킨다.

메서드 파라미터와 값 리턴

  • 응용 서비스가 제공하는 메서드는 도메인을 이용해서 사용자가 요구한 기능을 실행하는 데 필요한 값을 파라미터를 통해 전달받아야 한다.
    • 각 파라미터를 개별로 전달받거나
    • 값 전달을 위한 별도 데이터 클래스를 만들어 전달할 수 있다.
  • 표현 영역에서 응용 서비스의 결과가 필요하다면 응용 서비스 메서드의 결과로 필요한 데이터를 리턴한다.
    • 필요한 값만을 리턴하거나
    • 애그리거트 객체를 그대로 리턴할 수도 있다.
  • 응용 서비스에서 애그리거트 자체를 리턴하면 코딩은 편할 수 있지만 도메인의 로직 실행을 응용 서비스와 표현 영역 두 곳에서 할 수 있게 된다.
    • 이는 기능 실행 로직을 응용 서비스와 표현 영역에 분산시켜 코드의 응집도를 낮추는 원인이 된다.
  • 애그리거트의 상태를 변경하는 응용 서비스가 애그리거트를 리턴할 경우 해당 애그리거트의 기능을 컨트롤러나 뷰 코드에서 실행하면 안 된다는 규칙을 정할 수 있겠지만, 그보다는 응용 서비스는 표현 영역에서 필요한 데이터만 리턴하는 것이 기능 실행 로직의 응집도를 높이는 확실한 방법이다.

표현 영역에 의존하지 않기

  • 응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 영역과 관련된 타입을 사용하면 안 된다는 점이다.
    • 예를 들어, 다음과 같이 표현 영역에 해당하는 HttpServletRequestHttpSession 을 응용 서 비스에 파라미터로 전달하면 안 된다.
      @Controller
      @RequestMapping("/member/changePassword")
      public class MemberPasswordController {
      
          @RequestMapping(method = RequestMethod.POST)
          public String submit(HttpServletRequest request) {
              try { // 응용 서비스가 표힌 영역에 대한 의존이 발생하면 안 됨!
                  changePasswordService.changePassword(request);
              } catch (NoMemberException ex) {
                  // 적절한 exception 처리 및 응답  
              }
          }
          
          ...
      }
    • 응용 서비스에서 표현 영역에 대한 의존이 발생하면 응용 서비스만 단독으로 테스트하기가 어려워진다.
    • 게다가 표현 영역의 구현이 변경되면 응용 서비스의 구현도 함께 변경해야 하는 문제도 발생한다.
    • 두 문제보다 더 나쁜 문제는 응용 서비스가 표현 영역의 역할까지 대신하는 상황이 벌어질 수도 있다는 것이다.
    • HttpSession이나 Cookie는 표현 영역의 상태에 해당하는데 이 상태를 응용 서비스에서 변경해 버리면 표현 영역의 코드만으로 표현 영역의 상태가 어떻게 변경되는지 이해하기 어려워진다. 즉, 표현 영역의 응집도가 깨지는 것이다. 이는 결과적으로 코드를 유지보수하는 비용을 증가시키는 원인이 된다.
  • 이러한 문제가 발생하지 않도록 하려면 철저하게 응용 서비스가 표현 영역의 기술을 사용하지 않도록 해야 한다. 이를 지키기 위한 가장 쉬운 방법이 서비스 메서드의 파라미터와 리턴 타입으로 표현 영역의 구현 기술을사용하지 않는 것이다.

트랜잭션 처리

  • 회원가입 에 성공했다고 하면서 실제로 회원 정보를 DB에 삽입하지 않으면 고객은 로그인을 할 수 없게 된다.
  • 비슷하게 배송지 주소를 변경하는 데 실패했다는 안내 화면을 보여줬는데 실제로는 DB에 변경된 배송지 주소가 반영되어 있다면 고객은 물건을 제때 받지 못하게 된다.
  • 이 두 가지는 트랜잭선과 관련된 문제로 트랜잭션을 관리하는 것은 응용 서비스의 중요한 역할이다.
  • 스프링과 같은 프레임워크를 사용하면 @Transactional 등 제공되는 트랜잭션 관리 기능을 이용해서 손쉽게 트랜잭션을 처리할 수 있다.
    • 프레임워크가 제공하는 규칙을 따르면 간단한 설정만으로
      • 트랜잭션을 시작하고
      • commit 하고
      • exception 이 발생하면 rollback 할 수 있다.
    • 스프링의 기본 동작은 @Transactional 이 적용된 메서드에서 RuntimeException이 발생하면 rollback하고 그렇지 않으면 commit.

도메인 이벤트 처리

  • 응용 서비스의 역할 중 하나는 도메인 영역에서 발생시킨 이벤트를 처리하는 것이다.
    • 여기서 이벤트는 도메인에서 발생한 상태 변경을 의미하며 '암호 변경됨', '주문 취소함' 과 같은 것이 이벤트가 될 수 있다.
  • 도메인 영역은 상대가 변경되면 이를 외부에 알리기 위해 이벤트를 발생시킬 수 있다.
    • 예를 들어, 암호 초기화 기능은 다음과 같이 암호 변경 후에 '암호 변경됨' 이벤트를 발생시킬 수 있다.
  • 도메인에서 이벤트를 발생시키면 그 이벤트를 받아서 처리할 코드가 필요한데, 그 역할을 하는 것이 바로 응용 서비스이다.
    • 응용 서비스는 이벤트를 받아서 이벤트에 알맞은 후처리를 할 수 있다.
    • 암호 초기화의 경우 암호 초기화됨 이벤트가 발생하면 변경한 암호를 이메일로 발송하는 이벤트 핸들러를 등록할 수 있을 것이다.
      public class InitPasswordService {
          @Transactional
          public void initializePassword(String memberId) {
      
              Events.handle((PasswordChangedEvent evt) -> {
                  // evt.getId() 에 해당하는 회원에게 이메일 발송하는 기능 구현
              });
      
              Member member = memberRepository.findById(memberId);
              checkMemberExists(member);
              member.initializePassword();
          }
      }
  • 이벤트를 사용하면 코드가 다소 복잡해지는 대신 도메인 간의 의존성이나 외부 시스템에 대한 의존을 낮춰주는 장점을 얻을 수 있다. 또한 시스템을 확장하는 데에 이벤트가 핵심 역할을 수행하게 된다.

표현 영역

  • 표현 영역의 책임
    • 사용자가 시스템을 사용할 수 있는 (화면) 흐름을 제공하고 제어한다.
      • ex) 게시글 쓰기를 표현 영역에 요청하면 게시글을 작성할 수 있는 폼 화면을 응답으로 제공한다.
    • 사용자의 요청을 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공한다.
      • ex) 암호 변경을 처리하는 표현 영역은 HTTP 요청 파라미터로부터 필요한 값을 읽어와 응용 서비스의 메서드가 요구하는 객체로 변환해서 요청을 전달한다.
      • 응용 서비스의 실행 결과를 사용자에게 알맞은 형식으로 제공하는 것도 표현 영역의 몫이다. 뷰를 반환하거나 특정 형식의 메시지 body를 반환한다.
    • 사용자의 세션을 관리한다.
      • 웹의 경우 쿠키나 서버의 세션을 이용해 사용자의 연결 상태를 관리한다.
      • 세션 관리는 권한 검사와도 연결된다.

값 검증

  • 값 검증은 표현 영역과 응용 서비스 두 곳에서 모두 수행할 수 있다.
    • 원칙적으로 모든 값에 대한 검증은 응용 서비스에서 처리한다.
    • ex) 회원 가입을 처리하는 응용 서비스는 파라미터로 전달받은 값이 올바른지 검사해야 한다. (값의 형식, 비즈니스 로직 검사 등)
    • 표현 영역은 잘못된 값이 존재하면 이를 사용자에게 알려주고 값을 다시 입력받아야 한다.
  • 스프링과 같은 프레임워크는 값 검증을 위한 Validator 인터페이스를 별도로 제공하므로 이 인터페이스를 구현한 검증기를 따로 구현하면 코드를 간결하게 줄일 수 있다.
    package org.springframework.validation;
    
    public interface Validator {
      boolean supports(Class<?> var1);
    
      void validate(Object var1, Errors var2);
    }
    @RequestMapping(value = "/orders/order", method = RequestMethod.POST)
    public String order(@ModelAttribute("orderReq") OrderRequest orderRequest,
                        BindingResult bindingResult,
                        ModelMap modelMap) {
        User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        new OrderRequestValidator().validate(orderRequest, bindingResult);
        orderRequest.setOrderer(createOrderer(user));
        if (bindingResult.hasErrors()) {
            populateProductsModel(orderRequest, modelMap);
            return "order/confirm";
        } else {
            OrderNo orderNo = placeOrderService.placeOrder(orderRequest);
            modelMap.addAttribute("orderNo", orderNo.getNumber());
            return "order/orderComplete";
        }
    }
    
    import org.springframework.validation.Errors;
    import org.springframework.validation.ValidationUtils;
    import org.springframework.validation.Validator;
    
    public class OrderRequestValidator implements Validator {
        @Override
        public boolean supports(Class<?> aClass) {
            return OrderRequest.class.isAssignableFrom(aClass);
        }
    
        @Override
        public void validate(Object o, Errors errors) {
            OrderRequest orderReq = (OrderRequest) o;
            if (orderReq.getOrderProducts() == null || orderReq.getOrderProducts().isEmpty()) {
                ValidationUtils.rejectIfEmptyOrWhitespace(errors, "orderProducts", "required");
            } else {
                for (int i = 0 ; i < orderReq.getOrderProducts().size() ; i++ ) {
                    OrderProduct orderProduct = orderReq.getOrderProducts().get(i);
                    if (orderProduct.getProductId() == null || orderProduct.getProductId().trim().isEmpty()) {
                        errors.rejectValue("orderProducts["+i+"].productId", "required");
                    }
                    if (orderProduct.getQuantity() <= 0) {
                        errors.rejectValue("orderProducts["+i+"].quantity", "nonPositive");
                    }
                }
            }
            ValidationUtils.rejectIfEmptyOrWhitespace(errors, "shippingInfo.receiver.name", "required");
            ValidationUtils.rejectIfEmptyOrWhitespace(errors, "shippingInfo.receiver.phone", "required");
            ValidationUtils.rejectIfEmptyOrWhitespace(errors, "shippingInfo.address.zipCode", "required");
            ValidationUtils.rejectIfEmptyOrWhitespace(errors, "shippingInfo.address.address1", "required");
            ValidationUtils.rejectIfEmptyOrWhitespace(errors, "shippingInfo.address.address2", "required");
        }
    }
    • 응용 서비스를 사용하는 표현 영역 코드가 한 곳에 있다면 구현의 편리함을 위해 다음과 같이 역할을 나눠 검증을 수행할 수 있다.
      • 표현 영역 : 필수 값, 값의 형식, 범위 등을 검증한다.
      • 응용 서비스 : 데이터의 존재 유무와 같은 논리적 오류를 검증한다.
    • 응용 서비스에서 어디까지 검증할지 여부는 엄격함이 어느 수준까지 필요하냐에 따라 달라질 수 있다.
      • 책 저자의 경험에 의하면, 응용 서비스를 실행하는 주체가 표현 영역이면 응용 서비스는 논리적 오류 위주로 값을 검증해도 문제가 없었지만 응용 서비스를 실행하는 주체가 다양하면 응용 서비스에서 반드시 파라미터로 전달받은 값이 올바른지 검사를 해야 한다고 적혀 있다.

권한 검사

  • 사용자 U가 기능 F를 실행할 수 있는지 확인하는 것이 권한 검사이므로 권한 검사 자체는 복잡한 개념이 아니다.
  • 개발할 시스템마다 권한의 복잡도가 달라진다. 단순한 시스템은 인증 여부만 검사 하면 되는데 반해, 어떤 시스템은 관리자인지 여부에 따라 사용할 수 있는 기능이 달라지기도 한다. 또, 실행할 수 있는 기능이 역할마다 달라지는 경우도 있다.
    • 이런 다양한 상황을 충족하기 위해 스프링 시큐리티나 아파치 Shiro 같은 프레임워크는 유연하고 확장 가능한 구조를 갖고 있다. (이는 유연한 만큼 복잡하다는 것을 의미하기도 한다.)
    • 이들 보안 프레임워크에 대한 이해가 부족하면 프레임워크를 무턱대고 도입하는 것보다 개발할 시스템에 맞는 권한 검사 기능을 구현하는 것이 시스템 유지보수에 유리할 수 있다.
  • 보통 다음의 세 곳에서 권한 검사를 수행할 수 있다.
    • 표현 영역
    • 응용 서비스
    • 도메인
  • 표현 영역에서 할 수 있는 가장 기본적인 검사는 인증된 사용자인지 아닌지 여부를 검사하는 것이다.
    • 대표적인 예가 회원 정보 변경 기능이다. 회원 정보 변경과 관련된 URL은 인증된 사용자만 접근해야 한다.
      • URL을 처리하는 컨트롤러에 웹 요청을 전달하기 전에 인증 여부를 검사헤서 인증된 사용자의 웹 요청만 컨트롤러에 전달한다.
      • 인증된 사용자가 아닐 경우 로그인 화면으로 리다이렉트시킨다.
    • 접근 제어를 하기에 좋은 위치가 서블릿 필터 이다. 서블릿 필터에서 시용자의 인증 정보를 생성하고 인증 여부를 검사하는 것이다. 인증된 시용자면 다음 과정을 진행하고 그렇지 않으면 로그인 화면이나 에러 화면을 보여주면 된다.
      • 인증 뿐 아니라 권한에 대해서도 동일한 방식으로 필터를 사용해 URL별 권한 검사를 할 수 있다.
      • 스프링 시큐리티는 이와 유사한 방식으로 필터를 이용해서 인증 정보를 생성하고 웹 접근을 제어한다.
  • URL 만으로 접근 제어를 할 수 없는 경우 응용 서비스의 메서드 단위로 권한 검사를 수행해야 한다.
    • 이것이 꼭 응용 서비스의 코드에서 직접 권한 검사를 해야 한다는 것을 의미하는 것은 아니다.
    • 예를 들어, 스프링 시큐리티는 AOP를 활용해서 @PreAuthorize() 와 같은 어노테이션으로 서비스 메서드에 대한 권한 검사를 할 수 있는 기능을 제공 한다.
      import org.springframework.beans.factory.annotation.Autowired;
      import org.springframework.security.access.prepost.PreAuthorize;
      import org.springframework.stereotype.Service;
      import org.springframework.transaction.annotation.Transactional;
      
      @Service
      public class BlockMemberService {
      
          private MemberRepository memberRepository;
      
          @PreAuthorize("hasRole('ADMIN')")
          @Transactional
          public void block(String memberId) {
              Member member = memberRepository.findById(new MemberId(memberId));
              if (member == null) throw new NoMemberException();
      
              member.block();
          }
      org.springframework.security.access.prepost 
      @Target({ElementType.METHOD,ElementType.TYPE}) 
      @Retention(RetentionPolicy.RUNTIME) 
      @Inherited 
      @Documented 
      public interface PreAuthorize
      extends annotation.Annotation
        Maven: org.springframework.security:spring-security-core:4.0.3.RELEASE
  • 개별 도메인 단위로 권한 검사를 해야 하는 경우 다소 구현이 복잡해진다.
    • ex) 게시글 삭제를 본인 또는 관리자 역할의 사용자만 할 수 있다면 게시글 작성자가 본인인지 확인하기 위해 게시글 애그리거트를 먼저 로딩하고 권한을 확인해야 한다.
  • 도메인 객체 수준의 권한 검사 로직은 도메인별로 다르므로 도메인에 맞게 보안 프레임워크를 확장하려면 프레임워크 자체에 대한 이해가 높아야 한다.
    • 이해가 높지 않아 프레임워크 확장을 원 하는 수준으로 할 수 없다면 프레임워크를 사용하는 대신 도메인에 맞는 권한 검사 기능을 직접 구현하는 것이 코드 유지보수에 유리할 수 있다.

조회 전용 기능과 응용 서비스

  • 서비스에서 조회 전용 기능을 사용하게 되면, 서비스 코드가 다음과 같이 단순히 조회 전용 기능을 호출하는 것으로 끝나는 경우가 많다.
    @Service
    public class OrderListService {
    	private OrderViewDao orderViewDao;
    	
    	public OrderListService(OrderViewDao orderViewDao) {
    	    this.orderViewDao = orderViewDao;
      	}
    
    	public List<OrderView> getOrderList(String ordererId) {
    	    return orderViewDao.selectByOrderer(ordererId);
    	}
    }
    • 서비스에서 수행하는 추가적인 로직이 없을뿐더러 조회 전용 기능이어서 트랜잭션이 필요하지도 않다.
  • 책에서는 조회 전용 기능만 있는 경우 굳이 서비스를 만들 필요 없이 표현 영역에서 바로 조회 전용 기능(Repository/DAO)을 사용해도 된다고 적혀 있으나
    • 조회 기능에 대해 추가적인 로직 변경에 대응이 용이하려면,
    • 그리고 서비스 로직의 단위 테스트를 통해 조회 전용 기능을 사용하고 있음을 서술해두려면 서비스를 두는게 나을 것 같다는 개인적인 생각이 들었다.
profile
그동안 마신 커피와 개발 지식, 경험을 기록하는 공간

0개의 댓글