Posts [도메인주도개발시작하기] Chapter6-응용 서비스와 표현 영역
Post
Cancel

[도메인주도개발시작하기] Chapter6-응용 서비스와 표현 영역

표현 영역과 응용 영역

  • 응용 영역과 표현 영역은 사용자와 도메인을 연결해주는 매개체 역할을 한다.

응용 영역의 책임

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

표현 영역의 책임

  • 응용 서비스가 요구하는 형식으로 사용자 요청을 변환
  • 응용 서비스를 실행 한 후 실행 결과를 사용자에 알맞은 형식으로 응답

사용자와의 상호작용은 표현 영역이 처리하기 때문에 응용 서비스는 표현 영역에 의존하지 않는다. 응용 영역은 사용자가 웹 브라우저를 사용하는지, REST API를 호출하는지, TCP 소켓을 사용하는지 여부를 알 필요가 없다. 단지, 응용 영역은 기능 실해에 필요한 입력값을 전달받고 실행 결과만 리턴하면 될 뿐이다.

응용 서비스의 역할

1. 도메인 객체 간의 실행 흐름 제어

  • 사용자(클라이언트)가 요청한 기능을 실행한다.
  • 사용자의 요청을 처리하기 위해 리포지터리로부터 도메인 객체를 구하고, 도메인 객체를 사용한다.
  • 도메인 객체 간의 흐름을 제어하기 때문에 아래와 같이 단순한 형태를 가진다.
1
2
3
4
5
6
7
8
9
10
11
public Result doSomeFunc(SomeReq req) {
    // 1. 리포지터리에서 애그리거트를 구한다.
    SomeAgg agg = someAggRepository.findById(req.getId());
    checkNull(agg);

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

    // 3. 결과를 리턴한다.
    return createSuccessResult(agg);
}
  • 응용 서비스가 이것보다 복잡하다면 응용 서비스에서 도메인 로직의 일부를 구현하고 있을 가능성이 높다.

2. 트랜잭션 처리

  • 데이터 일관성을 보장하기 위한 트랜잭션 처리를 한다.

위 두 가지 이외에도 접근 제어와 이벤트 처리가 있는데 뒤에서 살펴본다.

도메인 로직 넣지 않기

  • 아래 예제 코드와 같이 패스워드 일치 여부를 검사하는 도메인 로직은 도메인 영역에서 제공해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Member {

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

    // 현재 암호와 일치하는지 검사하는 도메인 로직
    public boolean matchPassword(String pwd) {
        return passwordEncoder.matchs(pwd);
    }

    private void setPassword(String newPw) {
        if (isEmpty(newPw)) throw new IllegalArgumentException("no new password");
        this.password = newPw;
    }
}
  • 도메인 로직을 도메인 영역과 응용 서비스에서 분산해서 구현하면 코드 품질에 두 가지 문제가 발생한다.

1. 코드의 응집성 떨어진다.

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

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

1
2
3
4
5
6
7
8
9
10
11
public class DeactivationService {
  public void deactivate(String memberId, String pwd) {
    Member member = memberRepository.findById(memberId);
    
    if (!passwordEncoder.matches(oldPw, member.getPassword())) {
      throw new BadPasswordException();
    }

    member.deactivate();
  }
}
  • 위처럼 도메인 로직을 여러 응용 서비스에서 구현하게 되면 중복 코드가 발생하게 된다.
    • 만약 패스워도 변경시에도 패스워드 일치 여부를 확인하는 로직이 필요하다면 중복해서 코드를 작성하게 될 것이다.
  • 이는 결과적으로 코드 변경을 어렵게 만든다.

Note: 소프트웨어의 중요한 경쟁 요소 중 하나는 변경의 용이성인데, 변경이 어렵게 된다는 것은 그만큼 소프트웨어의 가치가 떨어진다는 것을 뜻한다. 소프트웨어의 가치를 높이려면 도메인 로직을 도메인 영역에 모아서 코드 중복이 발생하지 않도록 하고 응집도를 높여야 한다.

응용 서비스의 구현

  • 응용 서비스는 표현 영역과 도메인 영역을 연결하는 매개체 역할을 하는데 이는 디자인 패턴에서 파사트(facade)와 같은 역할을 한다.
  • 응용 서비스 자체는 복잡한 로직을 수행하지 않기 때문에 응용 서비스의 구현은 어렵지 않다.
  • 응용 서비스 구현시 몇 가지 고려할 사항과 트랜잭션과 같은 구현 기술의 연동에 대해 살펴보자.

