Kakao API를 사용한 OAuth2 & OIDC 구현
1. Kakao Developers 접속
2. 내 애플리케이션 클릭

3. 애플리케이션 추가

애플리케이션 설정
- 앱 아이콘, 이름, 회사명, 카테고리 설정
- 사용자의 정보 호출 시 권한을 받기 위한 심사용으로 사용됨

4. 튜토리얼 따라하기
- 문서의 튜토리얼 및 각종 환경에서 API를 호출하는 방법 확인

5. OIDC
✅ OIDC란?
- *OIDC (OpenID Connect)**는 OAuth 2.0 위에서 동작하는 신원 인증 프로토콜입니다.
즉,
🔑 "누구인지 증명하는 인증 계층"을 OAuth 위에 추가한 것
🔁 OIDC vs OAuth 2.0 비교
항목 | OAuth 2.0 | OIDC |
목적 | 권한 위임 (Authorization) | 사용자 인증 (Authentication) |
핵심 토큰 | Access Token | ID Token (JWT 형식) |
사용자 정보 제공 | 없음 (또는 별도 API 필요) | 기본적으로 제공 (표준화된 방식) |
용도 | API 호출 권한 위임 | 로그인 시스템 (소셜 로그인 등) |
확장 | OIDC는 OAuth 2.0을 확장 | — |
🧾 OIDC의 주요 구성 요소
구성 요소 | 설명 |
ID Token | 사용자의 인증 정보가 담긴 토큰 (JWT 형식) |
UserInfo Endpoint | 인증된 사용자 정보(JSON)를 제공하는 API |
Discovery Endpoint | OIDC 서버의 설정 정보를 자동으로 가져오는 URL ( .well-known/openid-configuration ) |
Scopes | openid , email , profile 등 인증을 위한 스코프를 지정 |
🧭 OIDC 인증 흐름 (Authorization Code Flow 기준)
- 클라이언트가 로그인 요청
- 사용자가 로그인하고 동의
- 클라이언트는 인가 코드(auth code)를 받음
- 클라이언트가 auth code로 토큰 요청
- OIDC 서버는 Access Token + ID Token 반환
- 클라이언트는 ID Token을 디코딩하여 사용자의 신원을 확인

6. 앱 키 확인
- 앱 키는 ID Token 및 Access Token을 받아올 수 있는 비밀 키이므로 노출되지 않도록 주의 필요

7. 구현 방식 설정
- Flutter의 경우 리다이렉션을 사용할 수 없기 때문에 네이티브 앱 서비스 사용
- 카카오톡 앱을 사용하지 못하는 환경(Windows, Web)은 웹 서비스 사용

8. 카카오 API 활성화
- OpenID Connect (OIDC)도 함께 활성화 : ID Token으로 서버에서 인증을 진행할 것임

