Just Do IT!

스프링 이벤트를 활용한 비동기 처리 방법 (ex. 프로젝트 생성 횟수 통계) 본문

개발 공부/Spring

스프링 이벤트를 활용한 비동기 처리 방법 (ex. 프로젝트 생성 횟수 통계)

MOON달 2025. 4. 28. 18:01
728x90
반응형
비동기 처리를 적용하는 배경

 

SODA 프로젝트 진행 중, 프로젝트 생성 횟수 추이를 그래프로 보여주기 위해 API를 만들어야 했었다.

이 기능을 구현하기 위해 두 가지를 고민했었다.

  1. 실제 DB에서 전체 조회해서 생성일에 맞춰서 카운트해서 조회하기
  2. 스프링배치를 이용해 통계 테이블을 따로 생성해서 거기에 하루에 한번씩 카운트 저장하기

첫 번째 방법은 너무 비효율적이라 생각만 하고 바로 지워버렸고 두번째가 적합하다고 생각했었다.

왜냐면 규모가 커진다면, 하루에 프로젝트를 생성하는 횟수가 많아질 거라고 생각했고 생성할때마다 매번 통계 테이블에 저장하는 것보다는

스프링 배치를 적용헤 일정한 시간에 한 번에 저장하는 게 더 나을 것이라고 생각했기 때문이다.

 

실제로 주말 내내 스프링 배치를 적용해서 테스트해보면서 잘 적용되는 걸 확인했었다.

그런데 회의를 하면서 생각이 바뀌었다.

 

아무리 규모가 커져도 프로젝트를 하루에 백 개 이상 생성하지는 않을 텐데, 스프링 배치를 적용하는 건 오버엔지리어링이 아닐까 싶어서.

기술을 하나 적용하려면 적절한 이유가 있어야 하는데, 스프링 배치를 적용하는 적절한 이유를 찾지 못했다.

그래서 검색도 해보고 ai 친구들에게 물어본 결과,

스프링 이벤트를 활용해 비동기 처리하는 방법을 적용하기로 했다.

 

비동기 처리하는 부분은 다음과 같다.

  1. 프로젝트 생성
  2. 생성 후 통계 테이블에 해당 날짜 조회
  3. 만약 해당 날짜가 없으면 새로 컬럼 생성 후 count +1, 존재하는 경우 기존 count+1

이렇게 되면

프로젝트 생성 API는 이벤트만 발행하고 즉시 응답하게 된다.

통계 업데이트는 백그라운드에서 비동기적으로 처리되므로 API 성능에 전혀 영향을 주지 않는다.

 

 

 

 

 

 

 

 

 

스프링 이벤트의 기본 구조와 작동 원리

스프링 이벤트는 ApplicationEvent 클래스를 상속받아 정의할 수 있다. 이벤트 발행은 ApplicationEventPublisher 인터페이스를 통해 이루어지며, 이벤트 리스너는 @EventListener 어노테이션을 사용하여 이벤트를 처리한다.

=> 개발자가 이벤트의 발생과 처리를 명확하게 구분할 수 있게 해주기 때문에 코드의 가독성과 유지보수성을 향상시킨다.

 

 

 

 

스프링 이벤트를 활용한 비동기 프로그래밍의 장점

  • Spring Event는 기본적으로 동기로 동작한다.
    • 때문에 하나의 thread에서 핵심로직뿐만 아니라 핵심이 아닌 로직인 이벤트도 모두 처리해야 하므로 전체 프로세스가 길어지게 된다.
  • 이벤트 이벤트 처리 과정을 비동기적으로 처리하게 되면, 이벤트 처리 과정이 애플리케이션의 주 흐름을 방해하지 않으므로 성능을 더 향상시킬 수 있다.
  • 나중에 다른 작업(알림 발송 등)이 필요하면 새로운 이벤트 리스너를 추가하기만 하면 되므로 확장성이 높아진다.

이벤트 발행과 스레드 동작 예시

  1. 프로젝트 생성
  2. 프로젝트 생성 성공 이벤트를 발행함
  3. 다른 스레드에서 이벤트 리스너가 활성화되어 통계 테이블에서 생성 횟수를 추가하는 메서드 비동기적으로 실행
  4. 메인 스레드에서는 프로젝트 생성되었으므로 통계와 전혀 관련없이 성공적으로 생성 응답을 반환할 수 있음

 

 

 

 

프로젝트 생성, 통계 카운트 +1 실제 동작 코드 예제

1. event class 생성

"프로젝트가 생성되었다"는 사실(이벤트)을 ProjectStats에 알리기 위해 사용되는 이벤트 객체 생성

@Getter
public class ProjectCreatedEvent {
    private final LocalDate creationDate;

    public ProjectCreatedEvent (LocalDate creationDate) {
        this.creationDate = creationDate;
    }

}

 

2. event listener 생성

  • EventListener와 @Async를 사용하여 ProjectCreatedEvent를 비동기적으로 수신하고 처리하는 리스너 생성
@Slf4j
@Component
@RequiredArgsConstructor
public class ProjectEventListener {

    private final ProjectStatsUpdateService statsUpdateService;

    @EventListener
    @Async
    public void handleProjectCreatedEvent(ProjectCreatedEvent event) {
        log.info("ProjectCreatedEvent 수신: Date = {}", event.getCreationDate());
        try {
            statsUpdateService.incrementProjectCount(event.getCreationDate());
            log.info("비동기 통계 업데이트 로직 호출 완료: DATE = {}", event.getCreationDate());
        } catch (Exception e) {
            log.error("비동기 통계 업데이트 처리 중 최종 오류 발생: Date = {}, Error={}", event.getCreationDate(), e.getMessage(), e);
        }
    }
}
  • @EventListener
    • handleProjectCreatedEvent 메소드가 이벤트 리스너 메소드임을 스프링에게 알리는 역할
    • 스프링은 ProjectCreatedEvent 타입의 이벤트가 애플리케이션 컨텍스트 내에서 발행(publish)되면, 이 메소드를 자동으로 호출
  • @Async
    • 프로젝트 생성 작업은 이 리스너의 작업 완료를 기다리지 않고 즉시 다음 로직을 수행하거나 응답을 반환
    • 통계 업데이트 작업(=프로젝트 생성 횟수 카운트)은 별도의 스레드 풀에서 비동기적으로 처리되도록 하는 역할

3. 프로젝트 생성 통계 서비스 로직 생성

@Slf4j
@Service
@RequiredArgsConstructor
public class ProjectStatsUpdateService {
    private final ProjectDailyStatsRepository statsRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void incrementProjectCount(LocalDate date) {
        log.info("통계 업데이트 시작: Date = {}", date);

        try {
            // 해당 날짜의 통계 조회 (없으면 새로 생성)
            ProjectDailyStats stats = statsRepository.findByStatDate(date)
                    .orElseGet(() -> {
                        log.info("해당 날짜({})의 통계 없음. 새로 생성 시작", date);
                        return ProjectDailyStats.builder()
                                .statDate(date)
                                .creationCount(0L) // 초기값 0
                                .build();
                    });

            // count 증가
            stats.incrementCount();

            // DB 저장
            statsRepository.save(stats);
            log.info("통계 업데이트 완료 : Date={}, New Count={}", date, stats.getCreationCount());
        } catch (Exception e) {
            // 동시성 문제 또는 DB 오류 발생
            log.error("통계 업데이트 중 오류 발생: Date={}, Error={}", date, e.getMessage(), e);
            throw e;
        }
    }
}

 

기존에 해당 날짜가 존재하는 경우에는 count 증가되도록 하고,

존재하지 않는 경우 초기값 0으로 설정하여 생성한 뒤 count 증가되도록 서비스 로직 구현

 

4. 프로젝트 생성 로직에 이벤트 발행 추가

@Slf4j
@Transactional(readOnly = true)
@Service
@RequiredArgsConstructor
public class ProjectService {
    /*
    기존 로직들
    */
    private final ApplicationEventPublisher eventPublisher; // 이벤트 발행기
    
    @Transactional
    public ProjectCreateResponse createProject(String userRole, ProjectCreateRequest request) {
        /*
        기존 로직들
        */

        // 이벤트 발행
        try {
            LocalDate creationDate = project.getCreatedAt().toLocalDate();
            eventPublisher.publishEvent(new ProjectCreatedEvent(creationDate));
            log.info("ProjectCreatedEvent 발행: Date={}", creationDate);
        } catch (Exception e) {
            log.error("ProjectCreatedEvent 발행 실패: Project ID={}, Error={}", project.getId(), e.getMessage(), e);
        }

        log.info("프로젝트 생성 완료: 프로젝트 ID = {}", project.getId());

        // response 생성
        return createProjectCreateResponse(project);
    }

 

eventPublisher를 이용해 프로젝트가 생성됨을 ProjectEventListener에 알리는 역할 추가

 

5. 통계 조회 서비스 로직 생성

public ProjectStatsResponse getProjectCreationTrend(Long userId, String userRole, ProjectStatsCondition statsRequest) {
        // ADMIN 유효성 검사
        validateAdminRole(userRole);

        // 날짜 유효성 검사
        LocalDate startDate = statsRequest.getStartDate();
        LocalDate endDate = statsRequest.getEndDate();
        if (startDate == null || endDate == null || startDate.isAfter(endDate)) {
            log.warn("잘못된 조회 기간: StartDate={}, EndDate={}", startDate, endDate);
            throw new GeneralException(ProjectErrorCode.INVALID_DATE_RANGE);
        }

        // 집계 데이터 조회
        List<Tuple> statsData = projectDailyStatsRepository.findProjectCreationStats(startDate, endDate, statsRequest.getTimeUnit());
        log.debug("프로젝트 생성 통계 데이터 조회 완료: {}개의 데이터", statsData.size());

        // 조회 결과를 Map 변환
        Map<String, Long> statsMap = statsData.stream()
                .collect(Collectors.toMap(
                        tuple -> tuple.get(0, String.class),
                        tuple -> tuple.get(1, Long.class) != null ? tuple.get(1, Long.class) : 0L
                ));
        
        // 조회 기간 내 모든 날짜 생성 및 0으로 채우기
        List<ProjectStatsResponse.DataPoint> trendData = generateFullTrendData(startDate, endDate, statsRequest.getTimeUnit(), statsMap);
        log.debug("누락 기간 0 처리 및 최종 DataPoint 리스트 생성 완료. Size: {}", trendData.size());

        return ProjectStatsResponse.from(statsRequest, trendData);
    }

 

조회 시에 해당 날짜에 프로젝트를 생성하지 않은 경우 오류가 발생해서, 그런 경우에는 0으로 채우기 위해

generateFullTrendData를 통해 리스트를 생성한다. 이건 중요하지 않으니까 생략.

 

6. application에 @EnableAsync 추가

  • 스프링 애플리케이션에서 비동기 처리 기능을 활성화
  • @Asnyc 어노테이션이 붙은 메서드들이 비동기적으로 실행하도록 하는 역할

 

최종 정리

프로젝트 생성할때 이벤트 발행 코드를 추가하여 통계 테이블에서 제대로 카운트 횟수를 증가시키도록 하였다.

그렇게 하였더니 API 테스트 시 원하는 기간 동안 프로젝트 생성 횟수를 조회할 수 있었다.

이후에 프론트에 연동하니까 아래와 같이 제대로 잘 구현되는 걸 확인할 수 있다.

FE 연동 이후 생성 추이 그래프

 

자세한 코드는 아래 PR을 통해 확인할 수 있다.

https://github.com/Kernel360/KDEV4-SODA-BE/pull/257

 

[Feature] 프로젝트 생성 통계 구현 by seoyeon-jung · Pull Request #257 · Kernel360/KDEV4-SODA-BE

요약 관리자가 원하는대로 월간/주간/일간 프로젝트 생성 통계를 조회하는 기능을 구현하였습니다. 연관 이슈 #254 확인 사항 ProjectDailyStatus entity 추가 BaseEntity를 상송받지 않고 날짜를 기본키로

github.com

 

 

 

 

 

 

 

 

 

 

 


 

통계 부분을 구현하는 방법은 많아서 이렇게 적용하긴 했지만 언제 다른 방법으로 리팩토링될지는 잘 모르겠다.

그래도 그래프로 시각화해서 보니까 좀 뿌듯하긴 하다.

처음으로 비동기 프로그래밍에 대해 고민해보고 이벤트 프로그래밍을 적용해봤다. 역시 아직 공부할게 너무 많다 ㅋㅋ

 

참고 자료:

https://f-lab.kr/insight/spring-event-asynchronous-processing

 

스프링 이벤트를 활용한 비동기 처리 방법

스프링 프레임워크에서 이벤트 기반 프로그래밍을 활용한 비동기 처리 방법과 그 장점에 대해 설명합니다. @Async 어노테이션을 사용한 비동기 이벤트 처리 예제를 제공합니다.

f-lab.kr

https://mangkyu.tistory.com/292

 

[Spring] 스프링에서 이벤트의 발행과 구독 방법과 주의사항, 이벤트 사용의 장/단점과 사용 예시

이벤트(Event)는 매우 유용하지만 상당히 간과되는 기능 중 하나입니다. 작년에 아마존 CTO는 이벤트 드리븐 아키텍처로 가야 한다고 기조 연설을 하기도 했는데, 이번에는 스프링 프레임워크에서

mangkyu.tistory.com

 

728x90