알람 서비스 트러블슈팅

2024. 4. 4. 00:09
728x90

1. 알림 서비스 구현하기

🖥️ 상황

  • 사용자는 아티스트와 장르에 대해 구독할 수 있다.
  • 새로운 공연 정보가 업로드될 때 사용자가 구독한 아티스트가 참여하거나, 사용자가 구독한 장르의 공연이라면 사용자에게 이메일로 알림을 보낸다.
  • 이때, 이메일 알림은 Jakarta Mail로 메일 발송 처리를 하는데, 공연 정보를 업로드하는 메서드 내부에 구현해야할지? 구체적으로 어떤 방식으로 구현해야 할지 고민이 되었다.

 

🖋️ 알림 처리에 대한 공부

1) Spring 비동기 처리 기능

: 알림 작업을 비동기적으로 처리하여 메인 작업(공연 정보 업로드)의 응답 시간을 최소화할 수 있다.

  • @EnableAsync + @Async : 단순한 스레드를 만들어준다.
  • SpringAsyncConfig(@EnableAsync) + @Async(”threadPoolName”) : 스레드 풀을 만든다.
  • 리턴 값이 있는 경우 : Future, ListenableFuture, CompleatableFuture 사용

👍🏻 장점 

- 높은 응답성 : 메인 스레드가 작업을 기다리지 않고 다음 작업으로 넘어갈 수 있다.

- 자원 효율성 : 필요에 따라 스레드 생성/관리하여 자원을 효율적으로 사용할 수 있다.

👎🏻 단점

- 코드 복잡도 : 비동기 로직을 관리하고 디버깅하는 것이 복잡해질 수 있다.

- 메모리 공유 문제 : 스레드 간 메모리 공유로 인한 동시성 문제 발생할 수 있다. 

 

2) 이벤트 기반 아키텍처

: 공연 정보가 업로드와 같은 특정 이벤트를 발생했을 때, 이를 감지하고 처리하는 리스너나 핸들러를 통해 알림을 발송한다.

  • Spring의 이벤트 프레임워크 사용 + 이를 처리하는 리스너 등록
  • 이벤트 발행 : ApplicationEventPublisher 주입받아 사용
  • 이벤트 구독 : ApplicationListener 인터페이스 구현 혹은 @EventListener 사용
  • 멀티 캐스팅 관계 : 다수의 수신자가 존재할 수 있는 통신 형태
  • 동기 방식으로 동작 (트랜잭션이 하나의 범위로 묶일 수 있다.)
  • 비동기 방식으로 하기 위해서는 별도의 설정이 필요
    • @Async 메서드로 비동기 구현
    • ApplicationEventMulticaster로 비동기 구현

👍🏻 장점 

- 의존성 분리: 알림 로직을 별도의 서비스로 분리하여 관리할 수 있다. 

- 재사용성 및 테스트 용이: 이벤트 처리 로직을 다른 도메인에서도 재사용하고 단위 테스트하기가 쉽다.

👎🏻 단점

작업량 증가: 이벤트 발행과 구독 로직을 추가해야 하므로 전반적인 작업량이 많아진다.

- 처리 순서 복잡성: 여러 구독자가 있을 경우 메시지 처리 순서를 관리해야 하는 복잡성이 증가할 수 있습니다.

 

3) 알림 대기열

: 메시지 큐나 대기열 시스템을 사용하여 알림 메시지를 임시 저장하고, 대기열을 정기적으로 확인하여 알림을 발송하는 방식.

  • RabbitMQ 사용 (AMQP(Advanced Message Queuing Protocol)을 구현한 오픈 소스 메시지 브로커)
  • Apache Kafka 사용 (고성능, 분산형 스트리밍 플랫폼. 대량의 데이터 스트리밍 처리에 특화)
  • Amazon SQS (Simple Queue Service) 사용
  • ActiveMQ 사용 

👍🏻 장점 

