Nick Dev

[SPURT] SPURT를 개발하면서 최적화하려고 했던 부분들 본문

SPURT

[SPURT] SPURT를 개발하면서 최적화하려고 했던 부분들

Nick99 2025. 4. 18. 16:12
반응형
  1. 이벤트 처리를 통해 관심사 분리
  2. 1분단위 스케줄링을 피하기 위한 동적 스케줄러 도입
  3. Elastic APM을 활용해 미리 병목 가능 지점 파악

1. 이벤트 기반 아키텍처 사용의 이점

1-1. 컴포넌트 간의 결합도 감소

// TaskService.java에서 - 이벤트 발행자는 이벤트 핸들러에 대해 알 필요가 없음
eventPublisher.publishEvent(DeleteTaskNotificationEvent(memberId, taskId));

TaskService는 어떤 컴포넌트가 이벤트를 처리할지, 어떻게 처리될지 알 필요 없이 이벤트를 발행합니다.

이는 시스템의 다른 부분 간에 느슨한 결합을 생성하여 코드를 더 모듈화하고 유지보수하기 쉽게 만듭니다.

1-2. 관심사의 분리

// PushNotificationListener는 알림 처리에만 집중
@Component
class PushNotificationListener(
    private val pushNotificationService: PushNotificationService,
    private val memberService: MemberService,
    private val taskService: TaskService,
) : Logger {

    @Async
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    fun saveNotification(event: TriggerActionNotificationSaveEvent) {
        // 알림 처리 로직
    }
    // ...
}

이벤트 리스너는 특정 책임에만 전적으로 집중할 수 있습니다.

PushNotificationListener의 유일한 역할은 알림 관련 이벤트를 처리하는 것이며, 이는 코드를 깔끔하고 집중적으로 유지합니다.

1-3. 트랜잭션 무결성

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)

Spring의 이벤트 시스템은 트랜잭션과 잘 통합됩니다.

이벤트는 트랜잭션이 성공적으로 커밋된 후에만 트리거되도록 구성될 수 있어, 부작용이 발생하기 전에 데이터 일관성을 보장합니다.

1-4. 비동기 처리

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)

@Async 어노테이션을 사용하면 이벤트가 비동기적으로 처리될 수 있어, 이벤트가 처리되는 동안 이벤트 발행자가 차단되는 것을 방지합니다.

1-5. 확장성

시스템은 기존 코드를 수정하지 않고 새로운 이벤트 리스너를 추가하여 확장할 수 있습니다.

예를 들어, 모든 작업 상태 변경을 로깅하는 새로운 요구사항이 발생하면 TaskService를 변경하지 않고 새 리스너를 추가할 수 있습니다.

2. 1분단위 스케줄링을 피하기 위한 동적 스케줄러 도입

문제점: 1분 단위 스케줄링의 한계

기존에 많은 애플리케이션에서는 주기적인 작업을 처리하기 위해 고정된 시간 간격으로 실행되는 스케줄링 방식을 사용합니다.

예를 들어, @Scheduled(cron = "0 * * * * *") 어노테이션을 사용해 매 분마다 작업을 실행하는 방식이죠.

@Scheduled(cron = "0 * * * * *") // 매 분 0초에 실행
fun checkOverdueTasks() {
    // 모든 작업을 조회해서 마감 시간이 지난 작업을 처리
    val tasks = taskRepository.findAll()
    tasks.forEach { task ->
        if (task.dueDatetime.isBefore(LocalDateTime.now())) {
            // 상태 업데이트 처리
        }
    }
}

하지만 이런 방식에는 몇 가지 심각한 문제점이 있었습니다.

  1. 불필요한 DB 조회: 작업 마감 시간과 관계없이 매 분마다 모든 작업을 조회
  2. 리소스 낭비: 처리할 작업이 없어도 주기적으로 실행
  3. 확장성 제한: 작업 수가 많아질수록 매 분마다의 DB 부하 증가

해결책: 동적 TaskScheduler 도입

이러한 문제를 해결하기 위해 Spring의 TaskScheduler를 활용한 동적 스케줄링 방식을 도입했습니다.

@Service
class OverdueTaskStatusUpdateScheduler(
    private val taskRepository: TaskRepository,
    private val taskScheduler: TaskScheduler,
    // 기타 의존성...
) : Logger {
    @EventListener
    fun scheduleTaskStatusUpdate(task: Task) {
        val scheduledTime = task.dueDatetime.plusMinutes(1)

        taskScheduler.schedule(
            { checkAndUpdateTaskStatus(task.id) },
            scheduledTime.atZone(ZoneId.systemDefault()).toInstant(),
        )

        logger.info("Task(${task.id}) 상태 체크 스케줄러 등록 완료: 예정 실행 시간 = $scheduledTime")
    }

    // 작업 처리 로직...
}

TaskScheduler 설정

스케줄러의 효율적인 작동을 위해 스레드 풀을 설정했습니다.

@Configuration
class SchedulerConfig {
    @Bean
    fun taskScheduler(): TaskScheduler {
        val taskScheduler = ThreadPoolTaskScheduler()
        taskScheduler.poolSize = 3  // 동시 작업 처리를 위한 스레드 수
        taskScheduler.setThreadNamePrefix("task-scheduler-")
        taskScheduler.initialize()
        return taskScheduler
    }
}

애플리케이션 시작 시 기존 작업 스케줄링

애플리케이션이 재시작되어도 기존 작업들이 정확한 시간에 처리될 수 있도록 초기화 로직을 구현했습니다.

