4.6 명령(Command) 및 명령 핸들러 구현 가이드
명령(Command) 및 명령 핸들러 구현 가이드
개요
명령과 명령 핸들러는 애플리케이션의 유스케이스를 구현하는 핵심 구성 요소입니다. 명령은 시스템에서 수행해야 할 작업을 표현하고, 명령 핸들러는 해당 작업을 실제로 수행합니다. 이 가이드에서는 효과적인 명령과 명령 핸들러의 구현 방법을 살펴보겠습니다.
기본 원칙
1. 명령의 불변성
명령은 실행될 작업을 표현하는 불변 객체여야 합니다. Record를 사용하여 이를 구현할 수 있습니다:
public record RegisterMemberCommand(
Username username,
Password password,
Email email,
FullName fullName
) implements Command {
// 생성자에서 유효성 검증
public RegisterMemberCommand {
Objects.requireNonNull(username, "사용자명은 필수입니다");
Objects.requireNonNull(password, "비밀번호는 필수입니다");
Objects.requireNonNull(email, "이메일은 필수입니다");
Objects.requireNonNull(fullName, "이름은 필수입니다");
}
}2. 명령 핸들러의 단일 책임
각 명령 핸들러는 하나의 명령만을 처리해야 합니다:
public interface CommandHandler<C extends Command, R> {
Result<R> handle(C command);
}
@RequiredArgsConstructor
public class RegisterMemberCommandHandler
implements CommandHandler<RegisterMemberCommand, UserId> {
private final UserRepository userRepository;
private final PasswordHasher passwordHasher;
@Override
public Result<UserId> handle(RegisterMemberCommand command) {
HashedPassword hashedPassword = passwordHasher.hash(command.password());
return Member.create(
command.username(),
hashedPassword,
command.email(),
() -> isEmailUnique(command.email()),
command.fullName()
)
.onSuccess(userRepository::create)
.map(User::getId);
}
private boolean isEmailUnique(Email email) {
return !userRepository.existsByEmail(email);
}
}명령 구현 패턴
1. 명확한 의도 표현
명령의 이름과 구조는 그 의도를 명확히 표현해야 합니다:
// 좋은 예: 의도가 명확한 명령
public record UpdateMembershipTierCommand(
UserId userId,
MembershipTier newTier,
String reason
) implements Command {
}
// 나쁜 예: 의도가 모호한 명령
public record UpdateMemberCommand(
UserId userId,
Map<String, Object> changes
) implements Command {
}2. 값 객체 활용
명령의 매개변수로 원시 타입 대신 값 객체를 사용합니다:
// 좋은 예: 값 객체 사용
public record CreateLoanCommand(
UserId memberId,
BookId bookId,
LoanDuration duration
) implements Command {
}
// 나쁜 예: 원시 타입 사용
public record CreateLoanCommand(
String memberId,
String bookId,
int durationDays
) implements Command {
}3. 검증 로직 분리
명령의 유효성 검증을 위한 별도의 Validator 구현:
public class RegisterMemberCommandValidator {
public Result<Void> validate(RegisterMemberCommand command) {
return ContractValidator.start()
.require(isValidUsername(command.username()),
ErrorCodes.INVALID_USERNAME)
.require(isValidPassword(command.password()),
ErrorCodes.INVALID_PASSWORD)
.require(isValidEmail(command.email()),
ErrorCodes.INVALID_EMAIL)
.validate();
}
}
public class ValidatingRegisterMemberCommandHandler
implements CommandHandler<RegisterMemberCommand, UserId> {
private final RegisterMemberCommandHandler delegate;
private final RegisterMemberCommandValidator validator;
@Override
public Result<UserId> handle(RegisterMemberCommand command) {
return validator.validate(command)
.flatMap(__ -> delegate.handle(command));
}
}명령 핸들러 구현 패턴
1. 트랜잭션 관리
명령 핸들러에서 트랜잭션 경계를 명확히 정의합니다:
@Service
@Transactional
public class UpdateMemberCommandHandler
implements CommandHandler<UpdateMemberCommand, Void> {
private final UserRepository userRepository;
private final EventPublisher eventPublisher;
@Override
public Result<Void> handle(UpdateMemberCommand command) {
return userRepository.findById(command.userId())
.flatMap(member -> updateMember(member, command))
.onSuccess(member ->
eventPublisher.publish(new MemberUpdatedEvent(member)));
}
}2. 의존성 주입
생성자 주입을 통한 명시적인 의존성 관리:
@Service
@RequiredArgsConstructor
public class LoanBookCommandHandler
implements CommandHandler<LoanBookCommand, LoanId> {
private final UserRepository userRepository;
private final BookRepository bookRepository;
private final LoanRepository loanRepository;
private final LoanStrategy loanStrategy;
@Override
public Result<LoanId> handle(LoanBookCommand command) {
return Result.combine(
userRepository.findById(command.userId()),
bookRepository.findById(command.bookId())
)
.flatMap(tuple -> {
Member member = tuple.get(0);
Book book = tuple.get(1);
return loanStrategy.canLoan(member, book)
.flatMap(__ -> Loan.create(member, book))
.onSuccess(loanRepository::save);
})
.map(Loan::getId);
}
}3. 이벤트 발행
명령 처리 결과로 도메인 이벤트를 발행:
public class RegisterMemberCommandHandler
implements CommandHandler<RegisterMemberCommand, UserId> {
private final EventPublisher eventPublisher;
@Override
public Result<UserId> handle(RegisterMemberCommand command) {
return Member.create(/*...*/)
.onSuccess(member -> {
var event = new MemberRegisteredEvent(
member.getId(),
member.getEmail(),
LocalDateTime.now()
);
eventPublisher.publish(event);
})
.map(User::getId);
}
}명령 처리 파이프라인
1. 로깅과 모니터링
명령 처리 과정을 추적하기 위한 데코레이터 패턴 활용:
public class LoggingCommandHandler<C extends Command, R>
implements CommandHandler<C, R> {
private final CommandHandler<C, R> delegate;
private final Logger logger = LoggerFactory.getLogger(getClass());
@Override
public Result<R> handle(C command) {
logger.info("명령 처리 시작: {}", command);
try {
Result<R> result = delegate.handle(command);
if (result.isSuccess()) {
logger.info("명령 처리 성공: {}", command);
} else {
logger.error("명령 처리 실패: {}, 오류: {}",
command, result.getErrorCode());
}
return result;
} catch (Exception e) {
logger.error("명령 처리 중 예외 발생: {}", command, e);
throw e;
}
}
}2. 유효성 검증 체인
여러 검증 단계를 체인으로 구성:
public class ValidationChain<C extends Command, R> {
private final List<CommandValidator<C>> validators;
public Result<Void> validate(C command) {
return validators.stream()
.reduce(
Result.success(null),
(result, validator) ->
result.flatMap(__ -> validator.validate(command)),
(r1, r2) -> r1.flatMap(__ -> r2)
);
}
}
@Service
public class ChainedCommandHandler<C extends Command, R>
implements CommandHandler<C, R> {
private final CommandHandler<C, R> delegate;
private final ValidationChain<C> validationChain;
@Override
public Result<R> handle(C command) {
return validationChain.validate(command)
.flatMap(__ -> delegate.handle(command));
}
}테스트 전략
1. 단위 테스트
명령 핸들러의 독립적인 테스트:
class RegisterMemberCommandHandlerTest {
private UserRepository userRepository;
private PasswordHasher passwordHasher;
private RegisterMemberCommandHandler handler;
@BeforeEach
void setUp() {
userRepository = mock(UserRepository.class);
passwordHasher = mock(PasswordHasher.class);
handler = new RegisterMemberCommandHandler(
userRepository,
passwordHasher
);
}
@Test
@DisplayName("새 회원 등록이 성공적으로 처리되어야 함")
void shouldRegisterNewMember() {
// Given
RegisterMemberCommand command = new RegisterMemberCommand(
Username.of("testuser").getValue(),
Password.of("Test123!").getValue(),
Email.of("test@example.com").getValue(),
FullName.of("Test User").getValue()
);
when(userRepository.existsByEmail(any())).thenReturn(false);
when(passwordHasher.hash(any(String.class)))
.thenReturn(HashedPassword.from("hashedpassword"));
// When
Result<UserId> result = handler.handle(command);
// Then
assertThat(result.isSuccess()).isTrue();
verify(userRepository).create(any(Member.class));
}
}2. 통합 테스트
실제 컴포넌트들과의 상호작용 테스트:
@SpringBootTest
class RegisterMemberCommandIntegrationTest {
@Autowired
private CommandHandler<RegisterMemberCommand, UserId> handler;
@Autowired
private UserRepository userRepository;
@Test
@DisplayName("회원 등록 후 데이터베이스에서 조회할 수 있어야 함")
void shouldPersistRegisteredMember() {
// Given
RegisterMemberCommand command = new RegisterMemberCommand(/*...*/);
// When
Result<UserId> result = handler.handle(command);
// Then
assertThat(result.isSuccess()).isTrue();
UserId userId = result.getValue();
Result<User> userResult = userRepository.findById(userId);
assertThat(userResult.isSuccess()).isTrue();
User user = userResult.getValue();
assertThat(user.getEmail()).isEqualTo(command.email());
}
}모범 사례
1. 명령 이름 규칙
명령의 이름은 의도를 명확히 표현해야 합니다:
- Register~/Create~: 새로운 리소스 생성
- Update~/Modify~: 기존 리소스 수정
- Delete~/Remove~: 리소스 삭제
- Process~/Handle~: 비즈니스 프로세스 실행
2. 실패 처리
모든 실패 케이스를 명시적으로 처리합니다:
public class LoanBookCommandHandler
implements CommandHandler<LoanBookCommand, LoanId> {
@Override
public Result<LoanId> handle(LoanBookCommand command) {
return userRepository.findById(command.userId())
.flatMap(member -> {
if (!member.isActive()) {
return Result.failure(
ErrorCodes.MEMBER_NOT_ACTIVE,
member.getId()
);
}
if (member.hasOverdueBooks()) {
return Result.failure(
ErrorCodes.MEMBER_HAS_OVERDUE_BOOKS,
member.getId()
);
}
return processLoan(member, command);
});
}
}3. 비동기 처리
시간이 오래 걸리는 작업의 비동기 처리:
@Service
public class AsyncCommandHandler<C extends Command, R>
implements CommandHandler<C, R> {
private final CommandHandler<C, R> delegate;
private final AsyncTaskExecutor executor;
@Override
public Result<R> handle(C command) {
CompletableFuture<Result<R>> future =
CompletableFuture.supplyAsync(
() -> delegate.handle(command),
executor
);
return Result.success(future)
.map(f -> f.get(30, TimeUnit.SECONDS))
.flatMap(r -> r);
}
}결론
명령과 명령 핸들러는 애플리케이션의 유스케이스를 구현하는 핵심 컴포넌트입니다. 효과적인 구현을 위해서는:
- 명령은 불변이어야 하며 의도를 명확히 표현해야 합니다
- 핸들러는 단일 책임을 가져야 합니다
- 적절한 검증과 오류 처리가 필요합니다
- 유지보수와 확장이 용이한 구조를 만들어야 합니다
- 성능과 자원 관리를 고려해야 합니다
구현 시 고려사항
1. 메모리 사용
명령과 핸들러 구현 시 메모리 사용을 고려해야 합니다:
public class LargeDataCommandHandler
implements CommandHandler<ProcessLargeDataCommand, Void> {
private static final int BATCH_SIZE = 1000;
@Override
public Result<Void> handle(ProcessLargeDataCommand command) {
return command.getDataStream()
.chunked(BATCH_SIZE)
.map(this::processBatch)
.reduce(Result.success(null),
(r1, r2) -> r1.flatMap(__ -> r2));
}
private Result<Void> processBatch(List<Data> batch) {
// 배치 단위로 처리
return Result.success(null);
}
}2. 재시도 전략
일시적인 실패를 처리하기 위한 재시도 메커니즘:
public class RetryingCommandHandler<C extends Command, R>
implements CommandHandler<C, R> {
private final CommandHandler<C, R> delegate;
private final int maxRetries;
private final long retryDelay;
@Override
public Result<R> handle(C command) {
int attempts = 0;
while (attempts < maxRetries) {
try {
Result<R> result = delegate.handle(command);
if (result.isSuccess() || !isRetryableError(result)) {
return result;
}
attempts++;
if (attempts < maxRetries) {
Thread.sleep(retryDelay * attempts);
}
} catch (Exception e) {
if (!isRetryableException(e) || attempts >= maxRetries - 1) {
return Result.failure(ErrorCodes.COMMAND_EXECUTION_FAILED, e);
}
attempts++;
try {
Thread.sleep(retryDelay * attempts);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return Result.failure(ErrorCodes.COMMAND_INTERRUPTED);
}
}
}
return Result.failure(ErrorCodes.MAX_RETRIES_EXCEEDED);
}
private boolean isRetryableError(Result<?> result) {
// 재시도 가능한 오류인지 판단
return Arrays.asList(
ErrorCodes.TEMPORARY_NETWORK_ERROR,
ErrorCodes.DATABASE_LOCK_TIMEOUT
).contains(result.getErrorCode());
}
private boolean isRetryableException(Exception e) {
return e instanceof TransientDataAccessException ||
e instanceof OptimisticLockingFailureException;
}
}3. 모니터링과 지표 수집
명령 처리의 성능과 상태를 모니터링:
public class MetricsCommandHandler<C extends Command, R>
implements CommandHandler<C, R> {
private final CommandHandler<C, R> delegate;
private final MeterRegistry registry;
private final String commandName;
@Override
public Result<R> handle(C command) {
Timer.Sample sample = Timer.start(registry);
try {
Result<R> result = delegate.handle(command);
sample.stop(Timer.builder("command.execution")
.tag("command", commandName)
.tag("status", result.isSuccess() ? "success" : "failure")
.register(registry));
if (result.isSuccess()) {
registry.counter("command.success", "command", commandName)
.increment();
} else {
registry.counter("command.failure",
"command", commandName,
"error", result.getErrorCode().toString())
.increment();
}
return result;
} catch (Exception e) {
sample.stop(Timer.builder("command.execution")
.tag("command", commandName)
.tag("status", "error")
.register(registry));
registry.counter("command.error",
"command", commandName,
"exception", e.getClass().getSimpleName())
.increment();
throw e;
}
}
}4. 도큐멘테이션
명령과 핸들러의 문서화:
/**
* 회원 등록을 처리하는 명령입니다.
* 이메일 중복 검사를 수행하고, 비밀번호를 해시화하여 저장합니다.
*
* @param username 사용자명 (필수)
* @param password 비밀번호 (필수, 8자 이상)
* @param email 이메일 주소 (필수, 유효한 형식)
* @param fullName 실명 (필수)
*/
public record RegisterMemberCommand(
Username username,
Password password,
Email email,
FullName fullName
) implements Command {
// 구현...
}
/**
* RegisterMemberCommand를 처리하는 핸들러입니다.
* 다음과 같은 순서로 처리합니다:
* 1. 이메일 중복 검사
* 2. 비밀번호 해시화
* 3. 회원 엔티티 생성
* 4. 저장소에 저장
* 5. 회원가입 완료 이벤트 발행
*
* @throws DuplicateEmailException 이메일이 이미 존재하는 경우
*/
@Service
@Transactional
public class RegisterMemberCommandHandler
implements CommandHandler<RegisterMemberCommand, UserId> {
// 구현...
}5. 확장성 고려
새로운 요구사항을 수용할 수 있는 유연한 구조:
// 기본 명령 처리 체인
public class CommandHandlerChain<C extends Command, R> {
private final List<CommandHandlerDecorator<C, R>> decorators;
private final CommandHandler<C, R> targetHandler;
public Result<R> execute(C command) {
CommandHandler<C, R> chain = decorators.stream()
.reduce(targetHandler,
(handler, decorator) -> decorator.decorate(handler),
(h1, h2) -> h1);
return chain.handle(command);
}
}
// 새로운 기능을 추가하기 위한 데코레이터
public interface CommandHandlerDecorator<C extends Command, R> {
CommandHandler<C, R> decorate(CommandHandler<C, R> handler);
}
// 예: 캐싱 데코레이터
public class CachingDecorator<C extends Command, R>
implements CommandHandlerDecorator<C, R> {
private final Cache<C, R> cache;
@Override
public CommandHandler<C, R> decorate(CommandHandler<C, R> handler) {
return command -> {
if (isCacheable(command)) {
return getCachedResult(command)
.orElseGet(() -> {
Result<R> result = handler.handle(command);
if (result.isSuccess()) {
cache.put(command, result.getValue());
}
return result;
});
}
return handler.handle(command);
};
}
}이러한 고려사항들을 잘 반영하여 구현하면, 안정적이고 확장 가능한 명령 처리 시스템을 구축할 수 있습니다. 특히 모니터링과 재시도 메커니즘은 운영 환경에서 매우 중요한 요소가 됩니다.