1. CQRS
- 쓰기 요청과 읽기 요청의 책임을 분리하는 설계 방식
- 쓰기와 읽기는 목적이 다르므로 자연스럽게 관심사와 정책도 달라짐
ex) 관심사
더보기
- Command: 상태 변경, 입력 검증, 도메인 규칙, 트랜잭션, 이벤트 발행, 캐시 무효화, 후속 처리 등
- Query: 조회 조건, 커서, 페이징, 정렬 기준, 캐시 조회, 캐시 미스 복구, 응답 모델 최적화, 읽기 전용 트랜잭션 등
입력 모델
- 요청값의 의도를 표현하는 객체
- ✅ 컨트롤러는 HTTP 요청 값을 그대로 서비스에 넘기지 않고, 입력 모델로 변환해 애플리케이션에 전달하는 역할만 수행함
- ✅ 애플리케이션 서비스에서 입력 모델을 직접 해석해 처리 전략을 결정함
ex) Command 객체
더보기
public record ChangeMarketsCommand(
List<CreateMarketCommand> creates,
List<UpdateMarketCommand> updates,
List<DeleteMarketCommand> deletes
) {
public ChangeMarketsCommand {
creates = creates == null ? List.of() : List.copyOf(creates);
updates = updates == null ? List.of() : List.copyOf(updates);
deletes = deletes == null ? List.of() : List.copyOf(deletes);
}
public boolean isEmpty() {
return creates.isEmpty() && updates.isEmpty() && deletes.isEmpty();
}
public boolean hasCreates() {
return !creates.isEmpty();
}
public boolean hasUpdates() {
return !updates.isEmpty();
}
public boolean hasDeletes() {
return !deletes.isEmpty();
}
// CreateMarketCommand, UpdateMarketCommand, DeleteMarketCommand 등등..
}
@Override
@Transactional
public void changeMarkets(ChangeMarketsCommand command) {
if (command.isEmpty()) {
return;
}
if (command.hasDeletes()) {
marketPersistencePort.deleteMarketsByIds(command.deleteIds());
}
if (command.hasUpdates()) {
marketPersistencePort.updateMarkets(command.updates());
}
if (command.hasCreates()) {
marketPersistencePort.createMarkets(command.creates());
}
publishMarketChangedEvent(
Market.eventSource()
);
}
- 애플리케이션 서비스에서 커맨드를 해석해서 처리 전략을 결정함
ex) Query 객체
더보기
public record ListChatMessagesQuery(
String roomId,
String lastMsgId,
Long lastCreatedAtMs,
int limit
) {
public static ListChatMessagesQuery firstPage(String roomId, int limit) {
return new ListChatMessagesQuery(roomId, null, null, limit);
}
public static ListChatMessagesQuery prevPage(
String roomId,
String lastMsgId,
Long lastCreatedAtMs,
int limit
) {
return new ListChatMessagesQuery(roomId, lastMsgId, lastCreatedAtMs, limit);
}
public boolean hasNoCursor() {
return lastMsgId == null || lastMsgId.isBlank();
}
public long cursorCreatedAtMs() {
return lastCreatedAtMs == null ? 0L : lastCreatedAtMs;
}
}
@Override
@Transactional(transactionManager = "chatMongoTransactionManager", readOnly = true)
public List<ChatMessage> listMessages(ListChatMessagesQuery query) {
List<ChatMessage> cached = query.hasNoCursor()
? cache.listLatestMessages(query.roomId(), query.limit())
: cache.listMessagesBefore(query.roomId(), query.lastMsgId(), query.cursorCreatedAtMs(), query.limit());
if (!cached.isEmpty()) {
return cached;
}
return query.hasNoCursor()
? queryRepairService.repairLatest(query.roomId(), query.limit())
: queryRepairService.repairPrev(query);
}
- Query 객체를 해석해 조회 전략을 결정함
효과
- 컨트롤러 가벼워짐
- 애플리케이션 명세가 의도 중심으로 단순해짐
- 애플리케이션에 세부 전략이 모임
- 애플리케이션 변경 범위가 작아짐
2. DDD
- 비즈니스 중심으로 도메인을 모델링하는 방식
- ✅ 헥사고날 아키텍처는 도메인이 외부 기술에 오염되지 않도록 보호하는 설계 원칙
도메인 모델과 엔티티 분리
- 비즈니스 규칙을 기술 제약으로부터 보호할 수 있음
- ✅ 도메인: 업무 의미와 규칙을 가진 객체
- ✅ 엔티티: DB 저장 구조를 표현하는 객체
바운디드 컨텍스트
- 같은 단어라도 의미가 달라질 수 있는 경계
- ✅ 같은 단어라도 모든 곳에서 같은 의미로 쓰여지지 않음
- ✅ 바운디드 컨텍스트를 기준으로 코드 경계를 나누면, 각 도메인의 개념과 규칙을 명확하게 표현할 수 있음
- ➡️ 각 문맥마다 필요한 모델을 따로 둠
ex) user-service의 user
더보기
account - 계정 (가입 정보, 프로필, 권한을 가짐)
public class User {
private UUID publicId;
private String email;
private String nickname;
private String password;
private Set<Role> roles;
public void changeNickname(String nickname) {
validateNickname(nickname);
this.nickname = nickname;
}
public boolean hasRole(Role role) {
return roles.contains(role);
}
private void validateNickname(String nickname) {
if (nickname == null || nickname.isBlank()) {
throw new IllegalArgumentException("nickname must not be blank");
}
if (nickname.length() < 2 || nickname.length() > 20) {
throw new IllegalArgumentException("nickname length must be between 2 and 20");
}
}
}
ex) chat-service의 user
더보기
chatroom - 채팅방 참여자
public class ChatRoom {
private String roomId;
private String title;
private Set<String> memberIds;
private boolean deleted;
public void validateWritable(String writerId) {
if (deleted) {
throw new IllegalStateException("deleted room is not writable");
}
if (!memberIds.contains(writerId)) {
throw new IllegalArgumentException("writer is not a room member");
}
}
public void join(String memberId) {
memberIds.add(memberId);
}
public void leave(String memberId) {
memberIds.remove(memberId);
}
}
chatmessage - 메시지 작성자
public class ChatMessage {
private String messageId;
private String roomId;
private String writerId;
private String content;
private Instant createdAt;
public boolean writtenBy(String memberId) {
return writerId.equals(memberId);
}
}
ex) notification-service의 user
더보기
notification - 알림 수신자
public class Notification {
private String notificationId;
private String title;
private String message;
private String eventType;
public NotificationRecipient deliverTo(UUID receiverId) {
return new NotificationRecipient(
null,
receiverId,
false,
Instant.now(),
null
);
}
}
public class NotificationRecipient {
private String recipientId;
private UUID receiverId;
private boolean read;
private Instant deliveredAt;
private Instant readAt;
public void markAsRead() {
if (read) {
return;
}
this.read = true;
this.readAt = Instant.now();
}
public boolean isUnread() {
return !read;
}
}
- 포함 관계가 아닌 분리된 도메인 모델로 표현됨 (다대다 매핑)
3. MSA
- 시스템을 도메인 단위의 독립적인 서비스로 나누는 구조
- ✅ 헥사고날 아키텍처는 각 서비스 내부에서 도메인 로직과 외부 기술을 분리하는 설계하는 방식
서비스 간 통신 (port/adapter)
- Application Service가 필요한 기능을 Port로 정의하고 실제 통신 방식은 Adapter에서 구현함
- ✅ 다른 서비스를 의존하여 직접 요청해선 안됨
- ⚠️ 서비스 간 결합도가 발생함 (내부 모델이나 DB를 직접 알게되므로 수정 시 영향을 받음)
서비스별 독립 배포
- 각 서비스가 독립적으로 변경되고 배포될 수 있음
- 헥사고날 아키텍처를 적용하면 서비스 내부에서도 변경 범위가 줄어듬
출처
'Software Engineering' 카테고리의 다른 글
| [Hexagonal Architecture] 2. 구성 (0) | 2026.06.29 |
|---|---|
| [Hexagonal Architecture] 1. 헥사고날 아키텍처란? (0) | 2025.08.20 |