K-L1VERSE

K-L1VERSE의 EDD 도입기

ignuy 2024. 12. 13.

K-L1VERSE의 EDD 도입 이야기

안녕하세요, K-L1VERSE의 Aiden입니다. K-L1VERSE에 큰 변곡점이 생겼서 다시 블로그로 찾아오게 되었습니다. 몇 가지 마일스톤을 헤쳐나갔는데요. 오늘은 크게 세 가지 골자에서 포스팅을 남겨볼까 합니다.

1. EDD

블로그를 구독하신 분이라면 몇 달 전 올라왔던 우아한 형제들의 기술블로그 스터디 포스팅을 기억하실 겁니다.

이유도 없이 해당 포스팅을 준비한 것은 아니었습니다. K-L1VERSE에 EDD를 도입하기 위한 사전 공부느낌이었는데, 그래서 그런지 우아한 형제들의 포스팅과 유사한 내용이 많이 섞여 있습니다.

2. AWS SQS & SNS

우아한 형제들이 MQ로 활용했던 AWS의 클라우딩 서비스, SQS와 SNS도 K-L1VERSE에 도입하기에 좋은 장치였습니다. 도입 이유는 후술되겠지만 약간의 스포일러(?)를 남기자면, 혼자 서비스를 구축하시는 분들에게도 심금을 울릴만한 내용이 있었습니다. 궁금하시다면 끝까지 읽어주시기 바랍니다.

또한, 나름 K-L1VERSE의 기술블로그로 올리는 이 포스팅이 특정 기업 기술블로그의 인사이트를 복붙하는 양산형 스터디 글이 되지 않을까 우려하여 K-L1VERSE의 색깔을 찾기 위한 아래 한 가지 디자인 패턴을 더 활용하였습니다.

3. SAGA Pattern

Saga 패턴은 분산 애플리케이션의 여러 마이크로서비스에서 데이터 일관성을 유지하는 데 도움이 되는 오류 관리 패턴입니다. 작업의 성공과 실패 이벤트를 주고받으며 트랜잭션을 특별하게 처리하는 패턴으로 이 패턴 역시 MSA와 도메인의 분리를 표방하는 K-L1VERSE에게 찰떡인 디자인 패턴이었습니다.

 

자 서론이 너무 길었습니다. 필요하신 내용이 급하시다면 오른쪽의 북마크를 이용해주시기 바랍니다. 그럼 시작해보겠습니다!

1. EDD의 도입

도메인 주도 개발을 통해 K-L1VERSE라는 서비스를 여러 개의 도메인으로 쪼개고 각각의 도메인간의 결합도를 끊어 내기 위한 노력을 이어가고 있다. 오늘은 그 노력의 일환으로 K-L1VERSE에 도입된 Event Driven Development, EDD를 살펴볼 것이다.

a. 도메인 내의 비관심사 분리(내부 이벤트 전송)

우선 K-L1VERSE EDD의 주요한 컨셉은 [도메인 간 상태 변경의 행위는 이벤트가 트리거]한다는 점이다. 특정 도메인에서 유저의 활동으로 인해 타 도메인에서 관리하는 데이터가 변경되어야 한다면 이벤트를 발행하여 비동기식으로 동작하는 것이다.

1번의 문제는 그럼 간단하게 상태 변경을 주문하는 메서드 제일 밑에 이벤트 전송 코드를 추가하면 해결되는 것인가?

이상하리만큼 쉬워 보인다면 잘못한 것이다.

그냥 상태 변경을 이야기하는 모든 행위 제일 밑에 코드를 추가하면 되는 간단한 문제가 아니다. 단순히 코드를 삽입한다면 도메인 간의 결합도를 낮추기 위해 도메인 애플리케이션의 결합도를 높이는 하책(下策, worst decision)이 될 것이다.

해당 도메인에서 어떤 행위가 관심사이고, 어떤 행위가 비관심사인지 정확히 구분해야 할 필요가 있다.

지금부터 실제 K-L1VERSE의 코드로 설명해본다.

User Server의 “Badge 구매” 기능

K-L1VERSE는 유저가 응원하는 팀을 본인의 정체성으로 보여줄 수 있게 개인 프로필 오른쪽에 Badge를 보여주는 기능을 가지고 있다.

