Repository navigation
회원 가입 약관 매핑 최신화 - #445
회원 가입 약관 매핑 최신화#445
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughIntegrates a backend terms API into the signup flow. Adds ChangesTerms Agreement Signup Integration
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related issues
Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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 |
There was a problem hiding this comment.
Code Review
This pull request refactors the signup terms agreement flow to dynamically fetch and save terms from the backend instead of using hardcoded constants. It introduces a custom hook useTermsAgreement for managing state, a new TermsDetailModal component, and updates the signup API integration to pass numeric agreement IDs. While these changes greatly improve flexibility, there is a critical bug in the useTermsAgreement hook where including agreements in the useEffect dependency array can cause the UI to get permanently stuck in a loading state. Additionally, using non-null assertions (!) on API responses in memberService.ts poses a risk of runtime errors if the expected data is missing.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| return () => { | ||
| isMounted = false; | ||
| }; | ||
| }, [isSocial, onNext, setAgreements, showToast, agreements]); // 이제 agreements를 의존성 배열에 넣어도 초기 호출 방어가 됨 |
There was a problem hiding this comment.
critical: useEffect 의 의존성 배열에 agreements가 포함되어 있어 심각한 버그가 발생할 수 있습니다.
문제 원인
agreements가 변경될 때마다 (예: 약관 동의 체크박스를 클릭하거나setAgreements가 호출될 때)useEffect가 다시 실행됩니다.- React는 새 이펙트를 실행하기 전에 이전 이펙트의 정리(cleanup) 함수를 실행합니다. 이 정리 함수는
isMounted = false로 설정합니다. - 하지만 새 이펙트가 실행될 때
hasFetchedRef.current가true이므로 즉시return하여 아무 작업도 하지 않고 새로운 정리 함수도 등록하지 않습니다. - 결과적으로, 이미 진행 중이던 비동기 작업(
fetchTerms)이 완료되어도isMounted가false로 변경되어 있기 때문에setTermsData,setAgreements,setIsFetching(false)등의 상태 업데이트가 모두 무시됩니다. - 이로 인해 화면이 "약관을 불러오는 중입니다..." 상태에서 영원히 멈추는 현상이 발생할 수 있습니다.
해결 방법
agreements는 마운트 시점에 초기화 여부를 판단할 때만 필요하므로, useEffect 의 의존성 배열에서 agreements를 제거해야 합니다. 마운트 시점의 최신 agreements 값을 안전하게 참조하기 위해 useRef를 사용하거나, 의존성 배열에서 단순히 제거할 수 있습니다.
| }, [isSocial, onNext, setAgreements, showToast, agreements]); // 이제 agreements를 의존성 배열에 넣어도 초기 호출 방어가 됨 | |
| }, [isSocial, onNext, setAgreements, showToast]); |
| getTerms: async (): Promise<TermsResponse> => { | ||
| const response = await apiClient.get<ApiResponse<TermsResponse>>( | ||
| MEMBER_ENDPOINTS.TERMS | ||
| ); | ||
| if (!response.isSuccess) { | ||
| throw new Error(response.message || "약관을 불러오는 데 실패했습니다."); | ||
| } | ||
| return response.result!; | ||
| }, |
There was a problem hiding this comment.
medium: response.result!와 같이 non-null assertion (!)을 사용하고 있습니다. 만약 API가 성공(isSuccess: true)을 반환했지만 어떤 이유로든 result 필드가 누락되거나 null인 경우, 이를 구조 분해 할당하는 호출부(const { terms } = ...)에서 런타임 에러가 발생할 수 있습니다.
안전한 방어적 프로그래밍을 위해 result 존재 여부를 함께 검증하도록 개선하는 것을 권장합니다.
| getTerms: async (): Promise<TermsResponse> => { | |
| const response = await apiClient.get<ApiResponse<TermsResponse>>( | |
| MEMBER_ENDPOINTS.TERMS | |
| ); | |
| if (!response.isSuccess) { | |
| throw new Error(response.message || "약관을 불러오는 데 실패했습니다."); | |
| } | |
| return response.result!; | |
| }, | |
| getTerms: async (): Promise<TermsResponse> => { | |
| const response = await apiClient.get<ApiResponse<TermsResponse>>( | |
| MEMBER_ENDPOINTS.TERMS | |
| ); | |
| if (!response.isSuccess || !response.result) { | |
| throw new Error(response.message || "약관을 불러오는 데 실패했습니다."); | |
| } | |
| return response.result; | |
| }, |
| getMyTerms: async (): Promise<MemberTermsStatus> => { | ||
| const response = await apiClient.get<ApiResponse<MemberTermsStatus>>( | ||
| MEMBER_ENDPOINTS.MY_TERMS | ||
| ); | ||
| if (!response.isSuccess) { | ||
| throw new Error(response.message || "내 약관 동의 상태를 불러오는 데 실패했습니다."); | ||
| } | ||
| return response.result!; | ||
| }, |
There was a problem hiding this comment.
medium: response.result!와 같이 non-null assertion (!)을 사용하고 있습니다. 만약 API가 성공(isSuccess: true)을 반환했지만 어떤 이유로든 result 필드가 누락되거나 null인 경우, 이를 참조하는 호출부에서 런타임 에러가 발생할 수 있습니다.
안전한 방어적 프로그래밍을 위해 result 존재 여부를 함께 검증하도록 개선하는 것을 권장합니다.
| getMyTerms: async (): Promise<MemberTermsStatus> => { | |
| const response = await apiClient.get<ApiResponse<MemberTermsStatus>>( | |
| MEMBER_ENDPOINTS.MY_TERMS | |
| ); | |
| if (!response.isSuccess) { | |
| throw new Error(response.message || "내 약관 동의 상태를 불러오는 데 실패했습니다."); | |
| } | |
| return response.result!; | |
| }, | |
| getMyTerms: async (): Promise<MemberTermsStatus> => { | |
| const response = await apiClient.get<ApiResponse<MemberTermsStatus>>( | |
| MEMBER_ENDPOINTS.MY_TERMS | |
| ); | |
| if (!response.isSuccess || !response.result) { | |
| throw new Error(response.message || "내 약관 동의 상태를 불러오는 데 실패했습니다."); | |
| } | |
| return response.result; | |
| }, |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
src/types/auth.ts (1)
46-53: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse this exported
Termas the single contract.
src/components/base-ui/Join/steps/TermsAgreement/TermsDetailModal.tsx:46-53still defines its ownTermshape. Importing this type there will prevent the modal and hook from drifting the next time the backend contract changes.🤖 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/types/auth.ts` around lines 46 - 53, The modal is duplicating the Term contract instead of using the shared exported type, which can cause drift. Update TermsDetailModal to import and use the Term interface from the auth types module instead of redefining a local shape, and make sure any related props or hook return values reference that same Term symbol consistently.
🤖 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 `@src/app/`(public)/signup/[step]/SignupStepPageClient.tsx:
- Around line 55-57: The terms step is rendering before social mode is known,
which causes `useTermsAgreement` to fetch the wrong path and skip the later
social flow. Update `SignupStepPageClient` so `/signup/terms` is not mounted
until `setIsSocial(true)` has completed, using the existing step handling in
`SignupStepPageClient` and `useTermsAgreement` to gate rendering or redirect
away from `terms` until the social state is resolved.
In `@src/components/base-ui/Join/steps/TermsAgreement/TermsAgreement.tsx`:
- Around line 31-39: `TermsAgreement` currently only handles the `isFetching`
state, so when `useTermsAgreement` finishes after a `getTerms()` failure the
screen renders an empty checklist with a disabled next button. Update
`TermsAgreement` and the `useTermsAgreement` flow to track a recoverable error
state from `getTerms()`, then render an error UI with retry and/or back
navigation instead of falling through. Make sure the loading branch, checklist
rendering, and the `handleNext`/button disabled logic all respect this new error
state so signup can recover from transient API failures.
In `@src/components/base-ui/Join/steps/TermsAgreement/TermsDetailModal.tsx`:
- Around line 9-14: The consent modal currently looks up copy only through
TERMS_CONTENT_MAP by termsType, so it can render stale text when a backend
revision changes selectedTerm.version. Update TermsDetailModal to resolve the
displayed content using both selectedTerm.termsType and selectedTerm.version,
and make sure the versioned backend content is what gets shown for the consent
flow rather than the local constant. Use the existing TermsDetailModal logic and
TERMS_CONTENT_MAP lookup as the place to wire in the version-aware selection.
- Around line 28-31: Add an accessible name to the icon-only close control in
TermsDetailModal by updating the button that uses onClick={onClose} so screen
readers can announce its purpose. Use an aria-label on that close button in the
TermsDetailModal component, keeping the existing visual icon and behavior
unchanged.
In `@src/services/memberService.ts`:
- Around line 188-205: Both memberService methods, getTerms and getMyTerms,
assume ApiResponse.result is always present by forcing it with non-null
assertions, which can crash consumers like useTermsAgreement if the backend
returns success without data. Update these methods to validate response.result
after the isSuccess check and throw a clear error when it is missing, instead of
returning it directly; keep the existing response.message fallback and ensure
the returned value is only used when result is actually defined.
---
Nitpick comments:
In `@src/types/auth.ts`:
- Around line 46-53: The modal is duplicating the Term contract instead of using
the shared exported type, which can cause drift. Update TermsDetailModal to
import and use the Term interface from the auth types module instead of
redefining a local shape, and make sure any related props or hook return values
reference that same Term symbol consistently.
🪄 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: defaults
Review profile: CHILL
Plan: Pro
Run ID: dca94228-00de-4c53-a641-4b32b1fb1e63
📒 Files selected for processing (10)
src/app/(public)/signup/[step]/SignupStepPageClient.tsxsrc/components/base-ui/Join/steps/PasswordEntry/PasswordEntry.tsxsrc/components/base-ui/Join/steps/TermsAgreement/TermsAgreement.tsxsrc/components/base-ui/Join/steps/TermsAgreement/TermsDetailModal.tsxsrc/components/base-ui/Join/steps/TermsAgreement/useTermsAgreement.tssrc/contexts/SignupContext.tsxsrc/lib/api/endpoints/member.tssrc/services/memberService.tssrc/types/auth.tssrc/utils/profileCompletion.ts
| if (step === "email" || step === "password") { | ||
| router.replace(PROFILE_COMPLETION_ROUTE); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Don’t render the terms step before social mode is resolved.
Removing the redirect here means /signup/terms now mounts once before setIsSocial(true) finishes. useTermsAgreement only fetches once (hasFetchedRef), so that first non-social render prevents the later social path from loading getMyTerms() or auto-advancing. Existing social users can end up redoing agreements that the backend already marks as complete.
🤖 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/`(public)/signup/[step]/SignupStepPageClient.tsx around lines 55 -
57, The terms step is rendering before social mode is known, which causes
`useTermsAgreement` to fetch the wrong path and skip the later social flow.
Update `SignupStepPageClient` so `/signup/terms` is not mounted until
`setIsSocial(true)` has completed, using the existing step handling in
`SignupStepPageClient` and `useTermsAgreement` to gate rendering or redirect
away from `terms` until the social state is resolved.
| if (isFetching) { | ||
| return ( | ||
| <JoinLayout title="약관 동의"> | ||
| <div className="flex flex-col items-center w-full"> | ||
| {/* Detailed Box: Responsive width and padding */} | ||
| <div className="relative flex flex-col items-center justify-between w-[272px] t:w-[654px] h-[580px] t:h-[645px] max-h-[80vh] px-[12px] py-[30px] t:py-[80px] rounded-[8px] border border-[#BBAA9B] bg-white shadow-[2px_4px_4px_0_rgba(0,0,0,0.25)] transition-all"> | ||
| <button | ||
| onClick={() => setSelectedTermId(null)} | ||
| className="absolute right-[12px] top-[12px] w-[24px] h-[24px] flex items-center justify-center text-Gray-4 hover:text-Gray-6 transition-colors z-10" | ||
| > | ||
| <svg | ||
| width="24" | ||
| height="24" | ||
| viewBox="0 0 24 24" | ||
| fill="none" | ||
| stroke="currentColor" | ||
| strokeWidth="2" | ||
| strokeLinecap="round" | ||
| strokeLinejoin="round" | ||
| > | ||
| <line x1="18" y1="6" x2="6" y2="18"></line> | ||
| <line x1="6" y1="6" x2="18" y2="18"></line> | ||
| </svg> | ||
| </button> | ||
| <div className="flex flex-col w-full h-full overflow-hidden"> | ||
| <div className="flex-1 overflow-y-auto px-[8px] t:px-[64px] custom-scrollbar"> | ||
| <div className="flex flex-col w-full gap-6"> | ||
| {termData.map((term, i) => ( | ||
| <div key={i} className="flex flex-col gap-2"> | ||
| <h3 className="text-[16px] font-semibold text-Gray-7">{term.title}</h3> | ||
| {Array.isArray(term.content) ? ( | ||
| <ul className="flex list-disc flex-col gap-1 pl-5 text-[14px] text-Gray-5"> | ||
| {term.content.map((item, idx) => ( | ||
| <li key={idx}>{item}</li> | ||
| ))} | ||
| </ul> | ||
| ) : ( | ||
| <div className="flex flex-col gap-1 text-[14px] text-Gray-5"> | ||
| {term.content.split("\n").map((line, idx) => ( | ||
| <p key={idx}>{line.trim()}</p> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| <JoinButton | ||
| onClick={() => { | ||
| handleAgreementChange(selectedTermId, true); | ||
| setSelectedTermId(null); | ||
| }} | ||
| className="w-full t:w-[526px] h-[48px] px-[16px] py-[12px] mt-[20px] t:mt-[24px] shrink-0 font-sans text-[14px] font-semibold leading-[145%] tracking-[-0.014px]" | ||
| > | ||
| 동의 | ||
| </JoinButton> | ||
| </div> | ||
| <p className="text-Gray-5">약관을 불러오는 중입니다...</p> | ||
| </div> | ||
| </JoinLayout> | ||
| ); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Render a recoverable error state when terms loading fails.
useTermsAgreement clears isFetching even after getTerms() throws, so this screen falls through to an empty checklist with a permanently disabled “다음” button. A transient API error now dead-ends signup instead of offering retry/back navigation.
Also applies to: 60-123
🤖 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/components/base-ui/Join/steps/TermsAgreement/TermsAgreement.tsx` around
lines 31 - 39, `TermsAgreement` currently only handles the `isFetching` state,
so when `useTermsAgreement` finishes after a `getTerms()` failure the screen
renders an empty checklist with a disabled next button. Update `TermsAgreement`
and the `useTermsAgreement` flow to track a recoverable error state from
`getTerms()`, then render an error UI with retry and/or back navigation instead
of falling through. Make sure the loading branch, checklist rendering, and the
`handleNext`/button disabled logic all respect this new error state so signup
can recover from transient API failures.
| const TERMS_CONTENT_MAP: Record<string, { title: string; content: string | string[] }[]> = { | ||
| PRIVACY_COLLECTION: PRIVACY_DATA, | ||
| SERVICE_TERMS: TERMS_OF_USE_DATA, | ||
| THIRD_PARTY_PROVISION: THIRD_PARTY_DATA, | ||
| MARKETING: MARKETING_DATA, | ||
| }; |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Don’t key the consent copy only by termsType.
This modal ignores selectedTerm.version, so a backend revision can keep the same termsType while this screen still renders the old local constant. In that case the user is reading stale text but later consenting to the new term id/version, which breaks the consent trail.
Also applies to: 23-24
🤖 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/components/base-ui/Join/steps/TermsAgreement/TermsDetailModal.tsx` around
lines 9 - 14, The consent modal currently looks up copy only through
TERMS_CONTENT_MAP by termsType, so it can render stale text when a backend
revision changes selectedTerm.version. Update TermsDetailModal to resolve the
displayed content using both selectedTerm.termsType and selectedTerm.version,
and make sure the versioned backend content is what gets shown for the consent
flow rather than the local constant. Use the existing TermsDetailModal logic and
TERMS_CONTENT_MAP lookup as the place to wire in the version-aware selection.
| <button | ||
| onClick={onClose} | ||
| className="absolute right-[12px] top-[12px] w-[24px] h-[24px] flex items-center justify-center text-Gray-4 hover:text-Gray-6 transition-colors z-10" | ||
| > |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Add an accessible name to the close button.
The icon-only button is exposed as just “button” to screen readers. Please add an aria-label.
Proposed fix
<button
onClick={onClose}
+ aria-label="약관 상세 닫기"
className="absolute right-[12px] top-[12px] w-[24px] h-[24px] flex items-center justify-center text-Gray-4 hover:text-Gray-6 transition-colors z-10"
>📝 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.
| <button | |
| onClick={onClose} | |
| className="absolute right-[12px] top-[12px] w-[24px] h-[24px] flex items-center justify-center text-Gray-4 hover:text-Gray-6 transition-colors z-10" | |
| > | |
| <button | |
| onClick={onClose} | |
| aria-label="약관 상세 닫기" | |
| className="absolute right-[12px] top-[12px] w-[24px] h-[24px] flex items-center justify-center text-Gray-4 hover:text-Gray-6 transition-colors z-10" | |
| > |
🤖 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/components/base-ui/Join/steps/TermsAgreement/TermsDetailModal.tsx` around
lines 28 - 31, Add an accessible name to the icon-only close control in
TermsDetailModal by updating the button that uses onClick={onClose} so screen
readers can announce its purpose. Use an aria-label on that close button in the
TermsDetailModal component, keeping the existing visual icon and behavior
unchanged.
| getTerms: async (): Promise<TermsResponse> => { | ||
| const response = await apiClient.get<ApiResponse<TermsResponse>>( | ||
| MEMBER_ENDPOINTS.TERMS | ||
| ); | ||
| if (!response.isSuccess) { | ||
| throw new Error(response.message || "약관을 불러오는 데 실패했습니다."); | ||
| } | ||
| return response.result!; | ||
| }, | ||
| getMyTerms: async (): Promise<MemberTermsStatus> => { | ||
| const response = await apiClient.get<ApiResponse<MemberTermsStatus>>( | ||
| MEMBER_ENDPOINTS.MY_TERMS | ||
| ); | ||
| if (!response.isSuccess) { | ||
| throw new Error(response.message || "내 약관 동의 상태를 불러오는 데 실패했습니다."); | ||
| } | ||
| return response.result!; | ||
| }, |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Validate response.result before returning it.
ApiResponse.result is optional, but both methods force it with !. If the backend returns isSuccess: true without result, useTermsAgreement will crash when it destructures the response on the signup step.
Suggested fix
getTerms: async (): Promise<TermsResponse> => {
const response = await apiClient.get<ApiResponse<TermsResponse>>(
MEMBER_ENDPOINTS.TERMS
);
- if (!response.isSuccess) {
+ if (!response.isSuccess || !response.result) {
throw new Error(response.message || "약관을 불러오는 데 실패했습니다.");
}
- return response.result!;
+ return response.result;
},
getMyTerms: async (): Promise<MemberTermsStatus> => {
const response = await apiClient.get<ApiResponse<MemberTermsStatus>>(
MEMBER_ENDPOINTS.MY_TERMS
);
- if (!response.isSuccess) {
+ if (!response.isSuccess || !response.result) {
throw new Error(response.message || "내 약관 동의 상태를 불러오는 데 실패했습니다.");
}
- return response.result!;
+ return response.result;
},📝 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.
| getTerms: async (): Promise<TermsResponse> => { | |
| const response = await apiClient.get<ApiResponse<TermsResponse>>( | |
| MEMBER_ENDPOINTS.TERMS | |
| ); | |
| if (!response.isSuccess) { | |
| throw new Error(response.message || "약관을 불러오는 데 실패했습니다."); | |
| } | |
| return response.result!; | |
| }, | |
| getMyTerms: async (): Promise<MemberTermsStatus> => { | |
| const response = await apiClient.get<ApiResponse<MemberTermsStatus>>( | |
| MEMBER_ENDPOINTS.MY_TERMS | |
| ); | |
| if (!response.isSuccess) { | |
| throw new Error(response.message || "내 약관 동의 상태를 불러오는 데 실패했습니다."); | |
| } | |
| return response.result!; | |
| }, | |
| getTerms: async (): Promise<TermsResponse> => { | |
| const response = await apiClient.get<ApiResponse<TermsResponse>>( | |
| MEMBER_ENDPOINTS.TERMS | |
| ); | |
| if (!response.isSuccess || !response.result) { | |
| throw new Error(response.message || "약관을 불러오는 데 실패했습니다."); | |
| } | |
| return response.result; | |
| }, | |
| getMyTerms: async (): Promise<MemberTermsStatus> => { | |
| const response = await apiClient.get<ApiResponse<MemberTermsStatus>>( | |
| MEMBER_ENDPOINTS.MY_TERMS | |
| ); | |
| if (!response.isSuccess || !response.result) { | |
| throw new Error(response.message || "내 약관 동의 상태를 불러오는 데 실패했습니다."); | |
| } | |
| return response.result; | |
| }, |
🤖 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/services/memberService.ts` around lines 188 - 205, Both memberService
methods, getTerms and getMyTerms, assume ApiResponse.result is always present by
forcing it with non-null assertions, which can crash consumers like
useTermsAgreement if the backend returns success without data. Update these
methods to validate response.result after the isSuccess check and throw a clear
error when it is missing, instead of returning it directly; keep the existing
response.message fallback and ensure the returned value is only used when result
is actually defined.
📌 개요 (Summary)
🛠️ 변경 사항 (Changes)
📸 스크린샷 (Screenshots)
(UI 변경 사항이 있다면 첨부해주세요)
✅ 체크리스트 (Checklist)
pnpm build)pnpm lint)Summary by CodeRabbit
New Features
Bug Fixes