5.07 애플리케이션 서비스 구현

5.07 애플리케이션 서비스 구현

애플리케이션 서비스는 도메인 계층을 직접 조정하고 인프라스트럭처와의 연동을 담당합니다. 각 유스케이스별로 명령(Command)과 명령 핸들러를 구현하여 시스템의 기능을 제공합니다.

회원 가입 유스케이스

package net.badnom.library.user.application.member.command;

public record RegisterMemberCommand(
    Username username,
    Password password,
    Email email,
    FullName fullName
) implements Command {
    // 생성자를 통한 유효성 검증
    public RegisterMemberCommand {
        Objects.requireNonNull(username, "Username must not be null");
        Objects.requireNonNull(password, "Password must not be null");
        Objects.requireNonNull(email, "Email must not be null");
        Objects.requireNonNull(fullName, "Full name must not be null");
    }
}

@RequiredArgsConstructor
public class RegisterMemberCommandHandler implements CommandHandler<RegisterMemberCommand, UserId> {
    private final UserRepository userRepository;
    private final PasswordHasher passwordHasher;

    @Override
    @Transactional
    public Result<UserId> handle(RegisterMemberCommand command) {
        HashedPassword hashedPassword = passwordHasher.hash(command.password());
        
        return Member.create(
            command.username(),
            hashedPassword,
            command.email(),
            new UniqueEmailSpecification(userRepository),
            new UniqueUsernameSpecification(userRepository),
            command.fullName()
        )
        .onSuccess(userRepository::create)
        .map(User::getId);
    }
}

회원 등급 변경 유스케이스

package net.badnom.library.user.application.member.command;

public record ChangeMemberTierCommand(
    UserId memberId,
    MembershipTier newTier,
    String reason
) implements Command {
    public ChangeMemberTierCommand {
        Objects.requireNonNull(memberId, "Member ID must not be null");
        Objects.requireNonNull(newTier, "New tier must not be null");
        Objects.requireNonNull(reason, "Reason must not be null");
    }
}

@RequiredArgsConstructor
public class ChangeMemberTierCommandHandler implements CommandHandler<ChangeMemberTierCommand, Void> {
    private final UserRepository userRepository;

    @Override
    @Transactional
    public Result<Void> handle(ChangeMemberTierCommand command) {
        return userRepository.findById(command.memberId())
            .flatMap(user -> {
                if (!(user instanceof Member member)) {
                    return Result.failure(ErrorCodes.USER_NOT_MEMBER);
                }
                return member.changeTier(command.newTier())
                    .onSuccess(__ -> userRepository.save(member));
            });
    }
}

사용자 인증 유스케이스

package net.badnom.library.user.application.authentication.command;

public record AuthenticateUserCommand(
    String usernameOrEmail,
    String password
) implements Command {
    public AuthenticateUserCommand {
        Objects.requireNonNull(usernameOrEmail, "Username or email must not be null");
        Objects.requireNonNull(password, "Password must not be null");
    }
}

@RequiredArgsConstructor
public class AuthenticateUserCommandHandler implements CommandHandler<AuthenticateUserCommand, UserId> {
    private final UserRepository userRepository;
    private final PasswordHasher passwordHasher;

    @Override
    @Transactional(readOnly = true)
    public Result<UserId> handle(AuthenticateUserCommand command) {
        return findUser(command.usernameOrEmail())
            .flatMap(user -> authenticate(user, command.password()));
    }

    private Result<User> findUser(String usernameOrEmail) {
        return Email.of(usernameOrEmail)
            .flatMap(email -> userRepository.findByEmail(email))
            .orElseGet(() -> Username.of(usernameOrEmail)
                .flatMap(username -> userRepository.findByUsername(username)))
            .flatMap(optionalUser -> optionalUser
                .map(Result::<User>success)
                .orElseGet(() -> Result.failure(ErrorCodes.USER_NOT_FOUND)));
    }

    private Result<UserId> authenticate(User user, String password) {
        if (!user.isActive()) {
            return Result.failure(ErrorCodes.USER_NOT_ACTIVE);
        }

        if (!passwordHasher.matches(password, user.getPassword().toString())) {
            return Result.failure(ErrorCodes.INVALID_PASSWORD);
        }

        return Result.success(user.getId());
    }
}

사서 계정 생성 유스케이스

package net.badnom.library.user.application.librarian.command;

public record RegisterLibrarianCommand(
    Username username,
    Password password,
    Email email,
    FullName fullName,
    String employeeId
) implements Command {
    public RegisterLibrarianCommand {
        Objects.requireNonNull(username, "Username must not be null");
        Objects.requireNonNull(password, "Password must not be null");
        Objects.requireNonNull(email, "Email must not be null");
        Objects.requireNonNull(fullName, "Full name must not be null");
        Objects.requireNonNull(employeeId, "Employee ID must not be null");
    }
}

