Slack Bot을 운영하면서 일정 시간이 지나면 메시지 전송이 갑자기 먹통이 되는 경험을 했을까. 대부분의 개발자들은 이게 네트워크 오류나 Slack API 문제라고 착각하고 로그만 뒤지다가, 결국 Access Token이 만료되었다는 걸 나중에 깨닫는다. 이번에는 Slack OAuth 2.0 인증 흐름에서 토큰 갱신이 정확히 뭔지, 왜 필요한지, 어떻게 구현하는지 완벽하게 정리해서 소개하겠다.
Slack은 보안을 위해 기본적으로 Access Token에 유효기간을 설정한다. Access Token은 API 요청할 때 인증 정보로 사용되는 토큰인데, 이게 만료되면 401 Unauthorized 오류가 나면서 API 호출이 모두 실패한다. Refresh Token은 Access Token이 만료됐을 때 새로운 Access Token을 발급받기 위한 토큰이다. Refresh Token 자체는 훨씬 긴 유효기간을 가지고 있어서, Access Token 갱신에만 사용된다.
Slack Bot 설정 화면에서 'User Token Scopes' 권한을 설정할 때 실제로는 두 가지 토큰 유형이 있다는 걸 알아야 한다. Bot Token Scopes로 생성된 토큰은 Bot이 할 수 있는 작업만 허용하고, User Token Scopes는 해당 사용자 권한으로 작동한다. 둘 다 만료되는 건 동일하지만, 갱신 메커니즘이 조금 다르다.
먼저 Slack에서 새 Bot을 만들고 OAuth 인증 코드를 받아야 Access Token을 얻을 수 있다. Slack 앱 설정에서 'Redirect URLs'를 등록한 후, 사용자가 'Add to Slack' 버튼을 클릭하면 Slack 서버로 리다이렉트되고 권한 승인 화면이 나타난다. 사용자가 승인하면 Slack은 리다이렉트 URL로 인증 코드(code)를 담아서 돌려보낸다.
그 인증 코드를 가지고 당신의 서버에서 oauth.v2.access 엔드포인트로 POST 요청을 하면, Slack에서 Access Token과 Refresh Token을 반환한다. 이때 중요한 건 응답에 'expires_in' 필드가 있다는 것이다. 이 값이 바로 Access Token의 유효시간(초 단위)이다. 보통 12시간(43200초)이다.
Access Token이 만료되면 Slack API 응답이 401 Unauthorized와 함께 'invalid_token' 오류를 준다. 이때 두 가지 전략이 있다. 첫 번째는 매 API 호출마다 토큰 만료 시간을 확인하고 미리 갱신하는 방식이고, 두 번째는 API 호출이 실패했을 때 토큰을 갱신하고 재시도하는 방식이다. 실무에서는 보통 두 방식을 섞어서 사용한다.
아래는 Slack 토큰을 관리하고 자동으로 갱신하는 PHP 클래스다.
<?php
class SlackTokenManager {
private $clientId;
private $clientSecret;
private $refreshToken;
private $accessToken;
private $expiresAt;
private $db;
public function __construct($clientId, $clientSecret, $db) {
$this->clientId = $clientId;
$this->clientSecret = $clientSecret;
$this->db = $db;
$this->loadTokenFromDb();
}
// DB에서 저장된 토큰 불러오기
private function loadTokenFromDb() {
$stmt = $this->db->prepare("SELECT access_token, refresh_token, expires_at FROM slack_tokens LIMIT 1");
$stmt->execute();
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if ($result) {
$this->accessToken = $result['access_token'];
$this->refreshToken = $result['refresh_token'];
$this->expiresAt = $result['expires_at'];
}
}
// 토큰이 만료됐는지 확인
public function isTokenExpired() {
return time() >= $this->expiresAt;
}
// Refresh Token으로 새 Access Token 발급
public function refreshAccessToken() {
$ch = curl_init('https://slack.com/api/oauth.v2.access');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'grant_type' => 'refresh_token',
'refresh_token' => $this->refreshToken
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/x-www-form-urlencoded']
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new Exception("Failed to refresh token: {$response}");
}
$data = json_decode($response, true);
if (!$data['ok']) {
throw new Exception("Slack error: " . $data['error']);
}
// 새 토큰 저장
$this->accessToken = $data['access_token'];
$this->expiresAt = time() + $data['expires_in'];
// DB에 저장
$this->saveTokenToDb($data);
return $this->accessToken;
}
// 토큰을 DB에 저장
private function saveTokenToDb($tokenData) {
$stmt = $this->db->prepare(
"INSERT INTO slack_tokens (access_token, refresh_token, expires_at, updated_at)
VALUES (:access, :refresh, :expires, NOW())
ON DUPLICATE KEY UPDATE
access_token = :access,
refresh_token = :refresh,
expires_at = :expires,
updated_at = NOW()"
);
$stmt->execute([
':access' => $tokenData['access_token'],
':refresh' => $tokenData['refresh_token'],
':expires' => time() + $tokenData['expires_in']
]);
}
// 유효한 Access Token 얻기 (자동 갱신)
public function getValidAccessToken() {
if ($this->isTokenExpired()) {
$this->refreshAccessToken();
}
return $this->accessToken;
}
}
?>
이 클래스의 핵심은 getValidAccessToken() 메서드다. 이 메서드를 호출할 때마다 자동으로 토큰 만료 여부를 확인하고, 만료됐으면 Refresh Token으로 새 Access Token을 발급받는다. DB에 저장하는 이유는 서버를 재시작하거나 여러 PHP 프로세스가 동시에 실행될 때 토큰 정보를 공유하기 위함이다.
이제 실제로 Slack API를 호출할 때 이 토큰 관리 클래스를 사용해보자.
<?php
class SlackApiClient {
private $tokenManager;
public function __construct(SlackTokenManager $tokenManager) {
$this->tokenManager = $tokenManager;
}
// 메시지 전송
public function sendMessage($channel, $text) {
$accessToken = $this->tokenManager->getValidAccessToken();
$ch = curl_init('https://slack.com/api/chat.postMessage');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode([
'channel' => $channel,
'text' => $text
]),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $accessToken,
'Content-Type: application/json'
]
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$data = json_decode($response, true);
// ✗ 잘못된 방식: 401 오류를 무시하고 계속 진행
// if ($httpCode === 401) {
// // 토큰이 만료됐어도 그냥 넘어감
// return false;
// }
// ✓ 올바른 방식: 401 오류 발생 시 즉시 토큰 갱신 후 재시도
if ($httpCode === 401 || !$data['ok']) {
if ($data['error'] === 'invalid_token' || $data['error'] === 'expired_token') {
// 토큰을 강제로 갱신하고 한 번 더 시도
$accessToken = $this->tokenManager->refreshAccessToken();
return $this->sendMessage($channel, $text); // 재귀 호출
}
throw new Exception("Slack API error: " . $data['error']);
}
return $data;
}
}
?>
여기서 중요한 부분은 401 오류가 발생했을 때의 처리다. 단순히 오류를 반환하는 게 아니라, 토큰을 강제로 갱신한 후 API 호출을 다시 시도한다. 이렇게 하면 일시적인 토큰 만료로 인한 실패를 자동으로 복구할 수 있다.
✗ 문제 1: Refresh Token도 만료될 수 있다
Refresh Token의 유효기간은 12개월이다. 만약 12개월 이상 토큰을 갱신하지 않으면 Refresh Token 자체가 만료되어서 새 Access Token을 얻을 수 없다. 이 경우 사용자에게 다시 'Add to Slack' 승인을 받아야 한다. Refresh Token이 만료되기 전에 정기적으로 갱신하는 배치 작업을 추가하거나, 마지막 사용 시간을 기록해서 오래된 토큰은 미리 갱신하는 게 좋다.
✓ 올바른 방식: Refresh Token 자동 갱신 로직 추가
<?php
// 3개월마다 토큰을 미리 갱신하는 배치 작업
public function refreshTokenIfOld() {
$stmt = $this->db->prepare(
"SELECT updated_at FROM slack_tokens WHERE updated_at < DATE_SUB(NOW(), INTERVAL 3 MONTH) LIMIT 1"
);
$stmt->execute();
if ($stmt->rowCount() > 0) {
// 3개월 이상 갱신 안 된 토큰 발견, 지금 갱신
$this->refreshAccessToken();
}
}
?>
✗ 문제 2: 동시성 문제로 여러 번 갱신 시도
여러 프로세스가 동시에 같은 토큰을 사용하다가 동시에 만료를 감지하면, 모두 갱신을 시도한다. 그러면 Slack에 중복 요청이 가서 한 번은 성공하고 한 번은 실패한다. 이 경우 나중에 성공한 토큰이 DB에 저장되지 않을 수 있다.
✓ 올바른 방식: 데이터베이스 락으로 동시성 제어
<?php
public function refreshAccessToken() {
// 토큰 갱신 중임을 표시하는 락 획득
$stmt = $this->db->prepare(
"UPDATE slack_tokens SET is_refreshing = 1 WHERE is_refreshing = 0 LIMIT 1"
);
$stmt->execute();
if ($stmt->rowCount() === 0) {
// 다른 프로세스가 이미 갱신 중, 잠시 기다렸다가 DB에서 새 토큰 불러오기
sleep(2);
$this->loadTokenFromDb();
return $this->accessToken;
}
try {
// Slack 갱신 API 호출
$ch = curl_init('https://slack.com/api/oauth.v2.access');
curl_setopt_array($ch, [...]);
$response = curl_exec($ch);
curl_close($ch);
$data = json_decode($response, true);
if (!$data['ok']) {
throw new Exception("Slack error: " . $data['error']);
}
$this->saveTokenToDb($data);
} finally {
// 갱신 완료, 락 해제
$stmt = $this->db->prepare("UPDATE slack_tokens SET is_refreshing = 0");
$stmt->execute();
}
return $this->accessToken;
}
?>
✗ 문제 3: 토큰을 로그나 에러 메시지에 남기기
토큰 갱신 중에 실패했다고 해서 Access Token이나 Refresh Token을 통째로 로그에 출력하면, 로그 파일이 유출됐을 때 공격자가 당신의 Slack을 제어할 수 있다.
✓ 올바른 방식: 토큰 마스킹
<?php
function maskToken($token) {
$len = strlen($token);
return substr($token, 0, 4) . str_repeat('*', $len - 8) . substr($token, -4);
}
// 에러 로깅
error_log("Token refresh failed for: " . maskToken($this->refreshToken));
?>
토큰을 안전하게 저장하려면 아래와 같은 테이블이 필요하다.
CREATE TABLE slack_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
workspace_id VARCHAR(255) UNIQUE,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
expires_at BIGINT NOT NULL,
is_refreshing TINYINT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_expires (expires_at)
);
workspace_id를 추가한 이유는, 한 서버에서 여러 Slack 워크스페이스를 관리할 수도 있기 때문이다. is_refreshing은 동시성 제어용이고, expires_at 인덱스는 만료된 토큰을 빠르게 찾기 위함이다.
처음 Slack Bot을 설정할 때는 'Add to Slack' 버튼을 누른 후 리다이렉트되는 콜백 URL에서 인증 코드를 처리해야 한다.
<?php
// oauth_callback.php
if (!isset($_GET['code'])) {
die('Authorization code not provided');
}
$code = $_GET['code'];
$ch = curl_init('https://slack.com/api/oauth.v2.access');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'client_id' => 'YOUR_CLIENT_ID',
'client_secret' => 'YOUR_CLIENT_SECRET',
'code' => $code,
'redirect_uri' => 'https://your-domain.com/oauth_callback.php'
]),
CURLOPT_RETURNTRANSFER => true
]);
$response = curl_exec($ch);
$data = json_decode($response, true);
curl_close($ch);
if (!$data['ok']) {
die('Authorization failed: ' . $data['error']);
}
// 토큰을 DB에 저장
$pdo = new PDO('mysql:host=localhost;dbname=yourdb', 'user', 'pass');
$stmt = $pdo->prepare(
"INSERT INTO slack_tokens (workspace_id, access_token, refresh_token, expires_at)
VALUES (:workspace, :access, :refresh, :expires)"
);
$stmt->execute([
':workspace' => $data['team_id'],
':access' => $data['access_token'],
':refresh' => $data['refresh_token'],
':expires' => time() + $data['expires_in']
]);
echo "Slack Bot successfully authorized!";
?>
Slack Bot 운영에서 토큰 갱신은 필수 기능이다. Access Token은 12시간마다 만료되므로, 자동 갱신 로직 없이는 지속적인 서비스가 불가능하다. 이 글의 SlackTokenManager 클래스를 참고해서 데이터베이스에 토큰을 저장하고, API 호출 전에 자동으로 유효성을 확인하고, 실패 시 즉시 갱신하는 구조를 구축하면, 토큰 만료로 인한 서비스 중단 없이 안정적인 Slack Bot을 운영할 수 있을 것이다.