Skip to content

Conversation

@rbqks529
Copy link
Contributor

@rbqks529 rbqks529 commented Oct 5, 2025

🚀 이슈번호

✏️ 변경사항

  • 로그인 api 연결
  • 회원가입 api 연결
  • 이메일 인증번호 발급 api 연결
  • 이메일 인증번호 검증 api 연결
  • 임시 비밀번호 발급 api 연결

📷 스크린샷

✍️ 사용법

🎸 기타

Summary by CodeRabbit

  • New Features
    • 이메일 인증 요청/검증, 회원가입, 로그인, 비밀번호 재설정 연동 및 전용 뷰모델/저장소 추가
    • 스플래시에서 토큰 확인 후 홈/로그인 분기
    • 토큰 만료 시 자동 로그아웃 후 로그인 화면으로 이동
  • Refactor
    • 라우트와 내비게이션을 이메일/비밀번호 전달형으로 정비해 화면 간 데이터 전달 일관화
    • 로그인·회원가입·비밀번호 찾기에 로딩/에러 상태 표시 및 버튼 비활성화 등 상태 기반 UI 적용
    • 6자리 코드 입력 UX 개선(포커스/키보드), 비밀번호 검증 강화(8자·일치)
  • Chores
    • 특정 네트워크 보안 설정 파일 무시 규칙 추가 (.gitignore)

@rbqks529 rbqks529 self-assigned this Oct 5, 2025
@coderabbitai
Copy link

coderabbitai bot commented Oct 5, 2025

Walkthrough

토큰 만료 처리와 재발급 흐름을 도입하고, 인증 전용 원격 데이터소스/레포지토리/DI/뷰모델/화면을 추가·개편했다. 라우트에 파라미터를 추가하여 회원가입/검증 플로우를 연결했고, 스플래시에서 토큰 확인 후 분기한다. 다양한 요청/응답 DTO를 추가하고 기존 로그인/재발급 응답 스키마를 갱신했다.

Changes