@RequiredArgsConstructor
public class RegisterLibrarianCommandHandler implements CommandHandler<RegisterLibrarianCommand, UserId> {
    private final UserRepository userRepository;
    private final PasswordHasher passwordHasher;

    @Override
    @Transactional
    public Result<UserId> handle(RegisterLibrarianCommand command) {
        HashedPassword hashedPassword = passwordHasher.hash(command.password());

        return Librarian.create(
            command.username(),
            hashedPassword,
            command.email(),
            new UniqueEmailSpecification(userRepository),
            new UniqueUsernameSpecification(userRepository),
            command.fullName(),
            command.employeeId()
        )
        .onSuccess(userRepository::create)
        .map(User::getId);
    }
}

패키지 구조

net.badnom.library.user.application
├── authentication
│   └── command
│       ├── AuthenticateUserCommand
│       └── AuthenticateUserCommandHandler
├── member
│   └── command
│       ├── RegisterMemberCommand
│       ├── RegisterMemberCommandHandler
│       ├── ChangeMemberTierCommand
│       └── ChangeMemberTierCommandHandler
└── librarian
    └── command
        ├── RegisterLibrarianCommand
        └── RegisterLibrarianCommandHandler

애플리케이션 서비스 구현의 주요 특징:

  1. Command-Handler 패턴 사용

    • 각 유스케이스를 명령과 핸들러로 분리합니다
    • 명확한 입력 데이터 구조를 정의합니다
    • 단일 책임 원칙을 준수합니다
  2. 트랜잭션 관리

    • 각 유스케이스의 원자성을 보장합니다
    • 적절한 트랜잭션 경계를 설정합니다
    • 읽기 전용 트랜잭션을 최적화합니다
  3. 결과 처리

    • Result 타입을 통한 명시적 오류 처리를 구현합니다
    • 도메인 예외의 적절한 변환을 수행합니다
    • 일관된 응답 형식을 제공합니다
  4. 의존성 주입

    • 생성자 주입을 통한 명시적 의존성 관리를 구현합니다
    • 인터페이스 기반 결합도 관리를 적용합니다
    • 테스트 용이성을 확보합니다

이러한 구현을 통해 도메인 로직을 효과적으로 조율하고, 클라이언트에게 일관된 인터페이스를 제공할 수 있습니다.

4.7 도메인 이벤트 구현

사용자 도메인에서 발생하는 중요한 상태 변경을 도메인 이벤트로 표현하여, 시스템의 다른 부분에 이를 알립니다. 이 섹션에서는 회원과 사서 에그리거트 루트에서 발생하는 이벤트 구현 방법을 살펴보겠습니다.

도메인 이벤트 정의

회원 가입 완료 이벤트

package net.badnom.library.user.domain.user.event;

public record MemberRegisteredEvent(
    UserId memberId,
    LocalDateTime occurredAt
) implements TimedDomainEvent {
    public MemberRegisteredEvent(UserId memberId) {
        this(memberId, LocalDateTime.now());
    }

    public MemberRegisteredEvent {
        Objects.requireNonNull(memberId, "Member ID must not be null");
        occurredAt = Objects.requireNonNullElseGet(occurredAt, LocalDateTime::now);
    }

    @Override
    public String toString() {
        return String.format("Member registered: %s at %s", 
            memberId, occurredAt);
    }
}

회원 등급 변경 이벤트

package net.badnom.library.user.domain.user.event;

public record MemberTierChangedEvent(
    UserId memberId,
    MembershipTier previousTier,
    MembershipTier newTier,
    boolean isUpgrade,
    LocalDateTime occurredAt
) implements TimedDomainEvent {
    
    public MemberTierChangedEvent(
            UserId memberId,
            MembershipTier previousTier,
            MembershipTier newTier,
            boolean isUpgrade) {
        this(memberId, previousTier, newTier, isUpgrade, LocalDateTime.now());
    }

    public MemberTierChangedEvent {
        Objects.requireNonNull(memberId, "Member ID must not be null");
        Objects.requireNonNull(previousTier, "Previous tier must not be null");
        Objects.requireNonNull(newTier, "New tier must not be null");
        occurredAt = Objects.requireNonNullElseGet(occurredAt, LocalDateTime::now);
    }

    public boolean isDowngrade() {
        return !isUpgrade;
    }

    @Override
    public String toString() {
        String changeDirection = isUpgrade ? "upgraded to" : "downgraded to";
        return String.format("Member %s's tier %s %s from %s at %s",
            memberId, changeDirection, newTier, previousTier, occurredAt);
    }
}

