
프로젝트를 진행하면서 Slack 메시지에 버튼을 달고, 클릭 시 서버에서 이를 처리하는 기능이 필요했다. Slack에서 상호작용을 하는 방법은 과정을 정리해두면 나중에도 도움이 될 것 같아 블로그로 남긴다.
Slack Interactivity는 Slack 메시지 안에 버튼, 선택 메뉴, 모달 등의 UI 컴포넌트를 포함시키고, 사용자가 이를 조작했을 때 서버가 해당 이벤트를 수신해 처리할 수 있도록 하는 기능이다.
예를 들어 "승인 / 반려" 버튼이 포함된 알림 메시지를 Slack으로 보내고, 담당자가 버튼을 클릭하면 서버에서 해당 액션을 감지해 자동으로 후속 처리를 진행할 수 있다.

Slack API에서 대상 앱으로 이동한 뒤, Features → Interactivity & Shortcuts 메뉴에서 기능을 활성화한다.
활성화 후 Request URL에 interactive 요청을 수신할 API 엔드포인트를 입력해야 한다. 이 URL로 사용자의 버튼 클릭 이벤트 등이 전달된다.
로컬 테스트 시에는 Slack이 HTTPS만 허용하기 때문에 ngrok을 사용해야 한다
# ngrok 설치
brew install ngrok
# 앱 포트에 맞게 ngrok 실행 (예: 8082)
ngrok http 8082

ngrok 실행 후 생성된 Forwarding HTTPS URL을 복사해서, Slack App의 Request URL을 아래 형식으로 업데이트하면 된다.
http://127.0.0.1:4040)에서 요청 내역을 실시간으로 확인할 수 있다/slack/test-button API 호출로 버튼 메시지 전송slack:
signing-secret: ${SLACK_SIGNING_SECRET}
signing-secret은 Slack 요청의 진위 여부를 검증하기 위한 서명 시크릿이다. Slack App 설정 페이지의 Basic Information → App Credentials에서 확인할 수 있다. 외부에 노출되면 안 되므로 반드시 환경 변수로 관리해야 한다.
@Slf4j
@Component
public class SlackSignatureVerifier {
private static final String SLACK_SIGNATURE_VERSION = "v0";
private static final String HMAC_ALGORITHM = "HmacSHA256";
private static final long MAX_TIMESTAMP_AGE_SECONDS = 300; // 5분
public boolean verifySignature(String signingSecret, String slackSignature, String timestamp, String requestBody) {
// 타임스탬프 검증 (재생 공격 방지)
if (!isTimestampValid(timestamp)) return false;
// 서명 생성 및 비교 (상수 시간 비교로 타이밍 공격 방지)
String baseString = SLACK_SIGNATURE_VERSION + ":" + timestamp + ":" + requestBody;
String computedSignature = generateSignature(signingSecret, baseString);
return constantTimeEquals(slackSignature, computedSignature);
}
// ...
}
Slack이 전송한 요청이 실제 Slack에서 온 것인지 HMAC-SHA256 방식으로 검증한다. 보안을 위해 두 가지를 추가로 처리한다.
@Getter
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class SlackInteractivityPayload {
private String type;
private User user;
private List<Action> actions;
@JsonProperty("response_url")
private String responseUrl;
// 중첩 클래스: User, Action, Container, Channel, Message 등
// ...
public Action getFirstAction() {
return actions != null && !actions.isEmpty() ? actions.get(0) : null;
}
}
Slack이 전송하는 interactive payload를 역직렬화하기 위한 모델이다. @JsonIgnoreProperties(ignoreUnknown = true)를 적용해 불필요한 필드는 무시한다. getFirstAction(), findActionById() 헬퍼 메서드로 액션을 쉽게 조회할 수 있다.
@Service
@RequiredArgsConstructor
@Slf4j
public class SlackInteractivityService {
private final ObjectMapper objectMapper;
private final SlackSignatureVerifier signatureVerifier;
private final SlackProperties slackProperties;
public boolean verifyRequest(String slackSignature, String timestamp, String requestBody) {
return signatureVerifier.verifySignature(slackProperties.getSigningSecret(), slackSignature, timestamp, requestBody);
}
public String extractPayloadFromRequestBody(String requestBody) {
// form data에서 payload 파라미터 추출 후 URL 디코딩
}
public SlackInteractivityPayload parseJsonPayload(String payloadJson) throws JsonProcessingException {
return objectMapper.readValue(payloadJson, SlackInteractivityPayload.class);
}
}
Slack 요청 서명 검증, payload 추출 및 파싱을 담당한다. 한 가지 주의할 점은 Slack의 interactive 요청이 application/x-www-form-urlencoded 형식으로 전달된다는 것이다. 따라서 payload 파라미터를 직접 추출한 후 JSON으로 파싱하는 과정이 필요하다.
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/slack/interactive")
public class SlackInteractivityController {
private final SlackInteractivityService slackInteractivityService;
@Hidden
@PostMapping(value = "", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<?> handleInteraction(
HttpServletRequest request,
@RequestHeader("X-Slack-Signature") String slackSignature,
@RequestHeader("X-Slack-Request-Timestamp") String timestamp
) {
String requestBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
if (!slackInteractivityService.verifyRequest(slackSignature, timestamp, requestBody)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
String payload = slackInteractivityService.extractPayloadFromRequestBody(requestBody);
SlackInteractivityPayload slackPayload = slackInteractivityService.parseJsonPayload(payload);
// TODO: actionId에 따른 비즈니스 로직 처리 (비동기 권장)
return ResponseEntity.ok().build();
}
}
서버 내부에서만 사용하는 컨트롤러이기 때문에 @Hidden을 적용해 Swagger 문서에서 제외했다.
또한, Slack은 3초 내에 응답을 요구하므로, 비즈니스 로직은 비동기로 처리하고 즉시 200 OK를 반환해야 한다. 예외가 발생하더라도 Slack의 재시도를 방지하기 위해 항상 200 OK를 반환하는 것이 좋다.
이후에는 요청으로 들어온 actionId를 기반으로 원하는 비즈니스 로직을 연결하면 된다.
Slack 공식 문서를 순서대로 따라가면 어렵지 않게 Slack Interactivity를 구성할 수 있다. 버튼 클릭 한 번으로 승인/반려, 알림 처리 등 다양한 워크플로우를 자동화할 수 있으니, Slack을 메인 협업 도구로 쓰고 있는 경우에는 활용해보면 좋을 것 같다!