헥사고날 아키텍처의 애플리케이션 계층
애플리케이션의 UseCase
웹 계층에 들어온 요청은 UseCase 인터페이스를 통해서 Service 계층에 전달되고 Port를 통해서 모델의 상태를 변환하고 출력을 반환한다. UseCase를 애플리케이션을 사용하는 하나의 흐름이라는 관점에서 이 과정을 정리해보면 4단계로 구성된다.
- 입력을 받기 (web)
- 검증하고 비즈니스 로직 수행하기 (usecase + service)
- 모델 상태를 조작하기 (port)
- 출력을 반환하기 (web)
검증의 과정은 입력받은 값 자체에 대한 검증이 필요하기도 하고, 비즈니스 상의 검증 로직도 필요하다. usecase를 구현한 service가 비즈니스 상의 검증 로직만을 담당하도록 책임을 부여한다면, 입력 유효성 검증은 어디에서 수행되어야 할까?
package io.lucky.user.application.usecase;
import io.lucky.user.domain.UserId;
public interface CreateUserUseCase {
UserId create(CreateUserCommand command);
}
package io.lucky.user.application.usecase;
import jakarta.validation.constraints.Email;
import lombok.Getter;
import org.hibernate.validator.constraints.Length;
@Getter
public class CreateUserCommand extends SelfValidating<CreateUserCommand> {
@Email
private final String email;
@Length(min = 3)
private final String nickname;
@Length(min = 10)
private final String password;
public CreateUserCommand(String email, String nickname, String password) {
this.email = email;
this.nickname = nickname;
this.password = password;
super.validateSelf();
}
}
모든 usecase는 command 객체를 파라미터로 전달받는다. 그리고 모든 Command 객체는 SelfValidating 클래스를 사용해서 생성자에서 super.validateSelf()를 호출하고 각 필드를 검증한다. 이처럼 UseCase 인터페이스는 입력 유효성 검증과 비즈니스 로직 검증에 대한 모든 책임을 가지고 있다. usecase가 애플리케이션 계층에 존재하기 때문에 검증의 과정도 의존성의 방향이 애플리케이션 계층을 향하고 있다. Controller에서 @BindResult를 사용하는 방법과 검증이 적용되는 위치가 다르다. Controller에서는 사용하는 웹 기술에 맞는 최소한의 검증 로직만 수행하고, 애플리케이션에 종속된 입력 유효성 검증은 Command 클래스와 UseCase 인터페이스 안에 유지시킨다.
UseCase과 Query의 분리
읽기 전용 UseCase는 객체의 상태를 변경하지 않고 조회만 하는 행위를 말합니다. 조회만 수행되는 행위 Query라고 따로 이름을 부여하고 UseCase와 분리해서 관리하는 것이 좋습니다. 하나의 모듈 안에서 여러개의 도메인 정보를 조회해야할 때 어떤 인터페이스를 통해서 조회하는 것이 좋은지 고민했었습니다. 그리고 읽기 전용 UseCase를 Query라고 명명하고 분리해서 관리하고 사용하는 것이 적합하겠다는 생각을 했습니다.
package io.lucky.user.application.service;
import io.lucky.user.application.port.SearchUserPort;
import io.lucky.user.application.query.SearchUserQuery;
import io.lucky.user.domain.User;
import io.lucky.user.domain.UserId;
import io.lucky.user.exception.DomainNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class SearchUserService implements SearchUserQuery {
private final SearchUserPort searchUserPort;
@Override
public User getUser(UserId userId) {
return searchUserPort.findById(userId)
.orElseThrow(() -> new DomainNotFoundException(User.class, userId.getId()));
}
}
Command와 Query 분리하기, CQS
Command와 Query 분리하는 접근법은 Command Query Separation 이라는 이름이 있습니다. 도메인의 생성, 수정 ,삭제는 Command 클래스가 담당하고, 도메인의 읽기는 Query에서 수행하는 방법입니다. MSA로 나누어져있는 아키텍처를 선택할 때는 성능, 장애 격리, 데이터 동기화 등을 고려하게 됩니다. 헥사고날 아키텍처를 적용하는 것이 수정이 쉽도록 구성함에 목적이 있기 때문에, 당장은 애플리케이션이 작더라도 시작부터 Command와 Query를 분리하는게 좋습니다.
처음부터 서비스 전체에 CQS를 적용해두면 핵심 비즈니스 명령과 조회 중심의 사용자 서비스를 분리해야하는 시점에 왔을 때 도움을 받습니다. 서비스가 서브 도메인을 분리해야할만큼 커지기 전에도 Command와 Query를 분리하는 전략은 애플리케이션 유지보수에 큰 도움을 줍니다.
package io.lucky.user.application.service;
import io.lucky.user.application.port.CreateUserPort;
import io.lucky.user.application.usecase.CreateUserCommand;
import io.lucky.user.application.usecase.CreateUserUseCase;
import io.lucky.user.domain.User;
import io.lucky.user.domain.UserId;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional
public class CreateUserService implements CreateUserUseCase {
private final CreateUserPort createUserPort;
@Override
public UserId create(CreateUserCommand command) {
User user = new User(null,
command.getEmail(),
command.getNickname(),
command.getPassword());
return createUserPort.create(user);
}
}
위의 코드는 유저를 생성하는 코드입니다. 그리고 CreateUserUseCase는 UserId를 반환하도록 정의되어 있습니다. 새로 생성한 유저의 정보를 알기 위해서는 반환된 UserId로 다시 조회해야하지만, 이러한 번거로움을 감수하는게 좋습니다. User 객체를 생성하는 행위가 다양해질 수 있고 (판매자, 구매자, 관리자, 상위 관리자 등) 이렇게 생성된 User 객체가 필요한 상황에서 어떤 포멧으로 User 정보를 조회해야할지도 달라질 수 있습니다. CreateUser 이후에 User 객체 전체를 반환하도록 인터페이스가 정의 되어 있으면, 이 메서드의 반환값을 어느 서비스에서 얼마나 사용하게 될지 관리할 수 없습니다. 결국 나중에 create()를 호출한 모든 메서드를 확인해야하는 번거로움이 발생합니다.
다른 도메인 정보가 필요할 때는 Query 사용하기
상태를 변경하지 않고 도메인을 조회만 하는 쿼리를 목적에 맞게 분리하는 것이 좋습니다. 같은 User 객체 하나를 조회하더라도, 연관관계에 따라서 어떤 필드를 기준으로 조회하는지가 다양해질 수 있습니다. 프로젝트가 커지면 객체를 하나만 가져올지, 여러개를 가져올지, 단순한 객체 정보가 아닌 통계 정보를 가져올지가 명확하게 분리되어 있는 것이 좋습니다.
package io.lucky.user.application.query;
import io.lucky.user.domain.User;
import io.lucky.user.domain.UserId;
public interface LoadUserQuery {
User getUser(UserId userId);
}
package io.lucky.user.application.query;
import io.lucky.user.domain.User;
import io.lucky.user.domain.UserId;
import java.util.List;
public interface ListUsersQuery {
List<User> getUser(List<UserId> userId);
}
package io.lucky.user.application.query;
import io.lucky.user.domain.UserId;
public interface GetUserStatQuery {
Integer getUserLoginCountInRecentHours(UserId userId, int hours);
}
이벤트 방식의 아키텍처가 도입된다면
하나의 서비스에서 외부 서비스로 호출해야하는 경우의 수가 많아진다면, 비동기적으로 데이터를 조회해야하는 필요성이 발생합니다. 이러한 경우에는 이벤트 방식의 아키텍처가 검토될 수 있습니다. 앞서 이야기한 CQS가 서비스 전체에 적용되어 있다면, 최종적 일관성 Eventually Consistency를 사용한 동기화 방법을 사용하는데 도움을 받습니다.
- 비즈니스 명령 (생성, 수정, 삭제)을 담당하는 서비스는 이벤트만 발생시킨다. 이 이벤트에는 객체의 Id 정도만 들어있다. (Zero-Payload 방식)
- 조회를 담당하는 서비스에서는 이벤트를 감지하고, 이벤트에 포함된 Id를 사용해서 API를 사용해 최신 상태의 변경된 사항을 조회하고 자신의 서비스에 동기화한다. 이 과정에서 최대 1~3초의 지연시간이 발생할 수 있다.
CQS의 관점에서 보면, 하나의 비즈니스 명령의 이벤트를 구독하는 여러개의 서비스들이 필요한 정보는 각자 다를 수 있습니다. 구독하는 서비스는 이벤트 속에 들어있는 Id만 조회하고, 각자 필요한 API를 조회하게 됩니다. 만약 API가 실패할 경우에는 비즈니스적으로 해당 API가 얼마나 중요한지에 따라서 적당한 fallback 값을 리턴하게 됩니다.
이러한 전략은 이벤트가 발생하고 수신하는 과정에서의 이벤트 순서를 신경쓰지 않아도 되고, Eventually Constitency를 사용해서 데이터 정합성의 최종적 일관성을 유지할 수 있으며, 각 서비스는 자신의 서비스에 최적화된 데이터베이스를 사용할 수 있다는 뜻이 됩니다. 데이터베이스 종류부터 같은 도메인 객체에 대해서도 자신의 서비스가 필요로하는 최소 정보만 가지고 있을 수 있습니다.
이렇게 동기화된 조회 전용 서비스에서는 사용자 화면에 맞는 데이터를 Map<Id, CustomizedFormat>으로 메모리에 저장해둡니다. 물론 최대 메모리 사용량을 고려해서 모든 데이터를 메모리에 올려두지는 못하겠지만, 사용자의 요청이 들어오면 빠르게 조회할 수 있으며, 메모리에 데이터가 없는 경우라도 외부 데이터베이스가 아니라 자신의 데이터베이스에서 정보를 조회할 수 있어 장애 격리가 보장됩니다.
좀 더 사설을 붙이면, 이벤트 방식의 Eventually Consistency한 특성 때문에 Command를 통해서 동기화된 데이터와 Query가 수행되는 데이터 사이에서 정합성이 1~3초동안 일치하지 않을 수 있습니다. 그래서 Command에 의해서 수정되고 비동기적으로 동기화된 데이터가 Query를 통해서 사용될 때에는 데이터 정합성을 위해서 API로 한 번 검증하는 로직이 필요할 수 있습니다.
예를 들어 쇼핑몰에서 판매자가 상품의 가격을 수정했습니다. 그런데 구매자는 판매자가 수정하기 이전의 가격으로 조회를 하고 있었고, 판매자의 수정 사항이 반영되기 전에 구매자가 주문 버튼을 눌렀습니다. 이처럼 일단 사용자에게 변경되기 이전의 값이라도 보여주고 추후에 검증을 한 번 하는게 더 나은 상황이 있을 수 있습니다. 가격 정보가 변경되는 상황은 잘 일어나지 않고, 사용자가 상품을 조회하는 것은 실시간으로 계속 일어나기 때문입니다.
'DEV > Architecture' 카테고리의 다른 글
헥사고날 아키텍처 시리즈 7. 테스트 (1) | 2024.03.16 |
---|---|
헥사고날 아키텍처 시리즈 6. 영속성 계층 (0) | 2024.03.16 |
헥사고날 아키텍처 시리즈 4. 패키지 구조 (0) | 2024.03.15 |
헥사고날 아키텍처 시리즈 3. 헥사고날 아키텍처 시작하기 (0) | 2024.03.14 |
헥사고날 아키텍처 시리즈 2. 계층형 아키텍처 극복하기 (1) | 2024.03.14 |