Stan 기술블로그

self-hosted Sentry에 Slack 통합을 붙이며

TL;DR 사내 self-hosted Sentry에 Slack 알림을 붙였다. WebHooks 레거시 플러그인은 2025년 중반부터 신규 설치가 막혀 있어 조직 설정의 Integrations를 써야 했다. Event Subscriptions URL 검증이 계속 튕겼고, 원인은 리버스 프록시의 IP 화이트리스트가 Slack의 요청을 앱 앞에서 막고 있었기 때문이다. (당시엔 “외부 경로 자체가 없다”로 판단했다가 이후 실측으로 정정했다 — §4 정정 참고.) 결론은 인바운드 3종(Event Subscriptions·Slash Command·Interactivity)을 의도적으로 포기하고 아웃바운드 알림만 유지 — 원래 요청은 그것만으로 충족된다.


배경

이전 글에서 세팅한 self-hosted Sentry(sentry.x.xxx.kr) 위에, 이번엔 프론트팀에서 요청이 들어왔다.

“우리 프로젝트 에러가 뜨면 Slack 채널로 알림을 받고 싶어요.”

익숙한 요청이라 시작은 단순해 보였다. Slack App 하나 만들고, Sentry에 자격증명 등록하고, 프로젝트에 알림 규칙 하나 만들면 끝나는 줄 알았다. 실제로는 URL 검증에서 몇 시간을 태웠고, 마지막엔 기능 중 일부를 의도적으로 포기하는 것이 답이었다.


어떤 상황이었나

프론트팀에서 특정 프로젝트의 Sentry 이슈가 발생하면 Slack 채널에 알림을 받고 싶다는 요청이었다. 익숙한 흐름이라 프로젝트 설정 페이지로 바로 들어갔다.

sentry.x.xxx.kr/settings/<조직>/projects/<프로젝트>/plugins/

여기서 WebHooks를 켜면 될 줄 알았는데, 항목 자체가 뜨지 않았다. 다른 레거시 플러그인들도 대부분 비활성 표시(This Plugin is deprecated and not available to install on new projects)로 잠겨 있었다.

버그인 줄 알고 이슈 트래커부터 뒤졌더니, 이건 의도된 변경이었다.


WebHooks는 왜 사라졌나 — 개념 정리

Sentry는 2025년 중반에 레거시 플러그인의 신규 설치를 차단했다 (관련 PR #91550). 이후 대부분의 플러그인은 “deprecated” 상태로 잠기고, 새 프로젝트에는 설치가 안 된다.

권장 흐름은 조직 설정의 Integrations를 통과하는 구조로 재편됐다. Slack의 경우 두 층으로 나뉜다.

층 담당
조직 Integrations Slack OAuth로 워크스페이스에 붙임 (조직당 1회)
프로젝트 Alert Rule “Send a Slack notification” 액션으로 채널 지정

이 구조가 왜 나은가. 한 조직에서 Slack을 한 번만 연결해두면 여러 프로젝트가 재사용할 수 있고, 자격증명을 프로젝트 플러그인마다 흩어놓지 않아도 된다. 조직 자원과 프로젝트 자원의 경계를 분리한 방향이다.

이제 이 흐름을 따라가면 되는데 — 함정은 여기서부터였다.


층별 추적기

[1차] Slack App 만들고 자격증명 반영

api.slack.com/apps에서 App 생성 → Basic Information에서 Client ID / Client Secret / Signing Secret 확보 → 서버 sentry.conf.py에 등록.

SENTRY_OPTIONS['slack.client-id']       = '<masked>'
SENTRY_OPTIONS['slack.client-secret']   = '<masked>'
SENTRY_OPTIONS['slack.signing-secret']  = '<masked>'

docker compose restart 후 반영 여부를 실제로 확인.

docker compose exec web sentry shell -c \
  "from sentry import options; print(options.get('slack.signing-secret'))"

값이 정상 출력되면 옵션이 실제로 로드된 것. 이전 CSRF 작업에서 배운 감각이 여기서 쓰였다.

설정이 반영됐다고 믿기 전에 어디서 어떻게 읽고 있는지를 먼저 확인한다.

[2차] Slack App 세부 URL·스코프 설정

Slack App 설정에 Sentry 쪽 엔드포인트를 붙였다.

  • Slash Commands: /sentry → https://sentry.x.xxx.kr/extensions/slack/commands/
  • Interactivity Request URL: https://sentry.x.xxx.kr/extensions/slack/action/
  • Options Load URL: https://sentry.x.xxx.kr/extensions/slack/options-load/
  • Bot Token Scopes 12개 (channels:read, chat:write, chat:write.customize, chat:write.public, commands, groups:read, im:history, im:read, links:read, links:write, team:read, users:read)

여기까지는 문서 순서 그대로.

[3차 — 함정 시작] Event Subscriptions URL 검증 실패

Event Subscriptions에 https://sentry.x.xxx.kr/extensions/slack/event/를 넣고 저장하니 이 에러가 떴다.

Your request URL responded with an HTTP error.
Your URL didn't respond with the value of the challenge parameter.

Slack이 검증용 challenge 값을 POST하고 서버가 그 값을 응답 바디로 되돌려줘야 통과하는 방식인데, 응답이 이상하다는 뜻이다.

먼저 서버 내부와 도메인 양쪽에서 직접 찔러봤다.

curl localhost:9000/extensions/slack/event/            # 401
curl https://sentry.x.xxx.kr/extensions/slack/event/   # 401

둘 다 401 Unauthorized로 동일. 이 시점에선 “nginx 라우팅 문제는 아니고, 요청은 Sentry 앱까지 도달한다”고 결론 냈다.

컨테이너 로그도 봤다.

INFO   slack.event.url_verification  ...
ERROR  slack.action.auth             ...

URL 인식은 되는데 서명 검증에서 막힘. 다만 이건 서명 헤더 없이 수동으로 curl을 찌른 결과라 401은 정상이었다.

여기서 이상해서 로그 tail을 걸어두고 Slack App 화면에서 Save를 다시 눌렀다.

로그에 아무것도 찍히지 않았다.

서명이나 인증에서 막힌다고 보여도, 진짜 원인이 그 앞단(요청이 서버에 도달하는지)에 있을 수 있다. 도달성부터 확인한다.

[4차 — 원인 규명] 인바운드가 막힌 세계

셀프 curl은 앱까지 도달하는데, 진짜 Slack이 보내는 요청은 로그에 아예 찍히지 않는다. 답은 하나였다.

sentry.x.xxx.kr은 내부망 리버스 프록시로만 열려 있고, 외부 인터넷에 노출되지 않은 도메인이다. DNS는 풀리더라도 해당 IP까지 가는 경로는 사내망 밖에서는 존재하지 않는다. Slack 클라우드 서버는 아무리 우리를 부르려 해도 우리 게이트웨이까지 못 온다.

함정은 확인 과정 자체에 있었다. “외부 접근이 되는지” 확인한다고 셀프 서버에서 도메인 curl을 찔렀는데, 그건 결국 같은 사내망 안에서 도는 요청이었다. 진짜 외부 경로를 검증한 게 아니었다. 이 구분이 안 돼서 몇 시간을 태웠다.

셀프 curl은 애플리케이션이 요청을 어떻게 처리하는지를 확인하는 도구지, 인터넷에서 우리 서버에 도달할 수 있는지를 확인하는 도구가 아니다.

📌 정정 — ingest 외부 노출 편 이후

위에서 “사내망 밖에서는 경로가 존재하지 않는다”고 쓴 건 부정확했다. 이후 앱 SDK용 ingest 경로를 외부에 열면서 외부망(LTE)에서 실측해보니 타임아웃이 아니라 403이 돌아왔다. 요청은 서버까지 도달하고 있었고, 리버스 프록시(NPM)의 IP 화이트리스트(Access List)가 애플리케이션 앞에서 차단하고 있었을 뿐이다.

그러니 Slack의 URL 검증 요청이 앱 로그에 안 찍힌 진짜 이유는 “못 온 것”이 아니라 프록시에서 403으로 잘려 앱까지 못 간 것이다. 인바운드 3종을 포기한 결정 자체는 그대로 유효하다 — 화이트리스트를 Slack IP 대역에 여는 건 별개의 보안 판단이 필요하고, 그 판단은 이 글의 범위 밖이다. 실측 과정은 ingest 엔드포인트 외부 노출 편에.

[5차 — 방향 판단] 무엇을 포기할 것인가

여기서 갈래가 갈렸다.

  • A. 서버를 외부에 노출한다 — 공인 IP나 별도 프록시로 Slack이 도달할 수 있게 만든다. 대신 세무 데이터를 다루는 서비스의 외부 노출은 편의를 넘는 별도의 보안 결정이 된다.
  • B. 인바운드가 필요한 기능만 포기한다 — 원래 목적이 인바운드 없이도 충족되는지 다시 본다.

Slack 통합의 기능들을 방향으로 나눠봤다.

기능 방향 우리 환경에서
Event Subscriptions Slack → Sentry ❌ 인바운드 필요
Slash Commands (/sentry) Slack → Sentry ❌ 인바운드 필요
Interactivity (버튼·모달) Slack → Sentry ❌ 인바운드 필요
에러 알림 전송 Sentry → Slack ✅ 아웃바운드로 충분

원래 요청은 “에러 알림”이었다. Sentry가 Slack API를 호출해서 메시지를 밀어넣는 방향이라, Slack이 우리 서버에 접근할 필요가 자체가 없다. 필요한 방향이 이미 확보되어 있었다.

B로 갔다. 세무 데이터를 다루는 서비스의 외부 노출을 인바운드 편의 몇 개 때문에 감수하는 건 균형이 안 맞는다. Event Subscriptions·Slash Command·Interactivity는 검증 실패 상태 그대로 방치.

통합 기능은 “필요한 방향이 이미 확보되어 있는가”부터 좁혀서 본다. 인바운드가 안 되는 환경에서는 인바운드 기능 포기가 종종 정답이다.

[6차] OAuth 콜백 URL 등록

방향을 정리하고 Sentry Settings → Integrations → Slack → Add Workspace를 눌렀더니 다음 에러.

redirect_uri did not match any configured URIs.
Passed URI: https://sentry.x.xxx.kr/extensions/slack/setup/

원인은 Slack App의 OAuth Redirect URLs에 Sentry 콜백 주소가 등록되어 있지 않았다는 것.

한 가지 흥미로운 지점 — OAuth 리다이렉트는 Slack 서버가 직접 우리 서버를 호출하는 게 아니라 사용자 브라우저를 우리 서버로 튕겨보내는 방식이다. 즉 브라우저 경유라 인바운드가 안 되는 내부망 환경에서도 정상 작동한다. 방향으로 보면 이것도 “우리가 원인 제공하고 사용자 브라우저가 우리한테 오는 것”에 가깝다.

Slack App → OAuth & Permissions → Redirect URLs에 https://sentry.x.xxx.kr/extensions/slack/setup/를 추가하고 재시도하니 워크스페이스 연결 성공.

[7차] Alert Rule 생성과 첫 발송 실패

프로젝트 → Settings → Alerts → Create Alert Rule에서 “Issues” 템플릿 선택.

  • WHEN: A new issue is created
  • THEN: Send a Slack notification → 채널 #<프로젝트>-alerts
  • Action interval: 24시간 (동일 이슈 도배 방지)
  • Owner: 프론트팀 (팀에서 직접 규칙 수정할 수 있도록)

테스트 발송 시 다음 에러.

Slack: The resource "<프로젝트>-alerts" does not exist or has not been granted access

채널이 아직 Slack에 만들어져 있지 않았다. 채널을 공개(Public)로 새로 만들자 chat:write.public 스코프 덕에 봇 초대 없이도 발송이 됐다. 재테스트에서 실제 에러 알림(TypeError)이 채널에 도착했다.


왜 이렇게 걸렸나

한 번에 정리하면 층별로 사건이 흩어져 있었다.

층 사건
벤더 정책 1차 진입 — WebHooks 레거시 신규 설치 차단
자격증명 반영 1차 — SENTRY_OPTIONS에 등록 후 sentry shell로 검증
Slack App URL·스코프 2차 — 문서대로 세팅
접근 제어 3~4차 — IP 화이트리스트에 막힌 인바운드
판단 5차 — 기능 포기가 정답
OAuth 콜백 6차 — Redirect URL 누락
Slack 채널 상태 7차 — 채널 미생성

문제 자체는 “Slack 통합 안 됨”이라는 하나였지만, 원인은 벤더 정책·네트워크·판단·설정 여러 층에 흩어져 있었다.

이전 글에서도 같은 감각이 있었다 — 증상 하나로 뭉쳐 있어도 원인은 층별로 다르게 발현된다.


최종 설정 (기록용)

sentry/sentry.conf.py

SENTRY_OPTIONS['slack.client-id']       = '<masked>'
SENTRY_OPTIONS['slack.client-secret']   = '<masked>'
SENTRY_OPTIONS['slack.signing-secret']  = '<masked>'

Slack App 설정

OAuth Redirect URLs:
  https://sentry.x.xxx.kr/extensions/slack/setup/

Bot Token Scopes:
  channels:read, chat:write, chat:write.customize, chat:write.public,
  commands, groups:read, im:history, im:read, links:read, links:write,
  team:read, users:read

Event Subscriptions : 비활성 (IP 화이트리스트로 인바운드 차단, 의도적 포기)
Slash Commands      : 비활성
Interactivity       : 비활성

Sentry Alert Rule

WHEN : A new issue is created
THEN : Send a Slack notification
       → 워크스페이스: <사내 워크스페이스>
       → 채널:       #<프로젝트>-alerts
Action interval : 24h
Owner           : 프론트팀

다음번 순서

같은 유형의 통합을 다시 붙일 때, 시작 전에 다음 순서를 따르면 이번 케이스의 3~4차 우회는 대부분 앞단에서 걸러진다.

  1. 통합 페이지의 기능 목록을 방향별로 분류한다 (아웃바운드 / 인바운드 / OAuth 리다이렉트). 우리 환경에서 되는 것과 안 되는 것을 시작 전에 갈라둔다.
  2. 원래 요구사항이 어느 방향만으로 충족되는지 확인한다. 아웃바운드로 되면 인바운드는 안 뚫는다. 노출은 편의가 아니라 보안 결정이다.
  3. 외부 도달성 검증은 반드시 진짜 외부 네트워크에서 (개인 LTE, 외부 클라우드 인스턴스 등). 셀프 curl은 앱 처리만 확인해준다.
  4. 시크릿 반영은 sentry shell로 실제 로드 값까지 조회해서 검증한다. 재기동만으로 반영을 단정하지 않는다.

참고

self-hosted Sentry 카테고리의 글