Notice
Recent Posts
Recent Comments
Link
«   2024/12   »
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 31
Tags
more
Archives
Today
Total
관리 메뉴

우당탕탕 개발일지

[Spring] 스케줄링+ Discord webhook 본문

Spring

[Spring] 스케줄링+ Discord webhook

YUDENG 2024. 10. 6. 21:18

스케줄링이란 일정한 시간 간격으로 반복적인 작업을 수행하는 도구이다. 스프링 부트 애플리케이션에서는 주기적인 작업을 스케줄링하기 위한 어노테이션 기반의 방법을 제공한다. @Scheduled 어노테이션을 사용하면 일정한 시간 혹은 특정 시간에 코드가 실행되도록 설정할 수 있다.

 

스케줄러 속성

  • fixedDelay: 메서드의 실행이 끝난 시간을 기준으로, 설정된 밀리세컨드 간격마다 실행
  • fixedRate: 메서드의 실행이 시작하는 시간을 기준으로, 설정된 밀리세컨드 간격마다 실행
  • initialDelay: 설정된 밀리세컨드 시간 후부터 fixedDelay 간격마다 실행
  • cron: Cron 표현식을 사용하여 설정한 시간에 실행

* fixedDelay의 경우 해당 작업이 끝난 시점부터 시간을 측정하고, fixedRate의 경우 해당 작업의 시작 시점부터 시간을 측정한다.

 

현재 프로젝트의 경우, 오전 8시와 오후 8시 지정된 시간에 작업을 실행해야 했기에 cron을 사용하였다. Cron 표현식은 다음과 같다.

 

  • 오전 8시: 0 0 8 * * *
  • 오후 8시: 0 0 20 * * *

첫 번째 *부터 초(0-59) 분(0-59) 시간(0-23) 일(1-31) 월(1-12) 요일(0-6) (0: 일, 1: 월, 2:화, 3:수, 4:목, 5:금, 6:토) 로 표현할 수 있다. 스프링의 @Scheduled cron은 6자리 설정만 허용하며 연도 설정을 할 수 없다.

 

1. Application Class 작성

@EnableScheduling
@SpringBootApplication
public class BudgetApplication {

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

}

 

@Scheduled를 사용하기 위해서는 @EnableScheduling 어노테이션을 명시해주어야 한다.

 

 

2. 스테줄러 작성

@Slf4j
@Component
public class SchedulerService {
    private final ConsultingService consultingService;
    private final DiscordService discordService;

    public SchedulerService(ConsultingService consultingService, DiscordService discordService) {
        this.consultingService = consultingService;
        this.discordService = discordService;
    }

    @Scheduled(cron = "0 0 8 * * *")
    public void run() {
        log.info("스케줄러가 실행되었습니다.");
        discordService(consultingService.suggestExpense());
    }

    @Scheduled(cron = "0 0 20 * * *")
    public void run() {
        log.info("스케줄러가 실행되었습니다.");
        discordService(consultingService.getTodayExpense());
    }
}

 

스케줄러를 사용하기 위해서는 스프링 빈에 스케줄러가 등록되어야 한다. 스케줄러를 적용할 대상 클래스에 @Component를 추가하고, 주기적으로 실행하고 싶은 메서드에 @Scheduled를 추가한다.

 

스케줄러 메서드는 다음과 같은 규칙이 있다.

  • 메서드는 void 반환 타입을 가져야 한다.
  • 메서드는 매개변수 사용이 불가능하다.

처음에는 로그인된 유저 정보를 사용하여 스케줄러에 Security Context를 사용하려고 하였다.  여기서 문제점은 스케줄러가 동작할 때, 실제로는 백그라운드에서 동작하기 때문에 로그인된 유저의 정보를 직접 가져오는 것이 불가능했다.

 

그래서 각 사용자에게 디스코드 웹훅 URL을 등록할 수 있도록 User 엔티티 구조를 수정하였다. 사용자는 자신의 디스코드 웹훅 URL을 등록하거나 수정할 수 있도록 API를 요청한다. 알림을 보낼 땐 디스코드 웹훅 URL이 등록된 사용자에게만 알림을 전송한다.

 

3. User 엔티티 및 서비스 수정