Cohort / File(s) Summary
Git & Config
./.gitignore
하위 경로의 network_security_config.xml 무시 규칙 추가
Auth Core & Networking
composeApp/.../core/auth/TokenExpiredManager.kt, composeApp/.../core/network/HttpClientFactory.kt
토큰 만료 상태 관리 싱글턴 추가, 재발급 엔드포인트를 auth/reissue로 교체, 재발급 실패 시 토큰 제거 및 만료 플래그 설정
Navigation Model & Graph
composeApp/.../core/navigation/Route.kt, composeApp/.../core/navigation/WhosInNavGraph.kt, composeApp/.../client/App.kt
라우트에 이메일/패스워드 파라미터 추가(EmailVerification/PasswordInput/NicknameInput), 스플래시→홈 경로 및 만료 시 로그인으로 강제 네비게이션 처리
Auth DTOs (Request)
composeApp/.../data/dto/request/*
이메일 인증 요청/검증, 비밀번호 찾기, 회원가입 요청 DTO 신규 추가
Auth DTOs (Response)
composeApp/.../data/dto/response/EmailVerificationResponseDto.kt, FindPasswordResponseDto.kt, SignupResponseDto.kt, ReissueTokenResponseDto.kt, LoginResponseDto.kt
이메일 인증/비번찾기/회원가입/재발급 응답 DTO 추가, 로그인 응답에 status/timestamp 추가 및 data nullable 변경
Remote Data Sources
composeApp/.../data/remote/RemoteAuthDataSource.kt, composeApp/.../data/remote/RemoteMemberDataSource.kt
인증 전용 데이터소스 신설(로그인, 이메일 전송/검증, 회원가입, 비번찾기), MemberDataSource에서 로그인 제거
Repositories
composeApp/.../data/repository/AuthRepository.kt, composeApp/.../data/repository/MemberRepository.kt
AuthRepository 신설(인증 API 위임), MemberRepository에서 로그인 제거
DI Modules
composeApp/.../di/DIModules.kt
RemoteAuthDataSource/AuthRepository 및 Splash/Signup/FindPassword 뷰모델 주입 등록
Auth Screens
composeApp/.../presentation/auth/login/LoginScreen.kt, SignupEmailInputScreen.kt, EmailVerificationScreen.kt, PasswordInputScreen.kt, NicknameInputScreen.kt, FindPasswordScreen.kt, SplashScreen.kt
화면별 상태/로딩/에러 처리 추가, 비동기 호출 연결, 파라미터 및 콜백 시그니처 변경, 스플래시에서 토큰 확인 후 분기
Input UX
composeApp/.../presentation/auth/clubcode/ClubCodeInputScreen.kt, composeApp/.../presentation/auth/login/component/NumberInputBox.kt
숫자 입력 포커스/키보드 동작 개선, NumberInputBox에 onClick 옵션 추가
ViewModels
composeApp/.../presentation/auth/login/viewmodel/LoginViewModel.kt, SignupViewModel.kt, SplashViewModel.kt, FindPasswordViewModel.kt
로그인/회원가입/스플래시/비번찾기 뷰모델 추가 및 개편, 토큰 저장 연동, UI 상태 플로우 노출

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant S as SplashScreen
  participant VM as SplashViewModel
  participant T as TokenManager
  participant N as NavController

  S->>VM: checkToken()
  VM->>T: getAccessToken()
  T-->>VM: token or null
  VM-->>S: uiState(isLoading=false, hasToken=bool)
  alt hasToken
    S->>N: navigate(Home)
  else
    S->>N: navigate(Login)
  end
Loading
sequenceDiagram
  autonumber
  participant L as LoginScreen
  participant V as LoginViewModel
  participant R as AuthRepository
  participant DS as RemoteAuthDataSource
  participant TM as TokenManager
  participant N as NavController

  L->>V: login(email, password)
  V->>R: login(...)
  R->>DS: POST auth/login
  DS-->>R: ApiResult<LoginResponseDto>
  R-->>V: ApiResult
  alt Success with token
    V->>TM: saveTokens(access, refresh)
    V-->>L: uiState(isSuccess=true)
    L->>N: navigate(Home)
  else Error/No token
    V-->>L: uiState(errorMessage)
  end
Loading
sequenceDiagram
  autonumber
  participant C as HttpClient
  participant SR as Server
  participant TM as TokenManager
  participant EM as TokenExpiredManager

  C->>SR: Request with AccessToken
  SR-->>C: 401 Unauthorized
  C->>TM: getRefreshToken()
  alt refresh token exists
    C->>SR: POST auth/reissue
    SR-->>C: ReissueTokenResponseDto
    alt success & tokens
      C->>TM: saveTokens(new)
      C->>SR: Retry original request
      SR-->>C: Response
    else failure
      C->>TM: clearTokens()
      C->>EM: setTokenExpired()
      C-->>C: Return failure
    end
  else no refresh
    C->>TM: clearTokens()
    C->>EM: setTokenExpired()
    C-->>C: Return failure
  end
Loading
sequenceDiagram
  autonumber
  participant SE as SignupEmailInput
  participant AR as AuthRepository
  participant DS as RemoteAuthDS
  participant EV as EmailVerificationScreen
  participant NR as NicknameInputScreen

  SE->>AR: sendEmailVerification(email)
  AR->>DS: POST auth/email/send
  DS-->>AR: ApiResult<EmailVerificationResponseDto>
  AR-->>SE: ApiResult
  alt success
    SE->>EV: navigate(email)
    EV->>AR: validateEmailCode(email, code)
    AR->>DS: POST auth/email/validation
    DS-->>AR: ApiResult
    AR-->>EV: ApiResult
    alt success
      EV->>NR: navigate(email, password?)
    else error
      EV-->>EV: show error
    end
  else error
    SE-->>SE: show error
  end
Loading
sequenceDiagram
  autonumber
  participant A as App
  participant EM as TokenExpiredManager
  participant N as NavController

  EM-->>A: isTokenExpired=true (StateFlow)
  A->>N: navigate(Login, popUpTo=all, inclusive=true)
  A->>EM: reset()
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested labels

FEAT, OK Merge

Suggested reviewers

  • casper-jr
  • Nico1eKim
  • ikseong00
  • alswlekk

Poem

스플래시에 바람 불고, 토큰은 깃털 같네
만료되면 휙- 로그인으로, 새 길을 찾네
이메일은 띵- 코드로 답하고
닉네임은 싱긋-, 홈으로 이어지네
재발급 톡톡, 흐름은 매끈 :)
깃은 조용히 xml을 잊었네
오늘도 컴포즈는 말 없이 미소 짓네 🌿

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed 제목이 로그인 화면 API 연결이라는 구체적인 변경 사항을 명확히 나타내고 PR의 일부 변경을 잘 요약합니다. 그러나 이 PR에는 회원가입, 이메일 인증 및 임시 비밀번호 발급 API 연결도 포함되어 있습니다. 전체 변경 범위를 모두 반영하지는 않지만 PR의 핵심 중 하나를 대표하고 있어 타당합니다.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

@rbqks529 rbqks529 added FEAT 기능 개발 REFACTOR 기능을 건드리지 않는 리팩토링 OK Merge 완료된 PR labels Oct 5, 2025
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/FindPasswordScreen.kt (1)

52-131: 스낵바 플래그가 계속 true로 남아요.

showSuccessToast를 true로만 세팅하고 다시 false로 돌리지 않아서, 상태가 다른 값으로 바뀌어도 Snackbar가 계속 붙어 있을 수 있어요. delay 이후와 다른 상태 분기에서 false로 리셋 한번 해주면 깔끔하게 사라집니다.

         when (uiState) {
             is FindPasswordUiState.Success -> {
                 showSuccessToast = true
                 delay(2000) // 2초 후 로그인 화면으로 이동
+                showSuccessToast = false
                 onPasswordResetComplete()
             }
-            else -> {}
+            else -> {
+                showSuccessToast = false
+            }
         }
♻️ Duplicate comments (3)
composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt (3)

83-109: 에러 응답 파싱 시 예외 처리가 없습니다.

100줄에서 에러 응답 파싱 실패 시 안전장치가 없어요. 이는 sendEmailVerification과 동일한 문제입니다.


111-138: 에러 응답 파싱 시 예외 처리가 없습니다.

129줄에서 에러 응답 파싱 실패 시 안전장치가 없어요. 다른 함수들과 동일한 문제입니다.


140-163: 에러 응답 파싱 시 예외 처리가 없습니다.

154줄에서 에러 응답 파싱 실패 시 안전장치가 없어요. 다른 함수들과 동일한 문제입니다.

🧹 Nitpick comments (4)
composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/component/NumberInputBox.kt (1)

69-69: 중복된 clickable 수정자를 검토해주세요.

외부 Box(line 69)와 decorationBox의 내부 Box(lines 101-103) 모두에 clickable이 적용되어 있습니다. 이것이 의도적인 것인지, 아니면 터치 영역을 넓히기 위한 것인지 확인해주세요. 일반적으로 하나만 있어도 충분할 수 있습니다.

외부 Box의 clickable만 유지하는 것을 고려해보세요:

         BasicTextField(
             ...
             decorationBox = { innerTextField ->
                 Box(
-                    Modifier
-                        .fillMaxSize()
-                        .clickable { onClick?.invoke() },
+                    Modifier.fillMaxSize(),
                     contentAlignment = Alignment.Center
                 ) {
                     innerTextField()
                 }
             }
         )

Also applies to: 101-103

composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/LoginScreen.kt (1)

133-140: 로딩 중 시각적 피드백 추가 제안

로그인 요청 중에는 버튼만 비활성화되고 별도의 로딩 피드백이 없어 사용자가 진행 상황을 파악하기 어렵습니다. uiState.isLoadingtrue일 때 버튼 내부나 바로 아래에 CircularProgressIndicator를 노출하는 식으로 보완하면 UX가 한층 나아질 것 같아요.

composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/EmailVerificationScreen.kt (1)

62-64: UI와 데이터 레이어 결합 분리 제안

화면 컴포저블 안에서 바로 koinInject<AuthRepository>()를 호출하면 프리뷰나 분리된 테스트 환경에서 의존성 주입이 깨지고, UI가 도메인 레이어를 직접 다루게 됩니다. 뷰모델로 로직을 위임하거나, 최소한 외부에서 AuthRepository를 주입받도록 바꿔서 UI와 데이터 레이어의 경계를 명확히 해두는 편이 유지보수에 더 안전해 보여요.

composeApp/src/commonMain/kotlin/org/whosin/client/core/navigation/WhosInNavGraph.kt (1)

53-55: 로그인 성공 시 백스택 정리 제안

로그인 후 Home으로 이동해도 뒤로가기를 누르면 다시 로그인 화면이 나타나 UX가 어색합니다. AuthGraphpopUp해서 인증 플로우를 스택에서 제거하는 편이 자연스럽습니다.

다음과 같이 수정해 주세요:

                     onNavigateToHome = {
-                        navController.navigate(Route.Home)
+                        navController.navigate(Route.Home) {
+                            popUpTo(Route.AuthGraph) { inclusive = true }
+                        }
                     },
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between cb97b91 and 8623ced.

📒 Files selected for processing (33)
  • .gitignore (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/App.kt (2 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/core/auth/TokenExpiredManager.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/core/navigation/Route.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/core/navigation/WhosInNavGraph.kt (2 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/core/network/HttpClientFactory.kt (4 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/EmailValidationRequestDto.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/EmailVerificationRequestDto.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/FindPasswordRequestDto.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/SignupRequestDto.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/EmailVerificationResponseDto.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/FindPasswordResponseDto.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/LoginResponseDto.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/ReissueTokenResponseDto.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/SignupResponseDto.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteMemberDataSource.kt (0 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/AuthRepository.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/MemberRepository.kt (0 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt (2 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/clubcode/ClubCodeInputScreen.kt (2 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/EmailVerificationScreen.kt (7 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/FindPasswordScreen.kt (5 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/LoginScreen.kt (4 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/NicknameInputScreen.kt (5 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/PasswordInputScreen.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/SignupEmailInputScreen.kt (4 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/SplashScreen.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/component/NumberInputBox.kt (4 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/FindPasswordViewModel.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/LoginViewModel.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/SignupViewModel.kt (1 hunks)
  • composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/SplashViewModel.kt (1 hunks)
💤 Files with no reviewable changes (2)
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/MemberRepository.kt
  • composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteMemberDataSource.kt
🧰 Additional context used
🧬 Code graph analysis (12)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/EmailValidationRequestDto.kt (2)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/LoginRequestDto.kt (1)
  • email (6-12)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/ReissueTokenRequestDto.kt (1)
  • refreshToken (6-10)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/EmailVerificationResponseDto.kt (3)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/UpdateMyInfoResponseDto.kt (1)
  • success (6-16)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/AddClubResponseDto.kt (1)
  • success (6-16)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/ErrorResponseDto.kt (1)
  • success (6-16)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/FindPasswordRequestDto.kt (1)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/LoginRequestDto.kt (1)
  • email (6-12)
composeApp/src/commonMain/kotlin/org/whosin/client/core/network/HttpClientFactory.kt (2)
composeApp/src/commonMain/kotlin/org/whosin/client/core/datastore/TokenManagerImpl.kt (1)
  • dataStore (9-28)
composeApp/src/commonMain/kotlin/org/whosin/client/core/datastore/TokenManager.kt (2)
  • getRefreshToken (5-5)
  • getAccessToken (3-8)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/ReissueTokenResponseDto.kt (3)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/ReissueTokenRequestDto.kt (1)
  • refreshToken (6-10)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/AddClubResponseDto.kt (1)
  • success (6-16)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/ErrorResponseDto.kt (1)
  • success (6-16)
composeApp/src/commonMain/kotlin/org/whosin/client/di/DIModules.kt (5)
composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteMemberDataSource.kt (2)
  • client (19-115)
  • login (22-45)
composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteClubDataSource.kt (1)
  • client (19-165)
composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/MemberRepository.kt (1)
  • dataSource (9-21)
composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/ClubRepository.kt (1)
  • dataSource (10-30)
composeApp/src/jvmMain/kotlin/org/whosin/client/di/DIModules.jvm.kt (1)
  • single (14-18)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/SignupResponseDto.kt (2)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/AddClubResponseDto.kt (1)
  • success (6-16)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/ErrorResponseDto.kt (1)
  • success (6-16)
composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/LoginViewModel.kt (1)
composeApp/src/commonMain/kotlin/org/whosin/client/presentation/dummy/TokenTestViewModel.kt (1)
  • tokenManager (11-55)
composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/NicknameInputScreen.kt (1)
composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/component/CommonLoginButton.kt (1)
  • CommonLoginButton (18-47)
composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/LoginScreen.kt (1)
composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/component/CommonLoginButton.kt (1)
  • CommonLoginButton (18-47)
composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/EmailVerificationScreen.kt (1)
composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/component/CommonLoginButton.kt (1)
  • CommonLoginButton (18-47)
composeApp/src/commonMain/kotlin/org/whosin/client/core/navigation/WhosInNavGraph.kt (3)
composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/EmailVerificationScreen.kt (1)
  • EmailVerificationScreen (48-224)
composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/PasswordInputScreen.kt (1)
  • PasswordInputScreen (37-132)
composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/NicknameInputScreen.kt (1)
  • NicknameInputScreen (43-153)
🪛 Gitleaks (8.28.0)
composeApp/src/commonMain/kotlin/org/whosin/client/core/network/HttpClientFactory.kt

[high] 53-53: Uncovered a JSON Web Token, which may lead to unauthorized access to web applications and sensitive user data.

(jwt)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: ios-build
  • GitHub Check: android-build
🔇 Additional comments (10)
composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/SignupRequestDto.kt (1)

6-14: 깔끔한 DTO 구현입니다!

회원가입에 필요한 필드들이 적절하게 정의되어 있고, 다른 요청 DTO들과 일관된 패턴을 따르고 있네요.

composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/component/NumberInputBox.kt (2)

39-39: onClick 콜백 추가 좋습니다!

클럽 코드 입력 등 다른 화면에서 포커스 제어가 필요한 경우를 위한 좋은 확장이네요.


93-93: 투명한 커서로 인한 UX 영향을 확인해주세요.

커서를 투명하게 만들면 사용자가 현재 입력 위치를 파악하기 어려울 수 있습니다. 특히 숫자 입력 중 피드백이 부족할 수 있으니, 실제 사용성 테스트를 통해 이 변경이 의도한 대로 동작하는지 확인하는 것을 권장합니다.

composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/EmailVerificationResponseDto.kt (1)

6-18: 일관된 응답 DTO 구조입니다!

기존 응답 DTO들(AddClubResponseDto, ErrorResponseDto)과 동일한 패턴을 따르고 있어 일관성이 좋습니다. 선택적 필드들도 적절하게 null 기본값으로 처리되어 있네요.

composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/PasswordInputScreen.kt (1)

46-48: 비밀번호 유효성 검증 강화 좋습니다!

두 비밀번호 모두 최소 8자 이상이면서 동일해야만 완료되도록 로직이 개선되었네요. 사용자가 실수로 짧은 비밀번호를 입력하는 것을 방지할 수 있어 보안에도 도움이 됩니다.

composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/EmailValidationRequestDto.kt (1)

6-12: 깔끔한 요청 DTO입니다!

이메일 인증 코드 검증을 위한 필드들이 명확하게 정의되어 있고, 다른 요청 DTO들과 일관된 패턴을 따르고 있습니다.

composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/FindPasswordRequestDto.kt (1)

6-10: 심플하고 명확한 DTO입니다!

비밀번호 찾기에 필요한 이메일 필드만 포함하고 있어 목적이 명확하고, 기존 DTO 패턴을 잘 따르고 있네요.

composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/EmailVerificationRequestDto.kt (1)

6-10: 간결한 요청 DTO입니다!

이메일 인증 요청에 필요한 필드만 포함하고 있고, 다른 DTO들과 일관된 구조를 가지고 있습니다.

composeApp/src/commonMain/kotlin/org/whosin/client/App.kt (1)

23-32: TokenExpiredManager는 MutableStateFlow로 스레드 안전하며 중복 네비게이션 우려가 없습니다

composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/SplashScreen.kt (1)

38-45: 초기 로딩 상태 확인 필요

LaunchedEffectisLoadingfalse가 되는 순간 바로 실행되어 홈/로그인으로 분기합니다. 만약 SplashViewModel의 초기 상태가 isLoading = false라면, checkToken()이 끝나기도 전에 로그인 화면으로 이동해 버릴 수 있어요. 초기값이 true로 설정되어 있는지, 혹은 토큰 검사 완료 시점에만 false가 되도록 보장되는지 한 번 더 확인 부탁드립니다.

!src/**/build/
local.properties
.idea
**/network_security_config.xml
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