- 확장성: 알림 발송 요청이 많아지더라도 대기열 시스템을 통해 부하를 관리하고 처리할 수 있다.

- 결합도 감소: 알림 서비스와 메인 애플리케이션 간의 결합도가 낮아져, 시스템 간 독립적인 운영과 유지보수가 용이해진다.

- 부하 분산 : 피크 시간동안 발생할 수 있는 부하를 관리하고, 전체 시스템의 성능을 최적화하는데 유리하다. 

👎🏻 단점

-  복잡성 증가 : 전체 시스템의 아키텍처가 더 복잡해질 수 있다. (메시지 전송, 수신, 관리하는 로직 추가)

- 디버깅과 모니터링의 어려움 : 메시지가 예상대로 처리되지 않거나 지연될 때 문제의 원인을 파악하기 위한 추가적인 모니터링 및 로깅 도구가 필요할 수 있다.

 

 

🌟 선택

  • 2번과 3번은 현재 상황에서 시간적 여유 및 경험 부족으로 구현이 어려울 것으로 예상.
  • 그러나, 비동기 처리는 필수이므로 1번 방법을 활용해 알림 처리를 비동기로 처리하기로 했다.
  • 핵심 : 알림 처리가 지연되어도 공연 정보 업로드 요청은 완료되어야 하며, 다른 스레드에서 알림을 처리할 수 있도록 비동기로 처리하여야 한다.

📨 구현

1) AsyncConfig 설정

  • @EnableAsync : 비동기 기능 활성
  • TaskExecutor를 사용하여 비동기 작업을 스케줄링 (ThreadPoolTaskExecutor)
  • setCorePoolSize(n) : 기본적으로 실행 대기 중인 스레드 개수
  • setMaxPoolSize(n) : 동시에 동작하는 최대 스레드 개수
  • setQueueCapacity(n) : CorePool의 크기를 넘어서면 큐에 저장하는데, 그 큐의 최대 용량
  • setKeepAliveSeconds(n) : 최대 스레드가 작업을 완료한 후, 추가 작업이 없을 때 대기하는 시간 설정. 이 시간이 지나면 스레드는 종료된다.
  • setThreadNamePrefix("AsyncExecutor-") : 스레드 풀에서 생성된 스레드의 이름 접두사 설정. 로그 확인
  • executor.initialize() : 스레드 풀을 초기화하고 사용할 준비.
@Configuration
@EnableAsync // 스프링의 비동기 기능을 활성화하여 Async 어노테이션을 감지
public class AsyncConfig implements AsyncConfigurer {

  @Override
  @Bean(name = "threadPoolTaskExecutor")
  public Executor getAsyncExecutor() {
    int processors = Runtime.getRuntime().availableProcessors(); // 내 PC의 Processor 개수를 가져옴.
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // Thread Pool 관리 클래스
    executor.setCorePoolSize(processors); // 기본 스레드 개수
    executor.setMaxPoolSize(processors * 2); // 최대 스레드 개수 (Queue가 가득찬 이후 maxPoolSize만큼 생성)
    executor.setQueueCapacity(50); // 대기를 위한 Queue 크기
    executor.setKeepAliveSeconds(60);  // 스레드 재사용 시간
    executor.setThreadNamePrefix("AsyncExecutor-"); // 스레드 이름 prefix
    executor.initialize(); // ThreadPoolExecutor 생성

    return executor;
  }
}

 

2) 비동기 적용

