4.6 명령(Command) 및 명령 핸들러 구현 가이드

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. 명령은 불변이어야 하며 의도를 명확히 표현해야 합니다
  2. 핸들러는 단일 책임을 가져야 합니다
  3. 적절한 검증과 오류 처리가 필요합니다
  4. 유지보수와 확장이 용이한 구조를 만들어야 합니다
  5. 성능과 자원 관리를 고려해야 합니다

구현 시 고려사항

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);
        };
    }
}

이러한 고려사항들을 잘 반영하여 구현하면, 안정적이고 확장 가능한 명령 처리 시스템을 구축할 수 있습니다. 특히 모니터링과 재시도 메커니즘은 운영 환경에서 매우 중요한 요소가 됩니다.