🚀 프로젝트/🥁 두둥

[Spring] 스프링 Slack 메세지 전송하기 (Incoming WebHooks 활용하여 슬랙봇 만들기)

gengminy 2023. 3. 3. 14:35

500 에러 발생 시 메세지 보내주는 슬랙봇

개발 중 로컬 서버에서 발생한 에러는 바로 탐지가 가능하지만,

배포 서버에서 실행 중일 때 발생한 에러는 바로 탐지가 어렵다.

 

그래서 에러 발생 시 메세지를 띄워주면 좋겠다고

누구나 한 번 쯤은 생각해보았을 것이다.

 

Slack API 를 활용하여 알림을 보낸다면

이러한 알림 서비스를 구현하고 활용할 수 있다.

 

다만 이 파트에서는 에러 자동 탐지 알림 대신

사용자 편의를 의한 단순 메세지 발송에 대해서만 적었다.

 

500 서버 에러 탐지 알림은 아래 다른 게시글에 올려두었다.

https://gengminy.tistory.com/53

 

[Spring] 스프링 500 에러 발생 시 Slack 알림 전송하기 (슬랙 봇)

지난 글에 이은, 본격적으로 500 내부 서버 에러 발생 시 슬랙으로 알림을 전송해주는 봇을 만들어보는 글이다. Slack API 를 활용하여 이러한 알림 서비스를 구현할 수 있다. ⚙️ Dependency implementati

gengminy.tistory.com

 

 

⚙️ Dependency

implementation 'com.slack.api:slack-api-client:1.27.2'
implementation group: 'org.apache.commons', name: 'commons-lang3', version: '3.12.0'
  • Slack API 사용을 위한 slack api client 추가
  • StringUtils 를 사용하기 위한 apache 모듈 추가 (이건 반드시 필요한 것은 아님)

 

 

🚀 Incoming Webhooks URL 발급받기

토큰을 가져와 Slack API Bean 을 만들어 등록 및 사용하는 방식도 있지만,

이 글에서는 Incoming Webhooks 라는 서드파티 앱을 추가한 후

전용 URL 을 발급받아 메세지를 전송하는 방식을 택했다.

 

🔍 Incoming Webhooks URL 발급받는 방법

더보기

1. 채널 세부정보 보기

Slack 워크스페이스 생성 -> 채널 오른쪽 클릭 -> 채널 세부정보 보기

 

 2. 앱 추가 들어가기

통합 -> 앱 -> 앱 추가

 

3. Incoming Webhooks 앱 추가

Incoming WebHooks 검색 -> 앱 디렉터리에서 -> 설치

Slack에 추가 클릭

메세지를 받기 원하는 채널 선택 -> 수신 웹후크 통합 앱 추가 클릭

 

다음과 같이 웹후크 URL 이 발급된다!

이것을 슬랙 URL 로 등록하고, 메세지를 보내면 된다.

 

 

 

🔍 Incoming Webhooks URL 작동 테스트 하기

웹훅 URL 발급받은 창에서 아래로 내리면

다음과 같이 예시 요청을 보낼 수 있도록 샘플 코드가 주어진다.

 

curl -X POST --data-urlencode "payload={\"channel\": \"#webhook\", \"username\": \"webhookbot\", \"text\": \"이 항목은 #개의 webhook에 포스트되며 webhookbot이라는 봇에서 제공됩니다.\", \"icon_emoji\": \":ghost:\"}" https://hooks.slack.com/services/{고유 문자열}

 

위에 있는 요청을 보냈을 때 응답으로 ok 가 온다면

송수신이 정상적으로 이루어짐을 확인할 수 있다.

 

슬랙 채널에서도 알림이 성공적으로 오는 모습

 

 

 

🚀 Slack URL 등록 비즈니스 로직

이제 채널 URL 발급받는 방법을 알았으니

채널을 등록하고 메세지를 보내보도록 하자.

 

 

📝 HostController

@RestController
@RequestMapping("/v1/hosts")
@RequiredArgsConstructor
public class HostController {    
    private final UpdateHostSlackUrlUseCase updateHostSlackUrlUseCase;
    
    //... 생략

    @PatchMapping("/{hostId}/slack")
    public HostDetailResponse patchHostSlackUrlById(
            @PathVariable Long hostId,
            @RequestBody @Valid UpdateHostSlackRequest updateHostSlackRequest) {
        return updateHostSlackUrlUseCase.execute(hostId, updateHostSlackRequest);
    }
}

특정 호스트의 아이디를 통해 호스트를 가져온다

 

 

📝 UpdateHostSlackRequest

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UpdateHostSlackRequest {
    @NotBlank(message = "올바른 슬랙 URL 을 입력해주세요")
    @URL(message = "올바른 슬랙 URL 을 입력해주세요")
    private String slackUrl;
}

호스트 슬랙 업데이트를 위한 요청 DTO

단순하게 String 만 하나 가져오고

DTO 내부에서 Validation 한다.

 

 

📝 UpdateHostSlackUrlUseCase

@UseCase
@RequiredArgsConstructor
public class UpdateHostSlackUrlUseCase {
    private final HostService hostService;
    private final HostAdaptor hostAdaptor;
    private final HostMapper hostMapper;

    private final SlackMessageProvider slackMessageProvider;

    @Transactional
    @HostRolesAllowed(role = MANAGER, findHostFrom = HOST_ID)
    public HostDetailResponse execute(Long hostId, UpdateHostSlackRequest updateHostSlackRequest) {
        final Host host = hostAdaptor.findById(hostId);
        final String slackUrl = updateHostSlackRequest.getSlackUrl();
        hostService.validateDuplicatedSlackUrl(host, slackUrl);

        try {
            slackMessageProvider.register(slackUrl);
            return hostMapper.toHostDetailResponse(hostService.updateHostSlackUrl(host, slackUrl));
        } catch (UnknownHostException e) {
            throw InvalidSlackUrlException.EXCEPTION;
        }
    }
}

두둥 프로젝트에서는 Service 끼리의 참조에서 생기는

순환 참조와 복잡도 증가 등을 막기 위해

UseCase 라는 레이어를 하나 더 도입했다.

즉 UseCase 에서 종합적인 비즈니스 로직이 실행된다.

 

SlackUrl 의 중복 주입을 검증하고

레포지토리에서 Host 를 가져와 주입하는 로직이다.

 

try catch 로 감싼 이유는

이후 내부에서 반환되는 UnknownHostException 을 깔끔하게 처리하기 위함이다.

 

반환값은 Setter 호출 및 Response DTO 생성이니

무시해도 된다.

 

 

📝 SlackMessageProvider

@Service
@RequiredArgsConstructor
@Slf4j
public class SlackMessageProvider {

    @Value("${slack.webhook.username}")
    private String username;

    @Value("${slack.webhook.icon-url}")
    private String iconUrl;

    /** 이벤트 핸들러 자체에서 비동기로 실행하기 때문에 @Async 어노테이션 지움 */
    public void sendMessage(String url, String text) {
        // 슬랙 url 이 null 일경우 안보냄.
        if (Objects.isNull(url)) return;
        try {
            doSend(url, text);
        } catch (Exception ignored) {
        }
    }

    /** 호스트가 존재하는 지 확인하기 위해 동기로 처리 */
    public void register(String url) throws UnknownHostException {
        final String text = "두둥 슬랙 알림이 성공적으로 등록되었습니다!";
        doSend(url, text);
    }

    private void doSend(String url, String text) throws UnknownHostException {
        final Slack slack = Slack.getInstance();
        final Payload payload =
                Payload.builder().text(text).username(username).iconUrl(iconUrl).build();
        try {
            String responseBody = slack.send(url, payload).getBody();
            if (!StringUtils.equals(responseBody, "ok")) {
                throw new UnknownHostException("올바른 슬랙 URL이 아닙니다.");
            }
        } catch (UnknownHostException error) {
            // 호스트가 존재하지 않을 경우 abort
            throw error;
        } catch (IOException e) {
            log.error(e.getMessage(), e);
            throw new RuntimeException(e);
        }
    }
}

이제 슬랙 API 를 본격적으로 사용하는 부분이다.

doSend 라는 private method 에서는

해당 URL 에 텍스트를 보내는 로직을 수행한다.

 

아까 전에 해당 URL 에 Payload 를 담아 post 요청을 보내면

응답 값으로 ok 가 반환되는 것을 확인했다.

요청에 실패하면 슬랙 API 자체에서 UnknownErrorException 가 던져지는데,

 

이를 응용하여 "ok" 가 반환되면 통과시키고

그렇지 않으면 UnknownErrorException 을 임의로 던져주도록 변형했다.

 

IOException 은 슬랙 메세지 전송 실패에 대해 처리하기 위해 추가했다.

 

Slack 인스턴스를 가져와서 Payload 에 원하는 값을 넣고

slack.send() 메소드를 호출하면 메세지 전송이 된다.

 

다른 슬랙 알림 같은 경우는 @Async 를 통해 비동기로 처리했으나,

등록 로직 같은 경우는 응답이 제대로 오는지 안오는지 먼저 확인해야 하기 때문에

동기로 처리하였다.

 

이상한 url 을 입력하여 메세지 전송에 실패했을 때 오류
슬랙 url 등록 성공 시 메세지

 

🚀 Slack 비동기 메세지 전송 비즈니스 로직

 

이벤트를 간편하게 구현하기 위해 커스텀 객체들이 많으니 감안하고 봐주시길

 

이벤트 발행과 처리에 대해서 이 글에서 상세하게 다루지는 않겠다.

 

📝 Host

@Getter
public class Host extends BaseTimeEntity {
	
    //...생략
    
    public void setSlackUrl(String slackUrl) {
        if (StringUtils.equals(this.slackUrl, slackUrl)) {
            throw DuplicateSlackUrlException.EXCEPTION;
        }
        Events.raise(HostRegisterSlackEvent.of(this));
        this.slackUrl = slackUrl;
    }
    
	//...생략
}

Events 오브젝트는 ApplicationEventPublisher 를 감싸서 임의로 구현한 것이다.

publishEvent 의 의미라고 생각하면 되겠다.

Host 내부에서 setter 를 호출할 때 호스트 등록 이벤트를 발생시켰다.

 

 

📝 HostRegisterSlackEvent

@Getter
@Builder
@ToString
public class HostRegisterSlackEvent extends DomainEvent {
    private final Long hostId;
    private final String hostName;

    public static HostRegisterSlackEvent of(Host host) {
        return HostRegisterSlackEvent.builder()
                .hostId(host.getId())
                .hostName(host.toHostProfileVo().getName())
                .build();
    }
}

Events 에서 DomainEvent 라는 객체로 통합 처리하기 위해 이를 상속받는다.

발행시킬 이벤트는 POJO 로 구성한다.

 

 

📝 DomainEvent

@Getter
public class DomainEvent {
    private final LocalDateTime publishAt;

    public DomainEvent() {
        this.publishAt = LocalDateTime.now();
    }
}

DomainEvent 객체는 그냥 이렇게 생겼다.

 

 

📝 HostRegisterSlackEventHandler

@Component
@RequiredArgsConstructor
@Slf4j
public class HostRegisterSlackEventHandler {
    private final HostAdaptor hostAdaptor;
    private final SlackMessageProvider slackMessageProvider;

    @Async
    @TransactionalEventListener(
            classes = HostRegisterSlackEvent.class,
            phase = TransactionPhase.AFTER_COMMIT)
    public void handle(HostRegisterSlackEvent hostRegisterSlackEvent) {
        final Host host = hostAdaptor.findById(hostRegisterSlackEvent.getHostId());
        final String message = HostSlackAlarm.slackRegistrationOf(host);

        slackMessageProvider.sendMessage(host.getSlackUrl(), message);
    }
}

발행시킨 이벤트를 리스닝하고 처리해주는 핸들러를 구현한다.

 

@Async 를 통해 비동기로 처리하며,

@TransactionalEventListener 의 phase 를 AFTER_COMMIT 으로 설정해

트랜잭션이 종료되어 성공적으로 Host 엔티티에 값이 들어간 이후에

이 이벤트가 실행되도록 했다.

 

슬랙 url 등록 성공 시 메세지

그래서 아래의 메세지가 성공적으로 나오게 된다.

 

이를 활용하여 각종 호스트 유저 가입, 티켓 판매 알림 등등

알림을 구현하여 유저에게 편의성을 제공하였다.