설계방법

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

Lee_SJ 2024. 9. 2. 22:53

6.1 표현영역과 응용영역

도메인 영역을 잘 구현하지 않으면 사용자의 요구를 충족하는 제대로 된 소프트웨어를 만들지 못한다.

하지만 도메인 영역만 잘만든다고 끝이 아니다. 도메인이 제 기능을 사용하려면 사용자와 도메인을 연결해 주는 매개체가 필요하다.

응용 영역과 표현 영역이 사용자와 도메인을 연결해주는 역할을 한다.

 

표현 영역은 사용자 요청을 해석한다.

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

 

응용 영역은 사용자가 원하는 기능을 제공한다.

사용자가 회원 가입을 요청했다면 실제 그 요청을 위한 기능을 제공하는 주체는 응용 서비스에 위치한다.

 

6.2 응용 서비스의 역할

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

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

 

public Result doSomeFunc(SomdReq req){
 // 리포지터리에서 애그리거트를 구한다.
  SomeAgg agg = SomeAggRepository.findById(req.getId());
  checkNull(agg);

  // 애그리거트의 도메인 기능을 실행한다.
  agg.doFunc(req.getValue())

// 결과 리턴
  return createSuccessResult(agg);
}
 

 

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

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

응용 서비스는 트랜잭션 처리도 담당한다. 

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

한 번에 다수 회원을 차단 상태로 변경하는 응용 서비스를 생각해 보자. 이 서비스는 차단 대상이 되는 Member 애그리거트 목록을 구하고 차례대로 차단 기능을 실행할 것이다.

 

public void blockMember(String[] blockingId){
  if(blockingIds == null || blockingIds.length == 0) return;
  List<Member> members = memberRepository.findByIdIn(blockingIds);
  for (Member mem: members){
    mem.block()
  }
}
 

 

blockMembers()메서드가 트랜잭션 범위에서 실행되지 않는다 가정하자. 그럼 Member객체의 block() 메서드를 실행 해서 상태를 변경했는데 DB에 반영하는 도중 문제가 발생하면 일부 Member만 차단되어 데이터 일관성이 깨진다. 이를 방지하려면 이는 트랜잭션범위에서 응용 서비스를 실행해야 한다.

 

 

6.2.1 도메인 로직 넣지 않기

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

암호 변경 기능을 위한 응용 서비스는 Member애그리거트와 관련 리포지터리를 이용해서 도메인 객체간의 실행 흐름을 제어한다.

 

Member애그리거트는 암호를 변경하기 전에 기존 암호를 올바르게 입력했는지 기존 암호를 올바르게 입력했는지 확인하는 로직을 구현한다.

public class Member{
  public changePassword(String oldJPw, String newPw){
    if(!matchPassword(oldPw)) throw new BadPasswordException();
    setPassword(newPw)
  }

  public boolean matchPassword(String pwd){
    return passwordEncoder.matches(pwd)
  }

  private void setPassword(String newPw){
    if(isEmpty(newPw)) tharow new IllegalArgumentException("no new password");
   this.password = newPw;
  }

}

 

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

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

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

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

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

예를 들어 비정상적인 계정을 막기 위해 암호를 확인한다고 해보자.이 경우 계정정지 기능을 활용하는 응용서비스는 다음과 같이 코드를 구현해야 한다.

 

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

 

일부 도메인 로직이 응용 서비스에 출현하면서 발생하는 두 가지 문제(응집도가 떨어지고 코드 중복이 발생)는 결과적으로 코드 변경을 어렵게 만든다. 소프트웨어가 가져야 할 중요한 경쟁 요소 중 하나는 변경 용이성인데, 변경이 어렵다는 것은 그만큼 소프트웨어의 가치가 떨어진다는 것을 의미한다.

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

 

6.3 응용 서비스의 구현

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

응용 서비스 자체는 복잡한 로직을 수행하지 않기 때문에 응용 서비스의 구현은 어렵지 않다. 이 절에서는 응용 서비스를 구현할 때 몇가지 고려사황과 트랜잭션과 같은 구현 기술의 연동에 대해 살펴본다.

 

6.3.1 응용 서비스의 크기

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

회원 도메인을 생각해보자. 응용서비스는 회원 가입하기 회원 탈퇴하기 회원 암호 변경하기 등의 기능을 구현하기 위해 도메인 모델을 사용한다.

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

- 한 응용 서비스 클래스에 회원 도메인의 모든 기능을 구현하기

- 구분되는 기능별로 응용 서비스 클래스를 따로 구현하기

public class MemberService {
  private MemberRepository memberRepository;
  public void join(MemberJoinRequest joinRequest) {...}
  public void changePassword(String memberId, String curPw, String newPw) {..}
  public void initializePassword(String memberId) { ... }
  public void leave(String memberId, String curPw) {...}
}

 

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

예를 들어 저기서 회원이 존재하지 않으면 NoMemberException을 발생시킨다. 이는 private메서드로 구현하고 호출하는 방법으로 중복 로직을 쉽게 제거 가능하다.

 

단 클래스 의 크기(코드 줄 수 )가 커지는 것이 이 방식의 단점이다.

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

 

예를 들어 위 코드에서 암호 초기화 기능을 구현한 initializePassword()메서드는 암호 초기화 후에 신규 암호를 사용자에게 통지하기 위해 Notifier를 사용하는데 이 Notifier는 암호 변경 기능을 구현한 changePassword에서 중요하지 않은 기능이다.