응용 서비스의 크기

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

  • 장점: 한 도메인과 관련된 기능을 구현한 코드가 한 클래스에 위치하므로 각 기능에서 동일 로직에 대한 코드 중복을 제거할 수 있다.
  • 단점: 한 서비스 클래스의 크기가 커진다. 연관성이 적은 코드가 한클래스에 함께 위치할 가능성이 높아짐을 의미하는데, 결과적으로 관련 없는 코드가 뒤섞여서 코드를 이해하는데 방해가 될 수 있다.
    • 예를 들어, 암호 초기화 기능을 구현한 initializePassword() 메서드는 암호 초기화 후 신규 암호를 사용자에게 통지하기 위해 Notfier 를 사용하는데, changePassword() 메서드에선 필요치 않은 기능이다.
    • 하지만 Notifier 가 필드로 존재하므로 이 Notifier 가 어떤 기능 때문에 필요한지 확인하려면 각 기능을 구현한 코드를 뒤져야만 한다.
    • 그리고 엄연히 분리하는 것이 좋은 상황임에도 습관적으로 기존에 존재하는 클래스에 억지로 끼워 넣게 된다. 이는 코드를 점점 얽히게 만들어 코드 품질을 낮추는 결과를 초래한다.

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

  • 장점: 코드 품질을 일정 수준으로 유지하는데 도움이 된다. 또한, 각 클래스별로 필요한 의존 객체만 포함하므로 다른 기능을 구현한 코드에 영향을 받지 않는다.
  • 만약, 각 기능마다 동일한 로직을 구현할 경우 여러 클래스에 중복해서 동일한 코드를 구현할 가능성이 있는데, 이를 방지하기 위해 다음과 같이 별도 클래스에 로직을 구현해서 코드가 중복되는 것을 방지할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 각 응용 서비스에서 공통되는 로직을 별도 클래스로 구현
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 static com.myshop.member.application.MemberServiceHelper.*;

public class ChangePasswordService {

    private MemberRepository memberRepository;

    public void changePassword(String memberId, String oldPw, String newPw) {
        Member member = findExistingMember(memberRepository, memberId);
        member.setPassword(newPw);
    }
    ...
}

Note: 저자는 한 클래스가 여러 역할을 갖는 것보다(전자) 각 클래스마다 구분되는 역할을 갖는 것을 선호한다고 한다. 즉, 한 도메인과 관련된 기능을 하나의 응용 서비스 클래스에서 모두 구현하는 방식보단 구분되는 기능을 별도의 서비스 클래스로 구현하는 방식을 사용한다고 한다.

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

  • 인터페이스를 유용하게 사용할 수 있는 경우는 다음과 같다.
    • 1)구현 클래스가 다수 존재할때
    • 2)런타임에 구현 객체를 교체해야 할 경우
  • 하지만 응용 서비스는 보통 런타임에 이를 교체할 경우가 거의 없을 뿐만 아니라 한 응용 서비스의 구현 클래스가 두 개인 경우도 매우 드물다.
  • 인터페이스가 명확하게 필요하기 전까지는 으용 서비스에 대한 인터페이스를 작성하는 것이 좋은 설계라 볼 수 없다.
  • 표현 영역의 단위 테스트를 할 때 Mockito 와 같은 테스트 도구를 통해 테스트용 가짜 객체를 만들 수 있기 때문에 응용 서비스에 대한 인터페이스가 없어도 문제 없다.

메서드 파라미터와 값 리턴

  • 응용 서비스에서 애그리거트 자체를 리턴하면 코딩은 편할 수 잇찌만 도메인의 로직 실행을 응용 서비스와 표현 영역 두 곳에서 할 수 있게 된다.
  • 이는 기능 실행 로직을 응용 서비스와 표현 영역에 분산시켜 코드의 응집도를 낮추는 원인이 된다.
  • 물론 팀 내 컨벤션으로 응용 서비스가 애그리거트를 리턴할 경우 해당 애그리거트의 기능을 컨트롤러나 뷰 코드에서 실행하지 않도록 규정할 수도 있다.
  • 하지만, 그보다는 응용 서비스는 표현 영역에서 필요한 데이터만 리턴하는 것이 기능 실행 로직의 응집도를 높이는 확실한 방법이다.

