본문 바로가기
Server/Spring

[Spring] Thread: 알림은 내가 보낼게, Batch 처리는 누가할래?

by promedev 2026. 3. 15.

 

들어가며

 

안녕하세요. 프로미입니다 :)

 

오늘은 ‘코코스’ 알림 로그 찍기 개발 중에 생긴 트러블슈팅 과정을 설명드리려고 합니다.

세부적으로는 Thread Dump, visualVM, Async, batch에 대해서 다루는 글입니다. 

Thread의 상태 추적에 흥미가 있는 분들에게 추천합니다. 

 

1. 문제 상황 

 

코코스 프로젝트에서 알림 기능을 개발했었다.

실제로 알림이 전송되는 것은 아니고, 로그 같이 사용자의 알림 박스에 쌓이는 방식이다.

알림 화면 (코코스 디자이너가 디자인을 잘해요)

 

  1. 커뮤니티 활동 기록(댓글, 좋아요)은 특정 사용자한테만 알링이 간다.
  2. 코코스 매거진이 올라오면 전체 사용자한테 알림이 간다.

1)의 경우 큰 문제가 되지 않지만, 2)의 경우 DB Insert 연산이 사용자 수만큼 발생한다. 

 

2. 기존 코드의 동작

Sync 로 동작하는 코드

 

 

1. 새로 올라온 글이 매거진이면 알림 Event를 생성한다.

        if (post.isMagazine()) {
            eventPublisher.publishEvent(new MagazinePublishedEvent(
                    post.getId(),
                    post.getMemberId(),
                    post.getTitle(),
                    post.getContent()
            ));
        }

 

 

2. 이벤트를 받아 새로운 트랜잭션에서 실행한다.

AFTER_COMMIT, REQUIRES_NEW 옵션

A, B 트랜잭션이 A → B 순서로 실행될 때, A가 커밋되고 난 후에 B가 새로운 트랜잭션에서 시작된다.

따라서, A에서 오류가 발생했을 때 A, B는 롤백된다.

 

@Component
@RequiredArgsConstructor
public class MagazinePostNotificationListener {
    private final NotificationService notificationService;

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void handle(MagazinePublishedEvent event) {
        notificationService.createForMagazine(event);
    }
}

 

 

3. 모든 사용자에 대한 알림을 생성한다.

   public void createForMagazine(final MagazinePublishedEvent magazinePublishedEvent) {
        final List<Long> memberIds = memberRepository.findAllIds();
        final List<Notification> notifications = memberIds.stream()
                .map(memberId -> Notification.magazinePublished(memberId, magazinePublishedEvent))
                .toList();
        notificationRepository.saveAll(notifications);
    }

 

현재는 사용자가 많지 않아 문제 없이 동작하지만, 사용자가 많아지면 바로 문제가 될 것이라 예상했다.

실험을 통해 이 의심을 확인해보고자 했다.

 

3. 테스트 환경 구축하기 - Test 유저 1만 명 삽입

DELIMITER $$

DROP PROCEDURE IF EXISTS insertLoop$$

CREATE PROCEDURE insertLoop()
BEGIN
    DECLARE i INT DEFAULT 1;
    WHILE i <= 10000 DO
        INSERT INTO member (
            is_admin, 
            created_at, 
            updated_at, 
            email, 
            nickname, 
            sub, 
            platform, 
            is_review_terms_agree
        ) VALUES (
            0, 
            NOW(6), 
            NOW(6), 
            CONCAT('test', i, '@example.com'), 
            CONCAT('user', i), 
            CONCAT('sub_', i), 
            'KAKAO', 
            0
        );
        SET i = i + 1;
    END WHILE;
END$$

DELIMITER ;

-- 프로시저 실행 (1만 명 삽입)
CALL insertLoop();

 

4. 기존 Sync 코드 테스트

 

*실험 결과는 VisualVM으로 확인했다.

VisualVM은 명령줄 JDK 도구와 간단한 프로파일링 기능을 통합한 시각적 도구입니다.

개발 환경과 실제 운영 환경 모두에서 사용할 수 있도록 설계되었습니다. -https://visualvm.github.io/

 

[Thread 모니터링] 사진을 보면 Thread 7이 약 6초 동안 연산을 처리하고, 응답하는 것을 확인할 수 있다.

