Troubleshooting: 무엇이 문제였는가?/취하여 프로젝트

2주 차 : 알림 기능 찾아 삼만리 Day 7 - @Async 어노테이션(annotation)으로 바꾼 이유는?

writingforever162 2025. 2. 21. 16:32

[인용 및 참고 자료]

1. 구글 검색: Spring, "Spring ThreadPoolTaskScheduler Docs", ThreadPoolTaskScheduler, (2025.02.21)

2. 구글 검색: Spring, "Task Execution and Scheduling", Task Execution and Scheduling, (2025.02.21)

 

[알림 기능 찾아 삼만리 Day 1 링크]

[알림 기능 찾아 삼만리 Day 2 링크]

[알림 기능 찾아 삼만리 Day 3 링크]

[알림 기능 찾아 삼만리 Day 4 링크]

[알림 기능 찾아 삼만리 Day 5 링크]

[알림 기능 찾아 삼만리 Day 6 링크]

[깃허브(GitHub) 링크]

처음 ThreadPoolTaskScheduler를 빈(Bean)으로 등록했을 때는, 여러 다른 직업과 별도의 스레드 풀(Thread Pool)을 쓰기를 기대했다. 반대로 생각하면, 만약 이렇게 했을 때도 다른 작업과 같은 스레드 풀을 공유한다면 자칫 병목 현상이 생길 수 있었다. 

