BindProject

유저 닉네임 캐시전략 및 캐시데이터 무결성

dding-shark 2025. 8. 2. 16:56
728x90

 


[MSA 실전 아키텍처] 캐시, N+1 문제를 넘어 Kafka로 데이터 무결성까지

MSA(마이크로서비스 아키텍처)에서 성능과 데이터 일관성은 종종 트레이드오프 관계에 놓입니다. BFF(Backend For Frontend)가 여러 마이크로서비스의 데이터를 조합할 때, 단순한 API 호출은 N+1 문제로 성능을 저하하고, 캐시(Cache)는 데이터 불일치라는 또 다른 숙제를 남깁니다.

이 글에서는 '닉네임 조회'라는 간단한 기능에서 출발해, 배치(Batch) 처리로 N+1을 해결하고, 더 나아가 Kafka를 이용한 이벤트 기반 동기화로 캐시의 데이터 무결성까지 확보해 나간 저희 팀의 아키텍처 진화 과정을 공유합니다.

 

 

 

 

1. 시작점: N+1 문제와 배치(Batch) 처리의 도입

게시글 목록처럼 한 화면에 여러 작성자의 닉네임이 필요한 경우, 각 닉네임을 개별 API로 조회하는 것은 전형적인 N+1 문제입니다. 저희는 이 문제를 해결하기 위해, 캐시에서 찾지 못한(Cache Miss) ID들만 모아 단 한 번의 API 호출로 모두 가져오는 배치 처리 방식을 도입했습니다.

[BFF] UserNicknameService - 캐시와 배치를 결합한 조회 로직

public Map<String, String> getNicknamesByUserIds(List<String> userIds) {
    Map<String, String> result = new HashMap<>();
    List<String> cacheMissedUserIds = new ArrayList<>();

    // 1. 캐시에서 먼저 조회를 시도
    userIds.forEach(userId -> {
        String nickname = redisService.getData(createCacheKey(userId));
        if (nickname != null) {
            result.put(userId, nickname); // Cache Hit
        } else {
            cacheMissedUserIds.add(userId); // Cache Miss
        }
    });

    // 2. 캐시에 없던 ID가 있을 경우에만 '배치 API' 호출
    if (!cacheMissedUserIds.isEmpty()) {
        Map<String, String> fetchedNicknames = userProfileClient.fetchNicknames(cacheMissedUserIds);
        fetchedNicknames.forEach((userId, nickname) -> {
            result.put(userId, nickname);
            redisService.setData(createCacheKey(userId), nickname, CACHE_TTL); // 캐시에 저장
        });
    }
    return result;
}

이 방식으로 BFF는 user-profile 서비스에 대한 네트워크 호출을 극적으로 줄여 성능을 크게 향상시켰습니다. 하지만 진짜 문제는 다른 곳에 숨어 있었습니다.

 

 

 

 

 

2. 새로운 도전: 캐시의 데이터 무결성(Cache Coherency)

"만약 사용자가 닉네임을 변경하면 어떻게 될까?"

  1. 사용자가 user-profile 서비스에 닉네임 변경을 요청합니다.
  2. user-profile 서비스는 자신의 데이터베이스(Source of Truth)를 성공적으로 업데이트합니다.
  3. 하지만 BFF의 Redis 캐시에는 여전히 예전 닉네임이 남아있습니다.

캐시의 TTL(Time To Live)이 만료될 때까지, 사용자는 예전 닉네임이 보이는 현상을 겪게 됩니다. 이는 단순한 불편함을 넘어, 데이터 정합성이 중요한 서비스에서는 심각한 결함이 될 수 있습니다.

 

 

 

 

 

 

3. 아키텍처의 갈림길: 캐싱 전략 분석

이 문제를 해결하기 위해 우리는 두 가지 캐싱 아키텍처를 비교 분석했습니다.

패턴 1: 공유 데이터베이스/캐시 (Shared Database/Cache)

모든 마이크로서비스가 하나의 거대한 중앙 캐시(또는 DB)를 공유하는 방식입니다.

  • 장점:
    • 강력한 데이터 일관성: 모든 서비스가 동일한 데이터를 보기 때문에 불일치 문제가 원천적으로 발생하지 않습니다.
    • 구현의 단순함: 초기 구현이 직관적입니다.
  • 단점:
    • 강한 결합도(Tight Coupling): 중앙 캐시의 스키마나 상태가 변경되면, 이를 사용하는 모든 서비스가 영향을 받습니다. 이는 서비스의 자율성을 해치는 MSA의 안티패턴입니다.
    • 단일 장애점(SPOF): 중앙 캐시에 장애가 발생하면 전체 시스템이 마비됩니다.
    • 성능 병목: 모든 트래픽이 중앙 캐시로 몰리면서 성능 저하를 유발할 수 있습니다.

패턴 2: 분산 캐시 (Cache-per-Service)

