Skip to content

feat: 카카오 소셜 로그인 구현 (#29) - #29

Merged
Siul49 merged 5 commits into
devfrom
feature/28-kakao-login
May 27, 2026
Merged

feat: 카카오 소셜 로그인 구현 (#29)#29
Siul49 merged 5 commits into
devfrom
feature/28-kakao-login

Conversation

@kokkumong

Copy link
Copy Markdown
Collaborator

🚀 작업 내용 (What)

  • GET /api/auth/kakao/login — CSRF state 쿠키 발급 후 카카오 인가 URL로 리다이렉트
  • GET /api/auth/kakao/callback — 인가 코드 수신, state 검증(CSRF 방어), 카카오 토큰 교환, 사용자 조회/생성, JWT 쿠키 발급
  • SocialAccount 모델 추가 및 마이그레이션 (provider / providerUserId)
  • UserprofileCompleted 필드 추가 — 소셜 신규 가입 시 추가 정보 입력 페이지로 유도
  • JWT 유틸 구현 (jose 기반 HS256, 7일 유효)
  • 카카오 OAuth 유틸 구현 (인가 URL 생성, 토큰 교환, 사용자 정보 조회)

📣 핵심 변경 이유 (Why)

  • 카카오 계정으로 간편하게 서비스에 로그인/가입할 수 있는 소셜 로그인 기능 제공
  • 기존 이메일/비밀번호 로그인과 독립적으로 동작하며, 동일한 JWT 인증 체계 사용

📸 스크린샷 (Visuals, 선택)

  • UI 없음 (API only) — 로컬 테스트에서 카카오 로그인 후 /signup/additional-info?provider=kakao 리다이렉트 및 DB User/SocialAccount 생성 확인 완료

⚠️ 체크리스트 (Checklist)

  • 브랜치 컨벤션(feature/00-name)을 지켰나요?
  • 커밋 컨벤션(feat:, fix: 등)을 지켰나요?
  • 작업 전에 관련 이슈를 생성하고 연결했나요?
  • 내 코드가 팀의 기존 코드를 망가뜨리지 않았는지 확인했나요?

🔗 관련 이슈 (Issue)

Close #28


🤖 Generated with Claude Code

kokkumong and others added 5 commits May 21, 2026 16:54
- 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>
@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Prisma 기반 SQLite 데이터 계층(User, SocialAccount 모델), JWT 서명/검증 유틸, 카카오 OAuth 전체 루프(state CSRF 방어, 토큰 교환, 신규 가입/기존 매칭), 로컬 회원가입 API(Zod 검증, 비밀번호 해싱, 중복 처리)를 통한 멀티 인증 시스템 완성.

Changes

멀티 인증 시스템 통합

Layer / File(s) Summary
데이터베이스 스키마 및 마이그레이션
prisma/schema.prisma, prisma/migrations/..., .env.example, .gitignore
User 모델(email/phoneNumber/nickname 유니크, 동의 시간 추적), SocialAccount 모델(provider+providerUserId 복합 유니크) 정의. 두 마이그레이션으로 초기화 후 테이블 재구조. Prisma 싱글톤 클라이언트 구성 및 환경변수 템플릿 추가.
JWT 및 카카오 인증 유틸
src/lib/auth/jwt.ts, src/lib/auth/kakao.ts, src/lib/prisma.ts
HS256 JWT 서명/검증(7일 유효). 카카오 인증 URL 생성, 토큰 교환, 사용자 정보 조회 함수. kakaoId 추출 및 닉네임 기본값 처리(kakao_{id}). Prisma 클라이언트 전역 캐시로 핫 리로드 중복 방지.
카카오 OAuth 엔드포인트
src/app/api/auth/kakao/login/route.ts, src/app/api/auth/kakao/callback/route.ts
login: state 생성 후 카카오 인증 URL로 리다이렉트 (쿠키 httpOnly, secure, sameSite=lax, 600초). callback: state CSRF 검증, 토큰 교환, kakaoId 기준 기존 계정 조회, 신규 가입 시 닉네임 중복 확인 및 보정, JWT 발급 후 profileCompleted 상태에 따라 리다이렉트.
로컬 회원가입 엔드포인트
src/app/api/auth/signup/route.ts, src/features/auth/signup.schema.ts
Zod 스키마로 이메일/전화번호(010 형식)/닉네임/비밀번호(8자 이상, 영문+숫자) 검증. 전화번호 정규화(010-xxxx-xxxx). bcrypt 해싱 후 Prisma 사용자 생성. 중복 제약 위반 시 P2002 감지 후 필드별 409 응답. 동의 여부에 따라 termsAgreedAt/privacyAgreedAt 기록 또는 epoch 저장.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

근거:

  • 다층 인프라 변경: DB 스키마(2개 마이그레이션) + 환경설정 + 라이브러리 계층(JWT, Kakao, Prisma)
  • 밀도 높은 비즈니스 로직: OAuth CSRF 방어, 신규/기존 계정 매칭, 닉네임 충돌 처리, 중복 제약 감지 및 필드별 응답
  • 이질적 파일 분포: 마이그레이션(SQL), 스키마(Prisma), API 라우트(TS), 검증 스키마(Zod), 유틸(JWT, Kakao)
  • 보안 고려사항 검수 필요: state CSRF, 쿠키 옵션 (secure, httpOnly, sameSite), JWT 비밀키 관리, 환경변수 누락 처리

Suggested labels

feature


⚠️ 시니어 리뷰 지적

1. 환경변수 관리 불완전
src/lib/auth/jwt.ts 라인 14-18에서 JWT_SECRET 미설정 시 즉시 예외 던지는 방식은 런타임 장애다. 문제: 서버 시작 후 요청 시점에 감지되므로 배포 후 즉시 장애 발생.

개선: 서버 시작 단계에서 모든 필수 환경변수를 검증하는 validateEnv() 함수를 분리하고, 각 라우트가 아닌 애플리케이션 초기화 시점에서 호출해야 함.

// lib/env.ts
export function validateEnv() {
  const required = ['JWT_SECRET', 'DATABASE_URL', 'KAKAO_REST_API_KEY', 'KAKAO_REDIRECT_URI'];
  required.forEach(key => {
    if (!process.env[key]) throw new Error(`Missing required env: ${key}`);
  });
}

// middleware 또는 app.ts에서 서버 시작 시 호출

2. 닉네임 중복 시 보정 로직의 충돌 가능성
src/app/api/auth/kakao/callback/route.ts 라인 43-121에서 기존 사용자가 없으면 닉네임 중복을 확인한 후 kakao_{kakaoId}로 보정한다. 문제: 두 사용자가 동시에 같은 kakaoId 또는 같은 보정 닉네임을 할당받을 수 있음(Race condition).

개선:

// 현재: 별도의 SELECT → CREATE 조회
const existing = await prisma.user.findUnique({ where: { nickname } });
if (existing) { newNickname = `kakao_${kakaoId}`; }

// 개선: 트랜잭션으로 보호
const user = await prisma.$transaction(async (tx) => {
  const existing = await tx.user.findUnique({ where: { nickname } });
  return tx.user.create({
    data: {
      nickname: existing ? `kakao_${kakaoId}` : nickname,
      // ...
      socialAccounts: { create: [{ provider: 'kakao', providerUserId: kakaoId }] }
    }
  });
});

3. 회원가입 비밀번호 해싱 알고리즘 미검증
src/app/api/auth/signup/route.ts 라인 52-83에서 bcrypt.hash(password, 10)을 사용하나, 실제 salt rounds 상수가 하드코딩되어 있음. 문제: 보안 기준 변경 시 모든 라우트를 수정해야 함. 또한 해시 생성 성능(bcrypt.hash는 CPU 바운드)에 따른 요청 타임아웃 위험.

개선:

// lib/crypto.ts
const BCRYPT_ROUNDS = 10;

export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, BCRYPT_ROUNDS);
}

