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애플리케이션 서비스 구현의 주요 특징:
-
Command-Handler 패턴 사용
- 각 유스케이스를 명령과 핸들러로 분리합니다
- 명확한 입력 데이터 구조를 정의합니다
- 단일 책임 원칙을 준수합니다
-
트랜잭션 관리
- 각 유스케이스의 원자성을 보장합니다
- 적절한 트랜잭션 경계를 설정합니다
- 읽기 전용 트랜잭션을 최적화합니다
-
결과 처리
- Result 타입을 통한 명시적 오류 처리를 구현합니다
- 도메인 예외의 적절한 변환을 수행합니다
- 일관된 응답 형식을 제공합니다
-
의존성 주입
- 생성자 주입을 통한 명시적 의존성 관리를 구현합니다
- 인터페이스 기반 결합도 관리를 적용합니다
- 테스트 용이성을 확보합니다
이러한 구현을 통해 도메인 로직을 효과적으로 조율하고, 클라이언트에게 일관된 인터페이스를 제공할 수 있습니다.
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)
));
});
}
}이벤트 처리 시 주의사항:
-
트랜잭션 관리
- 이벤트 핸들러는 트랜잭션 커밋 후 실행합니다
- 실패 시 재시도 메커니즘을 구현합니다
- 멱등성을 보장합니다
-
이벤트 설계
- 과거 시제를 사용합니다
- 필요한 컨텍스트 정보를 포함합니다
- 불변 객체로 구현합니다
-
성능 고려사항
- 비동기 처리를 활용합니다
- 이벤트 순서 보장 필요성을 검토합니다
- 배치 처리를 고려합니다
-
모니터링과 추적
- 이벤트 발행과 처리를 로깅합니다
- 실패한 이벤트를 추적합니다
- 성능을 모니터링합니다
도메인 이벤트를 통해 시스템의 다른 부분에 상태 변경을 알리고, 이를 통해 느슨한 결합도를 유지하면서도 필요한 동작을 수행할 수 있습니다.