본문 바로가기

Java/기술회고

Kafka를 제대로 쓰고 싶어서 MSA로 전환한 이야기

개인 프로젝트 bowchat을 모놀리식으로 시작해서 MSA로 전환했다.
단순히 서비스를 쪼개는 게 목적이 아니었다.
Kafka를 제대로 써보고 싶었고, 서비스 간 데이터 정합성 문제를 직접 해결해보고 싶었다.

이번 글에서는

  • 왜 전환했는지
  • 전환하면서 어떤 문제를 만났는지
  • 각 문제를 어떻게 해결했는지

를 중심으로 정리해보려 한다.

왜 전환했는가

처음엔 모놀리식에 Kafka를 붙였다.
입찰 이벤트를 Kafka로 발행하고 Consumer가 처리하는 구조였는데, 실제로 써보니 이상했다.

[모놀리식 + Kafka]
같은 서비스 안에서
Producer → Kafka → Consumer
결국 DB도 같고, 코드도 같고
→ 그냥 메서드 호출이랑 다를 게 없는 구조

Kafka의 핵심은 "이벤트를 발행하면 구독한 서비스들이 각자 독립적으로 반응"하는 것인데,
모놀리식에서는 이 원리를 살릴 수가 없었다.

MSA로 전환하고 나서야 Kafka가 진짜 이벤트 버스로 동작하기 시작했다.

[MSA + Kafka]
user-service   → user.created 발행
                    ↓
product-service → UserSnapshot 저장 (독립적)
auction-service → UserSnapshot 저장 (독립적)
chat-service    → UserSnapshot 저장 (독립적)

auction-service → auction-bid 발행
                    ↓
chat-service    → WebSocket 브로드캐스트 (독립적)

발행한 서비스가 누가 받는지 알 필요가 없다.
받는 서비스도 누가 발행했는지 알 필요가 없다.
이게 내가 원하던 구조였다.

서비스 분리하면서 바로 만난 문제들

1. 이벤트 발행과 DB 저장의 원자성

회원가입 트랜잭션 안에서 Kafka를 직접 발행하면 문제가 생긴다.

DB 저장 (트랜잭션) → Kafka 발행 (트랜잭션 밖)

이 구조에서 두 가지 케이스가 생긴다.

  • DB는 성공했는데 Kafka 실패 → 다른 서비스가 해당 유저를 영원히 모름
  • Kafka는 됐는데 DB 롤백 → 존재하지 않는 유저 이벤트 발행

아웃박스 패턴으로 해결했다.

DB 저장 + outbox 저장 (같은 트랜잭션)
    ↓ 스케줄러 (1초마다)
Kafka 발행 (sendSync, 성공 확인 후 markPublished)

트랜잭션 안에서는 DB와 outbox 테이블에만 저장한다.
스케줄러가 outbox를 읽어서 Kafka에 발행하고, 성공하면 발행 완료 처리한다.

다중 서버 환경에서 스케줄러가 중복 실행되면 안 되기 때문에 ShedLock을 함께 적용했다.
이전에 ECS 환경에서 스케줄러 중복 실행으로 데드락이 발생했던 경험이 있어서, 이번엔 처음부터 걸었다.

2. 서비스 간 유저 정보를 어떻게 공유하는가

MSA에서는 각 서비스 DB가 분리된다.
product-service에서 유저 닉네임이 필요한데, user-service DB를 직접 참조할 수 없다.

처음엔 이벤트 기반으로만 해결하려 했다.
user.created 이벤트를 수신해서 로컬에 UserSnapshot을 저장하는 방식이었는데 한계가 있었다.

  • 서버 최초 배포 시 기존 유저 누락
  • 이벤트 유실 시 정합성 문제

그래서 3단계 조회 전략을 추가했다.

Redis 캐시 (TTL 10분)
    ↓ miss
로컬 UserSnapshot DB
    ↓ miss
user-service HTTP 호출 (FeignClient + X-Service-Token)
    ↓ 로컬 저장 후 반환

이벤트로 동기화하되, 없으면 HTTP로 Lazy하게 보완하는 방식이다.

Redis에서 꺼낼 때 LinkedHashMap으로 역직렬화되는 문제가 있었다.
직접 캐스팅하면 ClassCastException이 발생해서, ObjectMapper.convertValue()로 해결했다.

Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
    return objectMapper.convertValue(cached, UserSnapshot.class);
}

 

3. 트랜잭션 안에 HTTP 호출을 넣으면 안 된다

경매 시작 로직을 처음에 이렇게 짰다.

@Transactional
public void startAuction(Long productId, ...) {
    Long sellerId = productServiceClient.getSellerId(productId); // HTTP 호출
    Auction auction = Auction.of(productId, sellerId, ...);
    auctionRepository.save(auction);
}

@Transactional 안에서 HTTP 호출을 하면 HTTP 응답 기다리는 동안 DB 커넥션을 점유한다.
트래픽이 몰리면 커넥션 풀이 고갈된다.

