1. 글을 작성하게 된 계기
비동기를 활용한 병렬처리 프로젝트를 하며, 이벤트에 관심을 가지게 되었고, 이 과정에서 학습한 내용을 정리하기 위해 글을 작성하게 되었습니다.
이번 글에서 사용할 예제의 테이블 구조는 다음과 같습니다. 하나의 게시글에는 여러 개의 댓글, 북마크, 게시글 이미지가 있습니다. 즉, 게시글과 댓글, 북마크, 이미지는 일대 다의 관계입니다. 이를 토대로 이벤트를 어떻게 사용해야 하는지 살펴보겠습니다.
구현 방법은 다양하기 때문에, 하나의 케이스 라고 보면 될 것 같습니다.
2. 도메인 이벤트(Domain Event)
이벤트는 소프트웨어에 의해 인식되는 동작 또는 사건의 발생을 말합니다.
도메인 이벤트는 특정 도메인 모델에서 도메인에 관련된 일이 발생한 것을 말합니다. 이는 도메인 주도 설계(Domain-Driven Design)에서 사용되는 용어로, 이벤트가 발생하면 시스템 내부, 혹은 다른 시스템에 영향을 미칩니다.
Captures the memory of something interesting which affects the domain.
도메인 이벤트는 비즈니스 내용을 담고 있으며 이벤트보다 비교적 구체적입니다. 이는 시스템의 다른 부분들이 이벤트에 반응할 수 있도록 하는 것에 초점을 맞추며, 이를 통해 복잡한 시스템들 사이의 결합도를 낮추어 비즈니스 프로세스의 흐름을 더 명확하게 만듭니다.
도메인 이벤트는 주로 느슨한 결합(Loose Coupling)과 관심사의 분리와 같은 이유로 사용합니다.
예를 들어, 게시글이 삭제되면 게시글에 달린 댓글, 북마크, 이미지가 함께 삭제돼야 합니다. 따라서 게시글 삭제라는 이벤트가 발행되면, 해당 이벤트는 댓글, 북마크 등과 같은 다른 도메인에 영향을 미치게 됩니다.
3. 고려할 점
이벤트를 발행할 때 어떤 점을 주의해야 할 지에 대해 살펴보겠습니다.
- 어떤 것을 이벤트로 발행할 지(Domain Event)
- 메시지 내용은 무엇을 담아야 할 지(Message Contents)
- 이벤트 종류
- 이벤트 유실과 트랜잭션
3-1. 이벤트 발행
도메인은 소프트웨어로 해결하고자 하는 문제 영역을 말합니다. 따라서 발행해야 할 이벤트는 구체적 목적이 아닌 도메인이 해결하고자 하는 문제 그 자체여야 합니다.
구체적 목적이 있는 이벤트를 발행하면, 구독하는 측에서 어떤 문제인지에 대한 정보를 알게 되므로, 논리적으로는 강한 결합이 발생하기 때문입니다.
예를 들어, 게시글을 삭제하면 이와 연관된 북마크, 댓글, 이미지가 함께 삭제되어야 합니다. 이때 발행해야 할 이벤트는 게시글 삭제입니다. 북마크 삭제, 댓글 삭제, 이미지 삭제와 같이 구체적인 목적을 이벤트로발행해선 안 됩니다. 이를코드 레벨에서서 보면 다음과 같은데, 댓글 삭제라는 구체적 목적으로 이벤트를 발행하면, 이벤트는 사용했지만, 여전히 논리적인 결합이 남아있게 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
class PostService(
private val postJpaRepository: PostJpaRepository,
private val eventPublisher: EventPublisher<DomainEvent>
) : PostWriteUseCase, PostDeleteUseCase {
@Transactional
override fun delete(postId: Long) {
val findPost = postJpaRepository.findById(postId).orElseThrow()
findPost.deleteImages()
// 댓글 삭제라는 구체적인 이벤트 발행
eventPublisher.publishEvent(CommentDeleteEvent(findPost.id!!))
}
}
이를 제거하기 위해서는 다음과 같이 게시글 삭제와 같은 이벤트 그 자체를 발행해야 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
class PostService(
private val postJpaRepository: PostJpaRepository,
private val eventPublisher: EventPublisher<DomainEvent>
) : PostWriteUseCase, PostDeleteUseCase {
@Transactional
override fun delete(postId: Long) {
val findPost = postJpaRepository.findById(postId).orElseThrow()
findPost.deleteImages()
// 게시글 삭제 이벤트 발행
eventPublisher.publishEvent(PostDeleteEvent(findPost.id!!))
}
}
이는 @Async 어노테이션을 사용할 때도 적용되는데, 구체적 이벤트를 발행하게 되면 비동기 논 블로킹(Non-Blocking) 방식으로 이벤트를 발행한 것일 뿐, 논리적 결합도는 여전히 남아있게 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
public class AsyncConfiguration implements AsyncConfigurer {
@Override
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setThreadNamePrefix("async-threads");
executor.setCorePoolSize(25);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(500);
executor.setKeepAliveSeconds(30);
executor.setRejectedExecutionHandler(rejectedExecutionHandler());
executor.initialize();
return executor;
}
private RejectedExecutionHandler rejectedExecutionHandler() {
return (runnable, executor) -> {
throw new RuntimeException("Async Exception");
};
}
}
3-2. 메시지 내용
이벤트를 발행할 때 메시지에 어떤 내용을 담을지도 잘 생각해야 합니다. 구체적 데이터를 담으면 또 논리적으로 강한 결합이 발생할 수 있기 때문입니다. 따라서서 누가(who), 언제(when), 무엇을(what)을 해서 어떤 변화가일어났는지?를를 알 수 있을 정도의 최소한의 정보만 담도록 합니다. 이를코드 레벨에서서 보면 다음과 같습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class PostDeleteEvent(
val postId: Long,
val eventType: EventType
) : DomainEvent, ApplicationEvent(postId) {
val createdAt = Instant.now()
override fun getId(): Long {
return postId
}
override fun toString(): String {
return "$postId"
}
}
이를 구현하는 방법 중 Zero-Payload Events 라는 방식이 있습니다.
조금 더 상세히 보면, 다음과 같이 Service1에서 Service2로 이벤트를 발행한 후, 필요한 데이터는 Service1에 API 호출해 받아옵니다. 이때 발행하는 이벤트에는 위에서 설명한 최소한의 정보만 담습니다. 기본적으로는 PK만, 정말 필요한 경우 일부 데이터를 함께 보내는 것입니다. 이를 통해 느슨한 결합을 유지할 수 있으며, 또한 이벤트의 순서도 보장할 수도 있습니다.
이벤트의 순서를 보장하기 위해서는 별도의 장치가 조금 더 필요한데, 이에 대해서는 기회가 된다면 별도의 포스팅으로 작성해보겠습니다.
3-3. 이벤트의 종류
이벤트에는 애플리케이션 이벤트, 내부 이벤트, 외부 이벤트와 같은 이벤트 종류가 존재하는데, 이를 살펴보겠습니다.
- 애플리케이션 이벤트
- 내부 이벤트
- 외부 이벤트
3-3-1. 애플리케이션 이벤트
애플리케이션 이벤트는 스프링이 제공하는 ApplicationEventPublisher를 사용해 발행하는 이벤트 입니다. 이는 주요 행위와 강한 정합성을 보장하는 작업이 필요할 경우 사용됩니다.
이를 코드 레벨에서 보면 다음과 같습니다. 이를 통해 데이터의 정합성을 보장할 수 있으며, 이 과정에서 성능 또한 공유됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Service
class PostService(
private val postJpaRepository: PostJpaRepository,
private val eventPublisher: ApplicationEventPublisher
) : PostWriteUseCase, PostDeleteUseCase {
@Transactional
override fun delete(postId: Long) {
val findPost = postJpaRepository.findById(postId)
.orElseThrow{ PostNotFoundException() }
findPost.deleteImages()
val event = PostDeleteEvent(findPost.id!!, EventType.DELETE)
eventPublisher.publishEvent(event)
}
}
3-3-2. 내부 이벤트
내부 이벤트는 시스템 내부에서 발생하지만 외부 메시징 시스템을 이용하는 이벤트 입니다.
이를 코드 레벨에서 보면 다음과 같습니다. 내부 이벤트는 주요 행위와 강한 정합성을 보장하는 작업이 필요하지 않은 경우에 사용하며, 주요 행위와 성능을 분리합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
class PostService(
private val postJpaRepository: PostJpaRepository,
private val awsMessageSender: AwsMessageSender
) : PostWriteUseCase, PostDeleteUseCase {
@Transactional
override fun delete(postId: Long) {
val findPost = postJpaRepository.findById(postId)
.orElseThrow { PostNotFoundException() }
findPost.deleteImages()
val event = PostDeleteEvent(findPost.id!!, EventType.DELETE)
awsMessageSender.publishEvent(event)
}
}
1
2
3
4
5
6
7
8
9
class AwsMessageSender(
private val sqsSender: AmazonSQSSender,
private val objectMapper: ObjectMapper
) : EventPublisher<PostDeleteEvent> {
override fun publishEvent(event: DomainEvent) {
sqsSender.sendMessage(objectMapper.writeValueAsString(event))
}
}
이를 애플리케이션 이벤트로 처리할 수도 있는데요, 여기에는 트레이드 오프(Trade-Off)가 존재합니다. 주요 행위와 강한 정합성을 보장해야 할 경우, 애플리케이션 이벤트를 활용하며, 주요 행위와 강한 정합성이 필요하지 않을 경우, 내부 이벤트를 활용합니다.
- 애플리케이션 이벤트: 주요 행위와 트랜잭션/성능 공유
- 내부 이벤트: 주요 행위와 트랜잭션/성능 분리
3-3-3. 외부 이벤트
외부 이벤트는 이벤트가 외부로 전파되는 것입니다. 이를 통해 애플리케이션 내부에서 발생한 이벤트를 외부로 전파할 수 있습니다.
3-4. 이벤트 유실과 트랜잭션
내부 이벤트나 외부 이벤트를 발행할 경우, 메시지 유실을 방지하기 위해 이벤트 저장소(EventStore)를 구축해야 합니다.
만약 내부/외부 이벤트를 사용하면, 예를 들어, AWS SNS-SQS를 사용할 경우, HTTP 통신을 사용하므로 이벤트 발행 과정에 문제가 발생할 수 있습니다. 따라서 이를 위한 대비(Event Store)를 해야 합니다.
이를 위해 트랜잭션 아웃박스 패턴(Transaction Outbox Pattern) 등을 고려할 수도 있습니다. 이벤트 유실은 결국 하나의 트랜잭션 내에서 처리되던 작업이 여러 이벤트로 처리되기 때문에 발생한 문제입니다. 한 도메인 이벤트가 발생했을 때, 그 후속작업들을 어떻게 이어가고, 처리해야 할 지 하나의 큰 흐름을 잘 설계하도록 합니다.
4. 정리
도메인 이벤트와 연관 개념, 이를 사용할 때의 주의점에 대해 살펴보았습니다. 도메인 이벤트는 의존 관계를 느슨하게 맺는 한 가지 방법이지만, 이를 사용하기 위해서는 아키텍처가 뒷받침 하거나, 인프라적 도움이 있어야 합니다. 따라서 이를 사용 할 상황을 잘 파악하고 문맥에 맞게 사용할 수 있도록 합니다.
- 이벤트를 통해 느슨한 결합을 가져갈 수 있다.
- 이벤트를 발생할 때는
도메인 이벤트그 자체를 발행해야 한다. 구체적인 이벤트를 발행해선 안 된다. - 이벤트에는
최소한의 내용만 담는다. - 이벤트에도 종류가 있다. 상황에 맞는 적절한 이벤트를 사용한다.
- 하나의 트랜잭션에서 처리되던 로직이, 여러개로 분할 돼 처리되기 때문에 트랜잭션과 정합성을 고려해야 한다.