fix(kakao): Kakao SDK v2 authorize 방식으로 전환 + code 교환 엔드포인트
Deploy / deploy (push) Failing after 0s

- Kakao SDK v2.7에서 Auth.login() 제거 → authorize() 리다이렉트 플로우 사용
- services/kakao.ts: kakaoWebLoginStart() redirect 시작
- RootNavigator: 웹 URL의 code + state=kakao_login 감지 → kakaoCodeLogin 호출
- useAuthStore.kakaoCodeLogin 추가 (authApi.kakaoCode 호출)
- 백엔드 /auth/kakao/code: REST API 키로 code→access_token 교환 후 기존 processKakaoLogin 재사용
- api-secrets에 kakaoRestApiKey/kakaoClientSecret 슬롯 추가
- 환경변수: KAKAO_REST_API_KEY 서버에 저장 완료

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-23 01:27:49 +09:00
parent fc63746728
commit c53cc69d97
8 changed files with 133 additions and 77 deletions
+4
View File
@@ -78,6 +78,10 @@ spec:
valueFrom: { secretKeyRef: { name: api-secrets, key: codefClientId, optional: true } } valueFrom: { secretKeyRef: { name: api-secrets, key: codefClientId, optional: true } }
- name: CODEF_CLIENT_SECRET - name: CODEF_CLIENT_SECRET
valueFrom: { secretKeyRef: { name: api-secrets, key: codefClientSecret, optional: true } } valueFrom: { secretKeyRef: { name: api-secrets, key: codefClientSecret, optional: true } }
- name: KAKAO_REST_API_KEY
valueFrom: { secretKeyRef: { name: api-secrets, key: kakaoRestApiKey, optional: true } }
- name: KAKAO_CLIENT_SECRET
valueFrom: { secretKeyRef: { name: api-secrets, key: kakaoClientSecret, optional: true } }
readinessProbe: readinessProbe:
httpGet: httpGet:
path: /health path: /health
+2
View File
@@ -76,6 +76,8 @@ kubectl -n insurance create secret generic api-secrets \
--from-literal=solapiSenderKey="${SOLAPI_SENDER_KEY:-}" \ --from-literal=solapiSenderKey="${SOLAPI_SENDER_KEY:-}" \
--from-literal=codefClientId="${CODEF_CLIENT_ID:-}" \ --from-literal=codefClientId="${CODEF_CLIENT_ID:-}" \
--from-literal=codefClientSecret="${CODEF_CLIENT_SECRET:-}" \ --from-literal=codefClientSecret="${CODEF_CLIENT_SECRET:-}" \
--from-literal=kakaoRestApiKey="${KAKAO_REST_API_KEY:-}" \
--from-literal=kakaoClientSecret="${KAKAO_CLIENT_SECRET:-}" \
--dry-run=client -o yaml | kubectl apply -f - --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f deploy/k8s/postgres.yaml kubectl apply -f deploy/k8s/postgres.yaml
+72 -48
View File
@@ -1,4 +1,4 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance, FastifyReply } from 'fastify';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { z } from 'zod'; import { z } from 'zod';
@@ -17,8 +17,10 @@ const LoginBody = z.object({
password: z.string().min(1), password: z.string().min(1),
}); });
const KakaoBody = z.object({ const KakaoBody = z.object({ accessToken: z.string().min(10) });
accessToken: z.string().min(10), const KakaoCodeBody = z.object({
code: z.string().min(5),
redirectUri: z.string().url(),
}); });
type KakaoMe = { type KakaoMe = {
@@ -40,6 +42,62 @@ async function fetchKakaoProfile(accessToken: string): Promise<KakaoMe> {
return (await res.json()) as KakaoMe; return (await res.json()) as KakaoMe;
} }
async function exchangeKakaoCode(code: string, redirectUri: string): Promise<string> {
const restKey = process.env.KAKAO_REST_API_KEY;
if (!restKey) throw new Error('KAKAO_REST_API_KEY not configured');
const form = new URLSearchParams({
grant_type: 'authorization_code',
client_id: restKey,
redirect_uri: redirectUri,
code,
});
if (process.env.KAKAO_CLIENT_SECRET) {
form.set('client_secret', process.env.KAKAO_CLIENT_SECRET);
}
const res = await fetch('https://kauth.kakao.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=utf-8' },
body: form.toString(),
});
if (!res.ok) throw new Error(`Kakao token exchange ${res.status}: ${await res.text()}`);
const data = (await res.json()) as any;
return data.access_token as string;
}
async function processKakaoLogin(app: FastifyInstance, reply: FastifyReply, accessToken: string) {
let me: KakaoMe;
try {
me = await fetchKakaoProfile(accessToken);
} catch {
return reply.code(401).send({ message: '카카오 인증 실패' });
}
const kakaoId = String(me.id);
const email = me.kakao_account?.email;
const name = me.kakao_account?.profile?.nickname ?? me.properties?.nickname ?? '카카오사용자';
const profileImage = me.kakao_account?.profile?.profile_image_url ?? me.properties?.profile_image;
let user = await app.prisma.user.findUnique({
where: { kakaoId },
include: { profile: true },
});
if (!user) {
user = await app.prisma.user.create({
data: {
kakaoId,
email: email ?? null,
name,
phone: me.kakao_account?.phone_number,
provider: 'KAKAO',
profileImage,
profile: { create: { age: 30, gender: 'MALE', job: '기타' } },
},
include: { profile: true },
});
}
const token = await reply.jwtSign({ sub: user.id, email: user.email ?? undefined });
return { token, user: publicUser(user) };
}
export async function authRoutes(app: FastifyInstance) { export async function authRoutes(app: FastifyInstance) {
app.post('/register', async (req, reply) => { app.post('/register', async (req, reply) => {
const body = RegisterBody.parse(req.body); const body = RegisterBody.parse(req.body);
@@ -54,13 +112,7 @@ export async function authRoutes(app: FastifyInstance) {
name: body.name, name: body.name,
phone: body.phone, phone: body.phone,
provider: 'EMAIL', provider: 'EMAIL',
profile: { profile: { create: { age: body.age, gender: body.gender, job: body.job ?? '기타' } },
create: {
age: body.age,
gender: body.gender,
job: body.job ?? '기타',
},
},
}, },
include: { profile: true }, include: { profile: true },
}); });
@@ -86,46 +138,18 @@ export async function authRoutes(app: FastifyInstance) {
app.post('/kakao', async (req, reply) => { app.post('/kakao', async (req, reply) => {
const body = KakaoBody.parse(req.body); const body = KakaoBody.parse(req.body);
let me: KakaoMe; return processKakaoLogin(app, reply, body.accessToken);
});
app.post('/kakao/code', async (req, reply) => {
const body = KakaoCodeBody.parse(req.body);
let accessToken: string;
try { try {
me = await fetchKakaoProfile(body.accessToken); accessToken = await exchangeKakaoCode(body.code, body.redirectUri);
} catch (e) { } catch (e: any) {
return reply.code(401).send({ message: '카카오 인증 실패' }); return reply.code(401).send({ message: `카카오 토큰 교환 실패: ${e?.message ?? ''}` });
} }
return processKakaoLogin(app, reply, accessToken);
const kakaoId = String(me.id);
const email = me.kakao_account?.email;
const name = me.kakao_account?.profile?.nickname ?? me.properties?.nickname ?? '카카오사용자';
const profileImage = me.kakao_account?.profile?.profile_image_url ?? me.properties?.profile_image;
let user = await app.prisma.user.findUnique({
where: { kakaoId },
include: { profile: true },
});
if (!user) {
user = await app.prisma.user.create({
data: {
kakaoId,
email: email ?? null,
name,
phone: me.kakao_account?.phone_number,
provider: 'KAKAO',
profileImage,
profile: {
create: {
age: 30,
gender: 'MALE',
job: '기타',
},
},
},
include: { profile: true },
});
}
const token = await reply.jwtSign({ sub: user.id, email: user.email ?? undefined });
return { token, user: publicUser(user) };
}); });
app.get('/me', { onRequest: [app.authenticate] }, async (req) => { app.get('/me', { onRequest: [app.authenticate] }, async (req) => {
+3
View File
@@ -35,6 +35,9 @@ export const authApi = {
kakao: (accessToken: string) => kakao: (accessToken: string) =>
api<AuthResponse>('/auth/kakao', { method: 'POST', body: { accessToken }, skipAuth: true }), api<AuthResponse>('/auth/kakao', { method: 'POST', body: { accessToken }, skipAuth: true }),
kakaoCode: (code: string, redirectUri: string) =>
api<AuthResponse>('/auth/kakao/code', { method: 'POST', body: { code, redirectUri }, skipAuth: true }),
me: () => api<User>('/auth/me'), me: () => api<User>('/auth/me'),
}; };
+18 -3
View File
@@ -1,5 +1,5 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { View, ActivityIndicator } from 'react-native'; import { View, ActivityIndicator, Platform } from 'react-native';
import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { createNativeStackNavigator } from '@react-navigation/native-stack';
import BottomTabs from './BottomTabs'; import BottomTabs from './BottomTabs';
import LoginScreen from '@/screens/auth/LoginScreen'; import LoginScreen from '@/screens/auth/LoginScreen';
@@ -44,11 +44,26 @@ export type RootStackParamList = {
const Stack = createNativeStackNavigator<RootStackParamList>(); const Stack = createNativeStackNavigator<RootStackParamList>();
export default function RootNavigator() { export default function RootNavigator() {
const { user, loading, hydrate } = useAuthStore(); const { user, loading, hydrate, kakaoCodeLogin } = useAuthStore();
useEffect(() => { useEffect(() => {
// 카카오 OAuth redirect callback 처리 (웹 전용)
if (Platform.OS === 'web' && typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
if (code && state === 'kakao_login') {
const redirectUri = window.location.origin + '/';
// code 사용 후 URL 정리
window.history.replaceState({}, '', window.location.pathname);
kakaoCodeLogin(code, redirectUri).catch(() => {
hydrate();
});
return;
}
}
hydrate(); hydrate();
}, [hydrate]); }, [hydrate, kakaoCodeLogin]);
if (loading) { if (loading) {
return ( return (
+5 -9
View File
@@ -8,7 +8,7 @@ import Button from '@/components/Button';
import { colors } from '@/theme/colors'; import { colors } from '@/theme/colors';
import { radius, spacing, typography } from '@/theme/typography'; import { radius, spacing, typography } from '@/theme/typography';
import { useAuthStore } from '@/store/useAuthStore'; import { useAuthStore } from '@/store/useAuthStore';
import { kakaoWebLogin } from '@/services/kakao'; import { kakaoWebLoginStart } from '@/services/kakao';
import { socialApi } from '@/api/endpoints'; import { socialApi } from '@/api/endpoints';
import { saveToken } from '@/api/client'; import { saveToken } from '@/api/client';
@@ -38,16 +38,12 @@ export default function LoginScreen() {
}; };
const onKakao = async () => { const onKakao = async () => {
const token = await kakaoWebLogin(); if (Platform.OS === 'web') {
if (!token) { // v2.7 authorize: 페이지 리다이렉트 후 콜백에서 code 처리
if (Platform.OS !== 'web') Alert.alert('카카오 로그인', '네이티브 SDK 연동은 빌드 후 동작합니다.'); await kakaoWebLoginStart();
return; return;
} }
try { Alert.alert('카카오 로그인', '네이티브 SDK 연동은 모바일 빌드 후 동작합니다.');
await kakaoLogin(token);
} catch (e: any) {
Alert.alert('카카오 로그인 실패', e?.message ?? '');
}
}; };
const onNaver = async () => { const onNaver = async () => {
+16 -17
View File
@@ -24,28 +24,27 @@ async function loadKakaoSDK() {
if (!Kakao.isInitialized() && KAKAO_JS_KEY) Kakao.init(KAKAO_JS_KEY); if (!Kakao.isInitialized() && KAKAO_JS_KEY) Kakao.init(KAKAO_JS_KEY);
} }
export async function kakaoWebLogin(): Promise<string | null> { /**
if (Platform.OS !== 'web') return null; * 웹 카카오 로그인 (v2.7 authorize 방식).
* 리다이렉트가 발생하므로 즉시 반환값이 없고, 콜백 URL에서 code를 처리해야 함.
* 콜백은 RootNavigator에서 `code`, `state=kakao_login` 쿼리스트링 감지.
*/
export async function kakaoWebLoginStart(): Promise<void> {
if (Platform.OS !== 'web') return;
await loadKakaoSDK(); await loadKakaoSDK();
const Kakao = (window as any).Kakao; const Kakao = (window as any).Kakao;
if (!Kakao || !Kakao.isInitialized?.()) { if (!Kakao || !Kakao.isInitialized?.()) {
const manual = window.prompt( window.alert('카카오 JS 키가 설정되지 않았습니다. EXPO_PUBLIC_KAKAO_JS_KEY 확인 필요.');
'카카오 JS 키가 설정되지 않았습니다 (EXPO_PUBLIC_KAKAO_JS_KEY).\n' + return;
'개발 테스트용: 이미 발급된 Kakao access_token을 직접 붙여넣으세요.'
);
return manual;
} }
return new Promise((resolve) => { Kakao.Auth.authorize({
Kakao.Auth.login({ redirectUri: window.location.origin + '/',
scope: 'profile_nickname,profile_image,account_email', state: 'kakao_login',
success: (authObj: any) => resolve(authObj.access_token), scope: 'profile_nickname,profile_image,account_email',
fail: () => resolve(null),
});
}); });
} }
export async function kakaoNativeLogin(): Promise<string | null> { export function getKakaoRedirectUri(): string | null {
// @react-native-seoul/kakao-login 은 bare workflow + Android/iOS 네이티브 설정이 필요 if (Platform.OS !== 'web') return null;
// 현재 Expo managed 환경에서는 불가 — 배포 빌드 후 동작 return window.location.origin + '/';
return null;
} }
+13
View File
@@ -10,6 +10,7 @@ type State = {
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
register: (body: Parameters<typeof authApi.register>[0]) => Promise<void>; register: (body: Parameters<typeof authApi.register>[0]) => Promise<void>;
kakaoLogin: (accessToken: string) => Promise<void>; kakaoLogin: (accessToken: string) => Promise<void>;
kakaoCodeLogin: (code: string, redirectUri: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
}; };
@@ -71,6 +72,18 @@ export const useAuthStore = create<State>((set) => ({
} }
}, },
kakaoCodeLogin: async (code, redirectUri) => {
set({ loading: true, error: null });
try {
const res = await authApi.kakaoCode(code, redirectUri);
await saveToken(res.token);
set({ user: res.user, loading: false });
} catch (e: any) {
set({ loading: false, error: e?.message ?? '카카오 로그인 실패' });
throw e;
}
},
logout: async () => { logout: async () => {
await clearToken(); await clearToken();
set({ user: null }); set({ user: null });