: 비동기 처리 메서드에 @Async 어노테이션 붙여주기

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailAlertService implements AlertService {
	// ...
    
	// 이메일 발송 메서드
    @Override
    @Async("threadPoolTaskExecutor")
    @Transactional
    public void sendMail(Alert alert) throws MessagingException {
        log.info("===== email sending start");

        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

        helper.setSubject(EMAIL_TITLE_PREFIX + alert.getTitle()); //제목
        helper.setFrom("stage alarm <noreply@stagealarm.com>");
        helper.setTo(alert.getUserEmail());
        HashMap<String, String> emailValues = new HashMap<>();
        emailValues.put("content", alert.getMessage());
        String text = setContext(emailValues);
        helper.setText(text, true);
        helper.addInline("logo", new ClassPathResource("static/images/logo.png"));
        helper.addInline("notice-icon", new ClassPathResource("static/images/image-1.png"));

        mailSender.send(message);
        log.info("===== email sending end");
    }
    
    // 공연 정보에 대한 알림 객체 생성 메서드 (내부에서 이메일 발송 메서드를 호출하므로 트랜잭션 처리를 했다)
    @Async("threadPoolTaskExecutor")
    public void createAlert(Long showInfoId) {
        // 해당 공연정보에서 아티스트 관련 알림 객체 생성
        log.info("===== artist alert creation start");
        List<ShowArtist> showArtists = showArtistRepo.findByShowInfoId(showInfoId);
        generateArtistSubAlert(showArtists);
        log.info("===== artist alert creation end");

        log.info("===== genre alert creation start");
        List<ShowGenre> showGenres = showGenreRepo.findByShowInfoId(showInfoId);
        generateGenreSubAlert(showGenres);
        log.info("===== genre alert creation end");

        List<Alert> alerts = alertRepository.findByShowInfoId(showInfoId);
        for (Alert alert : alerts) {
            try {
                log.info("send email start");
                alert.setMessage(generateMessage(alert, alert.getUserNickname()));
                sendMail(alert);
            } catch (MessagingException e) {
                log.warn(e.getMessage());
                throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
            }
        }
    }
}

 

**** 주의사항 ****

  • @Async 어노테이션이 붙은 메서드는 같은 클래스 내에서 다른 메서드가 직접 호출할 경우 비동기로 실행되지 않는다. 스프링의 프록시 기반 AOP가 작동하는 방식 때문이다. 이를 해결하기 위해서는 자기 자신의 프록시 객체를 주입받아 사용하거나, 다른 빈에서 해당 메서드를 호출해야 한다.
  • 비동기 메서드에서 발생하는 예외를 처리하기 위해서는 AsyncUncaughtExceptionHandler를 구현해야 한다.

 

🔥 트러블 발생

  • 실행했더니 이러한 당황스러운 문제가 발생했다.  proxy 개념에 익숙치 않다보니 매우 당황!!!! CGLib은 또 뭐고요...
  • 찾아봤더니 스프링은 프록시를 사용해서 별도의 스레드에서 Async 처리된 메서드를 실행할 때 프록시 기술을 사용한다고 한다. 음... 프록시가 뭔데 대체!....
  • 프록시 생성 방법에는 JDK 동적 프록시 / CGLib 사용 가능
  • 스프링 부트 사용시에는 AOP 적용시 기본으로 CGLib 사용
  • @EnableAsync는 스프링부트의 방식과는 무관하게 JDK 동적 프록시나 CGLib 중 선택 가능하다. (기본값이 Jdk 동적 프록시)

 

AOP Proxy 관련 간단 설명

더보기

1. JDK dynamic proxy

: 인터페이스 기반의 프록시 생성 방식. Java의 리플렉션을 이용해서 객체를 만든다.

 대상의 객체가 최소 하나의 인터페이스를 구현했다면 JDK 프록시를 사용한다.

스프링은 JDK의 Proxy 클래스를 사용하여 해당 인터페이스를 구현하는 프록시 객체를 동적으로 생성.

인터페이스를 통한 프록싱에 적합하다.

 

2. CGLib proxy (Code Generation Library)

: 클래스 기반의 프록시 생성 방식. 바이트코드를 조작해 프록시 객체를 만든다. 

대상 객체가 인터페이스를 구현하지 않거나, proxyTargetClass=true 설정을 사용하는 경우에 적용된다.