표현 영역에 의존하지 않기

  • 응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 영역과 관련된 타입을 사용하면 안된다는 점이다.
    • ex. HttpServletRequest, HttpSession
  • 표현 영역에 의존하지 않기 이렇게 했을 때 아래와 같은 문제점이 발생한다.

1) 응용 서비스만 단독으로 테스트하기 어려워짐

2) 표현 영역의 구현 변경시 응용 서비스의 구현도 함께 변경해줘야됨

3) 응용 서비스가 표현 영역의 역할까지 대신하는 상황이 벌어질 수도 있다.

  • 예를 들어, 응용 서비스에 파라미터로 HttpServletRequest를 전달했는데 응용 서비스에 HttpSession 을 생성 후 세션에 인증 관련 정보를 담는다 해보자.
1
2
3
4
5
6
7
8
9
10
11
12
public class AuthenticationService {
  public void authenticate(HttpServletRequest request) {
    String id = request.getParamter("id");
    String password = request.getParameter("password");
    if (checkIdPasswordMatching(id, password)) {
      // 응용 서비스에서 표현 영역의 상태 처리
      HttpSession session = request.getSession();
      session.setAttribute("auth", new Authentication(id));
    }
  }
  ...
}
  • HttpSession 이나 쿠키는 표현 영역의 상태에 해당하는데 이 상태를 응용 서비스에서 변경해 버리면 표현 영역의 코드만으로 표현 영역의 상태가 어떻게 변경되는지 이해하기 어려워진다.
    • 즉, 표현 영역의 응집도가 깨지는 것이다. 이는 결과적으로 코드 유지보수 비용을 증가시키는 원인이 된다.
  • 앞서 언급한 문제가 발생되지 않도록 하는 가장 쉬운 방법은 서비스 메서드의 파라미터와 리턴 타입으로 표현 영역의 구현 기술을 사용하지 않는 것이다.

트랜잭션 처리

  • 프레임워크가 제공하는 트랜잭션 기능을 적극 사용하는 것이 좋다. 스프링의 @Transactional 은 RuntimeException 이 발생하면 롤백시켜버리고 그렇지 않으면 커밋을 자동으로 하게 된다.

도메인 이벤트 처리

  • 응용 서비스의 역할 중 하나는 도메인 영역에서 발생한 이벤트를 처리하는 것이다.
  • 여기서 이벤트는 도메인에서 발생한 상태 변경을 의미하며 ‘암호 변경됨’, ‘주문 취소함’ 과 같은 것이 이벤트가 될 수 있다.
1
2
3
4
5
6
7
8
9
public class Member {
	private Password password;
	
  public void initializePassword() {
		String newPassword = generateRandomPassword();
		this.password = new Password(newPassword);
		Events.raise(new PasswordChangedEvent(this.id, password); // 도메인 이벤트 발생!!
	}
}
  • 도메인에서 이벤트를 발생시키면 그 이벤트를 받아서 처리할 코드가 필요한데, 그 역할을 하는 것이 바로 응용 서비스다.
  • 암호 초기화의 경우 암호 초기화됨 이벤트가 발생하면 변경한 암호를 이메일로 발송하는 이벤트 핸들러를 등록할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public class InitPasswordService {
  
  @Transactional
  public void initializePassword(String memberId) {
    Events.handle((PasswordChangeEvent evt) -> {
      // evt.getid()에 해당하는 회원에게 이메일 발송하는 기능 구현
    });
    
    Member member = memberRepository.findById(memberId);
    checkMemberExists(member);
    member.initializePassword();
  }
}
  • 위 이벤트 처리 코드를 보고 왜 다음과 같이 코드를 작성해도 되는데 다소 복잡해 보이는 이벤트를 사용했는지 궁금할 수 있다.
1
2
3
4
5
6
7
@Transactional
public void initializePassword(String memberId) {
  Member member = memberRepository.findById(memberId);
  checkMemberExists(member);
  member.initializePassword(); // 이벤트 발생하지 않음
  sendNewpasswordMailToMember(member); // 실행 안돼야 함
}
  • 이벤트를 사용하면 코드가 다소 복잡해지는 대신 도메인 간의 의존성이나 외부 시스템에 대한 의존을 낮춰주는 장점을 얻을 수 있다.
  • 또한 시스템을 확장하는 데에 이벤트가 핵심 역할을 수행하게 된다.
  • 이런 이유로 이벤트를 사용하는데, 이에 대한 자세한 내용은 이벤트를 다루는 10장과 CQRS 에 대해 다루는 11장에서 살펴볼 예정이다.

표현 영역

