배경
1월 7일에 최종 테스트를 하고, 1월 8일에 정식으로 Modulo를 배포하기로 하였다. 1월 5일인 현재 모든 API 연동이 완료되었고, 실배포하기 전에 MAU를 측정을 구현하기로 했다.
요구사항 분석
팀에서 마주한 과제는 다음과 같았다:
- 일별 활성 사용자(DAU)를 측정하고, 이를 월 단위로 집계하여 MAU 산출
- AOP를 활용한 사용자 활동 자동 측정
- 실시간성 필요 없음. 익일에 확인 가능하면 됨
- 데이터 정확성 보장
- 시스템 확장성 확보
현재 사용자 인증은 Spring Security를 사용하고 있었으며, SecurityContext의 ThreadLocal에서 사용자 ID를 가져오고 있었다:
public class SecurityUtil {
private static Long getCurrentMemberId() {
return Long.parseLong(SecurityContextHolder.getContext()
.getAuthentication().getName());
}
}
첫 번째 접근: WAS 메모리 저장 방식
가장 먼저 생각했던 방식은 WAS 메모리에 데이터를 저장하는 것이었다. AOP를 활용하여 컨트롤러 메소드 실행 시 자동으로 사용자 활동을 기록하도록 구현하였다:
@Aspect
@Component
public class UserActivityAspect {
private final ConcurrentHashMap<String, Set<Long>> dailyActiveUsers = new ConcurrentHashMap<>();
@Around("@annotation(TrackUserActivity)")
public Object trackActivity(ProceedingJoinPoint joinPoint) throws Throwable {
String today = LocalDate.now().toString();
Long memberId = SecurityUtil.getCurrentMemberId();
dailyActiveUsers.computeIfAbsent(today, k -> ConcurrentHashMap.newKeySet())
.add(memberId);
return joinPoint.proceed();
}
}
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TrackUserActivity {}
하지만 이 방식은 실제 운영 환경에서 여러 문제를 일으킬 수 있었다:
분산 환경에서의 데이터 불일치
- 로드 밸런서를 통해 여러 WAS 인스턴스로 요청이 분산되었다
- 각 WAS가 독립적인 메모리 공간을 가졌다
- 결과적으로 각 서버마다 다른 DAU/MAU 값을 가지게 되었다
예를 들어:
- WAS 1: 사용자 A, B 접속 → DAU = 2
- WAS 2: 사용자 C, D, E 접속 → DAU = 3
- 실제 전체 DAU는 5명이지만 어느 서버에서도 정확한 값을 얻을 수 없었다
이 문제를 해결하기 위해 각 WAS의 메모리에 있는 데이터를 주기적으로 Redis에 Write Back하는 방법도 고려해볼 수 있었다. 하지만 이는 결국 WAS 메모리 -> Redis -> Database라는 불필요한 데이터 이동 단계가 추가되는 것이었다. 차라리 처음부터 Redis를 활용하여 WAS -> Redis -> Database로 단순화하는 것이 더 효율적일 것으로 판단하였다.
두 번째 시도: 비관적 락(Pessimistic Lock)
WAS 메모리 방식의 한계를 극복하기 위해, 데이터베이스의 비관적 락을 활용한 방식을 생각하였다:
@Entity
@Table(name = "daily_activity")
public class DailyActivity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private LocalDate date;
@ElementCollection
@CollectionTable(
name = "daily_active_users",
joinColumns = @JoinColumn(name = "daily_activity_id")
)
private Set<Long> activeUserIds = new HashSet<>();
public void addUserId(Long userId) {
activeUserIds.add(userId);
}
public int getActiveUserCount() {
return activeUserIds.size();
}
}
@Service
@RequiredArgsConstructor
public class UserActivityService {
private final DailyActivityRepository dailyActivityRepository;
@Transactional
public void recordUserActivity() {
LocalDate today = LocalDate.now();
Long currentUserId = SecurityUtil.getCurrentMemberId();
DailyActivity activity = dailyActivityRepository
.findByDateForUpdate(today) // SELECT FOR UPDATE
.orElse(new DailyActivity(today));
activity.addUserId(currentUserId);
dailyActivityRepository.save(activity);
}
}
이렇게 수정하여 사용자 ID를 Set으로 관리함으로써 중복 집계를 방지하였다. 하지만 이 방식 역시 몇 가지 문제가 있었다:
장점
- 구현이 직관적이고 단순했다
- 데이터 정합성이 보장되었다
문제점
- 동시 접속자가 늘어날수록 성능이 저하된다
- activeUserIds Set이 계속 커지면 메모리 부하가 증가한다.
- 확장성이 제한적이다.
세 번째 시도: 낙관적 락(Optimistic Lock)
비관적 락의 성능 문제를 해결하기 위해 낙관적 락을 시도하였다:
@Entity
@Table(name = "daily_activity")
public class DailyActivity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true)
private LocalDate date;
private Long activeUserCount;
@Version
private Long version;
public void incrementUserCount() {
this.activeUserCount++;
}
}
@Service
@RequiredArgsConstructor
public class UserActivityService {
private final DailyActivityRepository dailyActivityRepository;
@Transactional(retryable = true)
public void recordUserActivity() {
LocalDate today = LocalDate.now();
DailyActivity activity = dailyActivityRepository
.findByDate(today)
.orElse(new DailyActivity(today));
activity.incrementUserCount();
try {
dailyActivityRepository.save(activity);
} catch (OptimisticLockException e) {
log.warn("Optimistic lock detected, retrying...");
throw e;
}
}
}
개선된 점
- 동시성 처리 성능이 향상된다.
- 데드락 위험이 감소한다
여전한 문제점
- 높은 동시성 상황에서 재시도로 인한 오버헤드가 발생한다.
이러한 문제들을 해결하기 위해 최종적으로 Redis Write Back 방식을 채택하게 되었다.
Redis Write Back 방식 채택
많은 고민 끝에 Redis를 활용한 Write Back 방식을 채택하였다. 이는 다음과 같은 구조로 구현되었다:
@Aspect
@Component
@RequiredArgsConstructor
public class UserActivityAspect {
private final RedisTemplate<String, String> redisTemplate;
private static final String DAU_KEY_PREFIX = "dau:";
@Around("@annotation(TrackUserActivity)")
public Object trackActivity(ProceedingJoinPoint joinPoint) throws Throwable {
try {
recordUserActivity();
return joinPoint.proceed();
} catch (Exception e) {
log.error("Failed to track user activity", e);
return joinPoint.proceed();
}
}
private void recordUserActivity() {
String key = DAU_KEY_PREFIX + LocalDate.now().toString();
Long memberId = SecurityUtil.getCurrentMemberId();
redisTemplate.opsForSet().add(key, memberId.toString());
}
}
@Service
@RequiredArgsConstructor
public class UserActivityService {
private final RedisTemplate<String, String> redisTemplate;
private final UserActivityRepository userActivityRepository;
@Async
@Scheduled(cron = "0 0 0 * * *")
public void writeBackToDatabase() {
List<String> keys = getAllActiveDayKeys();
List<CompletableFuture<Void>> futures = keys.stream()
.map(key -> CompletableFuture.runAsync(() -> processWriteBack(key)))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
private void processWriteBack(String key) {
Set<String> activeUsers = redisTemplate.opsForSet().members(key);
DailyActivity activity = DailyActivity.builder()
.date(extractDate(key))
.activeUserCount(activeUsers.size())
.lastUpdated(LocalDateTime.now())
.build();
dailyActivityRepository.save(activity);
}
public Long getMAU(YearMonth yearMonth) {
return dailyActivityRepository.findByDateBetween(
yearMonth.atDay(1),
yearMonth.atEndOfMonth()
).stream()
.mapToLong(DailyActivity::getActiveUserCount)
.sum();
}
}
결과
이러한 설계를 통해 다음과 같은 이점을 얻을 수 있었다:
- AOP를 통한 투명한 사용자 활동 추적
- 분산 환경에서의 데이터 정합성 보장
- Redis를 통한 고성능 처리
- 비동기 처리를 통한 배치 작업 최적화
- 안정적인 DAU/MAU 집계
남은 과제: Redis 고가용성 확보
현재 단일 Redis 서버를 사용하고 있어 고가용성 측면에서 우려가 있었다. Redis 장애 시 데이터 유실을 최소화하기 위한 방안으로 Snapshot과 AOF 방식을 검토하였다.
Snapshot 방식은 특정 시점의 데이터를 파일로 저장하는 방식이었다. 하지만 Snapshot을 생성한 시점 이후에 Redis 서버가 다운되면, 그 사이의 데이터가 유실될 수 있다는 단점이 있었다.
결국 메모리를 더 사용하더라도 모든 Write 작업을 로그로 기록하는 AOF 방식을 채택하였다. 이는 서버 장애 시에도 마지막 작업까지 복구할 수 있다는 장점이 있었다.
향후에는 Redis Cluster 구성을 통해 고가용성을 더욱 강화할 계획이다.
'[프로젝트] Modulo' 카테고리의 다른 글
Redis 캐시 도입을 통한 이력서 관리 시스템 성능 최적화: 응답 시간 87% 감소, 처리량 413% 향상 - Redis Template API vs Lua Script (0) | 2025.01.10 |
---|---|
운영환경, 개발환경 분리하기 (0) | 2025.01.07 |
[MONITOR] Prometheus+Grafana로 모니터링 환경 구축 (2) | 2024.12.18 |
[TEST] Domain 계층의 테스트를 작성하며 (2) | 2024.12.17 |
[TEST] 테스트 커버리지 측정( 100% 달성 목표) (1) | 2024.12.15 |