하지만 Notifier가 필드로 존재하기 때문에 이 Notifier가 어떤 기능 때문에 필요한지 확인하려면 각 기능을 구현한 코드를 뒤져야 한다.

 

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

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

 

구분되는 기능별로 서비스 클래스를 구현하는 방식은 한 응용 서비스 클래스에서 한 개 내지 2-3개의 기능을 구현한다. 다음과 같이 암호 변경 기능만을 위한 응용 서비스 클래스를 별도로 구현하는 방식이다.

이 방식을 사용하면 클래스 개수는 많아지지만 한 클래스에 관련 기능을 모두 구현하는 것과 비교해서 코드 품질을 일정 수준으로 유지하는데 도움이 된다.또한 각 클래스별로 필요한 의존 객체만 포함하므로 다른 기능을 구현한 코드에 영향을 받지 않는다.

 

각 기능마다 동일한 로직을 구현할 경우 여러 클래스에 중복해서 동일한 코드를 구현할 가능성이 있다.

다음과 같이 별도 클래스에 로직을 구현해서 코드가 중복되는 것을ㄹ 방지할 수 있다.

public final class MemberServiceHelper {
  public static Member findExistingMember(MemberRepository repo, String memberId){
    Member member = memberRepository.findById(memberId);
    if (member == null) throw new NoMemberExcption(memberId);
    return member;
  }
}


public class ChangePasswordService {
  private MemberRepository memberRepository;
 
  public void changePassword(String memberId, String curPw, String newPw){
    Mebmer member = findExistingMember(memberRepository, MemberId);
    member.changePassword(curPw,newPw);
  }
}

 

한 클래스가 여러 열할을 갖는 것보다 각 클래스마다 구분되는 역할을 갖는 것을 선호한다.

한 도메인과 고나련된 기능을 하나의 응용 서비스 클래스에서 모두 구현하는 방식보다는 구분되는 기능을 별도의 서비스 클래스로 구현하는 방식을 사용한다.

 

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

응용 서비스를 구현할 때 논쟁이 될만한 것은 인터페이스가 필요한 지이다. 다음과 같이 인터페이스를 만들고 이를 상속한 클래스를 만드는 것이 필요할까?

public interface ChangePasswordService{
  public void changePassword(String memberId, String curPw, String newPw);
}

public class ChangePasswordServiceImpl implements ChangePasswordService {
}

 

인터페이스가 필요한 몇 가지 상황이 있는데 그 중 하나는 구현 클래스가 여러 개인 경우다. 구현 클래스가 다수 존재하거나 런타임에 구현 객체를 교체해야 할 때 인터페이스를 유용하게 사용할 수 있다. 

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

 

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

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

 

TDD를 즐겨하고 표현 영역부터 개발을 시작한다면, 미리 응용서비스를 구현할 수 없으므로 응요;ㅇ 서비스의 인터페이스부터 작성하게 될 것이다. 예를 들어 스프링MVC의 컨트롤러를 TDD로 먼저 개발한다면 컨트롤러에서 사용할 응용 서비스 클래스의 구현은 존재하지 않으므로 응용 서비스의 인터페이스를 이용해서 컨트롤러의 구현을 완성해 나갈 수 있다.

 

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

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

 

 

표현 영역에 의존하지 않기

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

예를 들어 다음과 같이 표현 영역에 해당하는 HttpServletRequest나 HttpSession을 응용영역에 전달하면 안된다.

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

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

 

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

예를 들어 응용 서비스에 파라미커로 HttpServletRequest를 전달했는데 응용 서비스에서 HttpSession을 생성하고 세션에 인증과 관련된 정보를 담는다고 해보자.

 

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

즉 표현 영역의 응집도가 깨지는 것이다. 이것은 결과적으로 코드 유지 보수 비용을 증가시킨다.

 

 

표현 영역

표현 영역의 책임은 크게 다음과 같다.

- 사용자가 시스템을 사용할 수 있는 흐름(화면)을 제공하고 제어한다.

- 사용자의 요청을 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공한다.

- 사용자의 세션을 관리한다.

 

표현 영역의 첫 번째 책임은 사용자가 시스템을 사용할 수 있도록 알맞은 흐름을 제공하는 것이다.

웹 서시스의 표현 영역은 사용자가 요청한 내용을 응답으로 제공한다.

응답에는 다음 화면으로 이동할 수 있는 링크나 데이터를 입력하는 폼등이 포함된다.

 

사용자는 표현영역이 제공한 폼에 알맞은 값을 입력하고 다시 폼을 표현 ㅕ영역에 전송한다.

한 영역은 응용 서비스를 이용해서 표현 영역의 요청을 처리하고 그 결과를 응답으로 전송한다.

 

표현 영역의 두 번째 책임은 사용자의 요청에 맞게 응용 서비스에 기능 실행을 요청하는 것이다.

화면을 보여주는데 필요한 데이터를 읽거나 도메인의 상태를 변경해야 할 때 응용 서비스를 사용한다.

이 과정에서 표현 영역은 사용자의 요청 데이터를 응용 서비스가 요구하는 형식으로 변환하고 응용 서비스의 결과를 ㅅ마용자에게 응답할 수 있는 형식으로 변환한다.