입찰 로직도 같은 문제가 있었다.
DB 저장과 Kafka 발행이 같은 트랜잭션 안에 있으면, 롤백돼도 Kafka 메시지는 이미 나간 상태가 된다.

AuctionBidService를 분리해서 해결했다.

// AuctionService - 오케스트레이션, 트랜잭션 없음
public void placeBidAndBroadcast(Long auctionId, Long bidderId, Long bidAmount) {
    auctionBidService.placeBid(auctionId, bidderId, bidAmount); // 커밋됨
    sendBroadcast(auctionId, bidderId, bidAmount);              // 커밋 후 발행
}

// AuctionBidService - DB 작업만 트랜잭션
@Transactional
public void placeBid(Long auctionId, Long bidderId, Long bidAmount) { ... }

DB 커밋 이후에 Kafka 발행이 실행된다.
트랜잭션 경계를 명확히 나누는 게 핵심이었다.

4. 내부 서비스 간 API 보안

FeignClient로 내부 API를 호출할 때, 외부에서도 같은 API에 접근 가능하면 문제가 된다.

2레이어로 막았다.

레이어 1: Docker Network 격리
    → /internal/** 엔드포인트는 외부에서 접근 불가

레이어 2: X-Service-Token 헤더 검증
    → 헤더 없으면 401

FeignClient 설정에 헤더를 자동으로 추가하도록 인터셉터를 달았다.

5. 채팅방 타입별 입장 로직 - 전략 패턴

채팅방 타입이 3가지였다.

  • AUCTION: 경매방 (auction-service에서 경매 존재 여부 확인)
  • DIRECT: 상품 문의 1:1 방 (product-service에서 상품 확인)
  • GROUP: 그룹 채팅

타입마다 입장 로직이 달랐다.
if-else로 분기하면 타입이 추가될 때마다 서비스 코드를 수정해야 한다.

전략 패턴으로 분리했다.

public interface ChatRoomManager<T extends ChatRoomEnterRequest> {
    ChatRoomType supportType();
    Class<T> requestType();
    EnterChatResponse enterChatRoom(T request, Long userId);

    default EnterChatResponse enter(ChatRoomEnterRequest request, Long userId) {
        return enterChatRoom(requestType().cast(request), userId);
    }
}

ChatRoomService는 생성자 주입으로 모든 Manager를 자동 등록한다.

public ChatRoomService(List<ChatRoomManager<? extends ChatRoomEnterRequest>> managers, ...) {
    this.managers = managers.stream()
            .collect(Collectors.toMap(ChatRoomManager::supportType, Function.identity()));
}

새 타입이 추가되면 Manager 클래스만 추가하면 된다.
ChatRoomService 수정이 필요 없다.

userId를 Request Body에 포함하면 클라이언트가 임의로 다른 사람 userId를 주입할 수 있다.
JWT 클레임에서 추출한 값을 파라미터로 분리해서 위변조를 막았다.

// 기존 - Request에 userId 포함
EnterChatResponse enterChatRoom(T request)

// 변경 - JWT에서 추출한 userId를 파라미터로 분리
EnterChatResponse enterChatRoom(T request, Long userId)

 

6. LazyInitializationException

채팅방 조회 후 participants에 접근할 때 예외가 발생했다.

findByTypeAndProduct() → ChatRoom 조회
트랜잭션 종료
addOrActivateMember() → participants 접근 → LazyInitializationException

Repository에 JOIN FETCH 쿼리를 추가했다.

@Query("SELECT c FROM ChatRoom c LEFT JOIN FETCH c.participants " +
       "WHERE c.type = :type AND c.product = :productId")
Optional<ChatRoom> findByTypeAndProductWithParticipants(...);

트랜잭션 밖에서 컬렉션 건드리면 터진다는 걸 다시 한번 겪었다.

 

Redis 설계

여러 서비스가 같은 Redis를 공유할 때 database 번호로 분리하는 방법을 처음에 고려했다.

근데 Redis Cluster 환경에서는 database 0만 지원한다.
나중에 Cluster로 전환하면 database 분리 방식은 동작하지 않는다.

키 prefix로 논리적 분리하는 방식을 선택했다.

blacklist:{token}         → 전 서비스 공유
refresh_token:{email}     → user-service
product:user:{userId}     → product-service
auction:user:{userId}     → auction-service
chat:user:{userId}        → chat-service

 

정리

MSA 전환에서 제일 어려운 건 서비스를 쪼개는 게 아니었다. 경계를 어디에 그을 것인가였다.

  • 트랜잭션 경계
  • 이벤트 발행 시점
  • 유저 정보를 어떻게 공유할 것인가
  • 내부 API를 어떻게 보호할 것인가

이 모든 게 맞물려야 의미 있는 구조가 된다는 걸 직접 만들어보면서 경험했다.

코드는 아래의 링크를 통해 확인 할 수 있다.

 

GitHub - mangtaeeee/bowchat-auction

Contribute to mangtaeeee/bowchat-auction development by creating an account on GitHub.

github.com