network_security_config.xml은 버전 관리 대상입니다.

앱의 네트워크 보안 정책을 정의하는 파일을 전역으로 무시하면, 환경마다 다른 설정이 조용히 적용되고 보안 검토도 불가능해집니다. 규정·릴리스 안정성 모두 큰 리스크이니 해당 ignore 항목을 제거하거나, 필요한 경우 별도의 샘플 파일/로컬 경로만 선택적으로 무시해 주세요.

🤖 Prompt for AI Agents
In .gitignore around line 9, the global ignore entry for
**/network_security_config.xml causes the app's network security policy file to
be excluded from version control; remove this ignore line so
network_security_config.xml files are tracked, or replace it with a scoped
ignore that only excludes a sample/local path (e.g.,
/app/src/main/res/xml/network_security_config.sample.xml or a developer-specific
path) and add a tracked canonical network_security_config.xml (or a template
checked into repo) so environments and security reviews remain consistent.

Comment on lines +52 to 55
val accessToken = tokenManager.getAccessToken()
?: "eyJhbGciOiJIUzI1NiJ9.eyJ0b2tlblR5cGUiOiJhY2Nlc3MiLCJ1c2VySWQiOjUsInByb3ZpZGVySWQiOiJsb2NhbGhvc3QiLCJuYW1lIjoi7Iug7KKF7JykIiwicm9sZSI6IlJPTEVfTUVNQkVSIiwiaWF0IjoxNzU5MzgyMzg3LCJleHAiOjE3NTk5ODcxODd9.kT9IH60aCA-6ByEITb-_qPAJY0Oik1bbPKqcBWXzHIk"
val refreshToken = tokenManager.getRefreshToken() ?: "no_token"
BearerTokens(accessToken = accessToken, refreshToken = refreshToken)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