9. Tutorial : blogv4 (flutter)
Code
AndroidManifest.xml
- 문서 내부의 AndroidManifest.xml activity 항목을 추가하여 반영
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="mykakao"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- 카카오 로그인 커스텀 URL 스킴 설정 -->
<activity
android:name="com.kakao.sdk.flutter.AuthCodeCustomTabsActivity"
android:exported="true">
<intent-filter android:label="flutter_web_auth">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- "kakao${YOUR_NATIVE_APP_KEY}://oauth" 형식의 앱 실행 스킴 설정 -->
<!-- 카카오 로그인 Redirect URI -->
<data android:scheme="kakao${YOUR_NATIVE_APP_KEY}" android:host="oauth" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
build.gradle.kts
- main.dart에서 설정한 Native App Key를 읽어서 설정
- 설정이 불가능할 경우 직접 대입 : 실제 앱 키를 작성해주어야 함
- 대입된 kakaoAppKey 값을 YOUR_NATIVE_APP_KEY 환경 변수로 설정
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.example.mykakao"
compileSdk = flutter.compileSdkVersion
ndkVersion = "27.0.12077973"
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.mykakao"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
// 카카오 네이티브 앱 키 설정
// Flutter에서 --dart-define=KAKAO_NATIVE_APP_KEY=your_key로 전달 가능
val kakaoAppKey = project.findProperty("KAKAO_NATIVE_APP_KEY") as String? ?: "YOUR_NATIVE_APP_KEY"
manifestPlaceholders["YOUR_NATIVE_APP_KEY"] = kakaoAppKey
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
flutter {
source = "../.."
}
main.dart
- 최초 main 실행 전 상수로 Native App Key 저장
- 카카오 SDK에 Native App Key 저장
- 이후 main 실행
- 화면이 출력되고 카카오로 시작하기 버튼 클릭 시 웹에서 카카오 계정으로 로그인
- 카카오톡 앱이 설치되어 있을 경우 loginWithKakaoTalk() 메서드 사용해도 됨
import 'package:flutter/material.dart';
import 'package:kakao_flutter_sdk/kakao_flutter_sdk.dart';
// 카카오 네이티브 앱 키 (빌드 시점에 --dart-define로 전달 가능)
const String KAKAO_NATIVE_APP_KEY = String.fromEnvironment(
'KAKAO_NATIVE_APP_KEY',
defaultValue: 'YOUR_NATIVE_APP_KEY',
);
void main() {
WidgetsFlutterBinding.ensureInitialized();
// runApp() 호출 전 Flutter SDK 초기화
KakaoSdk.init(nativeAppKey: KAKAO_NATIVE_APP_KEY);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'My Kakao',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFFFEE500), // 카카오 브랜드 컬러
brightness: Brightness.light,
),
useMaterial3: true,
fontFamily: 'Pretendard',
),
home: const KakaoLoginPage(),
);
}
}
class KakaoLoginPage extends StatefulWidget {
const KakaoLoginPage({super.key});
@override
State<KakaoLoginPage> createState() => _KakaoLoginPageState();
}
class _KakaoLoginPageState extends State<KakaoLoginPage> {
bool _isLoggedIn = false;
void _handleKakaoLogin() async {
try {
OAuthToken token = await UserApi.instance.loginWithKakaoAccount();
print('카카오톡으로 로그인 성공 ${token.idToken}');
setState(() {
_isLoggedIn = true;
});
} catch (error) {
print('카카오톡으로 로그인 실패 $error');
}
// 로그인 성공 메시지 표시
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('카카오 로그인 성공! 🎉'),
backgroundColor: const Color(0xFFFEE500),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: ListView(
padding: const EdgeInsets.all(24.0),
children: [
// 상단 여백
const SizedBox(height: 60),
// 카카오 로고 및 타이틀
Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: const Color(0xFFFEE500),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: const Color(0xFFFEE500).withOpacity(0.3),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: const Icon(
Icons.chat_bubble_outline,
size: 40,
color: Color(0xFF3C1E1E),
),
),
const SizedBox(height: 24),
const Text(
'My Kakao',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Color(0xFF3C1E1E),
),
),
const SizedBox(height: 8),
Text(
'카카오와 함께 시작하세요',
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),
const SizedBox(height: 80),
// 카카오 로그인 버튼
Container(
width: double.infinity,
height: 56,
decoration: BoxDecoration(
color: const Color(0xFFFEE500),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFFFEE500).withOpacity(0.4),
blurRadius: 15,
offset: const Offset(0, 8),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: _handleKakaoLogin,
borderRadius: BorderRadius.circular(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: const Color(0xFF3C1E1E),
borderRadius: BorderRadius.circular(4),
),
child: const Icon(
Icons.chat_bubble,
size: 16,
color: Color(0xFFFEE500),
),
),
const SizedBox(width: 12),
const Text(
'카카오로 시작하기',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF3C1E1E),
),
),
],
),
),
),
),
const SizedBox(height: 24),
// 또는 구분선
Row(
children: [
Expanded(child: Container(height: 1, color: Colors.grey[300])),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'또는',
style: TextStyle(color: Colors.grey[500], fontSize: 14),
),
),
Expanded(child: Container(height: 1, color: Colors.grey[300])),
],
),
const SizedBox(height: 24),
// 빈 박스 카드들
_buildEmptyCard(),
const SizedBox(height: 16),
_buildEmptyCard(),
const SizedBox(height: 16),
_buildEmptyCard(),
const SizedBox(height: 60),
// 로그인 상태 표시
if (_isLoggedIn)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFEE500).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFFEE500), width: 1),
),
child: Row(
children: [
Icon(Icons.check_circle, color: Colors.green[600], size: 20),
const SizedBox(width: 8),
Text(
'로그인 완료! 환영합니다 👋',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.green[700],
),
),
],
),
),
const SizedBox(height: 40),
],
),
);
}
Widget _buildEmptyCard() {
return Container(
height: 80,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[200]!, width: 1),
boxShadow: [
BoxShadow(
color: Colors.grey[200]!,
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
);
}
Widget _buildLoginOption({
required IconData icon,
required String title,
required String subtitle,
required Color color,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[200]!, width: 1),
boxShadow: [
BoxShadow(
color: Colors.grey[200]!,
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF3C1E1E),
),
),
Text(
subtitle,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
),
),
Icon(Icons.arrow_forward_ios, size: 16, color: Colors.grey[400]),
],
),
);
}
}
Result
- 정상 로그인 시 로그인 완료 알림 출력