// route에서는
const passwordHash = await hashPassword(password);

그리고 bcrypt 연산이 느리므로 요청당 하나의 사용자만 처리하는 구조 확인 필수. 현재 코드는 OK.


4. JWT 페이로드에 이메일 누락 위험
src/lib/auth/jwt.tsJwtPayload에서 email이 선택형(email?: string)이나, 카카오 로그인 시 이메일이 없으면 undefined로 저장됨. 문제: 향후 미들웨어에서 이메일 기반 로직(예: 권한 검사, 감시 로깅)이 필요할 때 undefined 체크를 놓치기 쉬움.

개선:

export interface JwtPayload {
  userId: string;
  nickname: string;
  email: string | null;  // undefined 대신 null 사용
  provider: 'local' | 'kakao';
}

5. 카카오 API 응답 타입 검증 미흡
src/lib/auth/kakao.tsextractKakaoUserInfo() 라인 90-103에서 kakaoUser 객체 필드에 직접 접근하나, TypeScript 타입이 선택형으로 많음. 문제: 실제 응답에서 예상 필드가 없을 때 undefined 또는 빈 문자열로 처리되는 로직이 명확하지 않음.

개선:

export function extractKakaoUserInfo(kakaoUser: KakaoUser) {
  const kakaoId = String(kakaoUser.id);
  const email = kakaoUser.kakao_account?.email || null;
  const nickname = kakaoUser.kakao_account?.profile?.nickname 
    || kakaoUser.properties?.nickname 
    || null;
  
  if (!nickname) {
    throw new Error(`Kakao user ${kakaoId}: missing nickname and no fallback`);
  }
  
  return { kakaoId, email, nickname: nickname || `kakao_${kakaoId}` };
}