상속을 사용하여 대상 클래스의 하위 클래스를 동적으로 생성하고, 이를 통해 프록시 객체를 만든다.

클래스를 직접 상속하여 프록싱한다.

 

default : JDK dynamic proxy

만약  @EnableAsync(proxyTargetClass =true) 설정을 하였으면 CGLib proxy 강제

 

🤔사실 프록시에 대해 제대로 공부하지 않고, 그냥 어찌저찌 해결하다보니 실행이 되었다. 

이때 해결책으로 썼던 것은 @EnableAsync(proxyTargetClass=true)로 바꾸고, 된다! 하고 아무 생각 없이 넘겼다. 

 

하지만, 지금 트러블 슈팅을 적으며 프록시에 대해서 어느정도 개념을 공부하니, 얼떨결에~ 해결했다는 사실을 알게 되었다. 

 

위의 오류 상황을 제대로 살펴보면 Action에 두가지 해결책을 제시해주었다.

1) Consider injecting the bean as one of its interfaces

or

2) forcing the use of CGLib-based proxies by setting proxyTargetClass=true on @EnableAsync and/or @EnableCaching.

 

이렇게 두가지 방법이 있다. 

1) 인터페이스 상속을 통해서 빈 주입을 하여 JDK dynamic proxy로 제대로 생성되게 만든다.

2) proxyTargetClass=true 설정을 통해서 CGLib proxy로 강제하여 생성되게 만든다. 

 

위의 상황에서 나는 분명 interface를 구현하고 상속받았는데 왜 why? 이런 문제가 나는걸까 ? 했는데,

알고보니, Async 어노테이션이 달리는 메서드가 꼭 반드시 인터페이스에 구현되어있어야 한다. = 인터페이스로 빈을 주입한다. 그래서 두가지 메서드를 인터페이스 메서드로 구현하여 오버라이딩하여 구체적인 메서드를 구현해주었다.

public interface AlertService {
    void createAlert(Long showInfoId);
    void sendMail(Alert alert) throws MessagingException;
}

 

전체코드 정리

더보기
- AsyncConfig 
@Configuration
@EnableAsync// 스프링의 비동기 기능을 활성화하여 Async 어노테이션을 감지
public class AsyncConfig implements AsyncConfigurer {

  @Override
  @Bean(name = "threadPoolTaskExecutor")
  public Executor getAsyncExecutor() {
    int processors = Runtime.getRuntime().availableProcessors(); // 내 PC의 Processor 개수를 가져옴.
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // Thread Pool 관리 클래스
    executor.setCorePoolSize(processors); // 기본 스레드 개수
    executor.setMaxPoolSize(processors * 2); // 최대 스레드 개수 (Queue가 가득찬 이후 maxPoolSize만큼 생성)
    executor.setQueueCapacity(50); // 대기를 위한 Queue 크기
    executor.setKeepAliveSeconds(60);  // 스레드 재사용 시간
    executor.setThreadNamePrefix("AsyncExecutor-"); // 스레드 이름 prefix
    executor.initialize(); // ThreadPoolExecutor 생성

    return executor;
  }
}

 

- AlertService interface

 

public interface AlertService {
    void createAlert(Long showInfoId);
    void sendMail(Alert alert) throws MessagingException;
}

 

- class EmailAlertService implements AlertService

@Slf4j
@Service
@RequiredArgsConstructor
public class EmailAlertService implements AlertService {
    private final JavaMailSender mailSender;
    private final SpringTemplateEngine templateEngine;

    private final AlertRepository alertRepository;
    private final ShowArtistRepo showArtistRepo;
    private final ShowGenreRepo showGenreRepo;
    private final GenreSubscribeRepo genreSubscribeRepo;
    private final ArtistSubscribeRepo artistSubscribeRepo;

