Spring 7기 프로젝트/아웃소싱 팀 프로젝트

[트러블슈팅] 대시보드 성능 최적화: GROUP BY 지옥에서 이벤트 기반 통계로

JuNo_12 2025. 6. 15. 02:52

안녕하세요! TaskFlow 프로젝트를 진행하면서 겪은 대시보드 성능 최적화 경험을 공유하려고 합니다.

처음에는 단순하게 "그냥 쿼리로 통계 내면 되지!"라고 생각했는데, 실제로 구현해보니 성능과 아키텍처 측면에서 많은 문제가 있었어요. 이를 이벤트 기반 아키텍처로 해결할 수 있는 방안에 대해 설명해보겠습니다.


문제 상황: "매번 100만 개 데이터를 스캔하는 대시보드"

TaskFlow 프로젝트에서 대시보드에 태스크 상태별 통계를 보여줘야 했습니다.

초기 구현 방식

가장 직관적인 방법으로 시작했어요:

@Service
public class DashboardService {
    
    private final TaskRepository taskRepository;
    
    public TaskStatusRatioDto getStatusRatio() {
        // 매번 이 쿼리가 실행됨... 
        List<Object[]> result = taskRepository.countGroupByStatus();
        
        long todo = 0, inProgress = 0, done = 0;
        for (Object[] row : result) {
            TaskStatus status = (TaskStatus) row[0];
            Long count = (Long) row[1];
            switch (status) {
                case TODO -> todo = count;
                case IN_PROGRESS -> inProgress = count;
                case DONE -> done = count;
            }
        }
        return TaskStatusRatioDto.of(todo, inProgress, done);
    }
}

// Repository의 쿼리
@Query("SELECT t.status, COUNT(t) FROM Task t WHERE t.isDeleted = false GROUP BY t.status")
List<Object[]> countGroupByStatus();

 

예상되는 문제점들

: 데이터 양에 따라 쿼리 시간이 너무나도 길어진다는 것이였습니다.

 

더 큰 문제는 성능뿐만 아니었습니다...


더 깊은 문제: 강한 결합도

성능 문제를 해결하려고 고민하다 보니, 아키텍처적인 문제도 발견했어요.

TaskService가 너무 많은 것을 알고 있다면?

@Service
public class TaskService {
    private final TaskRepository taskRepository;
    private final DashboardService dashboardService;  //  직접 의존
    private final NotificationService notificationService;  //  직접 의존
    private final EmailService emailService;  //  직접 의존
    
    @Transactional
    public Task createTask(CreateTaskDto dto) {
        Task task = taskRepository.save(task);
        
        // 모든 서비스를 직접 호출... 
        dashboardService.updateTaskCount();
        notificationService.sendTaskCreatedNotification(task);
        emailService.sendTaskAssignedEmail(task.getAssignee());
        
        return task;
    }
}

이 방식의 문제점들

  1. 단일 책임 원칙 위반: TaskService가 너무 많은 걸 알아야 함
  2. 높은 결합도: 새로운 기능 추가 시마다 TaskService 수정 필요
  3. 트랜잭션 문제: 통계 업데이트 실패 시 태스크 생성도 실패
  4. 테스트 복잡성: 모든 의존성을 Mock해야 함

해결책: 이벤트 기반 아키텍처 도입

"TaskService는 Task만 신경쓰게 하자!"라는 생각으로 이벤트 기반에 대해 생각해보았습니다.

1단계: 이벤트 클래스 정의

@Getter
public class TaskCreatedEvent {
    private final Task task;
    private final LocalDateTime occurredAt;
    
    public TaskCreatedEvent(Task task) {
        this.task = task;
        this.occurredAt = LocalDateTime.now();
    }
}

@Getter
public class TaskStatusChangedEvent {
    private final Task task;
    private final String previousStatus;
    private final String currentStatus;
    private final LocalDateTime occurredAt;
    
    public TaskStatusChangedEvent(Task task, String previousStatus, String currentStatus) {
        this.task = task;
        this.previousStatus = previousStatus;
        this.currentStatus = currentStatus;
        this.occurredAt = LocalDateTime.now();
    }
}

 

2단계: TaskService 단순화

@Service
@RequiredArgsConstructor
public class TaskService {
    