이벤트 핸들러 구현

회원 가입 완료 핸들러

package net.badnom.library.user.application.event;

@Component
@RequiredArgsConstructor
public class MemberRegisteredEventHandler {
    private final EmailSender emailSender;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(MemberRegisteredEvent event) {
        emailSender.sendWelcomeEmail(event.memberId());
    }
}

회원 등급 변경 핸들러

package net.badnom.library.user.application.event;

@Component
@RequiredArgsConstructor
public class MemberTierChangedEventHandler {
    private final NotificationService notificationService;
    private final AuditService auditService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handle(MemberTierChangedEvent event) {
        // 회원에게 변경 알림 발송
        String message = String.format(
            "회원님의 등급이 %s에서 %s로 변경되었습니다.",
            event.previousTier(),
            event.newTier()
        );
        notificationService.notifyMember(event.memberId(), message);

        // 변경 이력 기록
        auditService.logMembershipChange(
            event.memberId(),
            event.previousTier(),
            event.newTier(),
            event.occurredAt()
        );
    }
}

이벤트 발행 메커니즘

package net.badnom.library.shared.core.event;

@Aspect
@Component
@RequiredArgsConstructor
public class DomainEventPublisher {
    private final ApplicationEventPublisher publisher;

    @AfterReturning(
        pointcut = "execution(* net.badnom.library.user.infrastructure.persistence.*Repository.save(..))",
        returning = "result"
    )
    public void publishEvents(JoinPoint jp, Result<?> result) {
        if (result.isSuccess() && result.getValue() instanceof AbstractAggregateRoot<?> aggregate) {
            aggregate.getDomainEvents().forEach(event -> {
                publisher.publishEvent(event);
                log.debug("Published domain event: {}", event);
            });
            aggregate.clearDomainEvents();
        }
    }
}

패키지 구조

net.badnom.library.user
├── domain
│   └── user
│       └── event
│           ├── MemberRegisteredEvent
│           └── MemberTierChangedEvent
└── application
    └── event
        ├── MemberRegisteredEventHandler
        └── MemberTierChangedEventHandler

도메인 이벤트 발행 예시

public final class Member extends User {
    public static Result<Member> create(Username username,
                                      HashedPassword password,
                                      Email email,
                                      Specification<Email> uniqueEmailSpec,
                                      Specification<Username> uniqueUsernameSpec,
                                      FullName fullName) {
        return uniqueEmailSpec.isSatisfiedBy(email)
                .flatMap(__ -> uniqueUsernameSpec.isSatisfiedBy(username))
                .map(__ -> new Member(
                        UserId.newId(),
                        username,
                        password,
                        email,
                        fullName,
                        MembershipTier.getDefaultTier()))
                .onSuccess(member -> 
                    member.registerDomainEvent(new MemberRegisteredEvent(member.getId())));
    }

    public Result<Void> changeTier(MembershipTier newTier) {
        return ContractValidator.start()
                .requireNotNull(newTier, ErrorCodes.ERROR_NULL_NEW_TIER)
                .require(!newTier.equals(currentTier), ErrorCodes.SAME_TIER)
                .validate()
                .onSuccess(__ -> {
                    MembershipTier previousTier = this.currentTier;
                    this.currentTier = newTier;
                    registerDomainEvent(new MemberTierChangedEvent(
                            this.getId(),
                            previousTier,
                            newTier,
                            newTier.isHigherThan(previousTier)
                    ));
                });
    }
}

이벤트 처리 시 주의사항:

  1. 트랜잭션 관리

    • 이벤트 핸들러는 트랜잭션 커밋 후 실행합니다
    • 실패 시 재시도 메커니즘을 구현합니다
    • 멱등성을 보장합니다
  2. 이벤트 설계

    • 과거 시제를 사용합니다
    • 필요한 컨텍스트 정보를 포함합니다
    • 불변 객체로 구현합니다
  3. 성능 고려사항

    • 비동기 처리를 활용합니다
    • 이벤트 순서 보장 필요성을 검토합니다
    • 배치 처리를 고려합니다
  4. 모니터링과 추적

    • 이벤트 발행과 처리를 로깅합니다
    • 실패한 이벤트를 추적합니다
    • 성능을 모니터링합니다

도메인 이벤트를 통해 시스템의 다른 부분에 상태 변경을 알리고, 이를 통해 느슨한 결합도를 유지하면서도 필요한 동작을 수행할 수 있습니다.