@Entity
@Getter @Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    private String userName; // 계정명

    private String password;

    private String discordWebhookUrl; // 디스코드 웹훅 URL

    public void setDiscordWebhookUrl(String discordWebhookUrl) {
        this.discordWebhookUrl = discordWebhookUrl;
    }
}
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

	. . .

    @Transactional
    public void updateDiscordWebhookUrl(CustomUserDetails customUserDetails, String discordWebhookUrl) {
        User user = customUserDetails.getUser();
        user.setDiscordWebhookUrl(discordWebhookUrl);  // 디스코드 웹훅 URL 설정
        userRepository.save(user);
    }

}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    . . .
    List<User> findByDiscordWebhookUrlIsNotNull();
}

 

 

다음은 디스코드 웹훅 연동 과정이다. 먼저 디스코드에 알림용 채널을 생성한 후, 채널 설정 페이지에 들어가면 다음과 같은 연동 설정을 볼 수 있다.

 

이름과 채널을 지정해준 후, 웹후크 URL을 복사한다.

 

Discord 웹훅은 별도로 API를 제공해주지 않기 때문에 WebClient를 사용하여 HTTP 통신을 직접 구현하였다.

 

1. 의존성 추가

repositories {
	mavenCentral()
	maven { url 'https://jitpack.io' }  // JitPack 저장소 추가
}

dependencies {

	. . .

	// Discord
	implementation 'com.github.napstr:logback-discord-appender:1.0.0'

	// WebClient
	implementation 'org.springframework.boot:spring-boot-starter-webflux'
}

 

2. WebConfig 작성

@Configuration
public class WebConfig {

    @Bean
    public WebClient webClient() {
        return WebClient.builder()
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }
}

 

3. DTO 작성

@Data
public class DiscordMessage<T> {
    private String content;
    private boolean tts = false; // 텍스트 음성 변환
    private  T data;
}

 

두 종류의 메세지를 보낼 예정이기 때문에 제네릭 타입으로 명시해주었다. 다음은 Discord에 보낼 메시지 형식이다.

@Data
@AllArgsConstructor
public class SuggestExpenseResponse {
    private long totalAmount; // 총액
    private Map<String, Long> categoryAmount; // 카테고리 별 금액
    private String message;
}
@Data
@AllArgsConstructor
public class GuideExpenseResponse {
    private long totalAmount; // 총액
    private Map<String, CategoryExpenseDetail> categoryDetails;

    @Data
    @AllArgsConstructor
    public static class CategoryExpenseDetail {
        private long idealAmount; // 적정 금액
        private long spentAmount; // 지출한 금액
        private double riskPercentage; // 위험도 (퍼센트로 표시)
    }
}

 

 

4. DiscordService 작성

@Slf4j
@Service
public class DiscordService {
    private final WebClient webClient;

    public DiscordService(WebClient webClient) {
        this.webClient = webClient;
    }

    public Mono<Void> sendSuggestExpense(String webHookUrl, SuggestExpenseResponse response) {
        DiscordMessage discordMessage = new DiscordMessage();
        discordMessage.setContent("오늘의 지출 추천");
        discordMessage.setData(response);

        log.info("Discord Webhook URL: {}", webHookUrl);
        log.info("Discord Message: {}", discordMessage);

        return webClient.post()
                .uri(webHookUrl)
                .bodyValue(discordMessage)
                .retrieve()
                .onStatus(
                        status -> !status.is2xxSuccessful(),
                        clientResponse -> clientResponse.bodyToMono(String.class)
                                .doOnNext(errorBody -> log.error("Discord 응답 실패: {}", errorBody))
                                .then(Mono.error(new RuntimeException("Discord Webhook 전송 실패")))
                )
                .bodyToMono(String.class)
                .doOnNext(responseBody -> log.info("Discord 응답: {}", responseBody)) // 응답 로그 추가
                .then()
                .doOnError(error -> log.error("디스코드 알림 전송 실패: {}", error.getMessage()));
    }

    public Mono<Void> sendGuideExpense(String webHookUrl, GuideExpenseResponse response) {
        DiscordMessage discordMessage = new DiscordMessage();
        discordMessage.setContent("오늘의 지출 안내");
        discordMessage.setData(response);

        log.info("Discord Webhook URL: {}", webHookUrl);
        log.info("Discord Message: {}", discordMessage);

        return webClient.post()
                .uri(webHookUrl)
                .bodyValue(discordMessage)
                .retrieve()
                .bodyToMono(String.class)
                .then()
                .doOnError(error -> System.err.println("디스코드 알림 전송 실패: " + error.getMessage()));
    }
}

 

728x90