@Component
class TaskSchedulerInitializer(
    private val taskRepository: TaskRepository,
    private val overdueTaskStatusUpdateScheduler: OverdueTaskStatusUpdateScheduler,
) : Logger {
    @EventListener(ApplicationReadyEvent::class)
    fun initializeTaskSchedulers() {
        logger.info("애플리케이션 시작 시 상태 수정 작업 스케줄러 초기화 시작")

        // 처리가 필요한 작업들만 조회
        val todoTaskEntities = taskRepository.findTodoTasks(statusesToFail)

        // 각 작업에 대해 스케줄러 등록
        todoTaskEntities.forEach { taskEntity ->
            val task = Task.fromEntity(taskEntity)
            overdueTaskStatusUpdateScheduler.scheduleTaskStatusUpdate(task)
        }
    }
}

도입 효과 및 이점

1. 정확성 향상

  • 즉각적인 상태 업데이트: 각 작업의 마감 시간 직후에 정확하게 상태 업데이트
  • 시간 정밀도: 고정 간격 폴링과 달리 정확한 타이밍에 실행
// 마감 시간 1분 후 정확히 실행
val scheduledTime = task.dueDatetime.plusMinutes(1)

2. 시스템 리소스 최적화

  • 불필요한 DB 조회 제거: 필요한 시점에만 특정 작업 조회
  • CPU 사용량 감소: 작업이 없는 시간대에는 스케줄러가 활성화되지 않음
  • 메모리 효율성: 작업별로 필요한 데이터만 메모리에 로드

3. 확장성 향상

  • 작업 수 증가에 강인: 작업 수가 많아져도 각 작업 마감 시간에만 처리
  • 병렬 처리: 동시에 여러 작업을 처리해야 할 경우 스레드 풀을 통해 병렬 처리

4. 다양한 시점 작업 통합

단일 스케줄러로 다양한 시점의 작업을 처리할 수 있게 되었습니다.

// 마감 직후 상태 업데이트
taskScheduler.schedule({ checkAndUpdateTaskStatus(task.id) },
    task.dueDatetime.plusMinutes(1).atZone(ZoneId.systemDefault()).toInstant())

// 마감 30분 후 회고 알림
taskScheduler.schedule({ checkRetrospectionAndSendPushNotification(task.id) },
    task.dueDatetime.plusMinutes(30).atZone(ZoneId.systemDefault()).toInstant())

구현 시 주의사항

1. 애플리케이션 재시작 고려

  • 서버 재시작 시 메모리 내 예약된 작업이 소멸됨
  • ApplicationReadyEvent 이벤트를 통해 시작 시 기존 작업 재등록

2. 트랜잭션 관리

  • 스케줄된 작업은 별도 스레드에서 실행되므로 트랜잭션 관리 필요
  • TransactionTemplate을 사용해 명시적 트랜잭션 관리
transactionTemplate.execute {
    // 트랜잭션 내에서 작업 처리
}

3. 스레드 풀 크기 설정

  • 동시에 실행되는 작업 수를 고려한 적절한 스레드 풀 크기 설정
  • SPURT 애플리케이션에서는 3개의 스레드로 설정 (poolSize = 3)

3. Elastic APM을 활용해 미리 병목 가능 지점 파악

알림 스케줄러의 구조와 잠재적 부하 시나리오

우리 서비스는 다음과 같은 알림 처리 구조를 가지고 있습니다:

  • 매 분마다 DB의 알림 테이블에서 현재 시점에 발송해야 할 알림을 조회하여 전송
  • 각 작업(Task)별로 최대 약 10개의 푸시 알림이 발생 가능
  • 사용자당 평균 20개의 작업을 관리

대략적인 계산을 해보면:

1 task = 최대 10개 알림
1 user = 평균 20 tasks
5,000 users × 20 tasks × 10 notifications = 1,000,000개 푸시 알림

서비스가 성장하여 5,000명의 사용자에 도달했을 때, 동시간대에 최대 100만 개의 알림이 데이터베이스에 쌓일 수 있는 상황이었습니다.

Elastic APM을 활용한 사전 부하 테스트

이런 시나리오에 대비하기 위해 테스트 환경에서 알림 테이블에 100만 개의 테스트 데이터를 삽입하고, Elastic APM을 활용해 성능을 모니터링했습니다.

  • 알림 조회 쿼리 수행 시간: 2,899ms (약 3초)
  • 스케줄러의 전체 수행 시간: 3초 이상

이는 매 분마다 3초 이상을 알림 조회에만 소비한다는 의미로, 서비스 확장 시 심각한 병목 현상이 예상되었습니다.

문제 해결: 간단하지만 확실한 최적화

Elastic APM의 트랜잭션 분석을 통해 문제가 알림 테이블의 scheduled_at 컬럼 조회에서 발생함을 확인했습니다. 해당 컬럼은 알림 발송 예정 시간을 저장하며, 스케줄러는 현재 시간과 일치하는 레코드만 조회합니다.

가장 간단한 해결책을 적용했습니다:

CREATE INDEX idx_push_notifications_scheduled_at ON push_notifications(scheduled_at);

단 한 줄의 인덱스 추가로 놀라운 결과를 얻었습니다:

  • 인덱스 적용 전: 2,899ms
  • 인덱스 적용 후: 23ms

마치며

간절히 하고 싶던 디프만을 4개월간 정말 좋은 사람들과 행복하게 개발했던거 같네요.

이런 경험, 이런 사람들 어디 가서 못 만난다고 생각해요.

여러분들도 디프만 꼭 하세요 강추합니다 ㅋㅋ

반응형