feat: 카카오 소셜 로그인 구현 (#29) - #29
Conversation
- Prisma 5 + SQLite 로컬 DB 구성 및 User 모델 마이그레이션 - zod v4 기반 서버 사이드 유효성 검사 (이메일·전화번호·닉네임·비밀번호·약관) - bcryptjs를 이용한 비밀번호 해싱 저장 (평문 미저장) - email·phoneNumber·nickname 중복 시 409 응답 및 field 명시 - 전화번호 입력값 010-XXXX-XXXX 형식으로 정규화 저장 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 이메일 저장 전 trim·toLowerCase 정규화 처리 - bcryptjs.hash를 try 블록 안으로 이동하여 해싱 오류도 500 처리 - normalizePhoneNumber에 비숫자 제거 및 유효성 검사 추가, 잘못된 입력 시 예외 발생 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- SocialAccount 테이블 추가 (provider, providerUserId, userId FK) - User에 profileCompleted 필드 추가 (소셜 신규 가입 시 추가 정보 입력 유도) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…back) - getKakaoAuthUrl / getKakaoToken / getKakaoUser / extractKakaoUserInfo 유틸 구현 - JWT signAccessToken / verifyAccessToken (jose HS256, 7일 유효) - /api/auth/kakao/login: CSRF state 쿠키 발급 후 카카오 인가 URL 리다이렉트 - /api/auth/kakao/callback: state 검증, 토큰 교환, 신규/기존 유저 처리, JWT 쿠키 발급 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- jose 패키지 추가 (JWT 서명/검증) - .env.example에 KAKAO_REST_API_KEY, KAKAO_CLIENT_SECRET, KAKAO_REDIRECT_URI 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WalkthroughPrisma 기반 SQLite 데이터 계층(User, SocialAccount 모델), JWT 서명/검증 유틸, 카카오 OAuth 전체 루프(state CSRF 방어, 토큰 교환, 신규 가입/기존 매칭), 로컬 회원가입 API(Zod 검증, 비밀번호 해싱, 중복 처리)를 통한 멀티 인증 시스템 완성. Changes멀티 인증 시스템 통합
Sequence Diagram(s)sequenceDiagram
participant Client
participant LoginRoute as /api/auth/kakao/login
participant CallbackRoute as /api/auth/kakao/callback
participant KakaoAPI as Kakao API
participant Database as Prisma
Client->>LoginRoute: GET login
LoginRoute->>LoginRoute: state 생성 및 쿠키 저장
LoginRoute-->>Client: redirect to kakao authz URL
Client->>KakaoAPI: 인증 후 code 획득
Client->>CallbackRoute: callback?code=X&state=Y
CallbackRoute->>CallbackRoute: state 검증 CSRF
CallbackRoute->>KakaoAPI: POST token endpoint
KakaoAPI-->>CallbackRoute: access_token
CallbackRoute->>KakaoAPI: GET user info
KakaoAPI-->>CallbackRoute: user data
CallbackRoute->>Database: SELECT SocialAccount
alt 기존 계정
Database-->>CallbackRoute: user found
else 신규 가입
CallbackRoute->>Database: SELECT User by nickname
alt 중복
CallbackRoute->>CallbackRoute: nickname 보정
end
CallbackRoute->>Database: INSERT User + SocialAccount
Database-->>CallbackRoute: created user
end
CallbackRoute->>CallbackRoute: JWT 발급
CallbackRoute-->>Client: Set-Cookie and redirect
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes 근거:
Suggested labels
|
| Check name | Status | Explanation | Resolution |
|---|---|---|---|
| Linked Issues check | JWT 유틸과 Kakao OAuth 유틸이 구현되었으나, TDD 원칙에 따른 단위 테스트(Unit Tests)가 전혀 추가되지 않았다. | 최소한 signAccessToken/verifyAccessToken [JWT], getKakaoAuthUrl/getKakaoToken/extractKakaoUserInfo [Kakao], signupSchema 검증 [Schema]에 대한 단위 테스트를 추가하시오. | |
| Out of Scope Changes check | signup API route가 #28 요구사항에 없으며, .env.example/gitignore 파일 추가 역시 범위 외 변경이다. |
#28의 요구사항(카카오 로그인·JWT·SocialAccount)과 직접 무관한 변경은 별도 PR로 분리하거나 제거하시오. #25 signup API도 명시적으로 연계 이슈 추가 필요. |
|
| Docstring Coverage | Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. | Write docstrings for the functions missing them to satisfy the coverage threshold. |
✅ Passed checks (2 passed)
| Check name | Status | Explanation |
|---|---|---|
| Description check | ✅ Passed | PR 설명이 작업 내용·변경 이유·체크리스트를 포함하며 카카오 소셜 로그인 구현과 직접 관련된 구체적인 내용을 담고 있다. |
| Title check | ✅ Passed | PR 제목이 'feat:' 접두사로 시작하며 카카오 소셜 로그인 구현이라는 주요 변경사항을 정확히 반영합니다. |
✏️ Tip: You can configure your own custom pre-merge checks in the settings.
✨ Finishing Touches
📝 Generate docstrings
- Create stacked PR
- Commit on current branch
🧪 Generate unit tests (beta)
- Create PR with unit tests
- Commit unit tests in branch
feature/28-kakao-login
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.
Comment @coderabbitai help to get the list of available commands and usage tips.
There was a problem hiding this comment.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
.env.example (1)
19-23:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
JWT_SECRET예시 항목을 반드시 추가하세요.Why:
src/lib/auth/jwt.ts는JWT_SECRET없으면 즉시 예외를 던지므로, 샘플 env 누락 시 실행 환경에서 인증이 바로 깨집니다.
How: 카카오 섹션 인근에 필수 키를 명시하세요.코드 스니펫
# 카카오 로그인 KAKAO_REST_API_KEY=your_kakao_rest_api_key_here KAKAO_CLIENT_SECRET=your_kakao_client_secret_here_or_leave_empty KAKAO_REDIRECT_URI=http://localhost:3000/api/auth/kakao/callback +JWT_SECRET=replace_with_at_least_32_char_random_secret🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.env.example around lines 19 - 23, Add a required JWT_SECRET example entry to the .env.example near the Kakao section so users don't run without it; mention the variable name JWT_SECRET, provide an example value placeholder (e.g., a long random string) and a brief note that it's mandatory because src/lib/auth/jwt.ts throws if JWT_SECRET is missing.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.env.example:
- Line 2: .env.example의 DATABASE_URL entry currently includes surrounding quotes
which triggers dotenv-linter's QuoteCharacter warning; edit the .env.example
file and remove only the double quotes around the value so the line reads
DATABASE_URL=file:./dev.db (keep the key and value unchanged except for removing
the quotes).
In `@prisma/schema.prisma`:
- Around line 27-37: Add a single-field index on SocialAccount.userId to avoid
full-table scans: update the Prisma model SocialAccount by adding an index for
the userId field (either annotate the field userId with `@index` or add a
model-level @@index([userId]) entry) and then run the Prisma migration commands
(e.g., prisma migrate dev) to generate and apply the migration.
In `@src/app/api/auth/kakao/callback/route.ts`:
- Around line 58-75: The social account creation path using
prisma.socialAccount.findUnique followed by prisma.socialAccount.create can race
on concurrent callbacks; update the logic around prisma.socialAccount.create in
route.ts so that if create throws (e.g., unique constraint violation) you catch
the error, re-query prisma.socialAccount.findUnique for the same
provider/providerUserId (provider: "kakao", providerUserId: kakaoId) and, if
found, proceed as the existing socialAccount result; otherwise rethrow or handle
the error—make changes around the socialAccount variable and the
prisma.socialAccount.create call to implement this retry/recover flow.
- Line 21: The current baseUrl constant falls back to "http://localhost:3000"
which breaks redirects in production; move baseUrl construction into the route
handler and replace the literal fallback with a derived origin from the incoming
Request (e.g., use request.headers.get('origin') || new URL(request.url).origin)
so the fallback reflects the actual request origin; update any uses of the
top-level baseUrl constant in this file (look for baseUrl and the route handler
function/default export) to use the request-scoped baseUrl instead.
In `@src/app/api/auth/signup/route.ts`:
- Line 104: The current console.error call in the signup route (the line logging
"[auth.signup] 서버 오류:" with the raw err) exposes the entire error object; change
it to log only a safe, minimal message and non-sensitive fields (e.g.,
err.message or a sanitized error code) from the signup route handler instead of
the full err object, or emit a generic "[auth.signup] 서버 오류 발생" plus
err.message; avoid printing stack traces or full error objects to production
logs and, if needed, send full error details to a secure error-tracking service
rather than console.
In `@src/features/auth/signup.schema.ts`:
- Line 53: The throw in signup.schema.ts currently interpolates the raw phone
input into the error (throw new Error(`유효하지 않은 전화번호입니다: ${phone}`)); remove the
phone value from the message to avoid leaking PII—replace it with a fixed,
non-sensitive message (e.g., "유효하지 않은 전화번호입니다") in the same throw site (the
error raised where phone validation fails) and keep all other behavior the same.
In `@src/lib/auth/jwt.ts`:
- Line 33: The current forced cast "return payload as unknown as JwtPayload" is
unsafe; instead validate that the decoded payload object contains the required
fields (at minimum userId and nickname) with the expected types before
returning. In the function that produces/returns the JwtPayload (where the local
variable payload is used and currently cast), check typeof payload === 'object'
&& payload !== null and that payload.userId and payload.nickname exist and match
their expected types (e.g., string/number for userId, string for nickname) and
optionally validate exp if relied on; if validation fails, throw a clear error
(e.g., "Invalid JWT payload") or return a safe failure value so downstream code
using JwtPayload cannot encounter missing fields. Ensure the function returns a
properly shaped object conforming to the JwtPayload interface rather than a
blind cast.
- Around line 31-35: The current try/catch around jwtVerify(token, getSecret())
swallows exceptions from getSecret(), hiding configuration errors; move the
getSecret() call out of the try block so configuration-loading errors propagate
(e.g., const secret = getSecret()), then call jwtVerify(token, secret) inside
the try and keep the catch to return null for verification failures only; update
the function that uses jwtVerify/getSecret in src/lib/auth/jwt.ts accordingly.
In `@src/lib/auth/kakao.ts`:
- Around line 57-63: Add a shared fetchWithTimeout utility and replace the
direct fetch calls to the Kakao endpoints with it: instead of calling
fetch("https://kauth.kakao.com/oauth/token", ...) and
fetch("https://kapi.kakao.com/v2/user/me", ...), implement fetchWithTimeout(url,
options, timeoutMs) that uses AbortController to abort after timeout and throws
on timeout, then call fetchWithTimeout for both token and user-info requests
with a sensible timeout value; ensure error handling/logging remains the same so
timeouts surface like other fetch errors.
- Around line 39-88: The getKakaoToken and getKakaoUser functions currently call
fetch directly; extract those HTTP calls into an injectable port (e.g., a
HttpClient or KakaoHttp interface) and move the concrete fetch-based
implementation into an adapter module; then change getKakaoToken and
getKakaoUser to depend on that interface (accept a client parameter or receive
it via a small factory) so production code can use the fetch-based adapter and
tests can inject a mock. Reference the existing symbols getKakaoToken and
getKakaoUser as the callers to update and create a new adapter implementing the
port that encapsulates the POST to https://kauth.kakao.com/oauth/token and the
GET to https://kapi.kakao.com/v2/user/me.
---
Outside diff comments:
In @.env.example:
- Around line 19-23: Add a required JWT_SECRET example entry to the .env.example
near the Kakao section so users don't run without it; mention the variable name
JWT_SECRET, provide an example value placeholder (e.g., a long random string)
and a brief note that it's mandatory because src/lib/auth/jwt.ts throws if
JWT_SECRET is missing.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: ae693a15-a02a-4c92-995a-4ad1dc9509b0
⛔ Files ignored due to path filters (2)
package-lock.jsonis excluded by!**/package-lock.json,!**/*.json,!package-lock.jsonpackage.jsonis excluded by!**/*.json
📒 Files selected for processing (13)
.env.example.gitignoreprisma/migrations/20260521073616_init_user/migration.sqlprisma/migrations/20260521091212_add_social_account/migration.sqlprisma/migrations/migration_lock.tomlprisma/schema.prismasrc/app/api/auth/kakao/callback/route.tssrc/app/api/auth/kakao/login/route.tssrc/app/api/auth/signup/route.tssrc/features/auth/signup.schema.tssrc/lib/auth/jwt.tssrc/lib/auth/kakao.tssrc/lib/prisma.ts
| @@ -1,3 +1,6 @@ | |||
| # 로컬 DB (Prisma + SQLite) | |||
| DATABASE_URL="file:./dev.db" | |||
There was a problem hiding this comment.
dotenv-linter 경고 제거를 위해 따옴표를 제거하세요.
Why: Line 2는 QuoteCharacter 경고가 이미 보고되어 CI/lint 신뢰도를 떨어뜨립니다.
How: 값 표현은 그대로 두고 따옴표만 제거하세요.
코드 스니펫
-DATABASE_URL="file:./dev.db"
+DATABASE_URL=file:./dev.db📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| DATABASE_URL="file:./dev.db" | |
| DATABASE_URL=file:./dev.db |
🧰 Tools
🪛 dotenv-linter (4.0.0)
[warning] 2-2: [QuoteCharacter] The value has quote characters (', ")
(QuoteCharacter)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.env.example at line 2, .env.example의 DATABASE_URL entry currently includes
surrounding quotes which triggers dotenv-linter's QuoteCharacter warning; edit
the .env.example file and remove only the double quotes around the value so the
line reads DATABASE_URL=file:./dev.db (keep the key and value unchanged except
for removing the quotes).
| model SocialAccount { | ||
| id String @id @default(cuid()) | ||
| userId String | ||
| user User @relation(fields: [userId], references: [id], onDelete: Cascade) | ||
| provider String | ||
| providerUserId String | ||
| createdAt DateTime @default(now()) | ||
| updatedAt DateTime @updatedAt | ||
|
|
||
| @@unique([provider, providerUserId]) | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
SocialAccount.userId 인덱스를 추가하세요.
Why: userId는 조인/조회 핵심 키인데 인덱스가 없어 사용자별 소셜 계정 조회 시 불필요한 풀스캔 위험이 있습니다.
How: 스키마에 단일 인덱스를 추가해 조회 비용을 낮추세요.
코드 스니펫
model SocialAccount {
id String `@id` `@default`(cuid())
userId String
user User `@relation`(fields: [userId], references: [id], onDelete: Cascade)
provider String
providerUserId String
createdAt DateTime `@default`(now())
updatedAt DateTime `@updatedAt`
+ @@index([userId])
@@unique([provider, providerUserId])
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| model SocialAccount { | |
| id String @id @default(cuid()) | |
| userId String | |
| user User @relation(fields: [userId], references: [id], onDelete: Cascade) | |
| provider String | |
| providerUserId String | |
| createdAt DateTime @default(now()) | |
| updatedAt DateTime @updatedAt | |
| @@unique([provider, providerUserId]) | |
| } | |
| model SocialAccount { | |
| id String `@id` `@default`(cuid()) | |
| userId String | |
| user User `@relation`(fields: [userId], references: [id], onDelete: Cascade) | |
| provider String | |
| providerUserId String | |
| createdAt DateTime `@default`(now()) | |
| updatedAt DateTime `@updatedAt` | |
| @@index([userId]) | |
| @@unique([provider, providerUserId]) | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@prisma/schema.prisma` around lines 27 - 37, Add a single-field index on
SocialAccount.userId to avoid full-table scans: update the Prisma model
SocialAccount by adding an index for the userId field (either annotate the field
userId with `@index` or add a model-level @@index([userId]) entry) and then run
the Prisma migration commands (e.g., prisma migrate dev) to generate and apply
the migration.
| const LOGIN_FAILURE_REDIRECT = "/login?error=kakao_login_failed"; | ||
|
|
||
| export async function GET(req: NextRequest) { | ||
| const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"; |
There was a problem hiding this comment.
프로덕션에서 리다이렉트 오동작 가능한 베이스 URL 폴백입니다.
이유(Why): Line [21]은 환경변수 누락 시 http://localhost:3000으로 이동해 OAuth 완료 후 리다이렉트가 깨집니다.
방법(How): 서버 요청 오리진을 폴백으로 사용해 배포 환경에서도 안전하게 동작시키세요.
코드 스니펫
- const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000";
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? req.nextUrl.origin;📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000"; | |
| const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? req.nextUrl.origin; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/api/auth/kakao/callback/route.ts` at line 21, The current baseUrl
constant falls back to "http://localhost:3000" which breaks redirects in
production; move baseUrl construction into the route handler and replace the
literal fallback with a derived origin from the incoming Request (e.g., use
request.headers.get('origin') || new URL(request.url).origin) so the fallback
reflects the actual request origin; update any uses of the top-level baseUrl
constant in this file (look for baseUrl and the route handler function/default
export) to use the request-scoped baseUrl instead.
| const socialAccount = await prisma.socialAccount.findUnique({ | ||
| where: { | ||
| provider_providerUserId: { | ||
| provider: "kakao", | ||
| providerUserId: kakaoId, | ||
| }, | ||
| }, | ||
| include: { | ||
| user: { | ||
| select: { | ||
| id: true, | ||
| email: true, | ||
| nickname: true, | ||
| profileCompleted: true, | ||
| }, | ||
| }, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
신규 소셜 계정 생성 구간에 경합 처리 누락으로 정상 로그인 실패가 발생할 수 있습니다.
이유(Why): Line [58]~Line [120]은 findUnique 후 create를 분리해 동시 콜백(재시도/중복 클릭)에서 한 요청이 실패 리다이렉트로 떨어질 수 있습니다.
방법(How): 생성 실패 시 같은 (provider, providerUserId)로 재조회해 이미 생성된 사용자로 복구하세요.
코드 스니펫
- const newUser = await prisma.user.create({
- data: {
- email: email ?? null,
- nickname: finalNickname,
- profileCompleted: false, // phoneNumber, 약관 동의 등 추가 정보 필요
- socialAccounts: {
- create: {
- provider: "kakao",
- providerUserId: kakaoId,
- },
- },
- },
- select: {
- id: true,
- email: true,
- nickname: true,
- profileCompleted: true,
- },
- });
-
- user = newUser;
+ try {
+ const newUser = await prisma.user.create({
+ data: {
+ email: email ?? null,
+ nickname: finalNickname,
+ profileCompleted: false, // phoneNumber, 약관 동의 등 추가 정보 필요
+ socialAccounts: {
+ create: {
+ provider: "kakao",
+ providerUserId: kakaoId,
+ },
+ },
+ },
+ select: {
+ id: true,
+ email: true,
+ nickname: true,
+ profileCompleted: true,
+ },
+ });
+ user = newUser;
+ } catch (createErr) {
+ const raced = await prisma.socialAccount.findUnique({
+ where: {
+ provider_providerUserId: {
+ provider: "kakao",
+ providerUserId: kakaoId,
+ },
+ },
+ include: {
+ user: {
+ select: {
+ id: true,
+ email: true,
+ nickname: true,
+ profileCompleted: true,
+ },
+ },
+ },
+ });
+ if (!raced) throw createErr;
+ user = raced.user;
+ }Also applies to: 84-120
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/api/auth/kakao/callback/route.ts` around lines 58 - 75, The social
account creation path using prisma.socialAccount.findUnique followed by
prisma.socialAccount.create can race on concurrent callbacks; update the logic
around prisma.socialAccount.create in route.ts so that if create throws (e.g.,
unique constraint violation) you catch the error, re-query
prisma.socialAccount.findUnique for the same provider/providerUserId (provider:
"kakao", providerUserId: kakaoId) and, if found, proceed as the existing
socialAccount result; otherwise rethrow or handle the error—make changes around
the socialAccount variable and the prisma.socialAccount.create call to implement
this retry/recover flow.
| ); | ||
| } | ||
|
|
||
| console.error("[auth.signup] 서버 오류:", err); |
There was a problem hiding this comment.
회원가입 실패 로그에 에러 객체 전체를 남기면 민감정보 노출 위험이 있습니다.
이유(Why): Line [104]는 원본 err를 그대로 출력해 운영 로그에 불필요한 내부 정보가 남을 수 있습니다.
방법(How): 인증 구간 로그는 메시지 중심으로 최소화하세요.
코드 스니펫
- console.error("[auth.signup] 서버 오류:", err);
+ console.error(
+ "[auth.signup] 서버 오류:",
+ err instanceof Error ? err.message : "알 수 없는 오류",
+ );📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| console.error("[auth.signup] 서버 오류:", err); | |
| console.error( | |
| "[auth.signup] 서버 오류:", | |
| err instanceof Error ? err.message : "알 수 없는 오류", | |
| ); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/app/api/auth/signup/route.ts` at line 104, The current console.error call
in the signup route (the line logging "[auth.signup] 서버 오류:" with the raw err)
exposes the entire error object; change it to log only a safe, minimal message
and non-sensitive fields (e.g., err.message or a sanitized error code) from the
signup route handler instead of the full err object, or emit a generic
"[auth.signup] 서버 오류 발생" plus err.message; avoid printing stack traces or full
error objects to production logs and, if needed, send full error details to a
secure error-tracking service rather than console.
| export function normalizePhoneNumber(phone: string): string { | ||
| const digits = phone.replace(/\D/g, ""); | ||
| if (!/^010\d{8}$/.test(digits)) { | ||
| throw new Error(`유효하지 않은 전화번호입니다: ${phone}`); |
There was a problem hiding this comment.
예외 메시지에 전화번호 원문을 포함하지 마세요.
이유(Why): Line [53]은 실패 입력값을 그대로 예외 문자열에 넣어 로그/모니터링 경로로 PII가 확산될 수 있습니다.
방법(How): 고정 메시지로 바꿔 개인정보 노출면을 줄이세요.
코드 스니펫
- throw new Error(`유효하지 않은 전화번호입니다: ${phone}`);
+ throw new Error("유효하지 않은 전화번호입니다.");📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| throw new Error(`유효하지 않은 전화번호입니다: ${phone}`); | |
| throw new Error("유효하지 않은 전화번호입니다."); |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/features/auth/signup.schema.ts` at line 53, The throw in signup.schema.ts
currently interpolates the raw phone input into the error (throw new Error(`유효하지
않은 전화번호입니다: ${phone}`)); remove the phone value from the message to avoid
leaking PII—replace it with a fixed, non-sensitive message (e.g., "유효하지 않은
전화번호입니다") in the same throw site (the error raised where phone validation fails)
and keep all other behavior the same.
| try { | ||
| const { payload } = await jwtVerify(token, getSecret()); | ||
| return payload as unknown as JwtPayload; | ||
| } catch { | ||
| return null; |
There was a problem hiding this comment.
설정 오류를 인증 실패로 삼키지 마세요.
Why: Line 32에서 getSecret() 예외까지 catch에 흡수되어, 운영 설정 오류가 단순 로그인 실패처럼 보입니다.
How: 시크릿 로딩을 try 밖으로 분리해 설정 오류는 즉시 드러나게 하세요.
코드 스니펫
export async function verifyAccessToken(
token: string,
): Promise<JwtPayload | null> {
+ const secret = getSecret();
try {
- const { payload } = await jwtVerify(token, getSecret());
+ const { payload } = await jwtVerify(token, secret);
return payload as unknown as JwtPayload;
} catch {
return null;
}
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/auth/jwt.ts` around lines 31 - 35, The current try/catch around
jwtVerify(token, getSecret()) swallows exceptions from getSecret(), hiding
configuration errors; move the getSecret() call out of the try block so
configuration-loading errors propagate (e.g., const secret = getSecret()), then
call jwtVerify(token, secret) inside the try and keep the catch to return null
for verification failures only; update the function that uses
jwtVerify/getSecret in src/lib/auth/jwt.ts accordingly.
| ): Promise<JwtPayload | null> { | ||
| try { | ||
| const { payload } = await jwtVerify(token, getSecret()); | ||
| return payload as unknown as JwtPayload; |
There was a problem hiding this comment.
JWT payload를 단순 캐스팅하지 말고 필수 필드를 검증하세요.
Why: Line 33의 강제 캐스팅은 userId, nickname 누락/타입 불일치를 통과시켜 후속 로직에서 런타임 오류를 유발할 수 있습니다.
How: 최소 필드만 정밀 검증 후 안전한 객체로 반환하세요.
코드 스니펫
try {
const { payload } = await jwtVerify(token, secret);
- return payload as unknown as JwtPayload;
+ if (typeof payload.userId !== "string" || typeof payload.nickname !== "string") {
+ return null;
+ }
+ return {
+ userId: payload.userId,
+ nickname: payload.nickname,
+ email: typeof payload.email === "string" ? payload.email : undefined,
+ provider:
+ payload.provider === "local" || payload.provider === "kakao"
+ ? payload.provider
+ : undefined,
+ };
} catch {
return null;
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/auth/jwt.ts` at line 33, The current forced cast "return payload as
unknown as JwtPayload" is unsafe; instead validate that the decoded payload
object contains the required fields (at minimum userId and nickname) with the
expected types before returning. In the function that produces/returns the
JwtPayload (where the local variable payload is used and currently cast), check
typeof payload === 'object' && payload !== null and that payload.userId and
payload.nickname exist and match their expected types (e.g., string/number for
userId, string for nickname) and optionally validate exp if relied on; if
validation fails, throw a clear error (e.g., "Invalid JWT payload") or return a
safe failure value so downstream code using JwtPayload cannot encounter missing
fields. Ensure the function returns a properly shaped object conforming to the
JwtPayload interface rather than a blind cast.
| export async function getKakaoToken(code: string): Promise<KakaoTokenResponse> { | ||
| const restApiKey = process.env.KAKAO_REST_API_KEY; | ||
| const redirectUri = process.env.KAKAO_REDIRECT_URI; | ||
| if (!restApiKey || !redirectUri) { | ||
| throw new Error("카카오 환경변수가 설정되지 않았습니다."); | ||
| } | ||
|
|
||
| const params = new URLSearchParams({ | ||
| grant_type: "authorization_code", | ||
| client_id: restApiKey, | ||
| redirect_uri: redirectUri, | ||
| code, | ||
| }); | ||
|
|
||
| if (process.env.KAKAO_CLIENT_SECRET) { | ||
| params.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: params.toString(), | ||
| }); | ||
|
|
||
| if (!res.ok) { | ||
| throw new Error(`카카오 토큰 발급 실패 (status: ${res.status})`); | ||
| } | ||
|
|
||
| return res.json() as Promise<KakaoTokenResponse>; | ||
| } | ||
|
|
||
| export async function getKakaoUser( | ||
| kakaoAccessToken: string, | ||
| ): Promise<KakaoUser> { | ||
| const res = await fetch("https://kapi.kakao.com/v2/user/me", { | ||
| method: "GET", | ||
| headers: { | ||
| Authorization: `Bearer ${kakaoAccessToken}`, | ||
| "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", | ||
| }, | ||
| }); | ||
|
|
||
| if (!res.ok) { | ||
| throw new Error(`카카오 사용자 정보 조회 실패 (status: ${res.status})`); | ||
| } | ||
|
|
||
| return res.json() as Promise<KakaoUser>; | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
src/lib 계층의 외부 의존 직접 호출을 분리하세요(DIP).
Why: 카카오 API 호출이 비즈니스 로직에 고정되어 테스트 대역 주입/실패 시나리오 검증이 어렵고 결합도가 높습니다.
How: HTTP 호출을 주입 가능한 포트(인터페이스/함수 타입)로 분리하고, 현재 구현은 어댑터로 이동하세요.
코드 스니펫
+export type HttpClient = (input: string, init: RequestInit) => Promise<Response>;
+
+export function createKakaoAuthApi(httpClient: HttpClient) {
+ return {
+ async getKakaoToken(code: string): Promise<KakaoTokenResponse> {
+ // ...params 구성
+ const res = await httpClient("https://kauth.kakao.com/oauth/token", {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded;charset=utf-8" },
+ body: params.toString(),
+ });
+ if (!res.ok) throw new Error(`카카오 토큰 발급 실패 (status: ${res.status})`);
+ return res.json() as Promise<KakaoTokenResponse>;
+ },
+ };
+}As per coding guidelines "src/lib/**: 비즈니스 로직 계층입니다. 외부 의존성(Supabase, API 등)이 직접 호출되면 의존성 역전 원칙 위반 여부를 지적하세요."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/auth/kakao.ts` around lines 39 - 88, The getKakaoToken and
getKakaoUser functions currently call fetch directly; extract those HTTP calls
into an injectable port (e.g., a HttpClient or KakaoHttp interface) and move the
concrete fetch-based implementation into an adapter module; then change
getKakaoToken and getKakaoUser to depend on that interface (accept a client
parameter or receive it via a small factory) so production code can use the
fetch-based adapter and tests can inject a mock. Reference the existing symbols
getKakaoToken and getKakaoUser as the callers to update and create a new adapter
implementing the port that encapsulates the POST to
https://kauth.kakao.com/oauth/token and the GET to
https://kapi.kakao.com/v2/user/me.
| const res = await fetch("https://kauth.kakao.com/oauth/token", { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/x-www-form-urlencoded;charset=utf-8", | ||
| }, | ||
| body: params.toString(), | ||
| }); |
There was a problem hiding this comment.
외부 API 호출에 타임아웃을 강제하세요.
Why: 현재 Line 57-63, Line 75-81은 네트워크 장애 시 대기 시간이 무제한이라 요청 스레드가 장시간 점유될 수 있습니다.
How: 공통 fetchWithTimeout을 두고 두 호출에 동일 적용하세요.
코드 스니펫
+async function fetchWithTimeout(input: string, init: RequestInit, ms = 5000) {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), ms);
+ try {
+ return await fetch(input, { ...init, signal: controller.signal });
+ } finally {
+ clearTimeout(timer);
+ }
+}
- const res = await fetch("https://kauth.kakao.com/oauth/token", {
+ const res = await fetchWithTimeout("https://kauth.kakao.com/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
body: params.toString(),
});
- const res = await fetch("https://kapi.kakao.com/v2/user/me", {
+ const res = await fetchWithTimeout("https://kapi.kakao.com/v2/user/me", {
method: "GET",
headers: {
Authorization: `Bearer ${kakaoAccessToken}`,
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
},
});Also applies to: 75-81
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/lib/auth/kakao.ts` around lines 57 - 63, Add a shared fetchWithTimeout
utility and replace the direct fetch calls to the Kakao endpoints with it:
instead of calling fetch("https://kauth.kakao.com/oauth/token", ...) and
fetch("https://kapi.kakao.com/v2/user/me", ...), implement fetchWithTimeout(url,
options, timeoutMs) that uses AbortController to abort after timeout and throws
on timeout, then call fetchWithTimeout for both token and user-info requests
with a sensible timeout value; ensure error handling/logging remains the same so
timeouts surface like other fetch errors.
🚀 작업 내용 (What)
GET /api/auth/kakao/login— CSRF state 쿠키 발급 후 카카오 인가 URL로 리다이렉트GET /api/auth/kakao/callback— 인가 코드 수신, state 검증(CSRF 방어), 카카오 토큰 교환, 사용자 조회/생성, JWT 쿠키 발급SocialAccount모델 추가 및 마이그레이션 (provider / providerUserId)User에profileCompleted필드 추가 — 소셜 신규 가입 시 추가 정보 입력 페이지로 유도jose기반 HS256, 7일 유효)📣 핵심 변경 이유 (Why)
📸 스크린샷 (Visuals, 선택)
/signup/additional-info?provider=kakao리다이렉트 및 DB User/SocialAccount 생성 확인 완료🔗 관련 이슈 (Issue)
Close #28
🤖 Generated with Claude Code