웹서비스에서 ‘Discord로 로그인’을 누르면 Discord 화면으로 이동했다가 다시 서비스로 돌아온다. 사용자에게는 짧은 과정이지만, 그 사이 브라우저와 백엔드, Discord가 여러 번 요청을 주고받는다.
백엔드는 Discord API로 사용자를 확인하고, 그 계정을 서비스 내부 사용자와 연결한 뒤 로그인 세션을 만든다. 이 흐름을 이해하면 로그인 버튼을 붙이는 일부터 회원가입 처리, 로그인 유지, 로그아웃까지 하나의 과정으로 볼 수 있다.
OAuth2는 사용자가 허용한 범위에서 외부 API에 접근할 수 있게 하는 인가 체계다. Discord 로그인에서는 그 권한으로 사용자 정보를 조회하고, 응답의 사용자 ID를 서비스 계정에 연결한다. 이 글은 이 연결 과정과 이후의 세션 처리를 함께 다룬다. [OAuth2 표준](https://www.rfc-editor.org/rfc/rfc6749.html#section-1)
먼저 정상적인 첫 로그인 흐름을 그림으로 살펴보자. 사용자 DB와 세션 저장소는 역할을 구분해 표시했다. 실제 저장 기술은 서비스 구성에 따라 선택할 수 있다.

출발점은 서비스의 로그인 시작 주소다. 프론트엔드는 이 주소로 브라우저를 이동시키면 된다.
```html
<a href="/auth/discord">Discord로 로그인</a>
```
백엔드는 인가 요청을 구성하고 브라우저를 `https://discord.com/oauth2/authorize`로 리다이렉트한다. 이를 준비하려면 Discord Developer Portal에 애플리케이션을 등록하고 client ID, client secret, 콜백 주소를 설정해야 한다. Client secret은 백엔드에서 관리한다. [Discord OAuth2 애플리케이션 설정](https://docs.discord.com/developers/topics/oauth2#shared-resources)
인가 요청의 주요 값은 다음과 같다.
| 항목 | 역할 |
| --- | --- |
| `client_id` | 어떤 애플리케이션의 요청인지 식별 |
| `response_type=code` | 인가 코드를 받는 방식 지정 |
| `redirect_uri` | 승인 후 브라우저가 돌아올 백엔드 콜백 주소 |
| `scope` | 애플리케이션이 요청하는 API 접근 범위 |
| `state` | 로그인 시작 요청과 콜백을 연결하는 값 |
Scope는 실제로 필요한 정보에 맞춰 정한다. 사용자 ID와 기본 프로필로 로그인하는 서비스라면 `identify`를 사용한다. 이메일이 필요한 기능에는 `email`을, 특정 Discord 서버의 멤버 확인에는 `guilds.members.read`를 추가할 수 있다. [Discord OAuth2 scope 문서](https://docs.discord.com/developers/topics/oauth2#oauth2-scopes)
사용자가 승인하면 Discord는 등록된 콜백 주소로 브라우저를 돌려보낸다. 백엔드가 받는 요청은 다음과 같은 형태다.
```text
GET /auth/discord/callback?code=AUTHORIZATION_CODE&state=RANDOM_STATE
```
백엔드는 돌아온 `state`가 로그인 시작 시 저장한 값과 일치하는지 확인한다. 여기서는 예측하기 어려운 값을 생성해 브라우저의 세션과 연결해 두는 구성을 사용한다. 값이 없거나 다르면 로그인을 중단한다. 이 검증은 콜백이 해당 브라우저에서 시작한 로그인 요청에 대응하는지 확인하는 데 필요하다. [OAuth2의 요청 위조 방어](https://www.rfc-editor.org/rfc/rfc6749.html#section-10.12)
이 때문에 로그인 완료 전에도 임시 로그인 정보를 담을 세션이 필요하다. 사용자가 Discord에 머무는 동안에도 세션 쿠키가 유지되어야 하고, 콜백 요청에서 백엔드가 같은 세션을 찾을 수 있어야 한다. 라이브러리가 이 과정을 처리한다면 해당 라이브러리의 인가 요청 저장 방식을 따른다.
검증이 끝나면 백엔드는 `code`를 액세스 토큰으로 교환한다. 인가 코드는 짧은 시간 동안 한 번 사용할 수 있는 값이고, 토큰 교환에는 콜백 주소와 클라이언트 인증 정보도 사용된다. 사용자의 Discord 비밀번호는 서비스가 전달받지 않는다. [Authorization Code 흐름](https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1)
Discord의 토큰 엔드포인트는 `POST https://discord.com/api/oauth2/token`이다. 요청 본문은 `application/x-www-form-urlencoded` 형식을 사용하며, `grant_type=authorization_code`, `code`, `redirect_uri`를 전달한다. 클라이언트 인증은 HTTP Basic 또는 본문의 client ID·client secret으로 처리할 수 있다. [Discord 토큰 교환 문서](https://docs.discord.com/developers/topics/oauth2#authorization-code-grant)
다음 단계는 액세스 토큰으로 현재 사용자 정보를 조회하는 것이다. 이 호출도 백엔드에서 수행한다.
```http
GET /api/v10/users/@me HTTP/1.1
Host: discord.com
Authorization: Bearer ACCESS_TOKEN
```
Discord는 이 요청에 사용자 객체를 반환한다. `identify` 권한으로 사용자 정보를 얻을 수 있고, 이메일을 받으려면 별도의 `email` 권한이 필요하다. [Discord 현재 사용자 조회 문서](https://docs.discord.com/developers/resources/user#get-current-user)
백엔드는 응답의 `id`를 계정 연결 기준으로 사용한다. 표시 이름은 사용자가 바꿀 수 있으므로 화면에 보여주는 용도로 사용한다. 예를 들어 `global_name`이 있으면 표시 이름으로 사용하고, 없으면 `username`을 사용하는 정책을 정할 수 있다.
서비스 내부에는 자체 사용자 ID를 두고 외부 계정과의 연결을 따로 관리할 수 있다. 아래는 데이터 구조의 한 예다.
| 값 | 예시 | 용도 |
| --- | --- | --- |
| 내부 사용자 ID | `42` | 서비스 데이터에서 사용자를 참조 |
| 로그인 제공자 | `discord` | 외부 계정의 출처 |
| 제공자 사용자 ID | `123456789012345678` | Discord 계정과 연결 |
| 표시 이름 | `사용자 이름` | 화면에 표시 |
외부 계정의 식별 기준은 `(provider, provider_user_id)` 조합으로 잡을 수 있다. 이 조합으로 기존 연결을 조회하고, 연결이 없다면 가입 정책에 따라 새 사용자를 만든다. 아래는 자동 가입을 허용하는 서비스의 의사 코드다.
```text
externalIdentity = ("discord", discordUser.id)
user = findUserByExternalIdentity(externalIdentity)
if user does not exist:
user = createUserAndLinkIdentity(externalIdentity, displayName)
rotateSessionId()
session.userId = user.id
```
같은 계정의 첫 로그인 요청이 동시에 도착해도 사용자가 중복 생성되지 않도록, 외부 계정 식별값에는 DB의 유일성 제약을 두고 사용자 생성과 계정 연결을 트랜잭션으로 처리할 수 있다. 충돌한 요청은 이미 만들어진 연결을 다시 조회하도록 설계한다. 기존 회원과 계정을 연결하는 기능은 로그인된 회원의 확인 절차를 별도로 두는 편이 명확하다.
원본 외부 ID를 저장하지 않는 정책이라면 HMAC으로 만든 식별값을 계정 연결에 사용하는 방법도 있다. 이 경우 같은 계정을 찾으려면 같은 키가 필요하므로 키 변경과 데이터 이전을 함께 설계해야 한다. 이는 저장 정책에 따른 선택 사항이다.
서비스 이용 조건이 있는 경우에는 계정을 확정하고 로그인 세션을 만들기 전에 추가 검사를 넣는다. 예를 들어 특정 Discord 서버 멤버에게만 서비스를 제공한다면 `guilds.members.read` 권한을 요청하고 다음 API를 호출할 수 있다.
```text
GET https://discord.com/api/v10/users/@me/guilds/{guildId}/member
```
`guildId`는 서비스가 확인하려는 Discord 서버의 ID다. 이 API는 현재 사용자의 해당 서버 멤버 정보를 반환한다. 모든 Discord 사용자가 이용할 수 있는 서비스라면 이 단계는 생략한다. [Discord 현재 사용자 멤버 조회 문서](https://docs.discord.com/developers/resources/user#get-current-user-guild-member)
멤버 확인을 추가했다면 ‘멤버가 아님’과 ‘Discord API 호출 실패’를 구분해 안내한다. 또한 로그인할 때만 확인하는 정책에서는 서버 탈퇴가 기존 세션에 즉시 반영되지 않는다. 계속 멤버여야 하는 서비스라면 재확인 시점과 접근 해제 정책을 추가로 정해야 한다.
계정 연결과 필요한 검사를 마쳤다면 서비스의 로그인 세션을 만든다. 서버가 세션에 내부 사용자 ID를 저장하고 브라우저에는 세션 ID를 담은 쿠키를 발급하는 방식이다. 로그인 성공 시 세션 ID를 갱신하는 처리는 프레임워크의 세션 고정 공격 방어 기능을 활용할 수 있다.
이후 브라우저는 서비스 API를 호출할 때 세션 쿠키를 보낸다. 백엔드는 세션을 조회해 요청한 사용자를 확인한다. 이 구조에서 Discord 액세스 토큰은 Discord API 접근에 사용하고, 서비스의 로그인 상태는 서비스 세션으로 판단한다. 두 값의 만료 시간과 폐기 정책도 따로 관리한다.
세션 쿠키를 설정할 때는 각 속성의 역할을 구분하면 좋다. `HttpOnly`는 자바스크립트의 직접 읽기를 제한하고, `Secure`는 HTTPS 전송에 사용한다. `SameSite`는 다른 사이트에서 시작한 요청에 쿠키를 보낼 조건을 제어한다. Discord에서 최상위 화면의 GET 요청으로 돌아오는 구성에서는 `Lax`가 콜백에 세션 쿠키를 전달할 수 있는 설정이다. [쿠키 속성 설명](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie)
세션은 애플리케이션 메모리, Redis, DB 등 구성에 맞는 저장소에서 관리할 수 있다. 서버 재시작 뒤에도 로그인을 유지하거나 여러 서버가 세션을 공유해야 한다면 저장소의 지속성과 공유 방식을 함께 고려한다. 예를 들어 Spring Session JDBC는 세션을 관계형 DB에 보관한다. [Spring Session JDBC 문서](https://docs.spring.io/spring-session/reference/configuration/jdbc.html)
로그인 성공 후 백엔드는 브라우저를 서비스 화면으로 이동시킨다. 프론트엔드는 `GET /api/me` 같은 API로 로그인 상태를 확인할 수 있다. 이 API는 세션에 연결된 내부 사용자 정보를 반환하도록 설계한다.
```json
{
"id": "42",
"name": "사용자 이름"
}
```
브라우저와 API가 같은 origin을 사용한다면 `fetch`에서 `credentials: 'same-origin'`으로 쿠키를 전송할 수 있다. 도메인이나 포트가 다른 구성을 사용한다면 credentials, CORS, 쿠키 속성을 함께 맞춰야 한다. 프론트엔드에서 로그인 화면으로 이동시키는 처리와 별개로, 보호 대상 API는 백엔드에서도 인증을 검증해야 한다.
쿠키 기반 인증에는 쓰기 요청의 CSRF 보호도 필요하다. 예를 들어 서버가 발급한 CSRF 토큰을 프론트엔드가 읽어 요청 헤더에 넣고, 백엔드가 검증하는 방식을 사용할 수 있다. OAuth 콜백의 `state`는 로그인 시도를 연결하고, API의 CSRF 토큰은 이후의 상태 변경 요청을 검증한다.
Spring Security에서는 `XSRF-TOKEN` 쿠키와 `X-XSRF-TOKEN` 헤더를 사용하는 구성이 가능하다. 이때 CSRF 토큰 쿠키는 자바스크립트가 읽도록 설정하고, 로그인 세션 쿠키에는 `HttpOnly`를 유지한다. 인증 성공이나 로그아웃으로 CSRF 토큰이 초기화되면 다음 쓰기 요청 전에 새 토큰을 받아야 한다. [Spring Security CSRF 문서](https://docs.spring.io/spring-security/reference/6.5/servlet/exploits/csrf.html)
로그아웃 API는 CSRF 검증을 거친 뒤 현재 서비스 세션을 무효화하고 세션 쿠키를 삭제하도록 구성할 수 있다. 프론트엔드는 성공 응답을 받은 다음 사용자 정보와 관련 캐시를 비우고 로그인 화면으로 이동시킨다.
서비스 로그아웃은 해당 서비스의 세션을 끝내는 동작이다. Discord 앱에 부여한 접근 권한까지 철회하려면 별도의 토큰 철회 처리가 필요하다. 로그인 이후에도 Discord API를 호출하는 기능이 있다면 토큰 보관·갱신·철회 정책을 그 기능의 요구에 맞게 정한다. [Discord 토큰 철회 문서](https://docs.discord.com/developers/topics/oauth2#token-revocation-example)
실패 처리도 로그인 흐름의 일부다. 다음은 서비스에서 정의할 수 있는 오류 분류 예시이며, 코드 이름은 애플리케이션이 정하는 값이다.
| 서비스 오류 코드 예시 | 상황 | 사용자 안내 |
| --- | --- | --- |
| `login_cancelled` | 사용자가 Discord 권한 요청 취소 | 원할 때 로그인 다시 시작 |
| `login_request_invalid` | 로그인 시도를 찾을 수 없거나 state 불일치 | 로그인 버튼부터 다시 시작 |
| `login_unavailable` | 토큰 교환·사용자 조회 등 외부 연동 실패 | 잠시 후 재시도 |
| `membership_required` | 선택적으로 적용한 서버 멤버 조건 미충족 | 필요한 가입 조건 안내 |
사용자가 할 수 있는 행동은 화면에 안내하고, 상세한 원인은 서버에서 추적한다. 로그인 시작과 콜백을 연결하는 시도 ID를 두면 여러 요청으로 나뉜 흐름을 따라가기 쉽다. 토큰, client secret, 쿠키, 콜백의 인가 코드가 로그에 노출되지 않도록 기록 항목을 정한다.
콜백 주소를 구성할 때는 ‘Discord에서 돌아오는 백엔드 주소’와 ‘로그인 완료 후 보여줄 프론트엔드 주소’를 구분한다. 예를 들어 콜백은 `https://example.com/auth/discord/callback`, 성공 후 화면은 `https://example.com/`로 정할 수 있다. 프록시나 개발 서버를 거친다면 실제 인가 요청의 `redirect_uri`가 Discord에 등록한 값과 일치하는지 확인한다. 사용자 입력으로 성공 후 이동 경로를 받는 경우에는 서비스 내부의 허용된 경로로 제한한다.
이 흐름을 구현할 때 라이브러리를 사용하면 인가 요청 생성, 콜백 검증, 토큰 교환 같은 공통 처리를 맡길 수 있다. Spring Security 6.5의 OAuth2 Login을 예로 들면 기본 로그인 시작 경로는 `/oauth2/authorization/{registrationId}`, 콜백 경로는 `/login/oauth2/code/{registrationId}`다. 등록 이름을 `discord`로 정했다면 아래와 같이 대응된다. [Spring Security OAuth2 로그인 문서](https://docs.spring.io/spring-security/reference/6.5/servlet/oauth2/login/advanced.html)
| 이 글의 설명용 경로 | Spring Security의 기본 경로 |
| --- | --- |
| `/auth/discord` | `/oauth2/authorization/discord` |
| `/auth/discord/callback` | `/login/oauth2/code/discord` |
사용자 정보 조회 이후의 계정 연결과 이용 조건 검사는 `OAuth2UserService` 같은 확장 지점에 구현할 수 있다. 라이브러리가 토큰을 어디에 저장하는지도 함께 확인해야 한다. 예를 들어 Spring Security의 기본 `OAuth2AuthorizedClientService` 구현은 토큰을 포함하는 Authorized Client를 메모리에 보관한다. 서비스 코드에서 더 이상 토큰을 참조하지 않는다는 이유만으로 토큰이 즉시 폐기됐다고 가정할 수는 없다. [Spring Security Authorized Client 문서](https://docs.spring.io/spring-security/reference/6.5/servlet/oauth2/client/core.html)
어떤 프레임워크를 사용하든 외부 사용자 정보를 내부 계정으로 연결하는 지점에는 서비스의 정책이 들어간다. 자동 가입을 허용할지, 표시 이름을 언제 갱신할지, 추가 이용 조건을 확인할지 정하고, 그 결과를 내부 사용자 ID와 로그인 세션으로 연결하면 된다.
'개발' 카테고리의 다른 글
| Promise, async, await는 어떻게 이어질까: Chromium으로 이해하는 비동기 실행 (0) | 2026.09.20 |
|---|---|
| HTML에서 JavaScript를 불러오는 방법과 차이점 (1) | 2026.09.13 |
| LLDB을 이용한 C++ 디버깅 원리 (1) | 2026.08.30 |
| 부동소수점은 왜 필요할까? (0) | 2026.08.23 |
| vscode에서 파이썬 프로그램을 디버깅 하는 방법 (0) | 2026.08.16 |