하드코딩된 JWT 제거 필요

Line 52~55에서 액세스 토큰과 리프레시 토큰이 하드코딩된 문자열로 대체되고 있습니다. 인증이 되지 않은 상태에서도 이 더미 토큰이 모든 요청 헤더에 실리기 때문에, 실서버로 빌드가 나가면 불특정 사용자에게 동일한 권한이 노출되는 치명적인 보안 사고로 이어질 수 있습니다. 또한 정적 분석(gitleaks)도 JWT 유출로 경고하고 있습니다. 토큰이 없으면 null을 반환해 Bearer 헤더를 보내지 않도록 고쳐 주세요.

-                    loadTokens {
-                        val accessToken = tokenManager.getAccessToken()
-                            ?: "eyJhbGciOiJIUzI1NiJ9.eyJ0b2tlblR5cGUiOiJhY2Nlc3MiLCJ1c2VySWQiOjUsInByb3ZpZGVySWQiOiJsb2NhbGhvc3QiLCJuYW1lIjoi7Iug7KKF7JykIiwicm9sZSI6IlJPTEVfTUVNQkVSIiwiaWF0IjoxNzU5MzgyMzg3LCJleHAiOjE3NTk5ODcxODd9.kT9IH60aCA-6ByEITb-_qPAJY0Oik1bbPKqcBWXzHIk"
-                        val refreshToken = tokenManager.getRefreshToken() ?: "no_token"
-                        BearerTokens(accessToken = accessToken, refreshToken = refreshToken)
-                    }
+                    loadTokens {
+                        val accessToken = tokenManager.getAccessToken()
+                        val refreshToken = tokenManager.getRefreshToken()
+                        if (accessToken != null && refreshToken != null) {
+                            BearerTokens(accessToken = accessToken, refreshToken = refreshToken)
+                        } else {
+                            null
+                        }
+                    }
📝 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
val accessToken = tokenManager.getAccessToken()
?: "eyJhbGciOiJIUzI1NiJ9.eyJ0b2tlblR5cGUiOiJhY2Nlc3MiLCJ1c2VySWQiOjUsInByb3ZpZGVySWQiOiJsb2NhbGhvc3QiLCJuYW1lIjoi7Iug7KKF7JykIiwicm9sZSI6IlJPTEVfTUVNQkVSIiwiaWF0IjoxNzU5MzgyMzg3LCJleHAiOjE3NTk5ODcxODd9.kT9IH60aCA-6ByEITb-_qPAJY0Oik1bbPKqcBWXzHIk"
val refreshToken = tokenManager.getRefreshToken() ?: "no_token"
BearerTokens(accessToken = accessToken, refreshToken = refreshToken)
loadTokens {
val accessToken = tokenManager.getAccessToken()
val refreshToken = tokenManager.getRefreshToken()
if (accessToken != null && refreshToken != null) {
BearerTokens(accessToken = accessToken, refreshToken = refreshToken)
} else {
null
}
}
🧰 Tools
🪛 Gitleaks (8.28.0)

