Today
-
Yesterday
-
Total
-
  • 06. 응용 서비스와 표현 영역
    책 공부/DDD 도메인 주도 개발 시작하기 2024. 1. 29. 09:56

    1. 표현 영역과 응용 영역

    p. 200


    표현영역

    표현 영역은 URL, 요청 파라미터, 쿠키, 헤더 등을 이용하여 사용자가 실행하고 싶은 기능을 판별하고 그 기능을 제공하는 응용 서비스를 실행한다.


    응용 서비스

    응용 서비스는 기능을 실행하는 데 필요한 입력 값을 메서드 인자로 받고 실행 결과를 리턴한다.




    응용 서비스의 메서드가 요구하는 파라미터와
    표현 영역이 사용자로부터 전달받은 데이터는 형식이 일치하지 않기 때문에
    표현 형역은 응용 서비스가 요구하는 형식으로 사용자 요청을 변환한다.




    응용 영역은 사용자가 웹 브라우저를 사용하는지 REST API를 호출하는지,
    TCP 소켓을 사용하는지를 알 필요가 없다.




    2. 응용 서비스의 역할

    p. 202


    응용 서비스는 사용자의 요청을 처리하기 위해 리포지터리에서 도메인 객체를 가져와 사용한다.


    응용 서비스는 주로 도메인 객체 간의 흐름을 제어하기 때문에
    다음과 같은 단순한 형태를 갖는다.

    // 애그리거트 조회 서비스
    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. 데이터 중복 등 데이터가 유효한지 검사한다.
        validate(req);
        
        // 2. 애그리거트를 생성한다.
        SomeAgg newAgg = createSome(req);
        
        // 3. 리포지터리에 애그리거트를 저장한다.
        someAggRespotitory.save(newAgg);
        
        // 4. 결과를 리턴한다.
        return createSuccessResult(newAgg);
    }
    

    응용 서비스가 복잡하다면
    응용 서비스에서 도메인 로직의 일부를 구현하고 있을 가능성이 높다.


    응용 서비스가 도메인 로직을 일부 구현하면
    코드 중복, 로직 분산 등 코드 품질에 안 좋은 영향을 줄 수 있다.


    응용 서비스는 도메인의 상태 변경을 트랜잭션으로 처리해야 한다.


    트랜잭션 외에 응용 서비스의 주요 역할로는 이벤트 처리와 접근 제어가 있다.




    1. 도메인 로직 넣지 않기

    도메인 로직은 도메인 영역에 위치하고
    응용 서비스는 도메인 로직을 구현하지 않는다.


    암호 변경 기능을 예로 들어보면,

    암호를 올바르게 입력했는지 확인하는 것은 도메인의 핵심 로직이기 때문에 응용 서비스에서 이 로직을 구현하면 안 된다.



    도메인 로직을 도메인 영역과 응용 서비스에 분산해서 구현하면
    코드 품질에 문제가 발생한다.



    첫 번째 문제는 코드의 응집성이 떨어진다는 것이다.

    도메인 데이터와 그 데이터를 조작하는 도메인 로직이 한 영역에 위치하지 않고 서로 다른 영역에 위치한다는 것은 도메인 로직을 파악하기 위해 여러 영역을 분석해야 한다는 것을 의미한다.



    두 번째 문제는 여러 응용 서비스에서 동일한 도메인 로직을 구현할 가능성이 높아진다는 것이다.

    예를 들어 비정상적인 계정 정지를 막기 위해 암호를 확인한다고 해보자.

    이 경우 계정 정지 기능을 구현하는 응용 서비스는 다음과 같이 암호를 확인하는 코드를 구현해야 한다.


    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 BadPasswordException();
            }
            member.deactivate();
        }
    }
    

    코드 중복을 막기 위해 응용 서비스 영역에 별도의 보조 클래스를 만들 수 있지만, 애초에 도메인 영역에 암호 확인 기능을 구현했으면 응용 서비스는 그 기능을 사용하기만 하면 된다.


    다음과 같이 암호 데이터를 가진 Member 객체에 암호 확인 기능을 구현하고 응용 서비스에서는 도메인이 제공하는 기능을 사용하면 응용 서비스가 도메인 로직을 구현하면서 발생하는 코드 중복 문제는 발생하지 않는다.


    public class DeactivationService {
        public void deactivate(String memberId, String pwd) {
            Member member = memberRepository.findById(memberId);
            checkMemberExists(member);
            if (!member.mathchPassword(pwd)) {
                throw new BadPasswordException();
            }
            member.deactivate();
        }
    }
    

    일부 도메인 로직이 응용 서비스에 출현하면서 발생하는 두 가지 문제 (응집도가 떨어지고 코드 중복이 발생)는 결과적으로 코드 변경을 어렵게 만든다.


    소프트웨어가 가져야 할 중요한 경쟁 요소 중 하나는 변경 용이성인데,
    변경이 어렵다는 것은 그만큼 소프트웨어의 가치가 떨어진다는 것을 의미한다.


    소프트웨어의 가치를 높이려면 도메인 로직을 도메인 영역에 모아서 코드 중복을 줄이고 응집도를 높여야 한다.




    3. 응용 서비스의 구현

    p. 207


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


    이 절에서는 응용 서비스를 구현할 때 몇 가지 고려할 사항과 트랜잭션과 같은 구현 기술의 연동에 대해 살펴본다.


    1. 응용 서비스의 크기

    응용 서비스 자체의 구현은 어렵지 않지만 몇 가지 생각할 거리가 있다.


    그중 하나가 응용 서비스의 크기다.


    회원 도메인을 생각해보자.

    응용 서비스는 회원 가입하기, 회원 탈퇴하기, 회원 암호 변경하기, 비밀번호 초기화하기와 같은 기능을 구현하기 위해 도메인 모델을 사용하게 된다.

    이 경우 응용 서비스는 보통 다음의 두 가지 방법 중 한 가지 방식으로 구현한다.


    • 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기
    • 구분되는 기능별로 응용 서비스 클래스를 따로 구현하기

    회원과 관련된 기능을 한 클래스에서 모두 구현하면 다음과 같은 모습을 갖는다.


    각 메서드를 구현하는 데 필요한 리포지터리나 도메인 서비스는 필드로 추가한다.


    public class MemberServuce {
        // 각 기능을 구현하는 데 필요한 리포지터리, 도메인 서비스 필드 추가
        private MemberRepository memberRepository;
        
        public void join(MemberJoinRequest joinRequest) {...}
        
        public void changePassword(String memberId, String curPw, String newPw) {...}
        public void initializePassword(String memberId) {...}
        public void leave(String memberId, String curPw) {...}
        ...
    }
    

    한 도메인과 관련된 기능을 구현한 코드가 한 클래스에 위치하므로 각 기능에서 동일 로직에 대한 코드 중복을 제거할 수 있다는 장점이 있다.


    각 기능에서 동일한 로직을 위한 코드 중복을 제거하기 쉽다는 것이 장점이라면
    한 서비스 클래스의 크기(코드 줄 수)가 커진다는 것은 이 방식의 단점이다.


    코드 크기가 커지면 연관성이 적은 코드가 한 클래스에 함께 위치할 가능성이 높아지게 되는데 결과적으로 관련 없는 코드가 뒤섞여 코드를 이해하는 데 방해가 된다.


    게다가 한 클래스에 코드가 모이기 시작하면 엄연히 분리하는 것이 좋은 상황임에도 습관적으로 기존에 존재하는 클래스에 억지로 끼워 넣게 된다.

    이것은 코드를 점점 얽히게 만들어 코드 품질을 낮추는 결과를 초래한다.


    구분되는 기능별로 서비스 클래스를 구현하는 방식은 한 응용 서비스 클래스에서 한 개 내지 2~3개의 기능을 구현한다.

    다음과 같이 암호 변경 기능만을 위한 응용 서비스 클래스를 별도로 구현하는 식이다.


    public class ChangePasswordServuce {
        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.changePassword(curPw, newPw);
            ...
        }
    
    }
    

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

    응용 서비스를 구현할 때 논쟁이 될 만한 것이 인터페이스가 필요한가? 이다.


    인터페이스가 필요한 몇 가지 상황이 있는데,

    그 중 하나는 구현 클래스가 여러 개인 경우다.


    구현 클래스가 다수 존재하거나 런타임에 구현 객체를 교체해야 할 때 인터페이스를 유용하게 사용할 수 있다.


    하지만 응용 서비스는 런타임에 교체하는 경우가 거의 없고 한 응용 서비스의 구현 클래스가 두 개인 경우도 드물다.


    이런 이유로 인터페이스와 클래스를 따로 구현하면 소스 파일만 많아지고 구현 클래스에 대한 간접 참조가 증가해서 전체 구조가 복잡해진다.


    따라서 인터페이스가 명확하게 필요하기 전까지는 응용 서비스에 대한 인터페이스를 작성하는 것이 좋은 선택이라고 볼 수는 없다.




    TDD를 즐겨 하고 표현 영역부터 개발을 시작한다면,
    미리 응용 서비스를 구현할 수 없으므로 응용 서비스의 인터페이스부터 작성하게 될 것이다.



    예를 들어 컨트롤러를 TDD로 먼저 개발한다면,

    컨트롤러에서 사용할 응용 서비스 클래스의 구현은 존재하지 않으므로 응용 서비스의 인터페이스를 이용해서 컨트롤러의 구현을 완성해 나갈 수 있다.


    표현 영역이 아닌 도메인 영역이나 응용 영역의 개발을 먼저 시작하면 응용 서비스 클래스가 먼저 만들어진다.


    이렇게 되면 표현 영역의 단위 테스트를 위해 응용 서비스 클래스의 가짜 객체가 필요한데 이를 윟 인터페이스를 추가할 수도 있다.


    하지만 Mockito와 같은 테스트 도구는 클래스에 대해서도 테스트용 대역 객체를 만들 수 있기 때문에 응용 서비스에 대한 인터페이스가 없어도 표현 영역을 테스트 할 수 있다.


    이는 결과적으로 응용 서비스에 대한 인터페이스 필요성을 약화시킨다.



    3. 메서드 파라미터와 값 리턴

    응용 서비스에 데이터로 전달할 요청 파라미터가 두 개 이상 존재하면 데이터 전달을 위한 별도 클래스를 사용하는 것이 편리하다.


    @Controller
    public class MemberPasswordController {
        @PostMapping
        public String submit(ChangePasswprdRequest changePwdReq) {
            try {
                changePasswordService.changePassword(changePwdReq);
            } catch(NoMemberException ex) {
                // 알맞은 익셉션 처리 및 응답
            }
        }
    }
    



    응용 서비스의 결과를 표현 영역에서 사용해야 한다면

    응용 서비스에서 애그리거트 객체를 그대로 리턴할 수도 있겠지만

    그렇게 한다면 코딩은 편할지라도 도메인의 로직 실행을 응용 서비스와 표현 영역 두 곳에서 할 수 있게 된다.


    이것은 기능 실행 로직을 응용 서비스와 표현 영역에 분산시켜 코드의 응집도를 낮추는 원인이 된다.


    응용 서비스는 표현 영역에서 필요한 데이터만 리턴하는 것이 기능 실행 로직의 응집도를 높이는 확실한 방법이다.




    4. 표현 영역에 의존하지 않기

    응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 영역과 관련된 타입을 사용하면 안 된다는 점이다.


    예를 들면 표현 영역에 해당하는 HttpServletRequest나 HttpSesion을 응용 서비스에 파라미터로 전달하면 안 된다.



    응용 서비스에서 표현 영역에 대한 의존이 발생하면 응용 서비스만 단독으로 테스트하기가 어려워진다.


    게다가 표현 영역의 구현이 변경되면 응용 서비스의 구현도 함께 변경되어야 하는 문제도 발생한다.


    이 두 문제보다 더 심각한 것은 응용 서비스가 표현 영역의 역할까지 대신하는 상황이 벌어질 수 도 있다는 것이다.


    HttpSession이나 쿠키는 표현 영역의 상태에 해당하는데 이 상태를 응용 서비스에서 변경해버리면 표현 영역의 코드만으로 표현 영역의 상태가 어떻게 변경되는지 추적하기 어려워진다.


    응용 서비스가 표현 영역의 기술을 사용하지 않도록 해야 한다


    이를 지키기 위한 가장 쉬운 방법이 서비스 메서드의 파라미터와 리턴 타입으로 표현 영역의 구현 기술을 사용하지 않는 것이다.




    5. 트랜잭션 처리

    스프링은 @Transactional이 적용된 메서드가 RuntimeException을 발생시키면 트랜잭션을 롤백하고 그렇지 않으면 커밋하므로 이 규칙에 따라 코드를 작성하면 트랜잭션 처리 코드를 간결하게 유지할 수 잇다.




    4. 표현 영역

    p. 218


    표현 영역의 책임은 크게 3가지로 구분할 수 있다.


    1. 사용자가 시스템을 사용할 수 있도록 알맞은 흐름을 제공


    2. 사용자의 요청에 맞게 응용 서비스에 기능 실행을 요청

    응용 서비스의 실행 결과를 사용자에게 알맞은 형식으로 제공하는 것도 표현 영역의 몫이다.


    3. 사용자의 연결 상태인 세션을 관리하는 것




    5. 값 검증

    p. 221


    표현 영역과 응용 서비스에서 값 검사를 나눠서 수행 할 수 있다.


    표현 영역

    필수 값, 값의 형식, 범위 등을 검증


    응용 서비스

    데이터의 존재 유무와 같은 논리적 오류를 검증




    응용 서비스에서 얼마나 엄격하게 값을 검증해야 하는지에 대해서는 의견이 갈릴 수 있다.


    응용 서비스에서 필요한 값 검증을 모두 처리하면 프레임워크가 제공하는 검증 기능을 사용할 때보다 작성할 코드가 늘어나는 불편함이 있지만, 반대로 응용 서비스의 완성도가 높아지는 이점이 있다.




    6. 권한 검사

    p.226


    “사용자 U가 기능 F를 실행할 수 있는지” 확인하는 것이 권한 검사이므로 권한 검사자체는 복잡한 개념이 아니다.


    그런데 개발하는 시스템마다 권한의 복잡도가 다르다.


    단순한 시스템은 인증 여부만 검사하면 되는데 반해, 어떤 시스템은 관리자인지에 따라 사용할 수 있는 기능이 달라지기도 한다.


    또 실행할 수 있는 기능이 역할마다 달라지는 경우도 있다.


    이런 다양한 상황을 충족하기 위해 스프링 시큐리티같은 프레임워크는 유연하고 확장 가능한 구조를 갖고 있다.


    이는 유연한 만큼 복잡하다는 것을 의미하기도 한다.


    이러한 보안 프레임워크에 대한 이해가 부족하면 프레임워크를 무턱대고 도입하는 것 보다 개발할 시스템에 맞는 권한 검사 기능을 구현하는 것이 시스템 유지 보수에 유리할 수 있다.


    보안 프레임워크의 복잡도를 떠나 보통 다음 세 곳에서 권한 검사를 수행할 수 있다.

    • 표현 영역
    • 응용 서비스
    • 도메인

    표현 영역

    표현 영역에서는 인증 여부 및 권한 검사 후 URL에 대한 접근 제어를 할 수 있다.


    접근 제어를 하기 좋은 위치는 서블릿 필터인데,
    서블릿 필터에서 사용자의 인증 정보를 생성하고 인증 여부를 검사한다.


    스프링 시큐리티는 이와 유사한 방식으로 필터를 이용해서 인증 정보를 생성하고 웹 접근을 제어한다.


    응용 서비스

    URL 만으로 접근 제어를 할 수 없는 경우 응용 서비스의 메서드 단위로 권한 검사를 수행해야 한다.


    예를 들어 스프링 시큐리티는 AOP를 활용해서 다음과 같이 애너테이션으로 서비스 메서드에 대한 권한 검사를 할 수 있는 기능을 제공한다.


    public class BlockMemberService {
        private MemberRepository memberRepository;
        
        @PreAuthorize("hasRole('ADMIN')")
        public void block(String memberId) {
            Member member = memberRepository.findById(memberId);
            if (member == null) throw new NoMemberException();
            member.block();
        }
        
        ...
    }
    

    도메인

    스프링 시큐리티와 같은 보안 프레임워크를 확장해서 개별 도메인 객체 수준의 권한 검사 기능을 프레임워크에 통합할 수도 있다.


    도메인 객체 수준의 권한 검사 로직은 도메인별로 다르므로 도메인에 맞게 보안 프레임워크를 확장하려면 프레임워크에 대한 높은 이해가 필요하다.


    이해도가 높지 않아 프레임워크 확장을 원하는 수준으로 할 수 없다면 프레임워크를 사용하는 대신 도메인에 맞는 권한 검사 기능을 직접 구현하는 것이 코드 유지 보수에 유리하다.




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

    p.231


    서비스에서 조회 전용 기능을 사용하면 조회 전용 기능은 추가적인 로직이 없을 뿐더러 단일 쿼리만 실행하는 조회 전용 기능이어서 트랜잭션이 필요하지도 않다.


    이 경우라면 굳이 서비스를 만들 필요 없이 표현 영역에서 바로 조회 전용 기능을 사용해도 문제가 없다.

    public class OrderController {
    
        private OrderViewDao orderViewDao;
        
        @RequestMapping("/myorders")
        public String list(ModelMap model) {
            String orderId = SecurityContext.getAuthentication().getId();
            List<OrderView> orders = orderViewDao.selectByOrderer(orderId);
            model.addAttribute("orders", orders);
            return "order/list";
        }
    }
    

    응용 서비스를 항상 만들었던 개발자는 컨트롤러와 같은 표현 영역에서 응용 서비스 없이 조회 전용 기능에 접근하는 것이 이상하게 느껴질 수 있다.


    하지만 응용 서비스가 사용자 요청 기능을 실행하는 데 별다른 기여를 하지 못한다면 굳이 서비스를 만들지 않아도 된다.


    조회 전용 기능에 대한 내용은 11장 CQRS에서 다시 살펴본다.




    책 정보

    책 클릭하면 구매처 안내 💁

Designed by Tistory / Custom by 얼거스