BindProject

[Backend] 유저 활동 로그 수집 모듈 개발기 2편 – 메모리 버퍼 기반 로그 수집기 설계와 구현

dding-shark 2025. 6. 21. 15:51
728x90

1. 도입 – 로그를 Kafka로 바로 보내면 안 되는 이유

1편에서 우리는 왜 유저 활동 로그를 수집해야 하는지, 그리고 Kafka를 도입하기로 결정한 이유를 설명했다.
하지만 Kafka를 도입한다고 해서 모든 게 해결되진 않았다.

유저 요청이 올 때마다 Kafka로 바로 전송한다면?

  • 요청 응답 속도에 영향을 줄 수 있다.
  • Kafka에 대한 의존성이 높아지며, 장애에 취약해진다.
  • 로그가 수백 TPS로 들어오는 경우, Kafka 자체도 부하를 받는다.

그래서 우리는 메모리 버퍼 기반 로그 수집기를 직접 만들기로 했다.


2. 설계 목표 – 단순히 "버퍼에 쌓기"가 아니다

우리가 만든 수집기는 단순한 List 저장소가 아니다.
아래 세 가지 요구사항을 만족해야 했다.

1. 로그 유실 없이, 빠르게 저장

→ 버퍼는 비동기로 데이터를 받되, Thread-safe 해야 한다.

2. 기준 도달 시 Kafka로 flush

→ 버퍼 크기 또는 주기 기준으로 flush 트리거가 작동해야 한다.

3. flush 중에도 로그를 받는 구조

→ flush 중인 버퍼와 새로 수집되는 버퍼는 분리돼야 한다.


3. 구조 설계 – DoubleBuffer 구조를 사용하자

단일 버퍼로는 flush 중에 write가 막힐 수 있다.
우리는 Double Buffer 구조를 도입했다.

구조 다이어그램

User Request
    │
    ▼
 LogBufferManager (write)
    ├── activeBuffer ← 계속 write
    └── flushBuffer  ← 일정 조건에서 Kafka 전송

흐름 요약

  1. 로그는 activeBuffer에 쌓인다.
  2. 크기 혹은 시간 기준에 도달하면, 버퍼를 swap한다.
  3. 이전 activeBuffer였던 flushBuffer는 별도 스레드에서 Kafka로 전송된다.
  4. 새로운 activeBuffer는 계속 로그를 받는다.

4. flush 조건 – 언제 버퍼를 Kafka로 보내는가?

우리는 두 가지 조건 중 하나라도 만족하면 flush를 실행하도록 설계했다.

  • 최대 로그 개수 예: 100건 도달
  • 최대 수집 시간 예: 3초 주기
if (buffer.size() >= MAX_SIZE || lastFlushTime + 3초 < now) {
    flush();
}

이 조건은 단순하지만 유실 방지와 응답 지연 최소화에 효과적이었다.


5. 임시 구현 코드 예시

로그 DTO

public class UserActivityLog {
    private String userId;
    private String action;
    private LocalDateTime timestamp;
    // ... 기타 정보
}

LogBufferManager

@Component
public class LogBufferManager {

    private final List<UserActivityLog> activeBuffer = new CopyOnWriteArrayList<>();
    private final ExecutorService flushExecutor = Executors.newSingleThreadExecutor();

    public synchronized void save(UserActivityLog log) {
        activeBuffer.add(log);

        if (activeBuffer.size() >= MAX_BUFFER_SIZE) {
            flush();
        }
    }

    @Scheduled(fixedRate = 3000)
    public synchronized void periodicFlush() {
        flush();
    }

    private void flush() {
        if (activeBuffer.isEmpty()) return;

        List<UserActivityLog> toFlush = new ArrayList<>(activeBuffer);
        activeBuffer.clear();

        flushExecutor.submit(() -> kafkaProducer.send(toFlush));
    }
}

Kafka Producer Stub

@Component
public class KafkaActivityProducer {
    public void send(List<UserActivityLog> logs) {
        for (UserActivityLog log : logs) {
            kafkaTemplate.send("logsave", serialize(log));
        }
    }
}

6. 테스트 전략 – 동시성과 유실 여부 검증

  • 10,000건 이상의 로그를 10개의 스레드로 동시에 save() 호출 → 유실 없음 검증
  • flush 중에도 save가 차단되지 않음 → 블로킹 없는 구조 확인
  • Kafka stub로 전달된 데이터 일관성 검증

실제 테스트는 3편에서 상세히 다룬다.


7. 회고 및 확장 고려사항

잘된 점

  • flush 중에도 로그를 받을 수 있는 구조로 설계
  • Kafka 장애가 발생하더라도 로그 손실 최소화
  • 확장성 고려한 구조

고민할 점

  • JVM이 죽으면 메모리에 쌓인 로그는 유실됨 → Outbox 패턴 도입 고려
  • flush 실패 시 재시도 정책 필요
  • Buffer 크기/주기 조절은 추후 실시간 모니터링 기반으로 튜닝할 예정

다음 편 예고

3편에서는 이 구조를 실제 Kafka와 연결하고,
단위 테스트, 통합 테스트, 성능 테스트까지 포함한 전체 흐름 검증을 다룬다.


728x90