이끌든지 따르든지 비키든지

Framework/Spring

[Spring] 혼자서 해보는 가상화폐 시세 알림 API - 확장

SeongHo5 2024. 1. 17. 23:38

새로운 요구사항에 대응하는 기능 확장 시나리오

 

추가된 요구 사항 : 슬랙의 슬래시 커맨드 기능 통합

  • 슬래시 커맨드 기능을 통해 애플리케이션의 기능에 접근할 수 있어야 한다.

 

1. 슬래시 커맨드 요청 처리를 위한 컨트롤러 구현

 

@RestController
@RequiredArgsConstructor
@RequestMapping("/slack")
public class SlackController {

    private final SlackCommandDispatcher commandDispatcher;

    @PostMapping("/slash-command")
    public ResponseEntity<String> handleSlashCommand(
            final @RequestParam("channel_id") String channelId,
            final @RequestParam("channel_name") String channelName,
            final @RequestParam("user_id") String userId,
            final @RequestParam("user_name") String userName,
            final @RequestParam("command") String command,
            final @RequestParam("text") String text
    ) {
        SlackSlashCommandDto commandDto =
                new SlackSlashCommandDto(
                        channelId,
                        channelName,
                        userId,
                        userName,
                        command,
                        text);
        return slackService.handleSlashCommand(commandDto);
    }
}

 

사용자의 명령어 입력을 수신하고, 적절한 서비스 로직을 호출하여 응답하는 SlackController를 구현했습니다. 

이 컨트롤러는 슬랙으로부터 전송된 슬래시 커맨드 요청 정보를 DTO에 매핑하고, 요청을 파싱하고, handleSlashComamnd에게 넘겨줍니다.

 


 

2. 서비스 레이어 구현

 

서비스 레이어에서 슬래시 커맨드 요청 정보를 이력으로 로깅하고, 파싱해 요청한 작업에 맞는 작업을 호출하도록 구성했습니다.

 

@Service
@RequiredArgsConstructor
public class SlackCommandDispatcher {

    private final SlackCommandService slackCommandService;

    public String handleSlashCommandInternal(RequestSlashCommand request) {
        slackCommandService.saveCommandHistory(request);

        String channelId = request.channelId();
        String parsedCommand = parseCommand(request.command());
        String arguments = request.text();

        return switch (parsedCommand) {
            case COMMAND_MARKET_LIST -> slackCommandService.callMarketListService();
            case COMMAND_INFO -> slackCommandService.callInfoService(channelId, arguments);
            case COMMAND_ALARM -> slackCommandService.callAlarmService(channelId, Integer.parseInt(arguments));
            case COMMAND_STOP -> slackCommandService.stopScheduledTask();
            default -> NO_SUCH_COMMAND.getMessage();
        };
    }

    private String parseCommand(String command) {
        if (command.startsWith(COMMAND_PREFIX)) {
            return command.substring(COMMAND_PREFIX.length());
        }
        throw new NoSuchServiceException(INVALID_INPUT_VALUE);
    }

}

 

 

각각의 서비스 로직은 작업에 필요한 정보를 캐싱하고, 작업을 호출한 뒤, 슬래시 커맨드 요청에 정상 처리 메세지를 반환합니다.

 

@Service
@Transactional
@RequiredArgsConstructor
public class SlackCommandService {

    private final SlackCommandHistoryRepository historyRepository;
    private final CryptoScheduledService cryptoScheduledService;
    private final CryptoService cryptoService;
    private final RedisService redisService;

	// 커맨드 이력 저장
    @Async
    public void saveCommandHistory(RequestSlashCommand request) {
        SlackCommandHistory history = SlackCommandHistory.of(request);
        historyRepository.save(history);
    }

    protected String callMarketListService() {
        List<MarketList> marketList = cryptoService.getMarketAll();
        return generateMarketListResponse(marketList);
    }

    protected String callInfoService(String channelId, String marketName) {
        storeInfoTaskInCache(channelId, marketName);
        cryptoScheduledService.startCurrenyInfoTask();
        return MESSAGE_WHEN_INFO_START;
    }

    protected String callAlarmService(String channelId, int targetPrice) {
        storeAlarmTaskInCache(channelId, targetPrice);
        cryptoScheduledService.startCurrencyAlarmTask();
        return MESSAGE_WHEN_ALARM_START;
    }

 

 

 

▶ 슬랙 API 상호작용에서의 빠른 응답의 중요성

슬랙 API는 상호작용 시 'Acknowledgment response'라는 응답이 존재하는데, 이는 일반적인 엔드포인트 요청의 처리 결과를 나타내는 것이 아니라, 요청이 성공적으로 수신되었음을 알리는 역할을 합니다.
슬랙은 요청을 받은 후 3초 이내에 응답을 받지 못하면 오류 메시지를 표시하므로, 앱은 빠르게 요청을 확인하고 응답을 보내야 합니다.

 

 


 

3. 스케줄링 로직 수정

 

이전에 @Scheduled를 활용해 정적으로 구성했던 스케줄링 작업을, 추가된 요구사항에 따라 더 유연하고 동적으로 관리할 수 있도록 서비스 객체를 추가했습니다.

 

@Service
public class SchedulerService {

    private final TaskScheduler taskScheduler;
    private ScheduledFuture<?> scheduledFuture;

    public SchedulerService(@Qualifier("taskScheduler") TaskScheduler taskScheduler) {
        this.taskScheduler = taskScheduler;
    }

    public void startScheduledTask(Runnable task, Duration duration) {
        if (scheduledFuture == null || scheduledFuture.isDone()) {
            scheduledFuture = taskScheduler.scheduleAtFixedRate(task, duration);
        }
    }
    public void stopScheduledTask() {
        if (scheduledFuture != null && !scheduledFuture.isDone()) {
            scheduledFuture.cancel(true);
        }
    }
}

 

 

이 스케줄링 서비스 객체를 통해 시세 정보 / 목표가 알림 작업을 제어하도록 CryptoScheduledService을 구성했습니다.

 

@Service
@Slf4j
@RequiredArgsConstructor
public class CryptoScheduledService {

    private final RedisService redisService;
    private final SchedulerService schedulerService;
    private final UpbitFeignClient upbitClient;
    private final ApplicationEventPublisher eventPublisher;

    public void startCurrenyInfoTask() {
        schedulerService.startScheduledTask(this::fetchCurrencyInfo, Duration.ofMinutes(1));
        publishNotificationEvent(INFO, MESSAGE_WHEN_INFO_START);
    }

    public void startCurrencyAlarmTask() {
        schedulerService.startScheduledTask(this::fetchTradePrice, Duration.ofSeconds(5));
        publishNotificationEvent(ALARM, MESSAGE_WHEN_ALARM_START);
    }

    public void stopScheduledTask() {
        schedulerService.stopScheduledTask();
    }

    // ========== PRIVATE METHODS ========== //

    private void fetchCurrencyInfo() {
        String infoData = redisService.getData(CommandType.INFO.getPrefix(), String.class);
        List<MarketPrice> data = upbitClient.getCandlesMinutes(1, parseArgumentFromData(infoData), 1);
        publishNotificationEvent(INFO, generateCurrencyInfoResponse(data));
    }

    /**
     * 거래 가격을 조회하고, 목표가에 도달했는지 확인한다.
     */
    private void fetchTradePrice() {
        TickerMessage tickerMessage = redisService.getData(TICKER_KEY, TickerMessage.class);
        String alarmData = redisService.getData(CommandType.ALARM.getPrefix(), String.class);
        checkIfReachedTargetPrice(tickerMessage.tradePrice(), new BigDecimal(parseArgumentFromData(alarmData)));
    }

    private void checkIfReachedTargetPrice(BigDecimal tradePrice, BigDecimal targetPrice) {
        boolean isReachedTargetPrice = tradePrice.compareTo(targetPrice) >= 0;
        if (isReachedTargetPrice) {
            publishNotificationEvent(ALARM, generateAlarmResponse(targetPrice, tradePrice));
        }
    }

    private String parseArgumentFromData(String data) {
        return data.split(ARGUMENTS_SEPARATOR)[1];
    }

    protected void publishNotificationEvent(CommandType type, String message) {
        eventPublisher.publishEvent(new SlackNotificationEvent(this, type, message));
    }
}

 

 

커맨드 수신 응답 메세지와, 알림 메세지를 ResponseTemplate 클래스에 응집해 관리하도록 구성했습니다.

알림 메세지는 Text Block을 사용해 구성해 가독성을 높였습니다.

 

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class ResponseTemplate {
    public static final String MESSAGE_WHEN_INFO_START = "1분마다 시세 정보를 알려드릴게요 :smile:";
    public static final String MESSAGE_WHEN_ALARM_START = "말씀하신 가격에 도달하면 알려드릴게요 :smile:";
    public static final String MESSAGE_WHEN_ALARM_STOP = ":bell: 실행 중인 알람을 모두 중지했어요.";

    public static String generateMarketListResponse(List<MarketList> marketList) {
        List<MarketList> topTenList = marketList.stream()
                .limit(10)
                .toList();
        return """
                :moneybag: 거래 가능한 마켓 목록 :moneybag:
                %s
                (총 %s개의 마켓이 조회되었어요.)
                (최대 10개의 마켓만 보여드려요.)
                (모든 마켓을 보고 싶으시다면 홈페이지에 방문해주세요.:money_with_wings:)
                ========================
                """
                .formatted(
                        convertMarketTitleToKorean(topTenList),
                        marketList.size());
    }


    public static String generateCurrencyInfoResponse(List<MarketPrice> marketPriceList) {
        MarketPrice marketPrice = marketPriceList.get(0);
        return """
                :money_mouth_face: %s 시세 정보 :money_mouth_face:
                현재가: %s원
                고가: %s원
                저가: %s원
                기준 시간: %s
                ========================
                """
                .formatted(
                        marketPrice.market(),
                        formatBigDecimalToKRW(marketPrice.tradePrice()),
                        formatBigDecimalToKRW(marketPrice.highPrice()),
                        formatBigDecimalToKRW(marketPrice.lowPrice()),
                        marketPrice.candleDateTimeKst());
    }

    public static String generateAlarmResponse(BigDecimal targetPrice, BigDecimal tradePrice) {
        return """
                :bell: 알람이 울렸어요! :bell:
                현재가: %s원
                목표가: %s원
                ========================
                """
                .formatted(
                        formatBigDecimalToKRW(tradePrice),
                        formatBigDecimalToKRW(targetPrice));
    }

    private static String convertMarketTitleToKorean(List<MarketList> marketLists) {
        return marketLists.stream()
                .map(market -> String.format("시장 정보 : %s, 한글명 : %s, 영문명 : %s, 유의 종목 여부 : %s",
                        market.market(), market.koreanName(), market.englishName(), market.marketWarning()))
                .collect(Collectors.joining("\n"));
    }
}

 

 


 

▶ 정리

 

1. SlackService는 사용자의 슬래시 커맨드 요청을 처리하고, 이에 따라 CryptoScheduledService를 호출합니다. 

 

2. CryptoScheduledService는 실제 작업의 실행 로직을 담당하며, SchedulingService를 사용하여 특정 작업을 스케줄링하거나 중지합니다. 이 과정에서, CryptoScheduledService 내의 fetchTradePrice와 fetchCurrencyInfo 메서드들이 스케줄링되어 주기적으로 실행됩니다.