구성
OAuthToken
- access_token : 로그인을 위한 인증 용 access_token
- 해시로 저장되어 있어 해독 불가 : kakao에서 대조해야 함
- expires_at : access_token 만료 시간
- refresh_token : access_token 재 발급 용 refresh_token
- refresh_token_expires_at : refresh_token 만료 시간
- scopes : 데이터 제공 허용 범위
- id_token : 사용자 신원 검증 용 JWT
- Spring 서버에서도 인증 가능하므로 id_token을 사용할 예정
id_token
- aud : 앱의 Native App Key
- sub : 로그인 한 사용자의 고유 ID
- auth_time : 사용자가 로그인 한 시간
- iss : Issuer (토큰 공급자)
- exp : Expired At (토큰 만료 시간)
- iat : Issued At (토큰 발급 시각)
- nickname : (확장) 사용자 이름
- picture : (확장) 사용자 프로필 주소
Code vs Credential
Code
- 사용자가 kakao에 로그인
- kakao에서는 사용자가 로그인했으므로 Access Token 및 기타 정보를 제공
- 사용자는 Spring 서버에게 Access Token 전달
- Spring 서버는 Access Token을 검증하기 위해 kakao에게 검증 요청
- kakao는 Access Token을 검증하면 유저 정보를 전송
- Spring 서버에서는 Access Token이 검증되었으므로 로그인 승인
Credential
- 사용자가 kakao에 로그인
- kakao에서는 사용자가 로그인했으므로 Access Token, ID Token 및 기타 정보를 제공
- 사용자는 Spring 서버에게 ID Token 및 개인 정보 전달
- Spring 서버는 kakao의 공개 키 다운로드 후 ID Token 복호화
- ID Token이 복호화되고 개인 정보가 토큰 안의 개인 정보와 일치하면 로그인 승인
- Kakao : Resource Server
- Spring 서버 : Client
- 사용자 : Resource Owner
10. Tutorial 2 : blogv3 (spring)
Code
- IndexController.java
package shop.mtcoding.blog;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import shop.mtcoding.blog._core.util.Resp;
import shop.mtcoding.blog.user.UserResponse;
import shop.mtcoding.blog.user.UserService;
@RequiredArgsConstructor
@RestController
public class IndexController {
private final UserService userService;
// redirect url : /kakao
@GetMapping("/kakao")
public String kakao(String code) {
return "카카오로부터 받은 임시 코드 : " + code;
}
@PostMapping("/oauth/login")
public ResponseEntity<?> login(@RequestBody String idToken) {
// 타입 추론 var : 카카오 로그인 함수에서 받을 데이터타입을 결정
var respDTO = userService.카카오로그인(idToken);
return Resp.ok(respDTO);
}
}
- UserService.java
@Transactional
public UserResponse.IdTokenDTO 카카오로그인(String idToken) { // application context에 담을 수 있음 : session
// 1. 공개키 존재 확인 없으면 다운로드
// 2. id Token 검증 (base64 디코딩, 서명검증)
OAuthProfile oAuthProfile = MyRSAUtil.verify(idToken);
User user = null;
// 3. 회원가입 유무 확인
Optional<User> userOP
= userRepository.findByUsername(ProviderType.KAKAO+"_"+oAuthProfile.getSub());
if(userOP.isEmpty()) {
// 4. 안되있으면 강제 회원가입
user = User.builder()
.username(ProviderType.KAKAO+"_"+oAuthProfile.getSub())
.password(UUID.randomUUID().toString())
.email(null)
.provider(ProviderType.KAKAO)
.build();
userRepository.save(user);
} else user = userOP.get();
// 5. 되어있다면 아무것도 안해도 됨
return new UserResponse.IdTokenDTO(user, idToken);
}
- MyRSAUtil.java
package shop.mtcoding.blog.user;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.util.Base64URL;
import com.nimbusds.jwt.SignedJWT;
import org.springframework.web.client.RestTemplate;
import java.math.BigInteger;
import java.util.Base64;
public class MyRSAUtil {
public static JwtKeySet downloadRSAKey() {
String jwtUrl = "https://kauth.kakao.com/.well-known/jwks.json"; // ← 실제 주소로 교체
RestTemplate restTemplate = new RestTemplate();
JwtKeySet keySet = restTemplate.getForObject(jwtUrl, JwtKeySet.class);
return keySet;
}
public static OAuthProfile verify(String idToken) {
JwtKeySet keySet = downloadRSAKey();
String n = keySet.getKeys().get(1).getN();
String e = keySet.getKeys().get(1).getE();
System.out.println("n : " + n);
System.out.println("e : " + e);
BigInteger bin = new BigInteger(1, Base64.getUrlDecoder().decode(n));
BigInteger bie = new BigInteger(1, Base64.getUrlDecoder().decode(e));
RSAKey rsaKey = new RSAKey.Builder(Base64URL.encode(bin), Base64URL.encode(bie)).build();
try {
// 1. 파싱
SignedJWT signedJWT = SignedJWT.parse(idToken);
// 2. 검증
RSASSAVerifier verifier = new RSASSAVerifier(rsaKey.toRSAPublicKey());
if (signedJWT.verify(verifier)) {
System.out.println("ID Token을 검증하였습니다");
String payload = signedJWT.getPayload().toString();
System.out.println("페이로드");
System.out.println(payload);
ObjectMapper objectMapper = new ObjectMapper();
OAuthProfile profile = objectMapper.readValue(payload, OAuthProfile.class);
return profile;
} else {
throw new RuntimeException("id토큰 검증 실패");
}
} catch (Exception ex) {
throw new RuntimeException(ex.getMessage());
}
}
}
- OAuthProfile.java
package shop.mtcoding.blog.user;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class OAuthProfile {
private String aud; // 앱 키
private String sub; // 고유 사용자 ID
@JsonProperty("auth_time")
private Long authTime; // 인증 시간 (Epoch 초)
private String iss; // 발급자 (issuer)
private String nickname; // 사용자 닉네임
private Long exp; // 만료 시간 (Epoch 초)
private Long iat; // 발급 시간 (Epoch 초)
private String picture; // 프로필 사진 URL
}
- JwtKeySet.java
package shop.mtcoding.blog.user;
import lombok.Data;
import java.util.List;
@Data
public class JwtKeySet {
private List<JwtKey> keys;
@Data
public static class JwtKey {
private String kid;
private String kty;
private String alg;
private String use;
private String n;
private String e;
}
}
- ProviderType.java
package shop.mtcoding.blog.user;
public enum ProviderType {
KAKAO, GOOGLE, NAVER;
}
Result
- 정상 로그인 시 로그인 완료 알림 출력

- 최초 로그인 시 : 신규 가입

- 로그인 성공

- 이후 로그인 시 : 가입된 유저 확인 후 반환


Share article