6. 로컬 가입 시 이메일 정규화 부재
src/app/api/auth/signup/route.ts 라인 36-51에서 이메일을 toLowerCase().trim()하나, 유니크 제약 위반 감지 시 정규화되지 않은 원본과 비교될 수 있음. 문제: DB에 Test@Example.comtest@example.com이 별도로 저장될 가능성.

개선: Prisma 스키마에서 유니크 제약 대신 @db.VarChar(255) @lower`` 또는 마이그레이션으로 사전 정규화 처리.

model User {
  email String `@unique` `@lower`
  // ...
}

만약 @lower가 지원되지 않으면, 모든 쓰기 전에 명시적 정규화:

const normalizedEmail = email.toLowerCase().trim();
// DB 조회/생성은 normalizedEmail 사용

7. profileCompleted 기본값 로직
prisma/schema.prisma에서 profileCompleted Boolean @default(false)이나, 카카오 로그인 후 callback에서 조건부로 리다이렉트. 문제: 닉네임만 있어도 프로필 완료로 간주하는 기준이 모호함. 향후 추가 필드(생년월일, 주소 등) 필요 시 확장 어려움.

개선: 애초부터 프로필 필드를 선택형으로 정의하고, 마이그레이션 후 쿼리에서 null 체크:

const profileCompleted = user.isAgeOver14 && user.termsAgreedAt && user.privacyAgreedAt;
const redirectPath = profileCompleted ? '/home' : '/profile/complete';

8. 쿠키 설정 일관성
로그인/콜백에서 secure: NODE_ENV === 'production'으로 조건부 설정하되, 개발 환경에서 HTTP를 쓴다면 localhost에서 쿠키가 저장되지 않을 수 있음. 문제: 개발 테스트 시 JWT 쿠키가 누락되어 인증 실패.

확인: NEXT_PUBLIC_BASE_URLhttp://localhost:3000이면 개발 테스트는 정상이나, https://localhost로 테스트하려면 자체 서명 인증서 + secure=true 필요. 현재 코드는 NODE_ENV만 보므로 개발 환경 구성에 따라 위험.

개선:

const secure = NODE_ENV === 'production' || (NODE_ENV === 'development' && process.env.FORCE_SECURE_COOKIES === 'true');
🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (3 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning JWT 유틸과 Kakao OAuth 유틸이 구현되었으나, TDD 원칙에 따른 단위 테스트(Unit Tests)가 전혀 추가되지 않았다. 최소한 signAccessToken/verifyAccessToken [JWT], getKakaoAuthUrl/getKakaoToken/extractKakaoUserInfo [Kakao], signupSchema 검증 [Schema]에 대한 단위 테스트를 추가하시오.
Out of Scope Changes check ⚠️ Warning signup API route가 #28 요구사항에 없으며, .env.example/gitignore 파일 추가 역시 범위 외 변경이다. #28의 요구사항(카카오 로그인·JWT·SocialAccount)과 직접 무관한 변경은 별도 PR로 분리하거나 제거하시오. #25 signup API도 명시적으로 연계 이슈 추가 필요.
Docstring Coverage ⚠️ Warning 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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.tsJWT_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

📥 Commits

Reviewing files that changed from the base of the PR and between ca8a4a3 and 00c054e.

⛔ Files ignored due to path filters (2)
  • package-lock.json is excluded by !**/package-lock.json, !**/*.json, !package-lock.json
  • package.json is excluded by !**/*.json
📒 Files selected for processing (13)
  • .env.example
  • .gitignore
  • prisma/migrations/20260521073616_init_user/migration.sql
  • prisma/migrations/20260521091212_add_social_account/migration.sql
  • prisma/migrations/migration_lock.toml
  • prisma/schema.prisma
  • src/app/api/auth/kakao/callback/route.ts
  • src/app/api/auth/kakao/login/route.ts
  • src/app/api/auth/signup/route.ts
  • src/features/auth/signup.schema.ts
  • src/lib/auth/jwt.ts
  • src/lib/auth/kakao.ts
  • src/lib/prisma.ts

Comment thread .env.example
@@ -1,3 +1,6 @@
# 로컬 DB (Prisma + SQLite)
DATABASE_URL="file:./dev.db"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Suggested change
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).

Comment thread prisma/schema.prisma
Comment on lines +27 to +37
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])
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 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.

Suggested change
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";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

프로덕션에서 리다이렉트 오동작 가능한 베이스 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.

Suggested change
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.

Comment on lines +58 to +75
const socialAccount = await prisma.socialAccount.findUnique({
where: {
provider_providerUserId: {
provider: "kakao",
providerUserId: kakaoId,
},
},
include: {
user: {
select: {
id: true,
email: true,
nickname: true,
profileCompleted: true,
},
},
},
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

신규 소셜 계정 생성 구간에 경합 처리 누락으로 정상 로그인 실패가 발생할 수 있습니다.

이유(Why): Line [58]~Line [120]은 findUniquecreate를 분리해 동시 콜백(재시도/중복 클릭)에서 한 요청이 실패 리다이렉트로 떨어질 수 있습니다.
방법(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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

회원가입 실패 로그에 에러 객체 전체를 남기면 민감정보 노출 위험이 있습니다.

이유(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.

Suggested change
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}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

예외 메시지에 전화번호 원문을 포함하지 마세요.

이유(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.

Suggested change
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.

Comment thread src/lib/auth/jwt.ts
Comment on lines +31 to +35
try {
const { payload } = await jwtVerify(token, getSecret());
return payload as unknown as JwtPayload;
} catch {
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

설정 오류를 인증 실패로 삼키지 마세요.

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.

Comment thread src/lib/auth/jwt.ts
): Promise<JwtPayload | null> {
try {
const { payload } = await jwtVerify(token, getSecret());
return payload as unknown as JwtPayload;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

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.

Comment thread src/lib/auth/kakao.ts
Comment on lines +39 to +88
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>;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ 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.

Comment thread src/lib/auth/kakao.ts
Comment on lines +57 to +63
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(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

외부 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.

@kokkumong kokkumong changed the title feat: 카카오 소셜 로그인 구현 (#28) feat: 카카오 소셜 로그인 구현 (#29) May 25, 2026
@Siul49
Siul49 merged commit 759a72a into dev May 27, 2026
1 check passed
@Siul49
Siul49 deleted the feature/28-kakao-login branch June 2, 2026 17:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants