이번 글은 이전 글의 다음 단계인 2, 3단계에 대해 다룹니다.
코드는 다음 링크를 통해 확인 가능합니다.
GitHub - GoGradually/pinit-auth
Contribute to GoGradually/pinit-auth development by creating an account on GitHub.
github.com
첫 MSA 도입, 한번에 다 만들려고 하면 복잡하다.
각각 독립적인 기술인 만큼, JWT + OIDC 조합 두 단계로 나눠서 접근하자.
목차
- 내 JWT 인증 흐름 먼저 만들기
- 그다음 OIDC 로그인 붙여서 “로그인 수단”만 확장하기
- 회원가입 이벤트 발행 + be 서버에 공개 키 공유해서 인증 연동하기
2. OIDC (외부 로그인) 붙이기
(0) OIDC의 이해
- User-Browser
- 사용자가 브라우저에서 앱에 접속
- Client (RP, Relying Party)
- 내 애플리케이션 (pinit-auth)
- 사용자를 대신해 인증 요청을 보낸다.
- Authorization Server / OpenID Provider (OP)
- 구글, 네이버, 카카오 등
- 사용자 인증 및 ID 토큰 발급
- Resource Server (RS)
- 보호된 API 서버
- 액세스 토큰으로 보호 자원 제공
OIDC 의 흐름을 살펴보면 다음과 같다.
공식 용어가 아닌, 백엔드 개발자 관점에서 이해하기 쉽게 풀어쓴 것이다.
원래는 "백엔드 서버"의 경우 OIDC에서는 OIDC 입장에서 "Client" 또는 "Relying Party (RP)"라고 부른다.
- 사용자가 앱/웹에 접속하여 로그인 버튼 클릭
- 백엔드 서버가 사용자를 OpenID Provider의
/authorize엔드포인트로 리다이렉트시킬 준비한다.response_type=codeclient_id,redirect_uriscope=openid profile emailstate,code_challenge,code_challenge_method(PKCE)- 이때, 서버는 사용자 세션에
state와code_verifier를 저장해둔다. (즉, 이때의 상태를 저장해둘 수 있어야 한다.)
- 사용자의 브라우저가 OpenID Provider의
/authorize엔드포인트로 요청을 보낸다. - OpenID Provider가 사용자에게 로그인 폼과 동의 화면을 제공한다.
- 사용자가 아이디/비밀번호를 입력하고 동의한다.
- OpenID Provider가 사용자를 우리 백엔드의
redirect_uri로 302 리다이렉트한다.- 쿼리 파라미터로
code=AUTH_CODE와state=...를 포함한다.
- 쿼리 파라미터로
- 백엔드 서버가 OpenID Provider의
/token엔드포인트로 백채널 요청을 보낸다.grant_type=authorization_codecode=AUTH_CODEredirect_uriclient_auth(또는 PKCE의 경우code_verifier)
- OpenID Provider가 백엔드 서버에
access_token,id_token(및 옵션으로refresh_token)을 응답한다. - 백엔드 서버가
id_token의 서명, 만료, nonce 등을 검증한다.sub,iss,aud등을 확인한다.
- 백엔드 서버가 로그인을 완료하고, 우리의 로그인 처리를 수행한다.
- 지금의 경우, 앞서 구현한 JWT 발급 로직을 재사용하여 자체 access/refresh 토큰을 발급한다.
만약 사용자의 Openid Provider 쪽 보호 자원(예: 사용자의 네이버 정보 등)에 접근해야 한다면
- 백엔드 서버가 Resource Server에 요청을 보낸다.
- 앞서 OP 쪽에게서 발급받은 Access Token을 이용하여
Authorization: Bearer access_token헤더를 포함하여 요청한다.
- 앞서 OP 쪽에게서 발급받은 Access Token을 이용하여
- Resource Server가 (옵션으로) OpenID Provider에 토큰의 유효성을 확인한다.
- introspection 엔드포인트 또는 userinfo 엔드포인트를 호출하거나,
- JWT인 경우 JWKS를 이용해 서명 검증을 수행할 수 있다.
- Resource Server가 보호 자원을 응답한다.
이를 시퀀스 다이어그램으로 표현하면 다음과 같다.

여기서 필요한 상태를 추출하면 다음과 같다. (명사 추출법/네이버 개발자 문서 참고)

OIDC 쪽에서 제공하는 출력 값은 다음과 같다.
- OpenID Provider가 사용자를 우리 백엔드의
redirect_uri로 302 리다이렉트할 때- API 요청 성공 시: http://콜백URL/redirect?code={AUTH_CODE}&state={...}
- API 요청 실패 시: http://콜백URL/redirect?state={...}&error={ERROR_CODE}&error_description={DESCRIPTION}
- access token 발급 요청 시
- access_token: String
- token_type: string (예: Bearer)
- expires_in: integer (초 단위)
- error: string (오류 시)
- error_description: string (오류 시)
다른 주요한 형식은 해당 OIDC 제공자 문서를 참고한다.
현재 이 글의 경우, 네이버 OIDC 문서를 참고하고 있다.
이제 OIDC를 통해 제공받을 수 있는 회원 정보(클레임)를 정리해보자.
{
"resultcode": "00",
"message": "success",
"response": {
"email": "openapi@naver.com",
"nickname": "OpenAPI",
"profile_image": "https://ssl.pstatic.net/static/pwe/address/nodata_33x33.gif",
"age": "40-49",
"gender": "F",
"id": "32742776",
"name": "오픈 API",
"birthday": "10-01",
"birthyear": "1900",
"mobile": "010-0000-0000"
}
}
우리는 이제 여기서 "id" (sub 역할)만 있으면 회원 매핑이 가능하다.
(1) OIDC 도메인 설계
- 네이버 로그인 인증 요청 도메인 추가 (백엔드가 세팅 후 프론트 네이버로 리다이렉트시킴) - NaverLoginSetting
- 접근 토큰 발급 요청 도메인/DTO 추가 - OpenIdTokenRequest, OpenIdPublishCommand
- 접근 토큰 갱신 요청 도메인/DTO 추가 - OpenIdTokenRequest, OpenIdRefreshCommand
- 접근 토큰 삭제 요청 도메인/DTO 추가 - OpenIdTokenRequest, OpenIdRevokeCommand
- 네이버 로그인 요청 출력 결과 도메인 추가(리다이렉트) - NaverLoginResult
- 접근 토큰 발급 요청의 출력 결과 도메인 추가 - OpenIdTokenResponse, OpenIdPublishResponse
- 접근 토큰 갱신 요청의 출력 결과 도메인 추가 - OpenIdTokenResponse, OpenIdRefreshResponse
- 접근 토큰 삭제 요청의 출력 결과 도메인 추가 - OpenIdTokenResponse, OpenIdRevokeResponse
- 사용자 프로필 조회 요청 도메인 추가 - 없음. 헤더에 엑세스 토큰 넣어서 호출
- 사용자 프로필 조회 요청의 출력 결과 도메인 추가 - NaverUserProfileResponse
(2) 사용자 매핑 도메인 설계
OauthAccount테이블 예:- id
- provider (
GOOGLE,NAVER,KAKAO…) - providerUserId (
sub,id등) - memberId (내부 Member FK)
- 로그인 성공 시:
- (provider, providerUserId)로
OauthAccount조회 - 있으면 → 해당 member 사용
- 없으면 → 새 Member 생성 →
OauthAccount도 함께 생성 (트랜잭션)
- (provider, providerUserId)로
- 이렇게 하면:
- 한 member가 여러 provider를 연결하는 것도 가능해짐.
(3) API 호출 설계
문제 1: 메소드의 형태
- 발급 -> 토큰 두개 반환
- 갱신 -> 토큰 하나 반환
- 삭제 -> 토큰 없음
이걸 하나의 getToken 메소드로 통합하려니 문제가 생기는 중이다.
행위 자체가 다른데 하나의 메소드로 묶이는 게 맞나?
API 명세가 하나로 합쳐져 있을 뿐, 행위 자체는 다르다.
해결 방법
- 세 개의 메소드로 분리
- 하나의 메소드로 합치되, 실제 행위는 커맨드 패턴으로 분리
OIDC 설계 방식은 2번에 가까운 듯 하다.
하지만 반환 값이 다르니, 이를 적절히 반환할 방법이 없다.
- 인터페이스인 Oauth2Provider에서 List 을 리턴 값으로 정의한다고 해도, NaverOP에서는 List를 반환해야 한다.
- 제네릭은 기본적으로 불공변성이다.
- 따라서 List 는 List 의 하위 타입이 아니다.
- 제네릭은 기본적으로 불공변성이다.
- 그러면 Oauth2Provider 에서 List<? extends Oauth2Token> 와일드카드 기법을 사용하면 된다.
- 와일드카드 기법을 사용하면, List 는 List<? extends Oauth2Token> 의 하위 타입이 된다.
- 따라서 Oauth2Provider 인터페이스를 구현할 수 있다.
- 하지만 JWT의 형식 자체는 이미 고정되어 있다.
- 굳이 NaverOauth2Token 같은 구체적인 토큰 클래스를 만들 필요가 없다.
- 따라서 List 하나로 통일하는 게 낫다.
문제 2: 토큰 객체로의 변환 방식
- Command
- 각 명령에 맞는 데이터 보관
- Provider
- 해당 OIDC 제공자의 API 호출
- 해당 OIDC와 관련된 정보 보관.
- Command 객체를 받아서 처리
- 결과를 Token 객체로 반환
커맨드 패턴으로 행위를 구분하더라도, 결국 API 호출은 Provider가 담당해야 한다.
- 이때 API 호출에 필요한 데이터가 결국 Provider에서 풀려야 한다.
- 이 API 호출에 필요한 형식은 결국 해당 Provider에 종속적이므로, Provider에서 Command 객체를 푸는 형태로 구현이 되어야 함.
그런데 이러면 Command 객체가 execute 메소드를 가질 이유가 없다.
- 커맨드 패턴으로 할 이유가 없어진다. (이미 외부(OP)에서 인풋에 대한 execute 메소드 형식을 가지고 있다.)
- 결과를 token 객체로 반환하는 걸 Command가 해야 하는 거 아닌가? -> jackson이 해야지 이건.
- 주어진 응답을 적절한 token 객체들로 매핑
해결 방법
토큰 객체의 형식을 결정하는 건 OIDC가 아닌 우리 서비스이다.
- Command
- 각 명령에 맞는 데이터 보관
- 토큰 객체의 형식 지정
- 결과를 Token 객체들로 반환
- Oauth2Service
- 해당 OIDC와 관련된 정보 보관.
- Provider
- 해당 OIDC 제공자의 API 호출
(4) Oauth2Provider, Service 구현

구현을 하던 도중, 외부 API와 통신을 해야하는 Provider들이 명료하게 보이지 않는 것이 아쉬웠다.
어떻게 하면 Provider들을 현재 모듈의 주요한 컴포넌트로 인식시킬 수 있을까?
문제 1: @Provider 어노테이션 붙이기
@Component
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Provider {
String value() default "";
}
이제 좀 더 욕심이 나기 시작했다.
굳이 각 Provider 별 서비스를 만드는 것보단, Oauth2Service를 정의해두면 Mapper를 이용해 전략 패턴으로 처리할 수 있을 것 같다는 생각이 들었다.
그렇다면 어떻게 이를 맵에 등록할까?
- ApplicationContext에서 빈들을 다 꺼내서, @Provider 붙은 것들만 골라내기
- @ComponentScan + @Provider 어노테이션 조합으로 직접 컴포넌트 스캔하기
굳이 컴포넌트 스캔을 다시 구현하는 것보단, 1번을 활용하면 스프링 빈들을 탐색해주는 Mapper 객체를 사용할 수 있다.

이러면 스프링 빈 등록이 잘 되나? 라는 생각이 들 수 있는데,
ListableBeanFactory 는 Bean Definition 을 가지고 있는데,
스프링 빈 인스턴스 생성 방식은 getBean이 호출 되는순간 해당 인스턴스를 먼저 생성하는 방식을 재귀적으로 적용하기 때문에 문제는 없다.
즉, "빈 정의" 계층과 "빈 생성" 계층이 나뉘어져 있기 때문에 빈 정보를 가지고 내 내부 필드를 채울 수 있는 것이다.
(스프링 DI 의 원리를 살짝 응용해 사용한 것에 가깝다.)

그럼 이제 들어온 provider 들을 이름에 맞게 적절한 Provider로 매핑하게 만들 수 있다.
문제 2: grant가 없다는 문제
이제 본격적으로 OIDC 호출을 구현하고 테스트하는데, grant_type이 없다는 오류가 발생했다.
로그를 찍어봐도 grant 값이 OpenIdTokenRequest 에 잘 들어가 있었기에 정말 원인을 알 수가 없었다.

요청과 응답 포맷을 봐도 원인을 알 수가 없었다...
정말 많은 시간을 소모했는데, 도저히 뭐가 잘못된건지 이해가 안가서 주어진 예제를 한번 더 살펴봤다.



나는 습관적으로 application/json으로 보내고 있었다.
이는 객체 전송 시 json 방식이 편하다고 생각했고, 주어진 출력 포맷도 json으로 응답을 받기에 그렇게 생각했다.
하지만 실제 예제는 x-www-form-urlencoded 방식이었고, 혹시나 하는 마음에 urlencoded 방식으로 바꿔보니 grant_type이 정상적으로 전달되었다.
아마 OIDC 표준이 x-www-form-urlencoded 방식을 요구하는 듯 하다.
3. 리소스 서버(pinit-be) 인증 연동
(1) 성공 핸들러에서 내 JWT 발급
그렇게 백엔드에서 OIDC 로그인이 성공하면, 이제 내 JWT를 발급해야 한다.
하지만 문제가 하나 있었다. OIDC로부터 액세스 토큰을 받았지만, 이상하게 백엔드에서 필터에 걸려 프로필 정보를 확인할 수 없었다.

처음 생각은 다음과 같았다.
- OIDC의 redirect_uri 를 백엔드 API 로 지정해야 한다.
- 사용자는 백엔드 API로 리다이렉트해서, 받은 응답을 그대로 GET 요청으로 OIDC 제공자에게 전달한다.
- OIDC 제공자는 Access Token 을 응답받고, 백엔드는 이를 통해 사용자 정보를 조회한 뒤 내 JWT를 발급한다.
하지만 이건 잘못된 생각이었다.
- OIDC의 redirect_uri 를 백엔드 API 로 지정해야 한다.
- 사용자는 백엔드 API로 리다이렉트해서, 받은 응답을 그대로 GET 요청으로 OIDC 제공자에게 전달한다.
- 이 부분이 잘못되었다. 사용자가 백엔드 API로 리다이렉트되는 순간, 사용자는 더이상 FE를 거치지 못한다.
- 따라서 FE로 내 JWT를 전달할 수 없다.

따라서 프론트에 별도의 콜백 엔드포인트를 만들어, OIDC 제공자의 redirect_uri로 지정했다.
그러자 정상적으로 Oauth2 로그인이 완료되었고, 프론트에서 내 JWT를 받을 수 있었다.
'Article - 깊게 탐구하기 > 시간 기록, 관리 서비스 Pinit' 카테고리의 다른 글
| [Pinit] 푸쉬 알림 발송해보기 - 직접 Web Push 사용 (0) | 2025.12.10 |
|---|---|
| [Pinit] AsyncAPI - EDA를 향한 이벤트 교환 계약 문서 작성 (0) | 2025.12.09 |
| [Pinit] JWT 기반 OIDC 로그인/인증 구현 - JWT 구현 (0) | 2025.12.05 |
| [Pinit] 통계 갱신의 누락 문제 (0) | 2025.11.24 |
| [Pinit] HTTP Patch의 구현 방식 (0) | 2025.11.11 |