각 마이크로서비스가 자신의 데이터에 대한 캐시를 독립적으로 소유하고 관리하는 방식입니다.

  • 장점:
    • 느슨한 결합도(Loose Coupling): 각 서비스는 자신의 캐시만 신경 쓰면 되므로, 다른 서비스의 변경 사항에 영향을 받지 않습니다. MSA의 철학에 부합합니다.
    • 높은 확장성과 장애 격리: 특정 서비스의 캐시에 문제가 생겨도 다른 서비스로 장애가 전파되지 않습니다.
  • 단점:
    • 데이터 무결성 문제: 이 글에서 우리가 직면한 바로 그 문제입니다. 서비스 A의 원본 데이터가 변경되었을 때, 서비스 B의 캐시를 어떻게 동기화할 것인가?

우리의 선택은 명확했습니다. MSA의 핵심 가치인 '서비스 자율성'과 '느슨한 결합'을 지키기 위해 분산 캐시 패턴을 선택했습니다. 이제 남은 과제는 이 패턴의 유일한 단점인 '데이터 무결성' 문제를 해결하는 것입니다.

 

 

 

 

 

 

4. 최종 해결책: Kafka를 통한 이벤트 기반 캐시 동기화

우리는 이 문제를 해결하기 위해 Kafka를 도입했습니다. 핵심 아이디어는 이렇습니다. "원본 데이터가 변경되면, 변경되었다는 사실을 '이벤트'로 발행(Publish)하여 모든 관심 있는 서비스에게 알리자."

동작 흐름:

  1. [user-profile] 이벤트 발행: user-profile 서비스는 닉네임 변경이 성공적으로 DB에 반영된 후, user-profile-updated 와 같은 이벤트를 Kafka 토픽으로 발행합니다. 이 이벤트 메시지에는 userId새로운 닉네임이 포함됩니다.
  2. [BFF] 이벤트 구독 및 처리: BFF 서비스는 해당 Kafka 토픽을 구독(Subscribe)하고 있다가, 이벤트가 도착하면 즉시 자신의 Redis 캐시를 업데이트합니다.

[user-profile] UserProfileCommandService - 데이터 변경 후 이벤트 발행

@Service
@RequiredArgsConstructor
public class UserProfileCommandService {
    private final UserProfileRepository userProfileRepository;
    private final UserProfileKafkaProducer userProfileKafkaProducer;

    @Transactional
    public void updateProfile(Long userId, UserProfileUpdateRequest request) {
        UserProfile userProfile = userProfileRepository.findByUserId(userId)
                .orElseThrow(() -> new EntityNotFoundException("User not found"));

        // 프로필 정보 업데이트
        userProfile.update(request.getNickname(), /* ... */);

        // 트랜잭션이 성공적으로 커밋된 후에 이벤트를 발행해야 함
        // @TransactionalEventListener 등을 활용하면 더 견고하게 구현 가능
        userProfileKafkaProducer.sendProfileUpdateEvent(
            String.valueOf(userProfile.getUserId()),
            userProfile.getNickname()
        );
    }
}

[BFF] UserProfileEventConsumer - 이벤트 수신 후 캐시 업데이트

@Service
@RequiredArgsConstructor
public class UserProfileEventConsumer {
    private final RedisService redisService;
    private static final String NICKNAME_CACHE_KEY_PREFIX = "nickname::";
    private static final Duration NICKNAME_CACHE_TTL = Duration.ofDays(1);

    @KafkaListener(topics = "user-profile-updated", groupId = "bff-group")
    public void handleProfileUpdate(UserProfileUpdateEvent event) {
        // 이벤트 메시지로부터 userId와 새로운 닉네임을 받는다.
        String userId = event.getUserId();
        String newNickname = event.getNickname();

        // BFF의 Redis 캐시를 즉시 업데이트(또는 삭제)한다.
        redisService.setData(
            createCacheKey(userId),
            newNickname,
            NICKNAME_CACHE_TTL
        );
    }

    private String createCacheKey(String userId) {
        return NICKNAME_CACHE_KEY_PREFIX + userId;
    }
}

이 아키텍처를 통해 우리는 실시간에 가까운 데이터 동기화를 달성했습니다. 이제 사용자가 닉네임을 변경하면, Kafka를 통해 거의 즉시 모든 관련 서비스의 캐시가 업데이트됩니다.

 

 

 

 

 

 

5. 결론: 진화하는 아키텍처

단순한 N+1 문제 해결에서 시작된 여정은 우리를 캐시 무결성이라는 더 깊은 문제로 이끌었고, 최종적으로 이벤트 기반 아키텍처라는 해답을 찾게 했습니다.

이 과정을 통해 저희 시스템은 다음을 얻었습니다.

  • 고성능: 배치 처리와 캐시로 빠른 응답 속도를 유지합니다.
  • 높은 확장성: 서비스는 독립적으로 확장될 수 있으며, Kafka를 통해 느슨하게 연결됩니다.
  • 강력한 데이터 무결성: 이벤트 기반 동기화로 서비스 간 데이터 불일치를 최소화했습니다.

좋은 아키텍처는 정답이 정해져 있는 것이 아니라, 비즈니스의 요구사항과 기술적 깊이를 더해가며 끊임없이 진화하는 과정임을 다시 한번 느낍니다. 이 글이 MSA 환경에서 성능과 데이터 일관성을 모두 잡기 위해 고민하는 다른 개발자분들께 도움이 되기를 바랍니다.

728x90