Sync 로 동작하기 때문에 게시글 업로드 요청은 알림 로그가 생성될 때까지 기다렸다가 응답한다.

 

 

sampler
Thread 모니터링

 

 

5. Async로 응답 시간 줄이기

 

사용자는 게시글 업로드를 기다리는 것이지, 알림 전송을 기다리는 것이 아니기 때문에 알림 전송 로직을 async로 분리하고자 했다.

비동기로 처리되면, 응답을 기다리는 시간도 단축시킬 수 있을 것이라 예상했다.

 

1. async 코드 추가하기

@Async("notificationExecutor")

  @Async("notificationExecutor")
    public void createForMagazine(final MagazinePublishedEvent magazinePublishedEvent) {
        final List<Long> memberIds = memberRepository.findAllIds();
        final List<Notification> notifications = memberIds.stream()
                .map(memberId -> Notification.magazinePublished(memberId, magazinePublishedEvent))
                .toList();
        notificationRepository.saveAll(notifications);
    }

poolSize는 4~10으로 설정했다. 실행할 환경인 t3.micro (2vCPU) 환경을 고려해 설정했다.

 

[Thread Pool 계산 공식: CPU Bound vs I/O Bound]
1. CPU Bound 작업 (연산 위주) 
• 공식: Core 수 + 1
• 이유: CPU가 계속 계산을 해야 하므로, 코어 수보다 스레드가 많아지면 스레드끼리 CPU를 차지하려는 '콘텍스트 스위칭' 비용만 발생하고 속도는 느려집니다.

2. I/O Bound 작업 (DB, API 호출 위주) 
알림 발송은 DB에 데이터를 넣고 응답을 기다리는 시간이 긴 I/O 작업입니다.
이때는 CPU가 노는 시간이 많으므로 스레드를 더 많이 배치해도 됩니다.
• 공식: Core 수 X (1 + write time/service time)
◦ Wait Time: DB 응답을 기다리는 시간 (대기)
◦ Service Time: 실제 CPU가 데이터를 가공하는 시간 (작업)

 

@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean(name = "notificationExecutor")
    public Executor notificationExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("Noti-Async-"); # async thread 이름 설정 > visualVM에서 빠르게 확인하기 위함

        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(60); # 서버 종료 전에 처리 중인 요청이 있다면 60초까지 기다린다. 
        executor.initialize();
        return executor;
    }
}

 

비동기 일꾼이 생성된다!

 

잠깐 !
Q: 서버 종료 중 새로운 요청이 들어오면 어떻게 되나요?
A: 스레드 풀은 종료 신호를 받는 즉시 새로운 작업을 거절합니다. 대신 waitForTasksToCompleteOnShutdown 설정을 통해 이미 접수된 작업들을 안전하게 마무리(Graceful Shutdown)함으로써 데이터의 유실을 최소화합니다.

 

 

2. async 도입 후 지표 살펴보기

[Thread 그림]을 보면 가장 마지막에 위치한 Noti-Async-1 이 알림 연산을 처리하는 것을 볼 수 있다.

사진에는 잘렸지만, 실제로 Thread 1이 게시글 생성 연산을 빠르게 처리했다.

사용자는 요청하고 1초도 안 되어 게시글 생성이 성공했다는 응답을 받을 수 있다.

 

Thread 그림

6. Batch로 Thead 점유 시간 줄이기

 

사용자는 응답을 빠르게 받을 수 있지만, 여전히 알림 연산을 처리하는 데 걸리는 시간은 6~7초다.

Async로 생성된 별도의 Thread도 자원을 점유하는 것이기 때문에, 점유를 줄일 수 있는 방법을 고민하였고 Batch 처리를 도입했다.

 

1. batch 코드 작성하기

JPA에서 GenerationType.IDENTITY id 생성 규칙을 사용하면, 벌크 연산할 때 id 값을 알 수 없다.

따라서 JDBC 쿼리로 작성했다. 
JDBC는 하드코딩되어 필드 변경의 영향을 많이 받기 때문에, SimpleJdbcInsert를 도입했다. 

@Repository
public class NotificationBulkRepository {
    private final SimpleJdbcInsert jdbcInsert;

    public NotificationBulkRepository(DataSource dataSource) {
        this.jdbcInsert = new SimpleJdbcInsert(dataSource)
                .withTableName("notification")
                .usingGeneratedKeyColumns("id");
    }

    public void saveAllInBatch(final List<Notification> notifications) {
        if (notifications.isEmpty()) return;

        int batchSize = 1000;
        for (int i = 0; i < notifications.size(); i += batchSize) {
            final int endIndex = Math.min(i + batchSize, notifications.size());
            final List<Notification> subList = notifications.subList(i, endIndex);

            final SqlParameterSource[] params = subList.stream()
                    .map(this::createParameterSource)
                    .toArray(SqlParameterSource[]::new);

            jdbcInsert.executeBatch(params);
        }
    }

    private SqlParameterSource createParameterSource(final Notification notification) {
        final LocalDateTime now = LocalDateTime.now();

        return new MapSqlParameterSource()
                .addValue("notifier_id", notification.getNotifierId())
                .addValue("actor_id", notification.getActorId())
                .addValue("actor_nickname", notification.getActorNickname())
                .addValue("notification_type", notification.getNotificationType().name())
                .addValue("notification_target_id", notification.getNotificationTargetId())
                .addValue("milestone", notification.getMilestone())
                .addValue("post_id", notification.getPostId())
                .addValue("title", notification.getTitle())
                .addValue("content", notification.getContent())
                .addValue("is_read", notification.isRead())
                .addValue("created_at", now)
                .addValue("updated_at", now);
    }
}

public void createForMagazine(final MagazinePublishedEvent magazinePublishedEvent) {
        final List<Long> memberIds = memberRepository.findAllIds();
        final List<Notification> notifications = memberIds.stream()
                .map(memberId -> Notification.magazinePublished(memberId, magazinePublishedEvent))
                .toList();
        notificationBulkRepository.saveAllInBatch(notifications);
    }

 

 

2. batch 도입 후 지표 살펴보기

아래 [Thread 그림]을 보면 Async Thread가 1초 내로 빠르게 작업을 처하고 있는 것을 볼 수 있다.

Sampler
Thread 그림

 

실험 결과를 정리하자면, 아래 표 내용과 같다.

Thread running을 비교했을 때 6.007ms -> 1.004ms로 약 83% 성능을 개선했다. 

구분 1단계: Sync (기존)  2단계: Async (전환)  3단계: Async + Batch (최종)
실제 처리 시간 약 6초 약 7초 약 1초
사용자 체감 응답 시간 약 6초 약 1초 약 1초
성능 병목 지점 사용자 대기 + DB 부하 DB 부하 (내부 지연) 최적화 완료

 

7. 트러블슈팅 - 설정 과정에서 겪었던 소소한 문제들

1. created_at, updated_at 자동 생성 X 이슈

JPA는 created_at을 자동으로 설정해 주었지만, JDBC는 수동으로 설정해줘야 한다.

 

2. camelCase, snake_case 변환 이슈

entity 필드명은 camelCase인데, db 칼럼명은 snake_case를 따른다.

이 변환을 자동으로 해주지 않기 때문에 별도로 필드를 매핑하는 코드를 추가했다.

(찾아봤을 때 snake_case to camelCase는 지원하는데, camelCase to snake_case는 지원하지 않는다고 한다. 실제 예제를 찾아봤을 때도 이렇게 자동 변환하는 예제는 없어서 직접 매핑했다. 더 찾아보면 있을 수도…)

 

8. 보완할 점들

현재는 알림 로그 찍다가 오류가 나면, 따로 대응하는 로직이 없다.

DLQ나 별도의 오류 알림 로직을 설계하는 것도 좋을 것 같다.

 

9. 느낀 점

자바는 멀티스레드다.라는 것을 일찍이 배웠지만, 멀티스레드를 시각화해서 본 건 처음이었다.

자바와 친해진 느낌.. (가까워지기)

구교환과 가까워지기..

 

의미 있는 성능 개선을 이뤄낸 만큼, 이 기능이 더 유용하게 쓰였으면 좋겠다.

다음에 기회가 되면 트래픽 부하가 있는 환경에서 어떻게 스레드들이 동작하는지 살펴보고 싶다. 

 

Ref

https://visualvm.github.io/

https://mangkyu.tistory.com/425

 

 

*잘못된 내용은 댓글로 알려주시면 글에 반영하도록 하겠습니다. 감사합니다.