Webhook을 사용해 slack 메세지 보내기

alsdl0629·2023년 9월 2일
1

기술 적용

목록 보기
2/6
post-thumbnail

이번 글에서는 Webhook을 사용해 Spring 서버와 Slack을 연동한 경험을 정리해 보려고 합니다.

Webhook이란?

한 시스템이 다른 시스템에 어떤 이벤트(일이 발생)가 발생했음을 알리는 방법입니다. 특정 이벤트가 발생하면 지정한 URL로 HTTP 요청(주로 POST 요청)을 보내는 방식입니다.

예를 들어, GitHub에서 Webhook 기능을 제공하여 사용자가 레포지터리에 커밋을 푸시하거나 새로운 이슈를 생성하는 등의 이벤트가 발생할 때마다 지정한 URL로 데이터를 전송합니다.

Webhook은 실시간성이 중요한 작업에 유용합니다.
그래서 Polling(주기적으로 데이터 상태 확인) 방식보다 효율적인 경우가 많습니다.

Polling 방식은 주기적으로 데이터를 확인해야 해서 불필요한 리소스 낭비를 하는 경우가 대부분이지만
Webhook은 실제로 발생할 때만 데이터를 전송하기 때문입니다.


Webhook 도입하게 된 배경

저희 서비스 기능 중 사용자가 버그를 제보할 수 있는 기능이 있습니다.
기존에는 프론트에서 버그 제보받은 것을 볼 수 있었습니다.

이 방식의 문제점은 수동으로 확인해야 해서 불편했고, 리소스가 낭비되었습니다. 게다가 버그 제보를 놓칠 일이 자주 발생할 것 같았습니다.

그래서 사용자가 버그를 제보할 때마다 팀 메신저인 Slack에 알림을 보내자는 의견이 나왔고, 이번에 도입하게 되었습니다.

추가로 서버 에러(500)가 발생했을 때도 Slack에 알림이 보내지도록 해서 개발자가 에러를 빨리 대응할 수 있도록 했습니다.


Slack 설정

Webhook과 연동할 Slack 채널 생성


먼저 이벤트 발생 시 알림 받을 채널을 생성해 주어야 합니다.

저는 현재 프로젝트에서 Slack을 사용 중이어서 에러와 버그 제보만 관리하는 채널을 따로 만들었습니다.


Webhook URL 생성

Incoming Webhook 을 사용하기 위해서는 먼저 Webhook URL 이 필요합니다.

Incoming WebHook이란?
직접 지정한 URL로 데이터를 보내주면 지정된 채널에 메시지를 보내주는 기능입니다.


  1. https://api.slack.com 접속 > Your apps > Create your first app

  1. From scratch > 앱 이름(소문자와 '-'로 구성) 과 슬랙 워크스페이스 선택 > Create App

  1. Collaborators > 동료 또는 절대삭제 안 되는 공용계정 추가

    만약 이 설정을 하지 않으면 설정을 만든 사용자가 워크스페이스를 나가게 되면 웹훅 URL도 비활성화되고 작동하지 않습니다.

    절대 워크스페이스를 나가지 않을 것 같은 팀원을 추가해주면 됩니다!


  1. Incoming Webhooks 선택 > On > Add New Webhook to Workspace > 채널 선택 > 허용

  1. Incoming Webhooks 선택 > Webhook URL 값 확인

Spring 프로젝트에 Slack Webhook 연동


의존성 추가

slack webhook을 사용할 수 있는 의존성을 추가해 줍니다.

	// slack
    implementation 'net.gpedro.integrations.slack:slack-webhook:1.4.0'

SlackConfig

위에서 생성한 Webhook URL을 복사해서 url, token에 각각 바인딩한 후 SlackApi 클래스를 Bean으로 등록합니다.

@Configuration
public class SlackConfig {

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

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

    @Bean
    public SlackApi slackApi() {
        return new SlackApi(url + token);
    }
}
slack:
  url: ${SLACK_URL}
  token: ${SLACK_TOKEN}

사용자 버그 제보 기능

// Event Publisher
@RequiredArgsConstructor
@Serviice
public class CreateBugReportUseCase {

    private final CommandBugReportPort commandBugReportPort;
    private final ApplicationEventPublisher eventPublisher;
    private final SecurityPort securityPort;

    public void execute(CreateBugReportRequest request) {
        BugReport savedBugReport = saveBugReport(request);
        String writer = securityPort.getCurrentStudent().getName();

         eventPublisher.publishEvent(BugReportEvent.builder()
                .bugReport(bugReport)
                .writer(writer)
                .build());
    }

    
// Event Class
@Getter
@Builder
public class BugReportEvent {
    private final BugReport bugReport;
    private final String writer;
}

// Event Listener
@RequiredArgsConstructor
@Component
public class BugReportHandler {

    private final WebhookUtil webhookUtil;

    @Async // 비동기 처리를 해주는 어노테이션
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // 트랜잭션이 commit된 이후 실행
    public void onBugReportEvent(BugReportEvent event) {
        webhookUtil.sendBugReport(
                event.getBugReport(), event.getWriter()
        );
    }
}

Spring Events(의도한 코드가 작동하면 다른 일을 함)를 사용해 서비스 로직과 외부 로직을 분리했습니다.

또한, Spring Events를 사용해 비동기 처리를 해서 효율적으로 사용하도록 했습니다.
만약 비동기 처리를 하지 않고 슬랙까지 메시지가 도달하는 데 1분이 걸리면
스레드가 1분 동안 계속 잡혀있어서 다른 일을 처리할 수 없기 때문에 비효율적입니다.


Slack 연동 코드

RequiredArgsConstructor
@Component
public class SlackAdapter implements WebhookUtil {

	// 상수를 한 곳으로 빼 관리하게 쉽게 만들었습니다.
    private static final String FALLBACK = "Ok";
    private static final String COLOR = "danger";
    private static final String BUG_REPORT_TITLE = "버그 제목";
    private static final String CONTENT = "버그 내용";
    private static final String DEVELOPMENT_AREA = "버그가 발생한 분야";
    private static final String WRITER = "버그 신고한 유저";
    private static final String BUG_TEXT = "버그 제보가 도착했습니다.";

    private final SlackApi slackApi;

    @Override
    public void sendBugReport(BugReport bugReport, String writer) {
        List<SlackAttachment> slackAttachments;

		// 이미지 존재 여부에 따라 메소드를 분리
        if (bugReport.getBugAttachments().isEmpty()) {
            slackAttachments = createBugReportSlackAttachments(bugReport, writer);
        } else {
            slackAttachments = createBugReportSlackAttachmentsWithImage(bugReport, writer);
        }
	
        SlackMessage slackMessage = createSlackMessage(BUG_TEXT, slackAttachments);

        slackApi.call(slackMessage);
    }

	// SlackAttachment는 SlackMessage 안에 있는 자료입니다.
    private List<SlackAttachment> createBugReportSlackAttachments(BugReport bugReport, String writer) {
        SlackAttachment slackAttachment = new SlackAttachment();

        List<SlackField> slackFields = createBugReportSlackFields(bugReport, writer);

        slackAttachment.setFallback(FALLBACK);
        slackAttachment.setColor(COLOR);
        slackAttachment.setFields(slackFields);

        return Collections.singletonList(slackAttachment);
    }

	// SlackAttachment는 이미지를 하나만 가질 수 있기 때문에 stream을 돌면서 이미지 개수에 맞게 생성해 주었습니다.
    private List<SlackAttachment> createBugReportSlackAttachmentsWithImage(BugReport bugReport, String writer) {
        List<SlackAttachment> slackAttachments = bugReport.getBugAttachments().stream()
                .map(bugAttachment -> {
                    SlackAttachment slackAttachment = new SlackAttachment();

                    List<SlackField> slackFields = createBugReportSlackFields(bugReport, writer);

                    slackAttachment.setFallback(FALLBACK);
                    slackAttachment.setColor(COLOR);
                    slackAttachment.setFields(slackFields);

                    slackAttachment.setImageUrl(url + bugAttachment.getAttachmentUrl());

                    return slackAttachment;
                })
                .toList();

        return slackAttachments;
    }

    private List<SlackField> createBugReportSlackFields(BugReport bugReport, String writer) {
        return List.of(
                createSlackField(BUG_REPORT_TITLE, bugReport.getTitle()),
                createSlackField(CONTENT, bugReport.getContent()),
                createSlackField(DEVELOPMENT_AREA, bugReport.getDevelopmentArea().toString()),
                createSlackField(WRITER, writer)
        );
    }

    private SlackField createSlackField(String title, String value) {
        return new SlackField()
                .setTitle(title)
                .setValue(value);
    }

	// SlackMessage는 Slack에 보내지는 메시지 단위입니다.
    private SlackMessage createSlackMessage(String text, List<SlackAttachment> slackAttachments) {
        SlackMessage slackMessage = new SlackMessage();
        slackMessage.setText(text);
        slackMessage.setAttachments(slackAttachments);
        return slackMessage;
    }
}

결과물


500번대 에러 발생 시 알림

클라이언트에서 요청할 때 서버에서 500번대 에러가 발생하는 경우가 있었습니다.
저희는 매번 EC2 인스턴스에 SSH(다른 컴퓨터에 접근)로 접속해서 docker log를 찍으며 수동으로 일일이 확인하고 고쳤습니다.

마침 슬랙과 연동하는 글을 보면서 500번대 에러가 발생할 때마다 알림을 보내는 것을 보고 프로젝트에 적용하면 좋을 것 같다고 생각했습니다.

@RequiredArgsConstructor
public class GlobalExceptionFilter extends OncePerRequestFilter {

    private final ObjectMapper objectMapper;
    private final ApplicationEventPublisher eventPublisher;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException {
        try {
            filterChain.doFilter(request, response);
        } catch (CustomException e) {
            writeErrorResponse(response, e.getErrorProperty());
        } catch (Exception e) {
            if (e.getCause() instanceof CustomException customException) {
                writeErrorResponse(response, customException.getErrorProperty());
            } else {
                e.printStackTrace();
                eventPublisher.publishEvent(ExceptionEvent.builder()
                        .request(request)
                        .exception(e)
                        .build());
                writeErrorResponse(response, GlobalErrorCode.INTERNAL_SERVER_ERROR);
            }
        }
    }
@Getter
@Builder
public class ExceptionEvent {

    private final HttpServletRequest request;
    private final Exception exception;
}
@RequiredArgsConstructor
@Component
public class ExceptionEventHandler {

    private final WebhookUtil webhookUtil;

    @Async
    @EventListener
    public void onExceptionEvent(ExceptionEvent event) {
        webhookUtil.sendExceptionInfo(event.getRequest(), event.getException());
    }
}

여기서도 동일하게 Spring Events와 비동기를 사용해 slack 알림을 보내도록 했습니다.
filter에서는 트랜잭션을 사용하지 않기 때문에 @EventListener 사용했습니다.


Slack 연동 코드

@RequiredArgsConstructor
@Component
public class SlackAdapter implements WebhookUtil {

	private static final String EXCEPTION_TITLE = "예외 발생";
    private static final String URL = "Request URL";
    private static final String METHOD = "Request Method";
    private static final String CURRENT_TIME = "Request Time";
    private static final String IP = "Request IP";
    private static final String USER_AGENT = "Request User-Agent";
    private static final String EXCEPTION_TEXT = "서버 에러 발생 😱😱😱";
    
    private final SlackApi slackApi;
    
    @Override
    public void sendExceptionInfo(HttpServletRequest request, Exception exception) {
        SlackAttachment slackAttachment = createExceptionSlackAttachment(request, exception);

        SlackMessage slackMessage = createSlackMessage(EXCEPTION_TEXT, Collections.singletonList(slackAttachment));

        slackApi.call(slackMessage);
    }
    
    private SlackAttachment createExceptionSlackAttachment(HttpServletRequest request, Exception exception) {
        SlackAttachment slackAttachment = new SlackAttachment();

        List<SlackField> slackFields = List.of(
                createSlackField(URL, request.getRequestURL().toString()),
                createSlackField(METHOD, request.getMethod()),
                createSlackField(CURRENT_TIME, new Date().toString()),
                createSlackField(IP, request.getRemoteAddr()),
                createSlackField(USER_AGENT, request.getHeader(USER_AGENT.substring(8)))
        );

        slackAttachment.setFallback(FALLBACK);
        slackAttachment.setColor(COLOR);
        slackAttachment.setTitle(EXCEPTION_TITLE);
        slackAttachment.setTitleLink(request.getContextPath());
        slackAttachment.setText(stackTraceToString(exception));
        slackAttachment.setFields(slackFields);
        return slackAttachment;
    }
    
    // 어떤 메서드들이 호출되었는지 확인하기 위해 사용
    private String stackTraceToString(Exception exception) {
        StringWriter stringWriter = new StringWriter();
        PrintWriter printWriter = new PrintWriter(stringWriter);
        exception.printStackTrace(printWriter);

        return stringWriter.toString();
    }

}

결과물

에러 관련 정보를 직접 docker log 명령어를 통해 수동으로 확인하던 것을 자동화하여 쉽고 빠르게 확인할 수 있도록 했습니다.


느낀점

저는 자동화를 통해 시간을 단축하고 실수를 줄이는 게 중요하다고 생각합니다.

이번 경험으로 사용자가 서비스 버그를 제보하거나 500번대 에러가 발생했을 때 Slack에 알림을 오게 함으로써 자주 확인이 가능하고, 버그 및 에러 대처를 빨리할 수 있게 되었습니다.

결과적으로 서비스의 안정성을 향상시키며, 사용자 만족도를 높이는데 기여할 수 있어서 뿌듯했습니다.

추후에 메시지 무손실을 보장하고, API 서버와 알림 서버를 분리해 결합도를 낮추기 위해 메시지큐를 적용할 예정입니다.

이외에도 저희는 Slack으로 노션과 깃허브를 연동해서 issue, pr, 노션 페이지 등 변경 사항이 있을때 마다 알림을 받을 수 있도록 해서 다른 팀원들에게 자신의 일을 간편하게 공유할 수 있도록 하고 있습니다.

프로젝트를 진행하는 데 도움이 되는 서비스가 있으면 연동해 보면 좋을 것 같습니다!

도움받은 글 🙇🙇🙇

https://shanepark.tistory.com/430
https://velog.io/@king/slack-incoming-webhook

profile
인풋보다 아웃풋

0개의 댓글