    private final TaskRepository taskRepository;
    private final ApplicationEventPublisher eventPublisher;  // 스프링 제공!
    
    @Transactional
    public Task createTask(CreateTaskDto dto) {
        // 1. 핵심 비즈니스 로직만!
        Task task = new Task(dto.getTitle(), dto.getDescription(), ...);
        Task savedTask = taskRepository.save(task);
        
        // 2. "야! 태스크 생성됐어!" 라고 외치기만 함
        eventPublisher.publishEvent(new TaskCreatedEvent(savedTask));
        
        return savedTask;  // 누가 듣든 말든 상관없이 성공!
    }
}

 

3단계: 실시간 통계 테이블 설계

성능 문제 해결을 위해 실시간 카운터 테이블을 만들었어요:

@Entity
@Table(name = "task_statistics")
public class TaskStatistics {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "stat_key", unique = true)
    private String statKey;  // "TODO_COUNT", "IN_PROGRESS_COUNT", "DONE_COUNT"
    
    @Column(name = "stat_value")
    private Long statValue;
    
    @LastModifiedDate
    private LocalDateTime updatedAt;
    
    public void increment() {
        this.statValue++;
    }
    
    public void decrement() {
        if (this.statValue > 0) {
            this.statValue--;
        }
    }
}

 

4단계: 이벤트 리스너로 실시간 통계 관리

@Component
@RequiredArgsConstructor
public class DashboardEventListener {
    
    private final TaskStatisticsRepository statisticsRepository;
    
    @EventListener
    @Async  // 비동기 처리로 TaskService와 독립!
    @Transactional
    public void handleTaskCreated(TaskCreatedEvent event) {
        log.info("새 태스크 생성됨: {}", event.getTask().getTitle());
        
        // 실시간으로 카운터 증가
        incrementCounter("TODO_COUNT");
        incrementCounter("TOTAL_TASK_COUNT");
    }
    
    @EventListener
    @Async
    @Transactional
    public void handleTaskStatusChanged(TaskStatusChangedEvent event) {
        // 이전 상태 카운트 감소
        decrementCounter(event.getPreviousStatus() + "_COUNT");
        
        // 새 상태 카운트 증가
        incrementCounter(event.getCurrentStatus() + "_COUNT");
    }
    
    private void incrementCounter(String statKey) {
        // 1. 기존 레코드가 있으면 증가
        int updated = statisticsRepository.incrementByStatKey(statKey);
        
        // 2. 없으면 새로 생성
        if (updated == 0) {
            statisticsRepository.save(new TaskStatistics(statKey, 1L));
        }
    }
}

 

5단계: 빠른 대시보드 조회

@Service
public class DashboardService {
    
    private final TaskStatisticsRepository statisticsRepository;
    
    public TaskStatusRatioDto getStatusRatio() {
        // 복잡한 GROUP BY 대신 단순 조회!
        long todo = getStatValue("TODO_COUNT");
        long inProgress = getStatValue("IN_PROGRESS_COUNT");
        long done = getStatValue("DONE_COUNT");
        
        return TaskStatusRatioDto.of(todo, inProgress, done);
    }
    
    private long getStatValue(String statKey) {
        return statisticsRepository.findByStatKey(statKey)
                .map(TaskStatistics::getStatValue)
                .orElse(0L);
    }
}

성능 비교: Before vs After

쿼리 복잡도 비교

Before (기존 방식):

-- 매번 이 무거운 쿼리 실행
SELECT t.status, COUNT(t) 
FROM tasks t 
WHERE t.is_deleted = false 
GROUP BY t.status;

-- 1,000,000개 데이터 스캔 필요

 

After (이벤트 방식):

-- 가벼운 인덱스 조회만
SELECT stat_value FROM task_statistics WHERE stat_key = 'TODO_COUNT';
SELECT stat_value FROM task_statistics WHERE stat_key = 'IN_PROGRESS_COUNT';
SELECT stat_value FROM task_statistics WHERE stat_key = 'DONE_COUNT';

-- 총 3개 행만 조회

 

데이터가 많아질수록 성능 차이가 극명해집니다!


예상외의 깨달음: "용량 걱정은 없었다"

처음에는 "통계 테이블 많이 만들면 용량 문제 있지 않을까?" 걱정했는데, 계산해보니 완전 기우였어요.

 

읽기 전용 테이블의 장점들

  1. 캐싱 최적화: 자주 변하지 않으므로 캐시 효과 극대화
  2. 인덱스 최적화: 읽기 전용이라 인덱스 설계가 단순함
  3. 백업 부담 없음: 크기가 작아서 백업/복구 시간 거의 0
  4. 메모리 올리기: 전체 통계를 메모리에 올려도 부담 없음

아키텍처 개선 효과

결합도 완전 분리

// TaskService는 이제 정말 단순해짐
@Service
public class TaskService {
    private final TaskRepository taskRepository;
    private final ApplicationEventPublisher eventPublisher;  // 이것만!
    
    public Task createTask(CreateTaskDto dto) {
        Task task = taskRepository.save(task);
        eventPublisher.publishEvent(new TaskCreatedEvent(task));
        return task;  // 끝!
    }
}

// 각 기능은 독립적인 리스너로
@Component public class DashboardEventListener { ... }
@Component public class NotificationEventListener { ... }  
@Component public class EmailEventListener { ... }
@Component public class SlackEventListener { ... }  // 새 기능도 쉽게 추가!

 

새로운 기능 추가가 쉬워짐

 

Before: TaskService 수정 필요 

// 새 기능마다 TaskService에 추가...
dashboardService.updateStats(task);
notificationService.send(task);
emailService.send(task);
slackService.send(task);  // 새로 추가
auditService.log(task);   // 새로 추가

 

 

After: 리스너만 추가 

// TaskService는 전혀 수정하지 않음!

@Component
public class SlackEventListener {
    @EventListener @Async
    public void handleTaskCreated(TaskCreatedEvent event) {
        slackService.sendMessage("새 태스크: " + event.getTask().getTitle());
    }
}

트레이드오프와 주의사항

데이터 정합성 관리

이벤트 기반의 약점은 데이터 정합성 문제 가능성입니다.

// 해결책: 정합성 검증 및 복구 메서드
@Transactional
public void recalculateStatistics() {
    log.info("통계 데이터 재계산 시작");
    
    // 실제 DB에서 정확한 값 조회
    List<Object[]> actualCounts = taskRepository.countGroupByStatus();
    
    // 카운터 테이블 보정
    for (Object[] row : actualCounts) {
        TaskStatus status = (TaskStatus) row[0];
        Long count = (Long) row[1];
        
        String statKey = status.name() + "_COUNT";
        TaskStatistics stat = new TaskStatistics(statKey, count);
        statisticsRepository.save(stat);
    }
    
    log.info("통계 데이터 재계산 완료");
}

 

복잡성 증가

  • 이벤트 클래스 관리
  • 리스너 클래스 관리
  • 비동기 처리 디버깅 어려움

하지만 이런 복잡성은 성능과 확장성 이득이 훨씬 큽니다.


결론: 언제 이벤트 방식을 사용할까?

이벤트 방식을 추천하는 경우

  • 성능이 중요한 대시보드/통계 기능
  • 여러 도메인이 하나의 이벤트에 관심을 가질 때
  • 비즈니스 로직과 부가 기능을 분리하고 싶을 때
  • 확장성이 중요한 서비스

 

기존 방식이 나은 경우

  • 데이터 양이 적고 성능이 문제없을 때
  • 단순한 프로젝트
  • 실시간성이 중요하지 않을 때
  • 팀 규모가 작을 때

마무리

처음에는 단순히 "GROUP BY 쿼리가 느려서" 시작한 최적화였는데, 결과적으로 전체 아키텍처가 깔끔해지는 경험을 했습니다.

특히 "용량은 걱정 없다"는 깨달음이 가장 인상적이었습니다. 

 

이벤트 기반 아키텍처는 처음에는 복잡해 보이지만, 성능과 확장성 측면에서 엄청난 이득을 가져다 줍니다. 특히 대시보드나 통계 기능에서는 거의 필수라고 생각해요.

 

TaskFlow 프로젝트를 통해 실무에서 자주 사용하는 패턴을 직접 경험할 수 있어서 정말 기뻤고, 이벤트를 이해한 그 순간에 온 몸에 소름이 돋았습니다...


읽어주셔서 감사합니다! 궁금한 점이나 다른 접근 방법이 있으시면 댓글로 공유해주세요!