[high] 53-53: Uncovered a JSON Web Token, which may lead to unauthorized access to web applications and sensitive user data.

(jwt)

🤖 Prompt for AI Agents
In
composeApp/src/commonMain/kotlin/org/whosin/client/core/network/HttpClientFactory.kt
around lines 52 to 55, remove the hardcoded JWT and "no_token" fallback;
instead, do not fabricate tokens — if tokenManager.getAccessToken() returns null
then return null (or omit constructing BearerTokens) so no Authorization header
is sent, and only construct BearerTokens when a real access token exists
(refreshToken may be null). Ensure no static token strings remain and update any
callers to handle a nullable BearerTokens result.

Comment on lines +24 to +163
suspend fun login(email: String, password: String): ApiResult<LoginResponseDto> {
return try {
val response: HttpResponse = client
.post("auth/login") {
setBody(
LoginRequestDto(email = email, password = password)
)
}
if (response.status.isSuccess()) {
ApiResult.Success(
data = response.body(),
statusCode = response.status.value
)
} else {
// 에러 응답 파싱 시도
try {
val errorResponse: ErrorResponseDto = response.body()
ApiResult.Error(
code = response.status.value,
message = errorResponse.message
)
} catch (e: Exception) {
// 파싱 실패 시 기본 에러 메시지
ApiResult.Error(
code = response.status.value,
message = "HTTP Error: ${response.status.value}"
)
}
}
} catch (t: Throwable) {
ApiResult.Error(message = t.message, cause = t)
}
}

suspend fun sendEmailVerification(email: String): ApiResult<EmailVerificationResponseDto> {
return try {
val response: HttpResponse = client
.post("auth/email/send") {
setBody(
EmailVerificationRequestDto(email = email)
)
}
if (response.status.isSuccess()) {
ApiResult.Success(
data = response.body(),
statusCode = response.status.value
)
} else {
val errorResponse: EmailVerificationResponseDto = response.body()
ApiResult.Error(
code = errorResponse.status,
message = errorResponse.message
)
}
} catch (t: Throwable) {
ApiResult.Error(message = t.message, cause = t)
}
}

suspend fun validateEmailCode(
email: String,
authCode: String
): ApiResult<EmailVerificationResponseDto> {
return try {
val response: HttpResponse = client
.post("auth/email/validation") {
setBody(
EmailValidationRequestDto(email = email, authCode = authCode)
)
}
if (response.status.isSuccess()) {
ApiResult.Success(
data = response.body(),
statusCode = response.status.value
)
} else {
val errorResponse: EmailVerificationResponseDto = response.body()
ApiResult.Error(
code = errorResponse.status,
message = errorResponse.message
)
}
} catch (t: Throwable) {
ApiResult.Error(message = t.message, cause = t)
}
}

suspend fun signup(
email: String,
password: String,
nickName: String
): ApiResult<SignupResponseDto> {
return try {
val response: HttpResponse = client
.post("users/signup") {
setBody(
SignupRequestDto(email = email, password = password, nickName = nickName)
)
}
if (response.status.isSuccess()) {
ApiResult.Success(
data = response.body(),
statusCode = response.status.value
)
} else {
val errorResponse: SignupResponseDto = response.body()
ApiResult.Error(
code = errorResponse.status,
message = errorResponse.message
)
}
} catch (t: Throwable) {
ApiResult.Error(message = t.message, cause = t)
}
}

suspend fun sendPasswordResetEmail(email: String): ApiResult<FindPasswordResponseDto> {
return try {
val response: HttpResponse = client
.post("auth/email/find-password") {
setBody(
FindPasswordRequestDto(email = email)
)
}
if (response.status.isSuccess()) {
ApiResult.Success(
data = response.body(),
statusCode = response.status.value
)
} else {
val errorResponse: FindPasswordResponseDto = response.body()
ApiResult.Error(
code = errorResponse.status,
message = errorResponse.message
)
}
} catch (t: Throwable) {
ApiResult.Error(message = t.message, cause = t)
}
}
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

함수들 간 코드 중복이 많습니다.

5개 함수가 거의 동일한 구조를 가지고 있어요. 엔드포인트 URL과 DTO만 다를 뿐입니다. 이런 중복은 유지보수를 어렵게 만들고 버그 발생 가능성도 높입니다.

제네릭 함수로 공통 로직을 추출하면 코드가 훨씬 깔끔해지고, 위에서 지적한 에러 처리 불일치 문제도 한 번에 해결할 수 있어요.

예시:

private suspend inline fun <reified T, reified E> executePost(
    endpoint: String,
    request: Any,
    useErrorDto: Boolean = true
): ApiResult<T> {
    return try {
        val response: HttpResponse = client.post(endpoint) {
            setBody(request)
        }
        if (response.status.isSuccess()) {
            ApiResult.Success(
                data = response.body(),
                statusCode = response.status.value
            )
        } else {
            try {
                val errorResponse: E = response.body()
                // errorResponse에서 code와 message 추출 로직
                ApiResult.Error(
                    code = response.status.value,
                    message = extractErrorMessage(errorResponse)
                )
            } catch (e: Exception) {
                ApiResult.Error(
                    code = response.status.value,
                    message = "HTTP Error: ${response.status.value}"
                )
            }
        }
    } catch (t: Throwable) {
        ApiResult.Error(message = t.message ?: "Unknown error", cause = t)
    }
}

suspend fun login(email: String, password: String): ApiResult<LoginResponseDto> {
    return executePost<LoginResponseDto, ErrorResponseDto>(
        endpoint = "auth/login",
        request = LoginRequestDto(email, password)
    )
}
🤖 Prompt for AI Agents
In
composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt
around lines 24-163, multiple POST functions repeat the same
request/response/error-handling logic; extract a single private suspend inline
reified generic helper (e.g., executePost<T, E>) that accepts endpoint and
request payload, performs the client.post call, returns ApiResult.Success on
success, and on non-success attempts to parse an error DTO E (with fallback to a
generic "HTTP Error: <status>" message) and returns ApiResult.Error; ensure the
helper normalizes throwable handling (use t.message ?: "Unknown error") and
provides a small extractor to pull code/message from different error DTO shapes,
then refactor
login/sendEmailVerification/validateEmailCode/signup/sendPasswordResetEmail to
call this helper with appropriate request and response DTO types.

