Slack Bot을 운영하면서 일정 시간이 지나면 메시지 전송이 갑자기 먹통이 되는 경험을 했을까. 대부분의 개발자들은 이게 네트워크 오류나 Slack API 문제라고 착각하고 로그만 뒤지다가, 결국 Access Token이 만료되었다는 걸 나중에 깨닫는다. 이번에는 Slack OAuth 2.0 인증 흐름에서 토큰 갱신이 정확히 뭔지, 왜 필요한지, 어떻게 구현하는지 완벽하게 정리해서 소개하겠다.

 

Slack Access Token과 Refresh Token의 차이

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 OAuth 인증 흐름과 토큰 발급

먼저 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 만료 감지 및 갱신 로직

Access Token이 만료되면 Slack API 응답이 401 Unauthorized와 함께 'invalid_token' 오류를 준다. 이때 두 가지 전략이 있다. 첫 번째는 매 API 호출마다 토큰 만료 시간을 확인하고 미리 갱신하는 방식이고, 두 번째는 API 호출이 실패했을 때 토큰을 갱신하고 재시도하는 방식이다. 실무에서는 보통 두 방식을 섞어서 사용한다.

 

실제 구현: PHP에서 토큰 관리 클래스

아래는 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 호출 시 토큰 자동 갱신

이제 실제로 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 인덱스는 만료된 토큰을 빠르게 찾기 위함이다.

 

실전: 봇 초기화 시 OAuth 인증 코드 처리

처음 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을 운영할 수 있을 것이다.