일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- c
- Redis
- Oidc
- Sharding
- 디프만16기
- 코드 트리
- flutter
- 코딩테스트
- 연습문제
- pub.dev
- Kafka
- kakao
- Spring
- depromeet
- Scaffold
- C언어
- 자료구조
- AOP
- java
- exception
- 코드트리
- nGrinder
- OAuth
- 코딩 테스트
- Kotlin
- 디프만
- 운영체제
- 코딩
- 부하 테스트
- dip
- Today
- Total
Nick Dev
[SPURT] SPURT를 개발하면서 최적화하려고 했던 부분들 본문
- 이벤트 처리를 통해 관심사 분리
- 1분단위 스케줄링을 피하기 위한 동적 스케줄러 도입
- 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())) {
// 상태 업데이트 처리
}
}
}
하지만 이런 방식에는 몇 가지 심각한 문제점이 있었습니다.
- 불필요한 DB 조회: 작업 마감 시간과 관계없이 매 분마다 모든 작업을 조회
- 리소스 낭비: 처리할 작업이 없어도 주기적으로 실행
- 확장성 제한: 작업 수가 많아질수록 매 분마다의 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개월간 정말 좋은 사람들과 행복하게 개발했던거 같네요.
이런 경험, 이런 사람들 어디 가서 못 만난다고 생각해요.
여러분들도 디프만 꼭 하세요 강추합니다 ㅋㅋ
'SPURT' 카테고리의 다른 글
[SPURT] Docker와 GitHub Actions를 활용한 CI/CD 구축 가이드 (0) | 2025.03.07 |
---|---|
[SPURT] Kakao OIDC로 소셜 로그인 구현하기 - 4편 (0) | 2025.02.25 |
[SPURT] Kakao OIDC로 소셜 로그인 구현하기 - 3편 (0) | 2025.02.25 |
[SPURT] Kakao OIDC로 소셜 로그인 구현하기 - 2편 (1) | 2025.02.24 |
[SPURT] Kakao OIDC로 소셜 로그인 구현하기 - 1편 (2) | 2025.02.24 |