// Spring의 TaskScheduler 인터페이스를 구현한 표준 클래스
// 내부적으로 ScheduledThreadPoolExecutor를 래핑하여 작업 실행 
// 기본적으로 스케줄러의 스레드 수는 1개
// - 'setPoolSize(int)' 메서드로 조정 가능 
// 별도의 실행 스레드가 아닌, 스케줄러 스레드에서 이루어짐
public class ThreadPoolTaskScheduler extends ExecutorConfigurationSupport
       implements AsyncListenableTaskExecutor, SchedulingTaskExecutor, TaskScheduler {

 

(1) Spring Framework는 비동기 작업 실행과 작업 스케줄링을 각각 'TaskExecuto'r와 'TaskScheduler' 인터페이스로 추상화해준다.

▷The Spring Framework provides abstractions for the asynchronous execution and scheduling of tasks with the 'TaskExecutor and TaskScheduler' interfaces, respectively.

 

(2) Spring의 TaskExecutor 인터페이스는 'java.util.concurrent.Executor' 인터페이스와 동일하다.

Spring’s TaskExecutor interface is identical to the 'java.util.concurrent.Executor interface'.

 

공식 문서에서 알 수 있듯이, ThreadPoolTaskScheduler도 결국 별도의 스레드 풀을 사용하지 않고 기존의 풀을 덮어씌우는 방식으로 동작했다. 현재 작업의 우선 순위를 정하지 않았기 때문에,  굳이 스레드 풀을 명시할 필요가 없었다. 

 

"일단은 지금 방식으로 놔두고, 지금 비동기이긴 한데 블로킹(Blocking)이라 좀 효율이 떨어져 보여서 개선하면 좋겠습니다. 스레드 풀(Thread Pool) 개수를 명시해도 좋지만, 스프링(Spring)이 관리하도록 두는 편이 더 나을 듯해요."

 

이런 이유로 지금 단계에서는 @async 어노테이션(annotation)을 사용하기로 했다.

 

(1) 어플리케이션(application)에 @EnableAsync 어노테이션 추가하기

더보기
package com.project.cheerha;

import com.project.cheerha.common.properties.BcryptSecurityProperties;
import com.project.cheerha.common.properties.JwtSecurityProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableJpaAuditing
@EnableScheduling
@SpringBootApplication
@EnableAsync
@EnableConfigurationProperties({JwtSecurityProperties.class, BcryptSecurityProperties.class})
public class CheerhaApplication {

	public static void main(String[] args) {
		SpringApplication.run(CheerhaApplication.class, args);
	}

}

 

(2) 비동기를 적용할 sendMail() 메서드에 @Async 어노테이션 추가하기 

더보기
package com.project.cheerha.domain.notice.service;

import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailService {

    private final JavaMailSender javaMailSender;
    private static final String SENDER_EMAIL = "발신자 이메일 주소";

    @Async
    public void sendMail(
        String recipientEmail, 
        Set<String> jobOpeningUrlSet
    ) {
        try {
            MimeMessage message = javaMailSender.createMimeMessage();

            MimeMessageHelper helper = new MimeMessageHelper(
                message, 
                true, 
                "UTF-8"
            );

            helper.setFrom(SENDER_EMAIL);
            helper.setTo(recipientEmail);
            helper.setSubject("📢 새로운 맞춤 채용 공고가 도착했어요!");

            StringBuilder content = new StringBuilder();
            content.append("<h1>🚀 새로운 채용 공고가 준비됐어요! 🎉</h1>");
            content.append("<p>맞춤형 채용 공고가 도착했답니다! 💼</p>");
            content.append("<p>아래 링크에서 확인해보세요! ⬇️</p>");
            content.append("<ul>");

            for (String url : jobOpeningUrlSet) {
                content.append("<li>👉 <a href=\"")
                    .append(url)
                    .append("\" target=\"_blank\">")
                    .append("채용 공고 자세히 보기</a></li>");
            }

            content.append("</ul>");
            content.append("<p>행운을 빕니다! 🙌</p>");

            helper.setText(content.toString(), true);

            javaMailSender.send(message);

            log.info("이메일 전송 완료: {}", recipientEmail);
        } catch (MessagingException e) {
            log.error("이메일 전송 실패: {}", recipientEmail, e);
        }
    }
}

 

(3) 사용하지 않는 submit() 메서드 삭제하기

더보기
package com.project.cheerha.domain.notice.scheduler;

import com.project.cheerha.domain.notice.UserDto;
import com.project.cheerha.domain.notice.service.EmailDataFetchService;
import com.project.cheerha.domain.notice.service.EmailService;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Component
@RequiredArgsConstructor
public class EmailScheduler {

    private final EmailDataFetchService fetchService;
    private final EmailService emailService;

    @Scheduled(cron = "*/30 * * * * *")
    @Transactional
    public void sendJobOpeningMatchingNotices() {

        ZonedDateTime referenceTime = ZonedDateTime.now()
            .minusDays(3L)
            .withZoneSameInstant(ZoneId.of("UTC"));

        Map<Long, List<String>> jobOpeningKeywordMap = fetchService.findJobOpeningKeywordMap(referenceTime);

        List<UserDto> userDtoList = fetchService.findUserKeywordList();

        Map<String, Set<String>> emailUrlMap = new HashMap<>();

        for (UserDto dto : userDtoList) {
            List<String> matchingUrlList = jobOpeningKeywordMap.getOrDefault(
                dto.keywordId(),
                List.of()
            );

            if (!matchingUrlList.isEmpty()) {
                emailUrlMap.computeIfAbsent(
                    dto.email(),
                    email -> new HashSet<>()
                ).addAll(matchingUrlList);
            }
        }

        emailUrlMap.forEach((email, urlSet) -> {
            log.info("매칭 결과: {} - {}", email, urlSet);
            emailService.sendMail(email, urlSet);
        });
    }
}

어노테이션을 쓴 다음에도 이메일 전송에는 문제가 없었다. 이제 이메일 하나를 보내는 데 걸리는 시간을 최대한 줄이고 실시간 알림 기능을 구현해야 했다. 이때 문득 의문이 들었다. 

 

'실시간 알림 기능을 만들 때 지금 로직(logic)을 재사용할 수는 없을까?'

 

'현재 이메일 스케줄러(Email Scheduler)는 너무 많은 일을 하지 않나?'

 

Day 8에서 계속…….