    private static final String EMAIL_TITLE_PREFIX = "[STAGE ALARM] 알림 : 새 공연이 등록되었습니다";
    private static final String EMAIL_ALARM_TITLE = "알림 : 새 공연이 등록되었습니다";


    @Override
    public void createAlert(Long showInfoId) {
        // 해당 공연정보에서 아티스트 관련 알림 객체 생성
        log.info("===== artist alert creation start");
        List<ShowArtist> showArtists = showArtistRepo.findByShowInfoId(showInfoId);
        generateArtistSubAlert(showArtists);
        log.info("===== artist alert creation end");

        log.info("===== genre alert creation start");
        List<ShowGenre> showGenres = showGenreRepo.findByShowInfoId(showInfoId);
        generateGenreSubAlert(showGenres);
        log.info("===== genre alert creation end");

        List<Alert> alerts = alertRepository.findByShowInfoId(showInfoId);
        for (Alert alert : alerts) {
            try {
                log.info("send email start");
                alert.setMessage(generateMessage(alert, alert.getUserNickname()));
                sendMail(alert);
            } catch (MessagingException e) {
                log.warn(e.getMessage());
                throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
            }
        }
    }

    private void generateArtistSubAlert(List<ShowArtist> shows) {
        for (ShowArtist artist : shows) {
            List<ArtistSubscribe> subscribes = artistSubscribeRepo.findByArtistId(artist.getArtist().getId());
            for(ArtistSubscribe subscribe : subscribes) {
                log.info("subscribe :: "+subscribe.toString());
                Alert alert = Alert.builder()
                    .showInfo(artist.getShowInfo())
                    .userEmail(subscribe.getUserEntity().getEmail())
                    .userNickname(subscribe.getUserEntity().getNickname())
                    .artistSubscribe(subscribe)
                    .title(EMAIL_ALARM_TITLE)
                    .build();

                alert = alertRepository.save(alert);
                log.info("saved..artist alert : "+alert.toString());
            }
        }
    }

    private void generateGenreSubAlert(List<ShowGenre> shows) {
        for (ShowGenre genre : shows) {
            List<GenreSubscribe> subscribes = genreSubscribeRepo.findByGenreId(genre.getGenre().getId());
            for (GenreSubscribe subscribe : subscribes) {
                String userEmail = subscribe.getUserEntity().getEmail();
                Optional<Alert> alertOptional = alertRepository.findByUserEmailAndShowInfoId(userEmail, genre.getShowInfo().getId());
                Alert alert;
                // 이미 해당 구독자 유저에 대한 알림이 생성된 상태이면 이미 생성된 alert에 추가만 하고 알림 이메일은 보내지 않음
                if (alertOptional.isPresent()) {
                    alert = alertOptional.get();
                    alert.setGenreSubscribe(subscribe);
                    alertRepository.save(alert);
                    log.info("==== set same alert for " + subscribe.getUserEntity().getNickname());
                    continue;
                } else { // 한 유저에 대한 알림이 생성되지 않은 상태이면 새로 생성
                    alert = Alert.builder()
                        .showInfo(genre.getShowInfo())
                        .genreSubscribe(subscribe)
                        .title(EMAIL_ALARM_TITLE)
                        .userEmail(userEmail)
                        .userNickname(subscribe.getUserEntity().getNickname())
                        .build();
                    alertRepository.save(alert);
                }
                log.info("saved..genre alert : "+alert.toString());
            }
        }
    }

    private String generateMessage(Alert alert, String userNickname){
        StringBuffer sb = new StringBuffer();
        sb.append("안녕하세요. ").append(userNickname).append("님, 스테이지 알람에서 알림 드립니다.   \n");
        sb.append("구독하신 ");
        if (alert.getGenreSubscribe() != null && alert.getArtistSubscribe()!=null) {
            sb.append("아티스트 :: ").append(alert.getArtistSubscribe().getArtist().getName()).append("와 ");
            sb.append("장르 :: ").append(alert.getGenreSubscribe().getGenre().getName()).append("의 공연 정보가 등록되었습니다.  \n");
        } else if (alert.getGenreSubscribe() == null) {
            sb.append("아티스트 :: ").append(alert.getArtistSubscribe().getArtist().getName()).append("의 공연 정보가 등록되었습니다.  \n");
        } else {
            sb.append("장르 :: ").append(alert.getGenreSubscribe().getGenre().getName()).append("의 공연 정보가 등록되었습니다.  \n");
        }
        sb.append("해당 공연 보기 : ").append(alert.getShowInfo().getTicketVendor()).append(" \n\n");
        sb.append("저희 스테이지 알람을 사랑해주셔서 감사합니다. ");

        return sb.toString();
    }

    @Override
    @Async("threadPoolTaskExecutor")
    public void sendMail(Alert alert) throws MessagingException {
        log.info("===== email sending start");

        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

        helper.setSubject(EMAIL_TITLE_PREFIX + alert.getTitle()); //제목
        helper.setFrom("stage alarm <noreply@stagealarm.com>");
        helper.setTo(alert.getUserEmail());
        HashMap<String, String> emailValues = new HashMap<>();
        emailValues.put("content", alert.getMessage());
        String text = setContext(emailValues);
        helper.setText(text, true);
        helper.addInline("logo", new ClassPathResource("static/images/logo.png"));
        helper.addInline("notice-icon", new ClassPathResource("static/images/image-1.png"));

        mailSender.send(message);
        log.info("===== email sending end");
    }

    private String setContext(Map<String, String> emailValues) {
        Context context = new Context();
        emailValues.forEach(context::setVariable);
        return templateEngine.process("email/index.html", context);
    }
}

 

- 실제 불러오는 서비스 내부

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class ShowInfoService {
	// ...
    private final AlertService alertService; //인터페이스를 불러올 수 있다.

    // 공연 정보 등록
    @Transactional
    public ShowInfoResponseDto create(ShowInfoRequestDto dto, MultipartFile file) {
        UserEntity userEntity = facade.getUserEntity();

        // 공연 기본정보 + 아티스트 정보 + 장르 정보 + 이미지 파일

		// ...
        
        // 공연정보 저장 후 알림 생성
        ShowInfo finalSaved = showInfoRepository.save(saved);
        alertService.createAlert(finalSaved.getId());

        return ShowInfoResponseDto.fromEntity(finalSaved, userEntity);
    }

📨📨📨 결과 로그

 

1) 공연 정보 업로드시 uploadIntoS3 하고,

2) 해당 공연 정보에 대해서 저장을 하고,

3) 이  생성된 ShowInfo 객체를 가지고 관련 ShowArtist, ShowGenre 객체를 생성해주고 

4) 이를 활용해 구독자와 ShowArtist, ShowGenre 연결짓는 Alert 객체를 만들어주고

5) 이 만들어진 Alert를 바탕으로 이메일 알림을 발송한다. (이때 확인하고 중복되게 알림이 보내지지 않는다)

6) 그렇게 알림은 비동기적으로 차례로 처리가 되고 이후 스레드가 닫힌다. 

 

🌟🌟동기 처리 방식 vs. 비동기 처리 방식 🌟🌟

동기 처리와 비동기 처리의 차이를 느껴보고자 실험을 했다. 

5명의 구독자에게 이메일을 발송하였다. 

 

1) 동기 처리 방식 : 공연정보 업로드 요청시 5명의 구독자에게 이메일을 모두 발송하는 시간까지 더해져 15.37s 기록

2) 비동기 처리 방식 : 공연정보 업로드 요청시 439ms 기록, 요청 보낸 후에 이메일 전송이 차례로 이루어지고, 이메일 전송이 이루어지는 사이에도 여러번 요청을 보낼 수 있다. 

 

 

 

 

 

728x90

BELATED ARTICLES

more