Software Engineering

[Hexagonal Architecture] 3. 설계 방식

noahkim_ 2026. 7. 3. 19:43

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를 직접 알게되므로 수정 시 영향을 받음)

 

서비스별 독립 배포

  • 각 서비스가 독립적으로 변경되고 배포될 수 있음
  • 헥사고날 아키텍처를 적용하면 서비스 내부에서도 변경 범위가 줄어듬

 

출처