Comment on lines +24 to +56
suspend fun login(email: String, password: String): ApiResult<LoginResponseDto> {
return try {
val response: HttpResponse = client
.post("auth/login") {
setBody(
LoginRequestDto(email = email, password = password)
)
}
if (response.status.isSuccess()) {
ApiResult.Success(
data = response.body(),
statusCode = response.status.value
)
} else {
// 에러 응답 파싱 시도
try {
val errorResponse: ErrorResponseDto = response.body()
ApiResult.Error(
code = response.status.value,
message = errorResponse.message
)
} catch (e: Exception) {
// 파싱 실패 시 기본 에러 메시지
ApiResult.Error(
code = response.status.value,
message = "HTTP Error: ${response.status.value}"
)
}
}
} catch (t: Throwable) {
ApiResult.Error(message = t.message, cause = t)
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

login 함수의 에러 처리 방식이 다른 함수들과 다릅니다.

login 함수는 ErrorResponseDto를 사용해 에러를 파싱하고 실패 시 폴백 처리까지 제공하는데(39-51줄), 나머지 함수들은 각자의 응답 DTO를 직접 사용하고 있어요. 이런 불일치가 있으면 나중에 에러 응답 구조가 달라질 때 문제가 생길 수 있습니다.

전체 함수에서 일관된 에러 처리 전략을 사용하는 걸 권장합니다.

🤖 Prompt for AI Agents
In
composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt
around lines 24 to 56, the login function implements special-case error parsing
using ErrorResponseDto with a fallback, while other functions use their own
response DTOs directly; make error handling consistent by extracting a shared
helper that, given an HttpResponse, returns ApiResult.Error by attempting to
parse ErrorResponseDto and falling back to a generic message if parsing fails,
then refactor login and all other remote methods to use that helper for
non-success responses and keep try/catch to convert thrown exceptions into
ApiResult.Error (preserving message and cause).

}
}
} catch (t: Throwable) {
ApiResult.Error(message = t.message, cause = t)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

에러 메시지가 null일 수 있습니다.

t.message는 null일 수 있는데, 이 경우 더 유용한 기본 메시지를 제공하는 게 좋을 것 같아요.

예시:

-ApiResult.Error(message = t.message, cause = t)
+ApiResult.Error(message = t.message ?: "Unknown error occurred", cause = t)

Also applies to: 79-79, 107-107, 136-136, 161-161

🤖 Prompt for AI Agents
In
composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt
around line 54 (and similarly at lines 79, 107, 136, 161), the code passes
t.message directly to ApiResult.Error which can be null; change each occurrence
to provide a non-null fallback (for example use t.localizedMessage ?:
"RemoteAuthDataSource: unexpected error" or build a message combining
t::class.simpleName and a default string) so ApiResult.Error always receives a
usable message while still attaching the throwable as the cause.

Comment on lines +58 to +81
suspend fun sendEmailVerification(email: String): ApiResult<EmailVerificationResponseDto> {
return try {
val response: HttpResponse = client
.post("auth/email/send") {
setBody(
EmailVerificationRequestDto(email = email)
)
}
if (response.status.isSuccess()) {
ApiResult.Success(
data = response.body(),
statusCode = response.status.value
)
} else {
val errorResponse: EmailVerificationResponseDto = response.body()
ApiResult.Error(
code = errorResponse.status,
message = errorResponse.message
)
}
} catch (t: Throwable) {
ApiResult.Error(message = t.message, cause = t)
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

에러 응답 파싱 시 예외 처리가 없습니다.

72줄에서 EmailVerificationResponseDto로 에러 응답을 파싱할 때, 파싱에 실패하면 예외가 발생해 78줄의 catch 블록에서 처리됩니다. 이렇게 되면 실제 HTTP 에러 코드 정보가 손실될 수 있어요.

login 함수처럼 중첩된 try-catch로 파싱 실패를 처리하거나, 공통 함수로 추출하는 방법을 고려해보세요.

Comment on lines +27 to +53
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)

when (val result = repository.login(email, password)) {
is ApiResult.Success -> {
_uiState.value = LoginUiState.Success(result.data.data)
val tokenData = result.data.data
if (tokenData != null) {
tokenManager.saveTokens(
accessToken = tokenData.accessToken,
refreshToken = tokenData.refreshToken
)
_uiState.value = _uiState.value.copy(
isLoading = false,
isSuccess = true,
errorMessage = null
)
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = "토큰 데이터를 받지 못했습니다."
)
}
}
is ApiResult.Error -> {
val message = result.message ?: result.cause?.message
_uiState.value = LoginUiState.Error(message)
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = result.message ?: "로그인에 실패했습니다."
)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

isSuccess 상태 초기화 누락으로 재로그인 시 오동작

이전에 성공한 뒤 다시 로그인 시도에서 실패하면 isSuccess가 계속 true로 남아 UI가 성공 흐름을 재실행할 수 있습니다. 로딩 시작과 실패 분기에서 isSuccess를 명시적으로 false로 초기화해 주세요.

아래와 같이 수정하면 됩니다:

-            _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
+            _uiState.value = _uiState.value.copy(
+                isLoading = true,
+                isSuccess = false,
+                errorMessage = null
+            )
...
-                        _uiState.value = _uiState.value.copy(
-                            isLoading = false,
-                            errorMessage = "토큰 데이터를 받지 못했습니다."
-                        )
+                        _uiState.value = _uiState.value.copy(
+                            isLoading = false,
+                            isSuccess = false,
+                            errorMessage = "토큰 데이터를 받지 못했습니다."
+                        )
...
-                    _uiState.value = _uiState.value.copy(
-                        isLoading = false,
-                        errorMessage = result.message ?: "로그인에 실패했습니다."
-                    )
+                    _uiState.value = _uiState.value.copy(
+                        isLoading = false,
+                        isSuccess = false,
+                        errorMessage = result.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
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
when (val result = repository.login(email, password)) {
is ApiResult.Success -> {
_uiState.value = LoginUiState.Success(result.data.data)
val tokenData = result.data.data
if (tokenData != null) {
tokenManager.saveTokens(
accessToken = tokenData.accessToken,
refreshToken = tokenData.refreshToken
)
_uiState.value = _uiState.value.copy(
isLoading = false,
isSuccess = true,
errorMessage = null
)
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = "토큰 데이터를 받지 못했습니다."
)
}
}
is ApiResult.Error -> {
val message = result.message ?: result.cause?.message
_uiState.value = LoginUiState.Error(message)
_uiState.value = _uiState.value.copy(
isLoading = false,
errorMessage = result.message ?: "로그인에 실패했습니다."
)
_uiState.value = _uiState.value.copy(
isLoading = true,
isSuccess = false,
errorMessage = null
)
when (val result = repository.login(email, password)) {
is ApiResult.Success -> {
val tokenData = result.data.data
if (tokenData != null) {
tokenManager.saveTokens(
accessToken = tokenData.accessToken,
refreshToken = tokenData.refreshToken
)
_uiState.value = _uiState.value.copy(
isLoading = false,
isSuccess = true,
errorMessage = null
)
} else {
_uiState.value = _uiState.value.copy(
isLoading = false,
isSuccess = false,
errorMessage = "토큰 데이터를 받지 못했습니다."
)
}
}
is ApiResult.Error -> {
_uiState.value = _uiState.value.copy(
isLoading = false,
isSuccess = false,
errorMessage = result.message ?: "로그인에 실패했습니다."
)
🤖 Prompt for AI Agents
In
composeApp/src/commonMain/kotlin/org/whosin/client/presentation/auth/login/viewmodel/LoginViewModel.kt
around lines 27 to 53, isSuccess is not reset when starting a new login or when
login fails, causing the success UI flow to persist after a subsequent failure;
set isSuccess = false at the start of the login (alongside isLoading = true,
errorMessage = null) and also set isSuccess = false in every failure branch
(when tokenData is null and in ApiResult.Error) so the UI does not mistakenly
remain in a success state.

@Nico1eKim Nico1eKim merged commit 6a1b9d4 into WhosInRoom:develop Oct 9, 2025
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

FEAT 기능 개발 OK Merge 완료된 PR REFACTOR 기능을 건드리지 않는 리팩토링

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants