<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Web Development on kenji.blog</title><link>http://kenji.blog/ko/categories/web-development/</link><description>Recent content in Web Development on kenji.blog</description><generator>Hugo -- gohugo.io</generator><language>ko</language><copyright>kenjinote</copyright><lastBuildDate>Sun, 13 Sep 2026 12:00:00 +0900</lastBuildDate><atom:link href="http://kenji.blog/ko/categories/web-development/index.xml" rel="self" type="application/rss+xml"/><item><title>OAuth 2.0 작동 원리 도해: Slack App 연동 구현으로 배우기</title><link>http://kenji.blog/ko/p/oauth2-architecture-slack-app-integration/</link><pubDate>Sun, 13 Sep 2026 12:00:00 +0900</pubDate><guid>http://kenji.blog/ko/p/oauth2-architecture-slack-app-integration/</guid><description>&lt;img src="http://kenji.blog/p/oauth2-architecture-slack-app-integration/img/eyecatch.jpg" alt="Featured image of post OAuth 2.0 작동 원리 도해: Slack App 연동 구현으로 배우기" />&lt;h1 id="들어가며-왜-oauth-20을-배워야-하는가">들어가며: 왜 OAuth 2.0을 배워야 하는가?
&lt;/h1>&lt;p>현대의 웹 애플리케이션에서 여러 서비스가 연동되어 동작하는 것은 더 이상 낯선 풍경이 아닙니다. 예를 들어, &amp;ldquo;Google 계정으로 로그인하기&amp;rdquo;, &amp;ldquo;Trello의 작업이 업데이트되면 Slack으로 알림 보내기&amp;rdquo;, &amp;ldquo;Zoom 회의 링크를 Google 캘린더에 자동 추가하기&amp;quot;와 같은 기능들입니다. 이 모든 것의 이면에서 활약하고 있는 것이 &lt;strong>OAuth 2.0 (Open Authorization 2.0)&lt;/strong> 이라는 인가(Authorization) 프레임워크입니다.&lt;/p>
&lt;p>과거에는 다른 서비스 간에 데이터를 주고받을 때, 사용자가 자신의 ID와 비밀번호를 연동 대상 서비스에 직접 전달하는 &amp;ldquo;기본 인증(Basic Authentication)&amp;ldquo;이나 &amp;ldquo;비밀번호 공유&amp;quot;라는 매우 위험한 방식이 사용되었습니다. 하지만 이 방법은 연동 대상 서비스가 사용자의 모든 권한을 쥐게 되어 치명적인 보안 위험을 수반합니다.&lt;/p>
&lt;p>OAuth 2.0은 이러한 &amp;ldquo;비밀번호 공유&amp;quot;를 방지하면서, &amp;ldquo;특정 권한(스코프)만&amp;quot;을 &amp;ldquo;제한된 시간 동안만&amp;rdquo; 서드파티 애플리케이션에 위임하기 위한 표준 프로토콜(RFC 6749)로 탄생했습니다.&lt;/p>
&lt;p>본 문서에서는 이 OAuth 2.0의 작동 원리를, 비즈니스 커뮤니케이션 툴의 사실상 표준(De facto standard)으로 자리 잡은 &lt;strong>Slack (Slack API)&lt;/strong> 을 대상으로 한 애플리케이션(Slack App) 구현을 통해 매우 상세하고 실천적으로 해설합니다. Node.js (Express)를 사용한 코드 예제, 프로토콜의 흐름을 도식화한 시퀀스 다이어그램, 그리고 보안상 중요한 개념인 &lt;code>state&lt;/code> 파라미터나 PKCE의 수학적·암호학적 배경까지 파고든, 1만 자가 넘는 결정판 해설입니다.&lt;/p>
&lt;hr>
&lt;h1 id="1-oauth-20의-기본-개념-4가지-역할roles">1. OAuth 2.0의 기본 개념: 4가지 역할(Roles)
&lt;/h1>&lt;p>OAuth 2.0을 이해하기 위한 첫걸음은 등장인물(Role)을 정확히 파악하는 것입니다. RFC 6749에서는 다음의 4가지 역할을 정의하고 있습니다.&lt;/p>
&lt;pre class="mermaid">
graph TD
RO[&amp;#34;Resource Owner (사용자)&amp;#34;] -- &amp;#34;인가를 부여&amp;#34; --&amp;gt; C[&amp;#34;Client (Slack App)&amp;#34;]
C -- &amp;#34;인가를 요청&amp;#34; --&amp;gt; AS[&amp;#34;Authorization Server (Slack 인가 서버)&amp;#34;]
AS -- &amp;#34;액세스 토큰 발급&amp;#34; --&amp;gt; C
C -- &amp;#34;토큰을 사용하여 접근&amp;#34; --&amp;gt; RS[&amp;#34;Resource Server (Slack API 서버)&amp;#34;]
RO -- &amp;#34;ID/비밀번호로 로그인&amp;#34; --&amp;gt; AS
&lt;/pre>
&lt;ol>
&lt;li>&lt;strong>Resource Owner (리소스 오너)&lt;/strong>
&lt;ul>
&lt;li>리소스에 대한 접근 권한을 부여할 수 있는 권한을 가진 엔티티입니다. 일반적으로 &amp;ldquo;엔드 유저(사람)&amp;ldquo;를 가리킵니다. 이번 예제에서는 &amp;ldquo;Slack 워크스페이스에 소속되어 있고 채널에 메시지를 게시할 권한을 가진 여러분 자신&amp;quot;입니다.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Client (클라이언트)&lt;/strong>
&lt;ul>
&lt;li>리소스 오너의 허가를 얻어 리소스 서버에 접근하려는 애플리케이션입니다. 이번 예제에서는 &amp;ldquo;여러분이 개발 중인 Node.js 애플리케이션(Slack App)&amp;ldquo;입니다. &amp;ldquo;클라이언트&amp;quot;라는 이름이지만, 서버 사이드에서 동작하는 웹 애플리케이션이라 하더라도 OAuth의 문맥에서는 &amp;ldquo;클라이언트&amp;quot;라고 불립니다.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Authorization Server (인가 서버)&lt;/strong>
&lt;ul>
&lt;li>리소스 오너를 인증하고, 리소스 오너로부터 인가를 얻은 후 클라이언트에게 액세스 토큰을 발급하는 서버입니다. 이번 예제에서는 &lt;code>slack.com/oauth/v2/authorize&lt;/code> 를 제공하는 Slack의 인증 기반입니다.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Resource Server (리소스 서버)&lt;/strong>
&lt;ul>
&lt;li>보호된 리소스를 호스팅하며, 액세스 토큰을 사용한 리소스 접근 요청을 접수하고 응답하는 서버입니다. 이번 예제에서는 &lt;code>chat.postMessage&lt;/code> 등의 API를 제공하는 &lt;code>slack.com/api/&lt;/code> 의 엔드포인트입니다.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ol>
&lt;p>OAuth의 흐름이란 한마디로 &lt;strong>&amp;ldquo;Client가 Resource Owner의 동의를 얻어 Authorization Server로부터 액세스 토큰을 받고, 이를 사용하여 Resource Server에서 데이터를 조회·조작하는&amp;rdquo;&lt;/strong> 일련의 절차를 의미합니다.&lt;/p>
&lt;hr>
&lt;h1 id="2-인가-코드-그랜트-authorization-code-grant-완전-해부">2. 인가 코드 그랜트 (Authorization Code Grant) 완전 해부
&lt;/h1>&lt;p>OAuth 2.0에는 여러 플로우(그랜트 타입)가 존재하지만, 웹 애플리케이션과 같이 서버 사이드에서 비밀키(Client Secret)를 안전하게 보관할 수 있는 환경에서 가장 권장되며 널리 쓰이는 것이 &lt;strong>인가 코드 그랜트(Authorization Code Grant)&lt;/strong> 입니다.&lt;/p>
&lt;p>인가 코드 그랜트의 가장 큰 특징은 &lt;strong>프론트 채널(브라우저를 경유하는 통신)&lt;/strong> 과 &lt;strong>백 채널(서버 간 직접 통신)&lt;/strong> 을 명확히 분리하고 있다는 점입니다. 프론트 채널에서는 일회성 &amp;ldquo;인가 코드(Authorization Code)&amp;ldquo;만을 주고받으며, 최종적인 &amp;ldquo;액세스 토큰&amp;quot;의 취득은 백 채널에서 수행함으로써 토큰이 브라우저의 방문 기록이나 리퍼러에 유출될 위험을 극적으로 낮춥니다.&lt;/p>
&lt;p>다음 시퀀스 다이어그램은 Slack App에서 인가 코드 그랜트의 전체 과정을 보여줍니다.&lt;/p>
&lt;pre class="mermaid">
sequenceDiagram
autonumber
participant U as &amp;#34;Resource Owner (Web Browser)&amp;#34;
participant C as &amp;#34;Client (Node.js App)&amp;#34;
participant AS as &amp;#34;Authorization Server (Slack Auth)&amp;#34;
participant RS as &amp;#34;Resource Server (Slack API)&amp;#34;
U-&amp;gt;&amp;gt;C: &amp;#34;앱 설치 버튼 클릭 (GET /slack/install)&amp;#34;
Note over C: &amp;#34;state 파라미터 생성&amp;#34;
C--&amp;gt;&amp;gt;U: &amp;#34;리다이렉트: 302 Found (Location: Slack Auth URL)&amp;#34;
U-&amp;gt;&amp;gt;AS: &amp;#34;GET /oauth/v2/authorize?client_id=...&amp;amp;scope=...&amp;amp;state=...&amp;#34;
AS--&amp;gt;&amp;gt;U: &amp;#34;Slack 로그인 화면 &amp;amp; 권한 승인 화면 (Consent Screen)&amp;#34;
U-&amp;gt;&amp;gt;AS: &amp;#34;권한 허용 (Allow)&amp;#34;
Note over AS: &amp;#34;인가 코드 (code) 생성&amp;#34;
AS--&amp;gt;&amp;gt;U: &amp;#34;리다이렉트: 302 Found (Location: Client Callback URL?code=...&amp;amp;state=...)&amp;#34;
U-&amp;gt;&amp;gt;C: &amp;#34;GET /slack/oauth_redirect?code=...&amp;amp;state=...&amp;#34;
Note over C: &amp;#34;state 파라미터 검증 (CSRF 대책)&amp;#34;
C-&amp;gt;&amp;gt;AS: &amp;#34;POST /api/oauth.v2.access (code, client_id, client_secret)&amp;#34;
Note over C,AS: &amp;#34;백 채널 통신 (브라우저를 경유하지 않음)&amp;#34;
AS--&amp;gt;&amp;gt;C: &amp;#34;200 OK (JSON: access_token 등 발급)&amp;#34;
C-&amp;gt;&amp;gt;RS: &amp;#34;POST /api/chat.postMessage (Authorization: Bearer &amp;lt;access_token&amp;gt;)&amp;#34;
RS--&amp;gt;&amp;gt;C: &amp;#34;200 OK (메시지 게시 성공)&amp;#34;
&lt;/pre>
&lt;p>이 흐름을 구체적인 Node.js (Express) 코드 구현을 통해 하나씩 분석해 보겠습니다.&lt;/p>
&lt;hr>
&lt;h1 id="3-구현-준비-slack-developer-console에서의-설정">3. 구현 준비: Slack Developer Console에서의 설정
&lt;/h1>&lt;p>코드를 작성하기 전에, Slack 시스템에 &amp;ldquo;새로운 클라이언트가 존재함&amp;quot;을 등록해야 합니다.&lt;/p>
&lt;ol>
&lt;li>&lt;a class="link" href="https://api.slack.com/apps" target="_blank" rel="noopener"
>Slack API: Applications&lt;/a> 에 접속하여 &amp;ldquo;Create New App&amp;quot;을 클릭합니다.&lt;/li>
&lt;li>&amp;ldquo;From scratch&amp;quot;를 선택하고, 앱 이름(예: &lt;code>My First OAuth App&lt;/code>)과 설치할 워크스페이스를 지정합니다.&lt;/li>
&lt;li>생성 후 나타나는 &amp;ldquo;Basic Information&amp;rdquo; 화면에서 다음의 두 가지 중요한 크리덴셜(자격 증명)을 확인합니다.
&lt;ul>
&lt;li>&lt;strong>Client ID&lt;/strong>: 앱을 공개적으로 고유하게 식별하는 ID. 브라우저를 경유하는 요청(프론트 채널)에 포함되어도 문제가 없습니다.&lt;/li>
&lt;li>&lt;strong>Client Secret&lt;/strong>: 여러분의 앱만 알고 있는 비밀 문자열. &lt;strong>절대 브라우저 측에 노출시키거나 GitHub 등에 커밋해서는 안 됩니다.&lt;/strong>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&amp;ldquo;OAuth &amp;amp; Permissions&amp;rdquo; 화면으로 이동하여 &amp;ldquo;Redirect URLs&amp;quot;에 콜백 받을 URL을 등록합니다. 이번에는 로컬 개발을 가정하여 다음을 설정합니다.
&lt;ul>
&lt;li>&lt;code>http://localhost:3000/slack/oauth_redirect&lt;/code>&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ol>
&lt;p>이제 준비가 완료되었습니다. 서버 구현에 들어가겠습니다.&lt;/p>
&lt;hr>
&lt;h1 id="4-구현-단계-1-slackinstall-과-csrf-대책을-위한-state-파라미터">4. 구현 단계 1: &lt;code>/slack/install&lt;/code> 과 CSRF 대책을 위한 &lt;code>state&lt;/code> 파라미터
&lt;/h1>&lt;p>사용자가 앱 이용을 시작하기(워크스페이스에 설치하기) 위한 첫 번째 엔드포인트를 생성합니다. 여기서의 가장 큰 책임은 Slack 인가 서버로 사용자를 리다이렉트시키는 것이지만, 보안상 극히 중요한 것이 &lt;strong>&lt;code>state&lt;/code> 파라미터의 생성과 저장&lt;/strong>입니다.&lt;/p>
&lt;h2 id="state-파라미터의-필요성-csrf-공격-방지">state 파라미터의 필요성 (CSRF 공격 방지)
&lt;/h2>&lt;p>만약 &lt;code>state&lt;/code> 파라미터가 없다면, 악의적인 공격자가 자신의 Slack 계정으로 인가 프로세스를 시작하여 얻은 &amp;ldquo;인가 코드&amp;quot;가 포함된 콜백 URL(예: &lt;code>http://localhost:3000/slack/oauth_redirect?code=ATTACKER_CODE&lt;/code>)을 피해자가 클릭하게 만들 수 있습니다. 피해자의 브라우저가 이를 실행하면, 피해자의 세션 상에서 공격자의 Slack 계정 연동이 완료되어 버리며 정보 유출이나 의도치 않은 조작의 원인이 됩니다(로그인 CSRF).&lt;/p>
&lt;p>이를 방지하기 위해 요청을 시작한 브라우저와 콜백을 받은 브라우저가 동일한지 검증하기 위한 추측 불가능한 무작위 문자열이 &lt;code>state&lt;/code>입니다.&lt;/p>
&lt;h2 id="state의-엔트로피-수학적-배경">state의 엔트로피 (수학적 배경)
&lt;/h2>&lt;p>안전한 &lt;code>state&lt;/code>를 생성하기 위해서는 충분한 &amp;ldquo;엔트로피(정보량)&amp;ldquo;를 가진 난수가 필요합니다. 엔트로피 $E$는 생성되는 문자열의 종류 $N$에 의존하며, 다음 수식으로 표현됩니다.&lt;/p>
$$
E = \log_2(N) \quad (\text{단위: bits})
$$&lt;p>예를 들어 16바이트의 암호학적으로 안전한 의사 난수 생성기(CSPRNG)를 사용하여 이를 16진수(Hex) 문자열로 변환할 경우, 표현 가능한 상태의 수는 $2^{128}$이 됩니다.&lt;/p>
$$
E = \log_2(2^{128}) = 128 \text{ bits}
$$&lt;p>128비트의 엔트로피가 있다면 현대 컴퓨터 과학에서 브루트포스(무차별 대입) 공격으로 충돌을 찾는 것은 사실상 불가능(천문학적 확률)합니다. 일반적으로 보안 요구사항으로서 최소 128비트 이상의 엔트로피를 가진 &lt;code>state&lt;/code>가 권장됩니다.&lt;/p>
&lt;h2 id="nodejs에-의한-구현">Node.js에 의한 구현
&lt;/h2>&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;span class="lnt">26
&lt;/span>&lt;span class="lnt">27
&lt;/span>&lt;span class="lnt">28
&lt;/span>&lt;span class="lnt">29
&lt;/span>&lt;span class="lnt">30
&lt;/span>&lt;span class="lnt">31
&lt;/span>&lt;span class="lnt">32
&lt;/span>&lt;span class="lnt">33
&lt;/span>&lt;span class="lnt">34
&lt;/span>&lt;span class="lnt">35
&lt;/span>&lt;span class="lnt">36
&lt;/span>&lt;span class="lnt">37
&lt;/span>&lt;span class="lnt">38
&lt;/span>&lt;span class="lnt">39
&lt;/span>&lt;span class="lnt">40
&lt;/span>&lt;span class="lnt">41
&lt;/span>&lt;span class="lnt">42
&lt;/span>&lt;span class="lnt">43
&lt;/span>&lt;span class="lnt">44
&lt;/span>&lt;span class="lnt">45
&lt;/span>&lt;span class="lnt">46
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// app.js (일부 발췌)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">const&lt;/span> &lt;span class="nx">express&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">require&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;express&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">crypto&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">require&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;crypto&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">session&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">require&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;express-session&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">dotenv&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">require&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;dotenv&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">dotenv&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">config&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">app&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">express&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 세션 미들웨어 설정 (state를 저장하기 위해)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">app&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">use&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">session&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">secret&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">process&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SESSION_SECRET&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">resave&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">saveUninitialized&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">cookie&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">secure&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">false&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="c1">// 운영 환경에서는 true로 설정
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">SLACK_CLIENT_ID&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">process&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SLACK_CLIENT_ID&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">SLACK_AUTHORIZE_URL&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;https://slack.com/oauth/v2/authorize&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">app&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/slack/install&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 16바이트의 강력한 난수를 생성하고 16진수 문자열로 변환 (엔트로피: 128 bits)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">state&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">crypto&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">randomBytes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">16&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toString&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;hex&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 콜백 시 검증할 수 있도록 세션에 저장
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">session&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">oauth_state&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">state&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 요청할 스코프(권한) 목록 (쉼표 구분)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// chat:write = 채널에 메시지를 전송할 권한
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// channels:read = 공개 채널의 정보를 가져올 권한
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">scope&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;chat:write,channels:read&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// Slack의 인가 서버로 보낼 URL 파라미터 구성
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">params&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">URLSearchParams&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">client_id&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">SLACK_CLIENT_ID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">scope&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">scope&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">state&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">state&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">redirect_uri&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;http://localhost:3000/slack/oauth_redirect&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">authUrl&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">SLACK_AUTHORIZE_URL&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">?&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">params&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">toString&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 사용자를 Slack의 인가 화면으로 리다이렉트 (302 Found)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">redirect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">authUrl&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>이 엔드포인트에 접속하면, HTTP 응답은 다음과 같이 됩니다.&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt">1
&lt;/span>&lt;span class="lnt">2
&lt;/span>&lt;span class="lnt">3
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-http" data-lang="http">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">HTTP&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="m">1.1&lt;/span> &lt;span class="m">302&lt;/span> &lt;span class="ne">Found&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">Location&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="l">https://slack.com/oauth/v2/authorize?client_id=123.456&amp;amp;scope=chat%3Awrite%2Cchannels%3Aread&amp;amp;state=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6&amp;amp;redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fslack%2Foauth_redirect&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">Set-Cookie&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="l">connect.sid=...; Path=/; HttpOnly&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>사용자의 브라우저는 즉시 지정된 &lt;code>Location&lt;/code>으로 이동하여 Slack 화면(Consent Screen)이 표시되고, &amp;ldquo;My First OAuth App이 워크스페이스에 대한 접근 권한을 요청하고 있습니다&amp;quot;라는 익숙한 화면이 나타납니다.&lt;/p>
&lt;hr>
&lt;h1 id="5-구현-단계-2-콜백-접수와-액세스-토큰-교환">5. 구현 단계 2: 콜백 접수와 액세스 토큰 교환
&lt;/h1>&lt;p>사용자가 Slack 화면에서 &amp;ldquo;허용(Allow)&amp;ldquo;을 클릭하면, Slack 서버는 사용자의 브라우저를 설정해둔 &lt;code>redirect_uri&lt;/code>로 리다이렉트시킵니다. 이때 URL의 쿼리 파라미터로 &lt;code>code&lt;/code>(인가 코드)와 앞서 보냈던 &lt;code>state&lt;/code>가 부여됩니다.&lt;/p>
&lt;p>백엔드에서는 다음의 처리를 수행합니다.&lt;/p>
&lt;ol>
&lt;li>전송된 &lt;code>state&lt;/code>와 세션에 저장해둔 &lt;code>state&lt;/code>가 완전히 일치하는지 확인한다.&lt;/li>
&lt;li>일치하는 경우, 받은 &lt;code>code&lt;/code>와 자신의 &lt;code>client_id&lt;/code>, 그리고 비밀 정보인 &lt;code>client_secret&lt;/code>을 사용하여 Slack API와 백 채널 통신을 통해 액세스 토큰을 요청한다.&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;span class="lnt">17
&lt;/span>&lt;span class="lnt">18
&lt;/span>&lt;span class="lnt">19
&lt;/span>&lt;span class="lnt">20
&lt;/span>&lt;span class="lnt">21
&lt;/span>&lt;span class="lnt">22
&lt;/span>&lt;span class="lnt">23
&lt;/span>&lt;span class="lnt">24
&lt;/span>&lt;span class="lnt">25
&lt;/span>&lt;span class="lnt">26
&lt;/span>&lt;span class="lnt">27
&lt;/span>&lt;span class="lnt">28
&lt;/span>&lt;span class="lnt">29
&lt;/span>&lt;span class="lnt">30
&lt;/span>&lt;span class="lnt">31
&lt;/span>&lt;span class="lnt">32
&lt;/span>&lt;span class="lnt">33
&lt;/span>&lt;span class="lnt">34
&lt;/span>&lt;span class="lnt">35
&lt;/span>&lt;span class="lnt">36
&lt;/span>&lt;span class="lnt">37
&lt;/span>&lt;span class="lnt">38
&lt;/span>&lt;span class="lnt">39
&lt;/span>&lt;span class="lnt">40
&lt;/span>&lt;span class="lnt">41
&lt;/span>&lt;span class="lnt">42
&lt;/span>&lt;span class="lnt">43
&lt;/span>&lt;span class="lnt">44
&lt;/span>&lt;span class="lnt">45
&lt;/span>&lt;span class="lnt">46
&lt;/span>&lt;span class="lnt">47
&lt;/span>&lt;span class="lnt">48
&lt;/span>&lt;span class="lnt">49
&lt;/span>&lt;span class="lnt">50
&lt;/span>&lt;span class="lnt">51
&lt;/span>&lt;span class="lnt">52
&lt;/span>&lt;span class="lnt">53
&lt;/span>&lt;span class="lnt">54
&lt;/span>&lt;span class="lnt">55
&lt;/span>&lt;span class="lnt">56
&lt;/span>&lt;span class="lnt">57
&lt;/span>&lt;span class="lnt">58
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">axios&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">require&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;axios&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">SLACK_CLIENT_SECRET&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">process&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">SLACK_CLIENT_SECRET&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">SLACK_ACCESS_TOKEN_URL&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;https://slack.com/api/oauth.v2.access&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">app&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/slack/oauth_redirect&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">code&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">state&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">error&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">query&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 사용자가 인가를 거부했을 경우의 핸들링
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">error&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;access_denied&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">403&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;접근이 거부되었습니다.&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 1. state 검증 (CSRF 대책)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">savedState&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">session&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">oauth_state&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">state&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nx">state&lt;/span> &lt;span class="o">!==&lt;/span> &lt;span class="nx">savedState&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">400&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Invalid State Parameter (CSRF Attack Detected)&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 사용된 state는 삭제한다 (리플레이 공격 방지)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">delete&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">session&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">oauth_state&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">try&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 2. 인가 코드를 액세스 토큰으로 교환 (백 채널 통신)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">tokenResponse&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">axios&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">post&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">SLACK_ACCESS_TOKEN_URL&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">URLSearchParams&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">client_id&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">SLACK_CLIENT_ID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">client_secret&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">SLACK_CLIENT_SECRET&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">code&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">code&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">redirect_uri&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;http://localhost:3000/slack/oauth_redirect&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}).&lt;/span>&lt;span class="nx">toString&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">headers&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;Content-Type&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;application/x-www-form-urlencoded&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">tokenResponse&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ok&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">console&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Token Exchange Error:&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">error&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">500&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`Slack API Error: &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">error&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 성공! 액세스 토큰 획득
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">accessToken&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">access_token&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">teamName&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">team&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">botUserId&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">bot_user_id&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">console&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">log&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`Successfully installed to &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">teamName&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">. Access Token: &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">accessToken&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1">// 원래라면 여기서 데이터베이스에 암호화하여 토큰을 저장합니다
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="c1">// saveToDatabase(data.team.id, encrypt(accessToken));
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`설치가 완료되었습니다! 워크스페이스: &lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">teamName&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span> &lt;span class="k">catch&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">console&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Network Error:&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">500&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">send&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;통신 오류가 발생했습니다.&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>이 &lt;code>/api/oauth.v2.access&lt;/code>의 응답으로 Slack으로부터 다음과 같은 JSON이 반환됩니다.&lt;/p>
&lt;div class="highlight">&lt;div class="chroma">
&lt;table class="lntable">&lt;tr>&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code>&lt;span class="lnt"> 1
&lt;/span>&lt;span class="lnt"> 2
&lt;/span>&lt;span class="lnt"> 3
&lt;/span>&lt;span class="lnt"> 4
&lt;/span>&lt;span class="lnt"> 5
&lt;/span>&lt;span class="lnt"> 6
&lt;/span>&lt;span class="lnt"> 7
&lt;/span>&lt;span class="lnt"> 8
&lt;/span>&lt;span class="lnt"> 9
&lt;/span>&lt;span class="lnt">10
&lt;/span>&lt;span class="lnt">11
&lt;/span>&lt;span class="lnt">12
&lt;/span>&lt;span class="lnt">13
&lt;/span>&lt;span class="lnt">14
&lt;/span>&lt;span class="lnt">15
&lt;/span>&lt;span class="lnt">16
&lt;/span>&lt;/code>&lt;/pre>&lt;/td>
&lt;td class="lntd">
&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;app_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;A12345678&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;authed_user&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;U12345678&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;scope&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;chat:write,channels:read&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;token_type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;bot&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;access_token&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;&amp;lt;YOUR_BOT_TOKEN_HERE&amp;gt;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;bot_user_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;B12345678&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;team&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;T12345678&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;My Workspace&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;enterprise&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">null&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/td>&lt;/tr>&lt;/table>
&lt;/div>
&lt;/div>&lt;p>이 &lt;code>xoxb-&lt;/code>로 시작하는 문자열이 Slack에서의 &lt;strong>Bot 액세스 토큰&lt;/strong>입니다. 이후 애플리케이션이 Slack API(Resource Server)로 요청을 보낼 때는 HTTP 헤더에 &lt;code>Authorization: Bearer xoxb-...&lt;/code>와 같이 부여함으로써 인증과 권한의 증명이 이루어집니다.&lt;/p>
&lt;hr>
&lt;h1 id="6-토큰-스코프와-최소-권한의-원칙-principle-of-least-privilege">6. 토큰 스코프와 최소 권한의 원칙 (Principle of Least Privilege)
&lt;/h1>&lt;p>OAuth 2.0에서 가장 중요한 개념 중 하나가 &amp;ldquo;스코프(Scope)&amp;ldquo;입니다. 스코프란 액세스 토큰에 연결된 권한의 범위를 의미합니다.&lt;/p>
&lt;p>Slack에서는 권한이 매우 세분화되어 있으며, 크게 &lt;strong>Bot Token Scopes&lt;/strong>와 &lt;strong>User Token Scopes&lt;/strong>가 존재합니다.&lt;/p>
&lt;ul>
&lt;li>&lt;code>chat:write&lt;/code> (Bot): 앱(봇) 자신으로서 채널에 메시지를 게시할 권한.&lt;/li>
&lt;li>&lt;code>chat:write&lt;/code> (User): 앱을 설치한 사용자를 대리하여(사용자의 이름과 아이콘으로) 메시지를 게시할 권한.&lt;/li>
&lt;li>&lt;code>channels:read&lt;/code>: 채널 목록을 가져올 권한.&lt;/li>
&lt;li>&lt;code>channels:history&lt;/code>: 채널의 과거 메시지 내역을 읽을 권한.&lt;/li>
&lt;/ul>
&lt;p>보안상의 대원칙인 &amp;ldquo;최소 권한의 원칙(Principle of Least Privilege)&amp;ldquo;에 따라, &lt;strong>앱이 제공하는 기능에 정말 필수불가결한 스코프만을 요청하는 것&lt;/strong>이 철칙입니다. 예를 들어 &amp;ldquo;알림만 보내는&amp;rdquo; 앱이라면 &lt;code>chat:write&lt;/code>만을 요청해야 하며, &lt;code>channels:history&lt;/code>(과거 대화를 모두 읽을 수 있는 권한)를 요청해서는 안 됩니다. 만에 하나 앱이 해킹되어 토큰이 유출될 경우 피해를 최소화하기 위해서입니다.&lt;/p>
&lt;hr>
&lt;h1 id="7-보다-고도화된-보안-pkce-proof-key-for-code-exchange">7. 보다 고도화된 보안: PKCE (Proof Key for Code Exchange)
&lt;/h1>&lt;p>최근 OAuth 2.0의 보안을 한층 더 강화하는 메커니즘으로 &lt;strong>PKCE (Proof Key for Code Exchange, RFC 7636, &amp;ldquo;픽시&amp;quot;라고 발음)&lt;/strong> 가 표준화되어 널리 이용되고 있습니다.&lt;/p>
&lt;p>원래 PKCE는 네이티브 앱(iOS/Android)이나 SPA(Single Page Application) 등 &lt;code>client_secret&lt;/code>을 안전하게 보관할 수 없는 &amp;ldquo;퍼블릭 클라이언트&amp;quot;를 위해 설계된 것이었습니다. 그러나 현재는 보안 모범 사례(OAuth 2.1 초안)에서 서버 사이드의 &amp;ldquo;컨피덴셜 클라이언트&amp;quot;일지라도 PKCE의 사용이 강력히 권장되고 있습니다.&lt;/p>
&lt;h2 id="pkce의-원리와-수학적-배경">PKCE의 원리와 수학적 배경
&lt;/h2>&lt;p>PKCE는 &amp;ldquo;인가 요청을 시작한 자&amp;quot;와 &amp;ldquo;토큰 교환 요청을 하는 자&amp;quot;가 동일하다는 것을 암호학적으로 증명합니다.&lt;/p>
&lt;ol>
&lt;li>클라이언트는 무작위 문자열 &lt;strong>&lt;code>code_verifier&lt;/code>&lt;/strong>(43~128자)를 생성합니다.&lt;/li>
&lt;li>이를 &lt;strong>SHA-256&lt;/strong>으로 해싱하고 BASE64URL 인코딩한 것을 &lt;strong>&lt;code>code_challenge&lt;/code>&lt;/strong> 로 삼습니다.&lt;/li>
&lt;/ol>
&lt;p>수식으로 나타내면 다음과 같습니다.&lt;/p>
$$
\text{code\_challenge} = \text{BASE64URL-ENCODE}( \text{SHA256}( \text{ASCII}(\text{code\_verifier}) ) )
$$&lt;ol start="3">
&lt;li>클라이언트는 &lt;code>/slack/install&lt;/code> 실행 시 &lt;code>state&lt;/code>에 더해 &lt;code>code_challenge&lt;/code>와 &lt;code>code_challenge_method=S256&lt;/code>을 인가 서버(Slack)로 보냅니다 (Slack은 이를 임시 저장합니다).&lt;/li>
&lt;li>콜백 후 토큰 교환(&lt;code>/api/oauth.v2.access&lt;/code>) 시 해싱하기 전의 원래 &lt;strong>&lt;code>code_verifier&lt;/code>&lt;/strong> 를 전송합니다.&lt;/li>
&lt;li>인가 서버(Slack)는 받은 &lt;code>code_verifier&lt;/code>를 스스로 SHA-256 해싱하여, 3단계에서 저장해둔 &lt;code>code_challenge&lt;/code>와 완전히 일치하는지 검증합니다.&lt;/li>
&lt;/ol>
&lt;pre class="mermaid">
sequenceDiagram
participant C as &amp;#34;Client&amp;#34;
participant AS as &amp;#34;Authorization Server&amp;#34;
Note over C: &amp;#34;code_verifier = 무작위 문자열&amp;#34;&amp;lt;br/&amp;gt;&amp;#34;code_challenge = SHA256(code_verifier)&amp;#34;
C-&amp;gt;&amp;gt;AS: &amp;#34;인가 요청 (code_challenge 전송)&amp;#34;
Note over AS: &amp;#34;code_challenge 유지&amp;#34;
AS--&amp;gt;&amp;gt;C: &amp;#34;인가 코드 (code) 발급&amp;#34;
C-&amp;gt;&amp;gt;AS: &amp;#34;토큰 요청 (code + code_verifier 전송)&amp;#34;
Note over AS: &amp;#34;SHA256(받은 verifier) == 유지하던 challenge?&amp;#34;
AS--&amp;gt;&amp;gt;C: &amp;#34;검증 성공: 액세스 토큰 발급&amp;#34;
&lt;/pre>
&lt;p>이 메커니즘을 통해 만약 악의적인 앱이나 통신 경로 도청으로 &amp;ldquo;인가 코드(code)&amp;ldquo;를 탈취당하더라도 공격자는 원래의 &lt;code>code_verifier&lt;/code>를 알지 못하므로(비가역적인 해시 함수 SHA-256의 성질상 challenge에서 verifier를 역산하는 것은 불가능), 액세스 토큰을 얻을 수 없습니다.&lt;/p>
&lt;p>현재 Slack API의 일부 새로운 플로우나 기타 모던 SaaS API(Auth0, Okta, X/Twitter API v2 등)에서는 PKCE 지원이 진행 중이며, 개발자는 적극적으로 도입해야 할 기술이 되었습니다.&lt;/p>
&lt;hr>
&lt;h1 id="8-액세스-토큰의-안전한-관리-및-운영">8. 액세스 토큰의 안전한 관리 및 운영
&lt;/h1>&lt;p>마지막으로 획득한 액세스 토큰의 보관 방법에 대한 모범 사례입니다.&lt;/p>
&lt;h2 id="1-데이터베이스-저장-시-암호화-필수">1. 데이터베이스 저장 시 암호화 필수
&lt;/h2>&lt;p>액세스 토큰(&lt;code>xoxb-...&lt;/code>)은 Slack 워크스페이스로 가는 &amp;ldquo;마스터 키&amp;rdquo; 그 자체입니다. 데이터베이스(MySQL, PostgreSQL, MongoDB 등)에 평문(플레인 텍스트)으로 저장해서는 안 됩니다. 만에 하나 SQL 인젝션 등으로 데이터베이스가 유출될 경우, 모든 고객의 Slack이 탈취되는 대참사가 발생합니다.&lt;/p>
&lt;p>반드시 애플리케이션 레이어에서 &lt;strong>AES-256-GCM&lt;/strong> 등의 강력한 대칭키 암호를 사용하여 암호화한 뒤 DB에 저장해야 합니다. 암호화/복호화를 위한 마스터 키는 AWS KMS(Key Management 경Service)나 GCP Cloud KMS 같은 안전한 키 관리 서비스를 이용하여 엄격하게 관리합니다.&lt;/p>
&lt;h2 id="2-토큰-로테이션-token-rotation">2. 토큰 로테이션 (Token Rotation)
&lt;/h2>&lt;p>장기적으로 유효한 토큰을 계속 사용하는 것은 위험이 따릅니다. 최신 OAuth 구현에서는 &amp;ldquo;리프레시 토큰(Refresh Token)&amp;ldquo;을 이용해 몇 시간마다 새로운 액세스 토큰을 다시 발급받는 메커니즘(Token Rotation)을 도입할 것을 권장합니다. Slack API에서도 옵션 설정으로 토큰 로테이션을 활성화할 수 있습니다.&lt;/p>
&lt;hr>
&lt;h1 id="요약">요약
&lt;/h1>&lt;p>본 문서에서는 OAuth 2.0의 인가 코드 그랜트 플로우에 대해 Slack App 연동의 구체적인 Node.js 구현 코드를 곁들여 상세히 해설했습니다.&lt;/p>
&lt;ol>
&lt;li>&lt;strong>4가지 역할(RO, Client, AS, RS)&lt;/strong> 을 의식함으로써 시스템 전체의 아키텍처가 명확해집니다.&lt;/li>
&lt;li>&lt;strong>인가 코드 그랜트&lt;/strong>는 브라우저와 서버 간의 통신 경로(프론트/백 채널)를 교묘하게 구분하여 사용함으로써 안전성을 보장합니다.&lt;/li>
&lt;li>&lt;strong>&lt;code>state&lt;/code> 파라미터&lt;/strong>를 통한 CSRF 방어와 &lt;strong>PKCE&lt;/strong>를 통한 인가 코드 인터셉트 공격 방지 등, 배경에 있는 암호학적 메커니즘을 이해하는 것이 안전한 구현으로 가는 지름길입니다.&lt;/li>
&lt;li>&lt;strong>최소 권한의 원칙&lt;/strong>에 입각한 스코프 설계와 DB 저장 시의 암호화는 운영상 절대 빼놓을 수 없는 요소입니다.&lt;/li>
&lt;/ol>
&lt;p>OAuth 2.0은 매우 심오하고 RFC 문서만 해도 방대한 사양이 존재하지만, 이처럼 실제 플랫폼(Slack)을 타겟으로 하여 직접 만들어보며 배우면 그 세련된 설계 사상과 견고한 보안 메커니즘을 실감할 수 있을 것입니다. 향후 애플리케이션 개발이나 API 연동 구현에 있어 이 문서의 지식이 도움이 되기를 바랍니다.&lt;/p></description></item></channel></rss>