이 뱃지를 구매하기 위해서는 서비스 내 포인트인 GOAL을 1000 소비해야 한다. 잔액을 확인한 후 1000 GOAL을 소비하여 뱃지를 구매하고 이에 대한 알람이 사용자에게 전송된다.

EDD 도입 전

@Transactional
public void buyBadge(HttpServletRequest request, BadgeBuyReqDto badgeBuyReqDto) {

        /** 
         * ... ellipsis
         */
    BadgeDetail badgeDetail = badgeDetailRepository.findByCode(badgeBuyReqDto.getCode())
            .orElseThrow(() -> new UserException(ResponseCode.BADGE_NOT_FOUND));

    Badge badge = Badge.builder()
        .user(user)
        .badgeDetail(badgeDetail)
        .buyAt(LocalDateTime.now())
        .build();
    badgeRepository.save(badge);

    userService.payGoals(user, badgeDetail.getPrice());             // change GOAL state
    notificationService.sendNotification(MessageReqDto.builder()    // change NOTIFICATION state
            .type(MessageReqDto.NotificationType.GOAL)
            .userId(user.getId())
            .message("Pay 1000 GOAL to purchase a badge.")
            .build());
}

위 코드는 EDD를 적용하기 전 코드이다. Event를 활용하고 있지 않으므로 Badge를 담당하는 Service 코드에서 GOAL state와 NOTIFICATION state를 변경하는 Service 코드가 각각 직접 삽입되어 있다.

무려 세 개의 Service 코드가 묶인 흔히 말하는 스파게티 코드 상태이다.

 

이 경우, 추후 Badge 구매에 추가로 다른 비즈니스 로직이 필요한 경우(ex. 뱃지를 구매한 사람만 이벤트 대상자로 추가 등) 불가피하게 buyBadge 메서드의 수정이 일어나야 한다(OCP 위배).

뿐만 아니라 buyBadge 메서드의 테스트에도 문제가 있다. buyBadge 메서드가 userServiceNotificationService를 직접 호출하고 있기 때문에, 단일 기능에 대한 테스트가 어려워지고 테스트 범위가 모호해진다(테스트의 어려움).

EDD 도입 후

@Transactional
public void buyBadge(HttpServletRequest request, BadgeBuyReqDto badgeBuyReqDto) {

    /** 
    * ... ellipsis
    */
    BadgeDetail badgeDetail = badgeDetailRepository.findByCode(badgeBuyReqDto.getCode())
            .orElseThrow(() -> new UserException(ResponseCode.BADGE_NOT_FOUND));

    Badge badge = Badge.builder()
        .user(user)
        .badgeDetail(badgeDetail)
        .buyAt(LocalDateTime.now())
        .build();
    badgeRepository.save(badge);
    
    publisher.publishEvent(new BoughtBadgeEvent(user, badgeDetail))      // publish event 
}
public class BoughtBadgeEvent {
    private final User user;
    private final BadgeDetail badgeDetail;

    public UserSignedInEvent(User user, BadgeDetail badgeDetail) {
        this.user = user;
        this.badgeDetail = badgeDetail;
    }

    public User getUser() {
        return user;
    }
    
    public BadgeDetail getBadgeDetail() {
        return badgeDetail;
    }
}

사용자가 뱃지를 구매했다는 이벤트를 정의하고 이를 발행하기 위한 코드를 buyBadge 메서드에 달아두었다. 이로써 기존에 userServicenotificationService를 직접 호출하며 엮여있던 의존관계를 제거할 수 있었다.

@Component
@RequiredArgsConstructor
@Slf4j
public class BadgeListener {

    private final UserService userService;
    private final NotificationService notificationService;
    
    @Async(EVENT_HANDLER_TASK_EXECUTOR)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void payBadgePrice(BoughtBadgeEvent e) {
        log.info("[BadgeListener - payBadgePrice]");
        User user = e.getUser();
        BadgeDetail badgeDetail = e.getBadgeDetail();
        
        userService.payGoals(user, badgeDetail.getPrice());
    }
		
    @Async(EVENT_HANDLER_TASK_EXECUTOR)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void sendBoughtBadgeNotification(BoughtBadgeEvent e) {
        log.info("[NotificationListener - sendBoughtBadgeNotification]")
        User user = e.getUser();
        
        notificationService.sendNotification(MessageReqDto.builder()
            .type(MessageReqDto.NotificationType.GOAL)
            .userId(user.getId())
            .message("Pay 1000 GOAL to purchase a badge.")
            .build()
        );
    }
}

