모듈러 모놀리스에서 모듈 설계가 커뮤니케이션 방식에 미치는 영향과, Clients/APIs 호출, 애플리케이션 이벤트, Outbox 패턴, 백그라운드 데이터 동기화 등 모듈 간 통신 접근법의 장단점을 정리한다.
I0I
0
MainAboutPostsAtom FeedNewsletter
BL Newsletter
Binary Log Newsletter를 구독하세요 - 깊이 있는 탐구, 폭넓은 탐색, 그리고 정제된 인사이트. 공예를 마스터하고자 하는 호기심 많은 개발자를 위해:
유효한 이메일이 필요합니다.
스팸도, 군더더기도 없이 - 순수한 신호만. 언제든 구독 해지할 수 있습니다.
Not Yet
Join Log
2024-05-19
모듈 커뮤니케이션을 이야기하기 전에, 선택한 모듈 설계가 가져오는 결과를 강조하는 것이 중요합니다. 모듈 간 커뮤니케이션에서 우리가 마주할 문제, 커뮤니케이션이 얼마나 자주 발생하고 얼마나 많은 양이 오갈지는 대부분 결정한 모듈 구조에 달려 있습니다. 모듈을 설계할 때 무엇을 원칙으로 삼아야 할까요? 무엇을 피해야 할까요?
모듈은 특정하고 명확히 정의된 기능 또는 서로 밀접하게 연관된 기능들의 집합을 책임져야 합니다. 기능은 가능한 한 하나의 모듈이 완결적으로 구현해야 합니다. 어떤 기능이나 동작을 구현하려고 할 때 두 개 이상의 모듈을 자주 변경하게 된다면 설계를 다시 생각해야 합니다. 그 어딘가에서 뭔가가 잘못된 것입니다. 모듈은 가능한 한 자체 완결적이고 독립적이어야 합니다. 이상적으로는 모든 모듈이 다른 모듈과 전혀 대화할 필요가 없어야 합니다. 물론 현실에서는 거의 불가능하지만, 우리가 지켜야 할 원칙은 다음과 같습니다:
다른 모듈에 대한 의존을 가능한 한 최소로(이상적으로는 0, 의존성 없음) 하면서 자신의 기능을 제공할 수 있도록 모듈을 설계하라.
어떻게 이를 달성할 수 있을까요? 우리 시스템에 특화된 기능과 동작을 발견하고 정의해야 하며, 그들 간의 관계와 의존도도 함께 매핑해야 합니다. 그러면 어떤 종류와 얼마나 많은 모듈을 두는 것이 합리적인지 명확해집니다. 모듈들이 서로에 대한 의존이 매우 적다고 가정하되, 가끔은 서로 대화해야 한다면—어떻게, 그리고 언제 해야 할까요?
가장 단순한 접근—모듈의 Clients/APIs 메서드를 그냥 호출하는 것입니다. 특히 가까운 미래에 여러 서비스/애플리케이션으로 마이그레이션할 계획이 없다면 대개는 이 정도로 충분합니다. 예를 들어:
// shared module, implementation in the user module
interface UserClient {
Optional<User> ofId(UUID id);
Map<UUID, User> ofIds(List<UUID> ids);
}
record User(UUID id, String name, String email)
// project module
record ProjectWithUsers(
UUID id,
String name,
String description,
List<User> users
)
record Project(
UUID id,
String name,
String description,
List<UUID> userIds
)
class ProjectService {
ProjectRepository projectRepository;
UserClient userClient;
List<Project> allOfNamespace(String namespace) {
var projects = projectRepository.allOfNamespace(namespace);
var userIdsOfProjects = projects.flatMap(p -> p.userIds());
var usersByIds = userClient.ofIds(userIdsOfProjects);
var projectsWithUsers = projects.map(p -> {
var projectUsers = p.userIds().map(uid -> usersByIds.get(uid));
return new ProjectWithUsers(p.id(), p.name(), p.description(), projectUsers);
});
return projectsWithUsers;
}
}
여기에는 shared, user, project 세 개의 모듈이 있습니다. _shared module_에 UserClient 인터페이스를 정의했고, _project module_은 _shared module_의 코드에만 의존하며 _user module_에 대해서는 아무것도 모릅니다. _user module_에는 UserClient 구현체가 있고, 런타임에 _project module_이 이를 사용합니다. 이 구현은 사용자 모듈의 데이터베이스/스키마에서 사용자를 조회하는 것처럼 단순할 수도 있습니다. 이후 _project module_은 코드 의존성으로 UserClient 인터페이스를 사용합니다. 이 인터페이스 덕분에 모듈 경계가 명확히 정의됩니다—UserClient는 외부에서 user 모듈 코드를 호출할 수 있는 유일하게 허용된 방법입니다. 따라서 다음 규칙이 성립합니다:
이 접근의 큰 장점은 단순함입니다. 또한 코드 레벨에서 결합도가 매우 낮습니다—각 모듈은 공유된 독립 인터페이스와 모델에만 의존합니다. 주요 단점은 무엇일까요?
이 단점들은 비교적 사소합니다. 특히 모듈 간 트랜잭션을 피한다면(좋은 모듈 설계라면 충분히 가능) 이 접근은 매우 좋은 트레이드오프를 제공합니다. 언젠가 일부 모듈을 별도 애플리케이션/서비스로 옮겨야 하더라도, 의존성이 명확히 정의되어 전용 추상화로 캡슐화되어 있기 때문에 상당히 쉽게 할 수 있습니다. 게다가 현실적으로, 대부분의 경우 우리는 단일 배포 단위—모듈러 모놀리스—에 머물러야 하고 그렇게 하는 것이 맞습니다.
이 글이 마음에 드시나요? Binary Log Newsletter를 구독하세요 - 깊이 있는 탐구, 폭넓은 탐색, 그리고 정제된 인사이트. 공예를 마스터하고자 하는 호기심 많은 개발자를 위해:
유효한 이메일이 필요합니다.
스팸도, 군더더기도 없이 - 순수한 신호만. 언제든 구독 해지할 수 있습니다.
Already In
Join Log
그 자체로 유용하긴 하지만, 일부 경우에만 쓸모 있고 적용 가능한 방식입니다. 즉, 다른 모듈이 관심 가질 만한 일이 발생했을 때 인메모리 이벤트를 보내는 것입니다. 예를 들어:
// shared module
record UserCreatedEvent(
UUID id,
String name,
String email
)
// user module
class UserService {
UserRepository userRepository;
Transactions transactions;
ApplicationEventPublisher applicationEventPublisher;
void handle(CreateUserCommand command) {
// some validation
transactions.execute(() -> {
userRepository.save(new User(...));
applicationEventPublisher.publish(new UserCreatedEvent(...));
});
}
}
// email module:
// saves user account activation email
// and sends it later on in a separate, scheduled process
class EmailScheduler {
EmailRepository emailRepository;
EmailSender emailSender;
void onUserCreatedEvent(UserCreatedEvent event) {
emailRepository.save(new UserAccountActivationEmail(event));
}
void sendAllScheduled() {
// later on, sends all scheduled emails that are waiting in the database
}
}
매우 단순하지만, Client/API 메서드 호출과 마찬가지로 모듈 간에 원치 않는 의존성을 도입할 수 있습니다. 이 예시에서는 user 모듈과 email 모듈이 데이터베이스를 공유한다고 가정합니다. 두 모듈에 걸친 트랜잭션이 만들어지기 때문입니다. 이것이 사실일 수도, 아닐 수도 있지만, 추가 의존성을 도입합니다—이제 이 커뮤니케이션이 신뢰 가능하려면 모듈이 데이터베이스를 공유해야만 합니다. 이 의존성은 나중에 별도의 데이터베이스를 갖거나 어느 한 모듈을 별도 서비스/애플리케이션으로 옮기려 할 때 문제를 일으킬 것입니다. 가까운 미래에(혹은 영원히) 그런 계획이 없거나, 트랜잭션 보장이 필요 없는 이벤트 발행이라면 이 방식의 단순함은 매력적입니다. 더 나아가, 나중에 _Outbox Pattern_으로 마이그레이션하기도 쉽습니다. _Outbox Pattern_은 데이터베이스 레벨 결합 없이 동일한 전달 보장을 제공합니다. 그렇다면 _Outbox Pattern_은 무엇일까요?
앞선 접근/패턴에서 가장 큰 단점은 모듈 간에 단일 공유 데이터베이스가 있다는 암묵적 가정입니다. _Outbox Pattern_을 사용해 이를 어떻게 해결할 수 있을까요?
데이터베이스 트랜잭션 중(또는 이후)에 인메모리 이벤트를 보내는 대신, 현재의 데이터 변경과 함께(혹은 같은 트랜잭션 안에서) 이벤트를 저장합니다. 그리고 데이터베이스에서 이벤트를 가져와 관심 있는 소비자/리스너에게 전송하는 별도의 독립 프로세스를 둡니다. 이벤트는 모든 소비자가 수신하고 성공적으로 처리한 뒤에만 데이터베이스에서 삭제됩니다. Application Events 예제를 이 패턴으로 바꿔보면:
// shared module
record UserCreatedEvent(
UUID id,
String name,
String email
)
// user module
class UserService {
UserRepository userRepository;
Transactions transactions;
OutboxRepository outboxRepository;
void handle(CreateUserCommand command) {
// some validation
transactions.execute(() -> {
userRepository.save(new User(...));
outboxRepository.save(new UserCreatedEvent(...));
});
}
}
class OutboxProcessor {
OutboxRepository outboxRepository;
ApplicationEventPublisher applicationEventPublisher;
// scheduled process, running every one to a few seconds
void process() {
var maxEvents = 500;
var events = outboxRepository.allEvents(maxEvents);
var successfulEvents = new ArrayList<Event>();
var failedEvents = new ArrayList<Event>();
for (var e : events) {
try {
// sends to all consumers synchronously, fails if any of them fails
applicationEventPublisher.publish(e);
successfulEvents.add(e);
} catch (Exception e) {
failedEvents.add(e);
}
}
outboxRepository.delete(successfulEvents);
if (!failedEvents.isEmpty()) {
logger.warn("Failed to publish some events. They will be retried in the next round: {}", failedEvents);
}
}
}
// email module: sends user account activation email
class EmailScheduler {
EmailSender emailSender;
void onUserCreatedEvent(UserCreatedEvent event) {
emailSender.send(new UserAccountActivationEmail(event));
}
}
보시다시피 추가적인 간접 계층을 도입했지만, 이벤트 처리 자체는 기본적으로 동일합니다. 핵심 차이는 다음입니다: 서로 다른 모듈의 데이터베이스에 대해 어떤 가정도 하지 않는다는 것—오직 단일 모듈의 데이터베이스에만 의존해 전달을 보장합니다. 보통 이렇게 동작합니다:
전송 프로세스는 이제 순수 Application Events 접근보다 분명 더 복잡하지만, 이점이 많습니다. 관련된 가정이 없기 때문에 모듈의 데이터베이스를 쉽게 교체할 수 있습니다. 또한 어떤 모듈을 별도 애플리케이션으로 옮기기로 결정했을 때, 또 하나의 _outbox message publisher_를 추가하기만 하면 상대적으로 쉽게 대응할 수 있습니다. 인메모리로 이벤트를 발행하는 대신 RabbitMQ나 Kafka 같은 _Message Broker/Service_로 보내거나, 관심 있는 서비스들에 단순한 http 요청을 보내는 식으로 바꿀 수 있습니다. 이 패턴은 매우 유연합니다.
이 패턴의 또 다른 중요한 결과는 이벤트/메시지 소비자가 idempotent 해야 한다는 점입니다. Application Events 패턴에서는 필요하지 않습니다. 이벤트를 전송하는 것과 데이터베이스에서 삭제하는 것은 완전히 독립된 프로세스이며, 서로 독립적으로 다양한 이유로 실패할 수 있습니다. 따라서 소비자/리스너는 중복 메시지를 처리할 준비가 되어 있어야 합니다. 전달 보장은 _exactly-once_가 아니라 at-least-once 입니다.
이 방법은 앞선 모든 방법의 확장이자 정교화입니다. Clients/APIs method calls 접근을 떠올리면, 주요 잠재 이슈는 다음과 같았습니다:
이 문제를 피하기 위해 단순하지만 큰 의미를 갖는 원칙을 도입할 수 있습니다:
외부 요청을 처리하는 동안, 한 모듈은 다른 모듈을 호출하면 안 된다.
또 어떤 똑똑한 사람의 비유를 빌리자면:
선생님이 질문을 하면, 스스로 답해야 한다. 다른 학생에게 도움을 요청할 수는 없다.
하지만 그 순간의 전후—이전과 이후—에는 다른 학생에게 자유롭게 물어보고 배울 수 있다.
이 단순한 아이디어는 심각한 결과를 가져옵니다. 이 제약을 만족하려면, 각 모듈은 자신이 책임지는 기능을 제공하는 데 필요한 모든 데이터를 스스로 가지고 있어야 합니다. 그 결과 모듈은 더 낮게 결합되고, 더 탄력적이며, 더 독립적이고, 더 자체 완결적이 됩니다. 이는 다시 훌륭한 모듈 설계의 중요성을 강조합니다. 올바른 모듈을 선택하고 그들에게 적절한 책임을 부여해야, 그들이 책임지는 것을 제공할 때 가능한 한 독립적일 수 있기 때문입니다. 데이터 크기와 변경 빈도에 따라 두 가지 선택지가 있습니다:
옵션 1의 경우 _초기 데이터 로드_도 처리해야 합니다. 이미 운영 중인 모듈로부터 데이터를 필요로 하는 새 모듈이 도입되면, 과거에 발행된 모든 이벤트로부터 데이터를 가져와야 할 수도 있습니다. 이는 보통 Clients/APIs 접근으로 비교적 쉽게 구현할 수 있지만, 이런 시간 의존성을 염두에 둬야 합니다. 예시로 넘어가 보겠습니다:
// shared module, implementation in the user module
interface UserClient {
Stream<User> allUsers();
}
record User(UUID id, String name, String email)
// published by the user module whenever a user is created or updated using Outbox Pattern;
// we should probably also have UserDeletedEvent, if that is possible
record UserChangedEvent(User user)
// project module:
// serves data only from its repositories (database)
// and synchronizes it with the user module in the background
record ProjectWithUsers(
UUID id,
String name,
String description,
List<User> users
)
record Project(
UUID id,
String name,
String description,
List<UUID> userIds
)
// returns projects with users solely from the project module database
class ProjectService {
ProjectRepository projectRepository;
ProjectUserRepository projectUserRepository;
List<Project> allOfNamespace(String namespace) {
var projects = projectRepository.allOfNamespace(namespace);
var userIdsOfProjects = projects.flatMap(p -> p.userIds());
var usersByIds = projectUserRepository.ofIds(userIdsOfProjects);
var projectsWithUsers = projects.map(p -> {
var projectUsers = p.userIds().map(uid -> usersByIds.get(uid));
return new ProjectWithUsers(p.id(), p.name(), p.description(), projectUsers);
});
return projectsWithUsers;
}
}
// synchronizes users data in the background
class ProjectUsersSync {
ProjectUserRepository projectUserRepository;
UserClient userClient;
void onUserChangedEvent(UserChangedEvent event) {
projectUserRepository.save(event.user());
}
void syncAll() {
userClient.allUsers()
.forEach(e -> projectUserRepository.save(u));
}
}
이 경우 실시간 업데이트를 위해 onUserChangedEvent 메서드에 의존합니다. syncAll로 구현된 초기(전체) 동기화는 모듈이 처음 배포될 때 시작 단계에서만 필요합니다—한 번 호출하고 나면 그 이후로는 이벤트에만 의존할 수 있습니다.
동기화되는 데이터가 자주 바뀌지 않거나 크기가 작고, 그리고/또는 즉시 최신 상태일 필요가 없으며 _eventual consistency_로 충분하다면—이벤트를 생략하고 모듈의 _Client/API_를 사용해 하루에 한 번, 몇 시간마다, 혹은 몇 분마다 등 필요에 맞춰 전체 데이터를 동기화하는 방식도 가능합니다.
이 접근에서는 (전달 보장을 위해 여기서도 사용하는) _Outbox Pattern_과 마찬가지로 모듈이 느슨하게 결합되며, 어떤 모듈이든 완전히 별도의 애플리케이션으로 옮기기가 꽤 쉽습니다. 예를 들어, 위 예시의 _user module_을 별도의 user-service로 옮기고 다른 모듈들은 modular-monolith에 남기기로 결정했다고 해봅시다. 무엇을 바꿔야 할까요? 이제 인메모리 이벤트 발행은 불가능하고, _Clients/APIs_도 단순 메서드 호출로 구현할 수 없습니다. 대신 우리는 다음을 할 수 있습니다:
user-service로 http 요청을 보내는 HttpUserClient로 UserClient를 구현한다user-service에서 _Outbox Pattern_을 사용할 때 선택지는 두 가지다:
user-service 안에서 이를 리스닝한 다음, modular-monolith에서 실행 중인 project 모듈로 간단한 http POST 요청을 보내 UserChangedEvent를 전달한다_user module_을 다른 독립 서비스로 옮기기 위해 해야 할 일은 이것이 전부입니다! 정말로 매우 유연한 접근입니다.
지금까지 모듈들이 서로 커뮤니케이션할 수 있는 몇 가지 좋은 방법을 살펴봤습니다. 특히 다음이 핵심입니다:
또한 가장 중요한 것은 훌륭한 모듈 설계에 집중하는 것임을 배웠습니다. 따라서 먼저 모듈을 올바르게 식별하고 정의하는 데 집중하고, 그 다음에 적절한 커뮤니케이션 패턴과 접근을 고민해야 합니다.
훌륭한 모듈 설계를 갖추고 커뮤니케이션 패턴을 현명하게 적용하면, _Modular Monolith_를 단순화할 뿐 아니라 더 유연하고 변경하기 쉽게 만들 수 있습니다. 또한 어떤 시점에 일부 또는 전체 모듈을 여러 애플리케이션으로 마이그레이션해야 할 필요가 생기더라도, 최소한의 노력으로 해낼 수 있습니다.
이런 유형의 콘텐츠가 좋나요? Binary Log Newsletter를 구독하세요 - 깊이 있는 탐구, 폭넓은 탐색, 그리고 정제된 인사이트. 공예를 마스터하고자 하는 호기심 많은 개발자를 위해:
유효한 이메일이 필요합니다.
스팸도, 군더더기도 없이 - 순수한 신호만. 언제든 구독 해지할 수 있습니다.
Already In
Join Log
유용한 피드백, 질문, 코멘트가 있거나 그냥 연락하고 싶다면 [email protected] 로 이메일을 보내주세요.
그곳에서 뵙겠습니다!
더 생각해볼 거리:
©Igor Roztropiński