  • 표현 영역의 책임은 크게 다음과 같다.
    • 1)사용자가 시스템을 사용할 수 있는 (화면)흐름을 제공하고 제어한다.
    • 2)사용자의 요청에 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공한다.
    • 3)사용자의 세션을 관리 (웹의 경우 쿠키나 서버 세션을 이용해 사용자의 연결 상태를 관리)

값 검증

  • 값 검증은 표현 영역과 응용 서비스 두 곳에서 모두 수행 가능하다.
  • 원칙적으로 모든 값에 대한 검증은 응용 서비스에서 처리한다.
  • 예를 들어, 회원 가입을 처리하는 응용 서비스는 다음과 같이 파라미터로 전달받은 값이 올바른지 검사해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class JoinService {
  @Transactional
  public void join(JoinRequest joinReq) {
    checkEmpty(joinRequest.getId(), "id");
		checkEmpty(joinRequest.getName(), "name");
    checkPassword(joinRequest.getPassword(), "password");
    if (joinReq.getPassword().equals(joinReq.getConfirmPassword())) {
      throw new InvalidPropertyException("confirmPassword");
    }

    // 로직 검사
    checkDuplicatedId(joinReq.getId());
    ...
  }

  private void checkEmpty(String value, String propertyName) {
    if(value == null || value.isEmpty()) {
      throw new EmptyPropertyException(propertyName);
    }
  }

  private void checkDuplicateId(String id) {
    int count = memberRepository.countsById(id);
    if (count > 0) throw new DuplicatedIdexception();
  }
}
  • 그런데 표현 영역은 잘못된 값이 존재하면 이를 사용자에게 알려주고 값을 다시 입력받아야 한다.
  • 스프링 MVC의 경우 폼에 입력한 값이 잘못된 경우 에러 메시지를 보여주기 위한 용도로 Errors 나 BindingResult를 사용하는데, 스프링 MVC의 컨트롤러에서 위와 같은 응용 서비스를 사용하면 폼에 에러 메시지를 보여주기 위해 다음과 같이 다소 번잡한 코드를 작성해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Controller
public class Controller {

  @RequestMapping
  public String join(JoinReuqest joinRequest, Errors erros) {
    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;
    }

  }
}
  • 응용 서비스에서 각 값이 존재하는지 형식이 올바른지 확인할 목적으로 익셉션을 사용할 때의 문제점은 사용자에게 좋지 않은 경험을 제공한다는 것이다.
    • 사용자는 폼에 값을 입력 후 전송했는데 입력한 값이 잘못되어 다시 폼에 입력해야 할 때 한 개 항목이 아닌 모든 항목에 대해 잘못된 값이 존재하는지 알고 싶을 것이다.
    • 그래야 한 번에 잘못된 값을 제대로 입력할 수 있기 때문이다.
  • 그런데, 응용 서비스에서 값을 검사하는 시점에 첫 번째 값이 올바르지 않아 익셉션을 발생시키면 나머지 항목에 대해서는 값을 검사하지 않게 된다.
  • 이러면 사용자는 첫번쨰 값에 대한 에러 메시지만 보게 되고 나머지 항목에 대해선 값이 올바른지 여부를 알 수 없게 된다. 이는 사용자가 같은 폼에 값을 여러 번 입력하게 만든다.
  • 이런 사용자 불편을 해소하려면 다음과 같이 응용 서비스에 값을 전달하기 전에 표현 영역에서 값을 검사하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Controller