위처럼 서비스의 핵심 로직인 “뱃지를 구매한다”는 코드만 유지하고 나머지 비관심사(ex. GOAL 포인트 차감, 유저에게 알림 전송 등)는 분리할 수 있다. 이때, @Async와 @TransactionalEventListener를 통해 트랜잭션과 비동기 작업을 효율적으로 처리할 수 있었다.

b. 외부 이벤트 전송

“1. 내부 이벤트 전송”의 주된 목적은 도메인 내부 애플리케이션의 결합도를 낮추는 것이었다. 이번엔 외부 이벤트 전송으로 도메인 간의 결합도를 낮춰보자.

아래의 그림은 K-L1VERSE가 목표를 달성하기 위한 인프라 설계를 psuedo로 표현한 청사진이다.

“상태의 변경에 직접 관여하지 말고, 이벤트를 이용하여 해당 도메인에게 안내하자.”라는 규칙에 맞게 Board Server에서 자유 형식의 게시글인 “Waggle”을 작성하고 User Server로 상태의 변화를 이벤트로 주문하는 코드를 작성하였다. 아래의 프로세스 다이어그램을 참고하자.

현재 K-L1VERSE는 유저들의 활동을 활발히 유지하기 위해 “Waggle”을 작성하면 일정량의 Goal획득하고 이를 알림으로 받아볼 수 있다는 서비스 정책이 있다.

위 프로세스 다이어그램을 코드로 구현하면 아래와 같다.

“Waggle” 작성

public void createWaggle(User user, String content) {
    // Logic to save the board to the database
    String waggleId = saveWaggleToDatabase(user, content);
	
    // Publish the "INNER" event
    publisher.publishEvent(new CreatedWaggleEvent(waggleId, user.getUserId()));
}
public class CreateWaggleEvent {
    private final String waggleId;
    private final String userId;

    public CreateWaggleEvent(String waggleId, String userId) {
        this.waggleId = waggleId;
        this.userId = userId;
    }

    public String getWaggleId() {
        return waggleId;
    }

    public String getUserId() {
        return userId;
    }
}

위 서비스 로직을 통해서 “Waggle”을 작성했다는 이벤트를 Spring Event로 Board 서버의 내부 이벤트 리스너로 전송한다. 여기서도 1번과 같이 핵심 로직의 관심사와 비관심사를 철저히 구분해야 한다. 이벤트를 발행하는 행위도 엄연히 비관심사이므로 비즈니스 핵심 로직에서 제외되어야 한다.

“Waggle” 작성 이벤트 핸들러

@EventListener
public void handleCreateWaggleEvent(CreateWaggleEvent event) {
    String message = String.format("{\\"waggleId\\": \\"%s\\", \\"userId\\": \\"%s\\"}", event.getWaggleId(), event.getUserId());
    SendMessageRequest sendMessageRequest = new SendMessageRequest()
            .withQueueUrl(queueUrl)
            .withMessageBody(message);

    amazonSQS.sendMessage(sendMessageRequest);
}

Board Server에서 AWS SQS&SNS를 활용하여 지정된 end point로 외부 Event를 발행한다. 이 Event는 User Server로 가서 User의 상태 변화를 주문할 것이다.

User Server SQS listener

