⚠️ 작성 중인 초안입니다 — 검토·확정 전이며, 각 문서의 "미확정" 항목은 정책이 아닙니다.
Skip to content

04 활용

로그인 구현(환경별 설정), 회원 연결, 앱, 세션·로그아웃, 계정 상태 변화, 로그인 버튼 — 시나리오별 구현 방법.

상태확정 (전환 전용 화면·로그아웃 복귀 주소·연결 해제 통지는 예정)
최종 확인2026-09-23

로그인

라이브러리로 구현 (권장)

디스커버리 주소만 넣으면 엔드포인트·서명키·PKCE·토큰 검증을 라이브러리가 처리한다. 검증된 라이브러리를 쓴다 — 직접 구현한 토큰 검증은 조용히 뚫린다.

공통 설정값:

issuer / discovery : {호스트}/.well-known/openid-configuration  ([02 환경과 주소](./02-사전-설정#환경과-주소))
client_id          : 발급받은 값
client_secret      : 발급받은 값 (서버 환경변수)
scope              : openid email
redirect_uri       : 등록한 값과 정확히 일치
PKCE               : S256 (서버가 필수로 요구)
client auth        : client_secret_post
clock skew         : 60초

Node.js — openid-client v6: 06 샘플 클라이언트 전체가 이것이다.

Java / Spring Boot 3 — Spring Security OAuth2 Client

yaml
# application.yml
spring:
  security:
    oauth2:
      client:
        registration:
          moneybox:
            provider: moneybox
            client-id: ${MEMBERSHIP_CLIENT_ID}
            client-secret: ${MEMBERSHIP_CLIENT_SECRET}
            client-authentication-method: client_secret_post
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/moneybox"   # 이 값을 등록한다
            scope: openid, email
        provider:
          moneybox:
            issuer-uri: https://membership.m-box.com     # 디스커버리를 자동으로 읽는다
java
// PKCE 강제 — Spring Security 6.x 는 공개 클라이언트가 아니면 기본으로 켜지 않는다
@Bean
SecurityFilterChain security(HttpSecurity http, ClientRegistrationRepository repo) throws Exception {
  var resolver = new DefaultOAuth2AuthorizationRequestResolver(repo, "/oauth2/authorization");
  resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
  http.oauth2Login(o -> o.authorizationEndpoint(a -> a.authorizationRequestResolver(resolver)));
  return http.build();
}

로그인 성공 후 OidcUser.getSubject()sub, getEmail()email(없으면 null)이다.

Next.js — Auth.js (NextAuth v5)

ts
// auth.ts
import NextAuth from 'next-auth'

export const { handlers, auth } = NextAuth({
  providers: [{
    id: 'moneybox',
    name: '머니박스 ID',
    type: 'oidc',
    issuer: process.env.MEMBERSHIP_ISSUER,            // https://membership.m-box.com
    clientId: process.env.MEMBERSHIP_CLIENT_ID,
    clientSecret: process.env.MEMBERSHIP_CLIENT_SECRET,
    authorization: { params: { scope: 'openid email' } },
    checks: ['pkce', 'state', 'nonce'],
    client: { token_endpoint_auth_method: 'client_secret_post' },
    profile: (p) => ({ id: p.sub, email: p.email ?? null }),   // email 은 없을 수 있다
  }],
  callbacks: {
    async signIn({ profile }) { /* profile.sub 로 회원 찾기/만들기 */ return true },
  },
})
// 콜백 주소 {origin}/api/auth/callback/moneybox 를 redirect_uri 로 등록한다

Python — Authlib

python
oauth.register(
    name="moneybox",
    server_metadata_url="https://membership.m-box.com/.well-known/openid-configuration",
    client_id=os.environ["MEMBERSHIP_CLIENT_ID"],
    client_secret=os.environ["MEMBERSHIP_CLIENT_SECRET"],
    client_kwargs={"scope": "openid email", "code_challenge_method": "S256",
                   "token_endpoint_auth_method": "client_secret_post"},
)

.NET / PHP: Microsoft.AspNetCore.Authentication.OpenIdConnect(Authority = 호스트, UsePkce = true, ResponseType = "code") / jumbojett/openid-connect-php(setCodeChallengeMethod('S256'), addScope(['email'])).

라이브러리가 ID 토큰 검증(서명·iss·aud·exp·nonce)을 실제로 수행하는지 확인이 필요하다. 일부 라이브러리는 옵션으로 꺼져 있다.

직접 구현

03 REST API의 인가 코드 요청 → 토큰 요청 → ID 토큰 검증 순서다. 검증 없이 id_token을 디코드만 해서 쓰면 위조 토큰을 받아들이게 된다. JWT 검증 라이브러리(jose, jjwt, PyJWT 등)로 JWKS 기반 RS256 검증을 하되, 라이브러리를 쓸 수 없는 환경이면 아래 절차를 그대로 구현한다.

ID 토큰 검증 절차 (로그인 응답 기준)

1. id_token 을 '.' 로 나눈다: header.payload.signature (각각 base64url)
2. header.alg 가 "RS256" 인지 확인. header.kid 로 JWKS(jwks_uri)에서 키를 고른다
   - 없으면 JWKS 를 다시 받는다(키 회전). 그래도 없으면 거부
3. 서명 검증: RSASSA-PKCS1-v1_5 / SHA-256 (RFC 8017 §8.2.2)
   - JWKS 의 n, e 는 base64url 로 인코딩된 빅엔디언 정수
   - m = signature^e mod n 을 바이트로 펴서 00 01 FF…FF 00 ‖ DigestInfo ‖ SHA256(header.payload) 와 비교
   - DigestInfo(SHA-256) 프리픽스 = 3031300d060960864801650304020105000420 (hex)
4. payload 검증
   - iss == 디스커버리의 issuer
   - aud == 내 client_id (단일 문자열. azp 없음)
   - exp + 60초 > now, iat ≤ now + 60초
   - nonce == 인가 요청에 보낸 값 (로그인 응답에서만. 갱신 응답에서는 검사하지 않음)
5. at_hash 검증(선택): BASE64URL( SHA256(access_token) 의 앞 16바이트 ) == at_hash
6. 통과하면 sub 를 쓴다. email 은 없을 수 있다

두 줄로 요약하면 verify_rs256(jwks[kid], header.payload, signature) 뒤에 assert iss, aud, exp(+60s), nonce. 표준 라이브러리만으로도 구현 가능하나(정수 거듭제곱 + SHA-256), 검증된 JWT 라이브러리가 있으면 그것을 쓴다.

로그인 이후 — 연동 서비스 세션

토큰을 받은 뒤 연동 서비스의 로그인 상태(세션 쿠키 또는 자체 JWT)를 발급한다. 멤버십 토큰은 다음 용도로만 쓴다.

토큰용도
id_token검증 후 sub·email 추출. 저장하지 않아도 된다
access_token사용자 정보 조회용(10분). 프론트엔드에 내려보내지 않는다
refresh_tokenoffline_access를 요청한 경우만. 서버에만 보관

멤버십 토큰을 프론트엔드에 저장하지 않는다. 연동 서비스 앱·브라우저는 멤버십 토큰을 볼 이유가 없다.

SSO — 자동 로그인

멤버십 세션(브라우저 쿠키, 14일)이 살아 있으면 어느 연동 서비스든 인가 요청을 보내기만 하면 화면 없이 인가 코드가 돌아온다. 첫 연결이면 동의 화면만 뜬다. 연동 서비스가 따로 할 일은 없다.

멤버십에 장애가 나면 신규 로그인은 안 되지만 연동 서비스 세션은 유효하다. 연동 서비스 세션 수명을 너무 짧게 잡지 않는다.

재인증

송금·비밀번호 변경처럼 민감한 동작 전에 사용자를 다시 확인하려면 인가 요청에 prompt=login(또는 max_age=0)을 붙인다. 멤버십 세션이 있어도 비밀번호를 다시 묻고, 이때만 ID 토큰에 auth_time이 들어온다. 연동 서비스는 auth_time이 최근 N초 이내인지로 판단한다.

연결

준비 — 컬럼 추가

회원 테이블에 membership_sub(문자열, 고유, NULL 허용)를 추가한다. 기존 회원은 전부 NULL로 시작한다. sub는 35자다.

sql
ALTER TABLE member ADD COLUMN membership_sub VARCHAR(40) NULL;   -- 현재 35자. 접두어 확장 여지로 40
CREATE UNIQUE INDEX ux_member_membership_sub ON member (membership_sub);

회원 매칭 키는 sub만 쓴다. sub는 영구 불변이다. 사용자가 이메일을 바꿔도, 로그인 수단을 바꿔도 같다. 이메일은 연락·표시용이고 바뀔 수 있으며 없을 수도 있다(01).

회원 확인과 등록

sub 로 회원 조회
├─ 있음 → 그 회원으로 로그인 (연동 서비스 세션 발급). email 이 왔으면 갱신
└─ 없음 → 신규 회원 생성(membership_sub = sub, email 은 있으면 저장·없으면 NULL)
          → 연동 서비스 온보딩 (email 이 없고 필요하면 여기서 받는다)

온보딩은 연동 서비스 것이다. 이름·연락처·본인인증·서비스 약관 등 서비스에 필요한 정보를 이 시점에 받는다. 멤버십은 로그인 수단과 멤버십 약관만 처리했다.

기존 회원 연결

연동 서비스에 이미 회원이 있다면, 그 회원을 멤버십 계정과 잇는 것은 회원 본인이 한 번 통과해야 한다. 멤버십은 연동 서비스 회원 DB를 모르고, 법적으로도 회원 명단을 넘겨받을 수 없다(타 법인 간 개인정보 제공).

경로 ① — 로그인한 상태에서 전환 (권장)

핵심은 마지막 줄이다. 누가 로그인돼 있는지 연동 서비스가 확실히 아는 상태에서 sub를 그 행에 적는다. 이메일 매칭이 필요 없다. 이 콜백은 일반 로그인 콜백과 다른 경로(예: /auth/membership/link)로 두어, "새 로그인"이 아니라 "현재 회원에 연결"로 처리하는 것이 안전하다 — 같은 경로를 쓰면 state에 용도를 담아 구분한다.

전환 진입은 지금은 login_hint=<이메일> 보낸다 — 일반 로그인 화면에 이메일이 채워진다. 전환 전용 화면("레밋 계정을 머니박스 ID로 전환합니다", 새 비밀번호 설정 안내)은 prompt=create로 진입하게 될 예정이며 예정 현재 서버는 prompt=createinvalid_request(unsupported prompt value)로 거부한다. 반영되면 07 변경 이력에 알리고, 그때 prompt=create를 추가하면 된다. 위 다이어그램의 prompt=create는 그 시점의 모습이다.

경로 ② — 로그아웃 상태에서 통합 로그인

sub로 회원을 못 찾았고, 넘어온 email이 기존 회원과 일치하는 경우:

연동 서비스 쪽 이메일 상태처리
연동 서비스가 검증한 이메일그 회원에 sub 저장 (자동 연결 가능)
미검증 이메일자동 연결 금지 — 남의 계정을 가로챌 수 있다. "기존 계정이 있으신가요?" → 기존 로그인으로 인증 후 연결
일치하는 회원 없음, 또는 email 부재신규 회원 생성

카카오 이메일 ≠ 연동 서비스 가입 이메일인 경우가 실제로 흔하다. 경로 ①을 기본으로 권장한다.

전환 기간 정책

기존 회원이 있는 서비스는 한동안 두 버튼이 공존한다. 어디까지 열어둘지는 연동 서비스의 결정이지만, 권장은 B다.

자체 가입자체 로그인결과
A열림열림미연결 회원이 계속 늘어 전환이 끝나지 않는다
B (권장)닫힘열림 (기존 회원용, 눈에 덜 띄게)신규는 전부 멤버십으로. 미연결 회원 수가 오픈 시점에 고정되고 줄어들기만 한다
C닫힘닫힘미연결 회원은 첫 로그인에 경로 ① 필수. 가장 빠르지만 이탈 위험

B로 시작해 미연결 회원이 충분히 줄면 C로 넘어간다. C에서는 membership_sub가 NULL인 회원이 기존 로그인하면 경로 ①을 바로 태운다. 연결이 끝난 회원의 비밀번호 등 자체 로그인 컬럼은 지워도 된다.

B 정책이면 연동 서비스 가입 버튼은 [머니박스 ID로 가입/로그인] 하나다. 신규 고객은 멤버십에서 계정을 만들고 돌아오며, 연동 서비스는 sub가 없으니 신규 회원을 만들고 온보딩한다.

금지 사항

  • email을 회원 매칭 키로 쓰기 — 바뀔 수 있고, 없을 수 있고, 미검증이면 탈취 경로
  • 멤버십 sub를 다른 회사와 대조하기 — 서비스마다 다르게 발급되므로 되지도 않고, 해서도 안 된다
  • 회원 명단을 멤버십에 보내 미리 만들어 달라고 하기 — 불가. 회원 본인이 가입·동의해야 한다

iOS · Android 앱은 웹과 세 가지가 다르다.

원칙이유
시스템 브라우저로 열기 (iOS ASWebAuthenticationSession, Android Chrome Custom Tabs)카카오·구글이 WebView 로그인을 차단한다. 시스템 브라우저는 멤버십 세션 쿠키를 공유해 SSO가 된다. WebView는 금지
복귀는 https 딥링크 (Universal Link / App Link)커스텀 스킴(remit://)은 다른 앱이 등록해 인가 코드를 가로챌 수 있다. 서버도 등록을 거부한다
토큰 요청은 앱 → 자사 백엔드 → 멤버십앱은 client_secret을 가질 수 없다

PKCE의 code_verifier앱이 만들고 앱이 보관한 뒤 백엔드에 넘긴다. 그래야 딥링크로 새어 나간 인가 코드만으로는 토큰을 못 받는다.

iOS (Swift)

swift
let verifier = PKCE.randomVerifier()                        // 43~128자
let challenge = PKCE.s256(verifier)
var url = URLComponents(string: "https://membership.m-box.com/oauth/authorize")!
url.queryItems = [
  .init(name: "client_id", value: "remit-ios"),
  .init(name: "redirect_uri", value: "https://www.remit.example/app/cb"),
  .init(name: "response_type", value: "code"),
  .init(name: "scope", value: "openid email"),
  .init(name: "state", value: state), .init(name: "nonce", value: nonce),
  .init(name: "code_challenge", value: challenge), .init(name: "code_challenge_method", value: "S256"),
]
// Universal Link 복귀: callbackURLScheme 은 https 딥링크에서 쓰지 않는다 — 대신 앱 델리게이트의
// continue userActivity 로 code·state 를 받는다.
let session = ASWebAuthenticationSession(url: url.url!, callback: .https(host: "www.remit.example", path: "/app/cb")) { url, error in
  // url 의 code·state 를 자사 백엔드에 verifier 와 함께 전달 → 백엔드가 토큰 요청
}
session.presentationContextProvider = self
session.start()

Android (Kotlin, AppAuth)

kotlin
val config = AuthorizationServiceConfiguration.fetchFromIssuer(Uri.parse("https://membership.m-box.com")) { cfg, _ ->
  val req = AuthorizationRequest.Builder(cfg!!, "remit-android", ResponseTypeValues.CODE,
      Uri.parse("https://www.remit.example/app/cb"))            // App Link
    .setScope("openid email").setNonce(nonce).setState(state)
    .setCodeVerifier(verifier)                                   // AppAuth 가 S256 challenge 생성
    .build()
  // Custom Tabs 로 열기
  startActivityForResult(AuthorizationService(this).getAuthorizationRequestIntent(req), RC_AUTH)
}
// onActivityResult: AuthorizationResponse.fromIntent(data) 의 authorizationCode 와 verifier 를
// 자사 백엔드에 전달. performTokenRequest 는 쓰지 않는다(시크릿이 앱에 있어야 하므로).

앱은 웹과 별도 클라이언트로 등록한다(remit-ios, remit-android). 같은 서비스이므로 sub는 웹과 같다. 딥링크 파일(apple-app-site-association, assetlinks.json)은 연동 서비스 도메인에 두는 것이므로 멤버십과 무관하다.

멤버십 화면이 웹이라 앱에서 카카오를 누르면 카카오톡 앱 점프가 아닌 카카오 웹 로그인으로 간다 소셜 예정. 카카오톡에 로그인돼 있으면 자동 진행된다.

로그아웃

방식구현결과
연동 서비스 로그아웃연동 서비스 세션 삭제. 리프레시 토큰을 보관했다면 토큰 폐기멤버십 세션 유지 → 다른 서비스 SSO 유지. 대부분 이걸로 충분
멤버십 로그아웃브라우저를 /oauth/logout으로 보낸다(id_token_hint 포함)멤버십 세션 종료 → 모든 서비스에서 재로그인 필요

사용자에게 "모든 서비스에서 로그아웃" 선택지를 줄 때만 멤버십 로그아웃을 쓴다. 로그아웃 후 복귀 주소(post_logout_redirect_uri)는 등록 기능 반영 후 사용 가능하다 예정.

토큰 갱신

사용자가 없는 시간에 서버가 멤버십을 호출할 일(사용자 정보 재조회 등)이 있을 때만 offline_access를 요청한다. 로그인 자체에는 필요 없다.

  • 인가 요청에 scope=openid email offline_access prompt=consent 를 함께 보낸다. prompt=consent가 없으면 표준 규정대로 offline_access가 조용히 무시되어 리프레시 토큰이 오지 않는다(응답 scope에서 확인 가능).
  • prompt=consent는 이미 동의한 사용자에게도 동의 화면을 매번 다시 보인다. SSO 무화면 로그인이 깨지므로 모든 로그인에 붙이지 말고, 리프레시 토큰이 실제로 필요한 진입점(예: 백그라운드 연동을 켜는 설정 화면)에서만 쓴다.
  • 30일 유효, 사용할 때마다 새 토큰으로 교체된다. 옛 토큰은 즉시 무효 — 응답의 새 값을 반드시 저장한다. 두 서버가 같은 토큰으로 동시에 갱신하면 한쪽이 invalid_grant를 받으므로 갱신은 한 곳에서만 한다.
  • 서버에만 보관한다. 토큰 갱신 참고.

계정 상태 변화

사용자에게 일어난 일연동 서비스가 보게 되는 것할 일
이메일 변경다음 로그인의 email이 다름. sub 동일email 갱신
멤버십 계정 정지로그인 시도: 안내 후 error=access_denied / 토큰 갱신: invalid_grant재로그인 유도
멤버십 계정 탈퇴토큰 갱신: invalid_grant. 재가입하면 sub(새 회원으로 보임)연결 해제 처리. 옛 회원 정리는 연동 서비스 정책
연동 서비스 연결 해제(동의 철회) 예정토큰 갱신: invalid_grant. 다음 로그인 시 동의 화면 재노출 후 같은 sub갱신 실패 시 재로그인 유도

invalid_grant를 받으면 리프레시 토큰을 버리고 그 회원을 연동 서비스 정책대로 처리한다. 회원 삭제 여부는 연동 서비스가 결정한다. 실시간 통지(웹훅)는 1차에 없다.

로그인 버튼

사용자가 "다른 계정으로 들어가는 것"임을 한눈에 알게 하는 것이 목적이다.

항목규칙
문구머니박스 ID로 로그인 (로그인·가입 겸용 버튼이면 머니박스 ID로 시작하기). 전환 배너는 머니박스 ID로 전환하기
로고머니박스 심볼을 문구 왼쪽에. 파일은 클라이언트 발급 시 제공(SVG·PNG)
크기다른 소셜 로그인 버튼과 같은 높이. 최소 높이 44px, 로고 최소 20px
제공된 심볼 색을 바꾸지 않는다. 버튼 배경은 연동 서비스 스타일에 맞춘다
위치자체 로그인 병행 기간에는 자체 로그인보다 위 또는 같은 열. 신규 고객은 이 버튼으로만 들어온다(전환 정책 B)
금지문구 변경("통합 로그인" 등), 로고 변형, 다른 소셜 로그인 아이콘과 합성

보안 점검

항목확인
state콜백에서 보낸 값과 다르면 중단하는가. 1회용인가
nonceID 토큰의 값과 비교하는가
ID 토큰 검증서명·iss·aud·exp를 실제로 확인하는가(라이브러리 옵션 확인)
iss 파라미터콜백의 iss를 디스커버리 issuer와 대조하는가(라이브러리가 하면 확인만)
시크릿서버 환경변수·시크릿 저장소에만 있는가
토큰 저장멤버십 토큰이 브라우저·앱 저장소에 없는가
인가 코드콜백 URL이 로그·리퍼러로 새지 않는가(콜백 처리 후 즉시 리다이렉트)
email 부재email 없는 ID 토큰으로도 회원 생성·로그인이 되는가