public class Controller {
	@RequestMapping
	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(DuplicateIdException ex) {
      erros.rejectValue(ex.getPropertyName(), "duplicate");
      return formView;
    }
	}

	private void checkEmpty(String value, String property, Errors errors) {
		if(isEmpty(value)) erros.rejectValue(property, "empty");
  }
}
  • 스프링에서 제공하는 Validator 인터페이스를 구현한 검증기를 따로 구현하면 위 코드를 더 간결하게 줄일 수 있다.
  • 표현 영역에서 필수 값과 값의 형식을 검사하면 실질적으로 응용 서비스는 논리적 오류만 검사하면 된다.
    • 표현 영역: 필수 값과 값의 형식, 범위 등을 검증
    • 응용 서비스: 데이터의 존재 유무와 같은 논리적 오류를 검증
  • 엄격하게 두 영역에서 모두 값검사를 하고 싶다면 동일한 검증기를 사용해서 검증 코드를 줄일 수 있다.

Note: 저자의 경험상 응용 서비스를 실행하는 주체가 표현 영역이면 응용 서비스는 논리적 오류 위주로 값을 검증해도 문제가 없었지만 응용 서비스를 실행하는 주체가 다양하면 응용 서비스에서 반드시 파라미터로 전달받은 값이 올바른지 검사를 해야 한다.

권한 검사

  • 권한을 검사하기 위해 스프링 시큐리티나 아파치 Shiro 같은 프레임워크는 유연하고 확장 가능한 구조를 가진다.
  • 보안 프레임워크의 복잡도를 떠나 보통 다음 세 곳에서 권한 검사를 수행할 수 있다.
    • 1)표현 영역
    • 2)응용 서비스
    • 3)도메인
  • 예를 들어, 회원 정보 변경을 처리하는 URL에 대해 표현 영역에서 다음과 같이 접근 제어를 할 수 있다.
    • 이 URL을 처리하는 컨트롤러에 웹 요청을 전달하기 전에 인증 여불르 검사해서 인증된 사용자의 요청만 컨트롤러에 전달한다.
    • 인증된 사용자가 아닐 경우 로그인 화면으로 리다이렉트 시킨다.
  • 이런 접근 제어를 하기 좋은 위치가 서블릿 필터이다.
    • 서블릿 필터에서 사용자의 인증 정보를 생성 후 인증 여부를 검사하는 것이다.
  • URL만으로 접근제어를 할 수 없는 경우 응용 서비스의 메서드 단위로 권한 검사를 수행해야 한다.
  • 스프링 시큐리티는 AOP를 활용해서 다음과 같이 어노테이션으로 서비스 메서드에 대한 권한 검사를 할 수 있는 기능을 제공한다.
1
2
3
4
5
6
7
8
9
10
11
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();
		...
	}
}
  • 개별 도메인 단위로 권한 검사를 해야 하는 경우는 응용 서비스의 메서드 수준에서 권한 검사를 할 수 없기 때문에 다음과 같이 직접 권한 검사 로직을 구현해줘야 한다.
1
2
3
4
5
6
7
8
9
10
11
public class DeleteArticleService {

	public void delete(String userId, Long articleId) {
		Article article = articleRepository.findById(articleId);
		checkArticleExistence(article);
		permissionService.checkDeletePermission(userId, article);
		article.markDeleted();
	}
	
  ...
}
  • 스프링 시큐리티와 같은 보안 프레임워크를 확장해서 개별 도메인 객체 수준의 권한 검사 기능을 프레임워크에 통합 할 수도 있을 것이다.
    • 하지만, 이해가 높지 않아 프레임워크 확장을 우너하는 수준으로 할 수 없다면 프레임워크를 사용하는 대신 도메인에 맞는 권한 검사 기능을 구현하는 것이 코드 유지보수에 유리할 수 있다.

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

  • 서비스 코드가 다음과 같이 단순한 조회 전용 기능을 호출하는 것으로 끝나는 경우가 많다.
1
2
3
4
5
public class OrderListService {
	public List<OrderView> getOrderList(String ordererId) {
		return orderViewDao.selectByOrderer(ordererId);
	}
}
  • 이런 경우라면 굳이 서비스를 만들 필요 없이 표현 영역에서 바로 조회 전용 기능을 사용해도 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
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";
	}
  
  ...
}

Reference

This post is licensed under CC BY 4.0 by the author.

[도메인주도개발시작하기] Chapter5-스프링 데이터 JPA를 이용한 조회기능

[도메인주도개발시작하기] Chapter7-도메인 서비스