@SqsListener(value = "${sqs.create-waggle}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
public void gainGoalsByWroteWaggle(@Payload Map<String, String> message) {
    String waggleId = message.get("waggleId");
    String userId = message.get("userId");

    // Call event handlers
    userServer.gainGoals(userId);
}

@SqsListener(value = "${sqs.create-waggle}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS)
public void sendWritingWaggleNotification(@Payload Map<String, String> message) {
    String waggleId = message.get("waggleId");
    String userId = message.get("userId");

    // Call event handlers
    notificationService.sendNotification(MessageReqDto.builder()
        .type(MessageReqDto.NotificationType.WAGGLE)
        .userId(userId)
        .message("You wrote a great Waggle. Thank you for enjoying the K League.")
        .build()
    );
}

이렇게 비관심사 분리를 통해 도메인의 응집도를 높이고, 각 부가 로직은 독립적으로 관리되며 재사용성을 높일 수 있다.

 

2. 왜 AWS SQS & SNS인가?

외부 도메인으로 이벤트를 전송하기 위해서 사용할 수 있는 MQ는 AWS SQS & SNS 이외에도 다양하다. 그렇다면 다른 경쟁자를 제치고 AWS의 클라우딩 서비스를 사용한 이유는 무엇이었을까?

 

우선 올해 초, K-L1VERSE는 고사양 EC2를 지원받던 부트캠프 시절에 구축했던 기존 시스템에서 외부 이벤트 서빙을 위해 Kafka를 구동 중이었다. Kafka의 적정 사양을 확인해보면 JVM 구동을 위한 최소 메모리만으로도 1GB가 필요하다. 소규모 배포 환경에서 안정적인 운영을 위해 Memory가 최소 4GB, Disk 최소 64GB가 필요하다. 즉, Kafka를 설치 및 띄우기만 해도 최소한 1GB의 메모리를 잡아먹고 안정적인 운영을 위해서는 더 좋은 리소스를 활용해야 한다. 돈 걱정 없이 리소스를 지원받던 부트캠프 시절엔…. 컴퓨터 리소스가 이렇게 비쌀 줄 몰랐다.

 

현재 K-L1VERSE는 총 4개의 Spring Server를 하나의 클라우드 컴퓨터에서 구동 중이다. Kafka를 배제한다면 4GB짜리 서버 인스턴스를 선택했을 때, 운영이 가능한 수준이었다. 하지만 Kafka가 동일한 인스턴스에 올라가게 되면 운영을 위해서 서버 인스턴스를 scale up 해야 한다.

컴퓨팅 리소스가 옆집 강아지 간식값도 아니고 이러면 문제가 커진다.

따라서 불필요한 리소스를 Scale up 하는 비경제적인 선택보다, 내가 사용한 만큼만 비용을 지불해야겠다는 생각이 커졌다. 그에 대한 해결책으로 생각한 것이 AWS의 SQS와 SNS이다.

AWS SQS

AWS의 완전관리형 메시지 대기열 서비스로, 분산 시스템 간의 비동기 통신을 가능하게 한다. SQS는 메시지를 큐에 저장하고, 수신자는 이 큐에서 메시지를 읽어 처리함으로써 시스템 간의 결합도를 낮출 수 있다. 이를 통해 메시지 손실 없이 안정적인 데이터 전송이 가능하다.

AWS SNS

AWS의 완전관리형 메시지 전송 서비스로, 이벤트 발행 후 여러 구독자에게 메시지를 동시에 전달할 수 있는 기능을 제공한다. SNS는 주로 발행-구독 모델을 지원하여, 특정 이벤트에 대한 알림을 여러 시스템에 동시에 전송할 수 있다. SNS와 SQS를 함께 활용하면, 이벤트 기반 아키텍처를 구성하고 시스템 간의 통신을 효율적으로 관리할 수 있다.

또한, SNS 속성을 활용하여 구독자가 원하는 이벤트만 필터링할 수 있다. AWS SNS의 속성을 이용하면 각 구독자는 필요로 하는 이벤트 형식이나 속성을 필터로 정의하여, 해당 애플리케이션에 필요한 이벤트만 유입되도록 할 수 있다. 필터링 기능을 통해 애플리케이션이 이벤트를 직접 분류하는 데 소모되는 리소스를 절약할 수 있다.

SAGA pattern 도입

K-L1VERSE는 DDD 기반의 MSA형태로 구축되어 있다.

 

도메인 간의 결합도를 최대한 낮추기 위해서 각 도메인에 연결된 스토리지도 모두 분리하여 “1 도메인, 1 애플리케이션 , 1 DB”를 설계 원칙으로 삼았다. 이런 구조에서 가장 심한 두통 유발 원인은 “분산 트랜잭션”이다. 하나의 시스템 안에선 RDBMS에 의해, 또는 Service에 의해 자동으로 엄격하게 관리되는 트랜잭션이 서비스가 분산되면서 활용할 수가 없게 되었다.

이 문제를 극복하기 위해, K-L1VERSE가 활용한 분산 트랜잭션 관리 전략은 SAGA 패턴이다.

분산 관리되는 베팅 포인트

K-L1VERSE에서는 “특정 팀의 경기 결과를 응원”하는 기능을 위해 시스템 내 포인트인 GOAL을 베팅하는 기능을 가지고 있다.

다만, GOAL 베팅 내역을 관리하는 서버는 Match 서버이고 유저의 전체 GOAL 내역을 관리하는 서버는 User 서버이다.

즉, 동일한 성격의 데이터가 두 군데에 쌓이고 있다. “데이터 정합성, 무결성” 측면에서 봤을 때, 반드시 트랜잭션이 이루어져야 서비스가 정상적으로 작동한다.

K-L1VERSE는 이러한 상황을 위하여 각 서버마다 로직의 성공과 실패 이벤트를 주고받는다.

성공 이벤트

Match Server에서 “베팅한다”는 이벤트를 발행하여 User Server에 GOAL 차감을 요청한다. User Server에서 베팅하는 User가 가지고 있는 GOAL의 잔액과 베팅 GOAL을 비교하여 로직 상 문제가 없다면 성공적으로 GOAL이 차감되었음을 알리는 완료(성공) 이벤트를 Match Server에게 전송한다.

실패 이벤트

만약, GOAL을 베팅하는 상황에서 잔여 GOAL보다 베팅 GOAL이 더 많거나, User Server의 기타 로직에서 에러가 난다면, 실패 이벤트를 Match Server에 전달하여 베팅 프로세스를 트랜잭션한다.

SAGA Choreography

SAGA 패턴은 사실 그 종류가 두 가지이다. Choreography(코레오그래피) 방식과 Orchestration(오케스트레이션) 방식으로 나뉘고 각각의 장단점이 뚜렷하다.

Orchestration 방식은 중앙 조정자(Coordinator)가 전체 트랜잭션을 관리한다. 중앙 조정자가 트랜잭션 단계를 순차적으로 호출하고 트랜잭션 실패 시 중앙 조정자가 보정 작업을 직접 지시한다. 트랜잭션 흐름이 명확하고 관리가 용이하지만, 중앙 조정자가 “단일 실패 지점(SPOF)”이 될 수 있다.

 

반대로, K-L1VERSE가 채택한 Choreography 방식은 서비스들이 이벤트 기반으로 서로 통신하며 트랜잭션을 관리한다. 각 도메인이 자신의 작업을 완료한 후 결과를 담은 이벤트를 발행하고 그다음 동작을 수행하는 도메인은 해당 이벤트를 수신하여 작업을 수행한다. 이때, 실패 시 보정 장업을 트리거하는 것은 다른 도메인이 보내는 실패 이벤트이다.

이 방식의 경우, 중앙 조정자가 없어 서비스 간 느슨한 결합을 유지할 수 있지만, 이벤트 흐름이 복잡해질 수 있다는 단점이 있다.

장단점이 명확하지만..

중앙 조정자가 SPOF가 된다는 사실은 너무 큰 부담이다. 다행히 K-L1VERSE에서는 3개 이상의 도메인이 서로 이벤트를 핑퐁하면서 복잡한 이벤트 흐름 구조를 가지진 않았으므로 Choreography 방식을 선택하게 되었다.

 

이야기를 정리하며

이상 여기까지 K-L1VERSE의 눈물겨운 EDD 도입기였습니다.

사실 K-L1VERSE의 레거시 코드는 해결해야 할 문제가 한두가지가 아니었습니다. 복잡하게 얽히고설킨 REST api도 문제였으며, 도메인 간의 결합도를 낮추고자 기획한 DDD에서 마음처럼 도메인 간의 결합도가 낮아지진 않았다는 현실이 K-L1VERSE에겐 힘든 난관이었습니다. 그럼에도 불구하고 DDD가 가질 수 있는 든든한 무기인 EDD를 통해 이런 현상을 조금이나마 해결했다는 점에서 고무적이라고 생각합니다.

 

K-L1VERSE의 다음 행보는 ‘MSA형태에서 자동화 API 명세서를 한꺼번에 볼 수는 없을까?’와 ‘K-L1VERSE의 K8S 도입기’입니다. K-L1VERSE의 앞길을 기대하며 쉽지 않은 도전을 이어나가 보도록 하겠습니다.

긴 글 읽어주셔서 감사합니다.

 

Sincerely,

Aiden

 

댓글