-
Notifications
You must be signed in to change notification settings - Fork 0
사용자 정보 수정 페이지 구현 #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
사용자 정보 수정 페이지 구현 #11
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Summary of ChangesHello @Dobbymin, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! 이 Pull Request는 사용자 프로필 설정 페이지를 전면적으로 개선하여, 사용자가 자신의 닉네임을 손쉽게 변경할 수 있도록 하는 데 중점을 둡니다. UI/UX 개선과 함께, 데이터 변경 사항이 페이지 새로고침 없이 즉시 반영되도록 Supabase 세션 관리 로직을 최적화하여 부드러운 사용자 경험을 제공합니다. 또한, 데이터 로딩 시 스켈레톤 UI를 도입하여 대기 시간을 시각적으로 완화합니다. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
이 PR은 사용자 정보 수정 페이지를 구현하고 닉네임 변경 기능을 추가하는 중요한 개선 사항을 담고 있습니다. 전반적으로 코드가 잘 구조화되어 있고, 스켈레톤 UI와 세션 새로고침 같은 UX 개선 노력이 돋보입니다. 몇 가지 추가적인 개선을 위해 유효성 검사 강화, 데이터 포맷팅 함수의 버그 수정, 그리고 닉네임 중복 검사와 같은 중요한 기능에 대한 제안을 포함한 리뷰 의견을 남겼습니다. 이러한 부분들을 반영하면 코드의 안정성과 사용자 경험이 더욱 향상될 것입니다. 수고하셨습니다!
| // 사용자 메타데이터 업데이트 | ||
| const { data, error } = await supabase.auth.updateUser({ | ||
| data: { | ||
| nickname, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| export const formattedDate = (dateString: string): string => { | ||
| const date = dateString ? new Date(dateString) : null; | ||
|
|
||
| const year = date ? date.getFullYear() : ""; | ||
| const month = date ? String(date.getMonth() + 1).padStart(2, "0") : ""; | ||
| const day = date ? String(date.getDate()).padStart(2, "0") : ""; | ||
|
|
||
| const Hour = date ? String(date.getHours()).padStart(2, "0") : ""; | ||
| const Minutes = date ? String(date.getMinutes()).padStart(2, "0") : ""; | ||
| const Seconds = date ? String(date.getSeconds()).padStart(2, "0") : ""; | ||
|
|
||
| return `${year}년 ${month}월 ${day}일 ${Hour}:${Minutes}:${Seconds}`; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
formattedDate 함수에 몇 가지 개선 사항을 제안합니다.
- 버그 수정:
dateString이 비어있을 때"년 월 일 : :"와 같은 잘못된 형식의 문자열이 반환됩니다. 유효하지 않은 입력에 대해서는"-"같은 대체 텍스트를 반환하도록 수정해야 합니다. - 유효성 검사 강화:
new Date(dateString)이Invalid Date를 반환하는 경우에 대한 처리를 추가하여 안정성을 높일 수 있습니다. - 네이밍 컨벤션: 변수명
Hour,Minutes,Seconds를 JavaScript/TypeScript 표준인 camelCase (hour,minutes,seconds)로 변경하여 코드 스타일의 일관성을 맞추는 것이 좋습니다.
| export const formattedDate = (dateString: string): string => { | |
| const date = dateString ? new Date(dateString) : null; | |
| const year = date ? date.getFullYear() : ""; | |
| const month = date ? String(date.getMonth() + 1).padStart(2, "0") : ""; | |
| const day = date ? String(date.getDate()).padStart(2, "0") : ""; | |
| const Hour = date ? String(date.getHours()).padStart(2, "0") : ""; | |
| const Minutes = date ? String(date.getMinutes()).padStart(2, "0") : ""; | |
| const Seconds = date ? String(date.getSeconds()).padStart(2, "0") : ""; | |
| return `${year}년 ${month}월 ${day}일 ${Hour}:${Minutes}:${Seconds}`; | |
| }; | |
| export const formattedDate = (dateString: string): string => { | |
| if (!dateString) { | |
| return "-"; | |
| } | |
| const date = new Date(dateString); | |
| if (isNaN(date.getTime())) { | |
| return "-"; | |
| } | |
| const year = date.getFullYear(); | |
| const month = String(date.getMonth() + 1).padStart(2, "0"); | |
| const day = String(date.getDate()).padStart(2, "0"); | |
| const hour = String(date.getHours()).padStart(2, "0"); | |
| const minutes = String(date.getMinutes()).padStart(2, "0"); | |
| const seconds = String(date.getSeconds()).padStart(2, "0"); | |
| return `${year}년 ${month}월 ${day}일 ${hour}:${minutes}:${seconds}`; | |
| }; |
| const { nickname } = body; | ||
|
|
||
| // 닉네임 검증 | ||
| if (!nickname || typeof nickname !== "string") { | ||
| return NextResponse.json({ status: "error", data: null, error: "닉네임을 입력해주세요." }, { status: 400 }); | ||
| } | ||
|
|
||
| if (nickname.length < 2 || nickname.length > 20) { | ||
| return NextResponse.json( | ||
| { status: "error", data: null, error: "닉네임은 2자 이상 20자 이하로 입력해주세요." }, | ||
| { status: 400 }, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
닉네임에 대한 유효성 검사를 강화하는 것을 제안합니다. 현재 코드는 앞뒤 공백이 포함된 닉네임을 허용할 수 있습니다. trim()을 사용하여 입력값의 양 끝에 있는 공백을 제거한 후 유효성을 검사하는 것이 좋습니다. 이렇게 하면 " test "와 같은 닉네임이 데이터베이스에 저장되는 것을 방지할 수 있습니다.
| const { nickname } = body; | |
| // 닉네임 검증 | |
| if (!nickname || typeof nickname !== "string") { | |
| return NextResponse.json({ status: "error", data: null, error: "닉네임을 입력해주세요." }, { status: 400 }); | |
| } | |
| if (nickname.length < 2 || nickname.length > 20) { | |
| return NextResponse.json( | |
| { status: "error", data: null, error: "닉네임은 2자 이상 20자 이하로 입력해주세요." }, | |
| { status: 400 }, | |
| ); | |
| } | |
| const { nickname: rawNickname } = body; | |
| const nickname = typeof rawNickname === "string" ? rawNickname.trim() : rawNickname; | |
| // 닉네임 검증 | |
| if (!nickname || typeof nickname !== "string") { | |
| return NextResponse.json({ status: "error", data: null, error: "닉네임을 입력해주세요." }, { status: 400 }); | |
| } | |
| if (nickname.length < 2 || nickname.length > 20) { | |
| return NextResponse.json( | |
| { status: "error", data: null, error: "닉네임은 2자 이상 20자 이하로 입력해주세요." }, | |
| { status: 400 }, | |
| ); | |
| } |
| }; | ||
|
|
||
| const { mutate: changeNickname, isPending } = useMutation({ | ||
| mutationFn: () => changeNicknameAPI({ nickname: inputValue }), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
API로 닉네임을 보내기 전에 inputValue의 양쪽 공백을 제거하는 것이 좋습니다. 이렇게 하면 불필요한 공백이 데이터베이스에 저장되는 것을 방지하고, onClickEditButton의 inputValue.trim() === userNickname 비교 로직과 일관성을 유지할 수 있습니다.
| mutationFn: () => changeNicknameAPI({ nickname: inputValue }), | |
| mutationFn: () => changeNicknameAPI({ nickname: inputValue.trim() }), |
| const onClickEditButton = () => { | ||
| if (fieldState === "edit") { | ||
| if (inputValue.trim() === userNickname) { | ||
| setFieldState("view"); | ||
| return; | ||
| } | ||
| changeNickname(); | ||
| } else { | ||
| setFieldState("edit"); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
사용자 경험을 개선하기 위해 닉네임 변경 API를 호출하기 전에 클라이언트 측에서 유효성 검사를 추가하는 것이 좋습니다. 현재는 서버에서만 유효성 검사를 수행하므로, 유효하지 않은 닉네임(예: 1글자)을 입력하고 저장 버튼을 누르면 API 호출 후에야 에러 메시지가 표시됩니다. onClickEditButton 함수에 닉네임 길이를 확인하는 로직을 추가하면 사용자에게 즉각적인 피드백을 줄 수 있습니다.
const onClickEditButton = () => {
if (fieldState === "edit") {
const trimmedNickname = inputValue.trim();
if (trimmedNickname === userNickname) {
setFieldState("view");
return;
}
if (trimmedNickname.length < 2 || trimmedNickname.length > 20) {
toast.error("닉네임은 2자 이상 20자 이하로 입력해주세요.");
return;
}
changeNickname();
} else {
setFieldState("edit");
}
};There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements a comprehensive user profile settings page with nickname editing functionality. It introduces a new settings UI with profile image display, user information fields (email, nickname, last login), and nickname change capability. The implementation includes API routes, client API wrappers, custom hooks, skeleton loading states, and session management optimization to ensure real-time UI updates without page refreshes.
- Renamed route path from
/settings(plural) to/setting(singular) with corresponding constant updates - Implemented complete nickname change feature with validation, API endpoint, and optimistic UI updates
- Added skeleton loading components to improve perceived performance during data fetching
- Enhanced Toaster configuration with rich colors and centered positioning
Reviewed changes
Copilot reviewed 25 out of 27 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| src/app/api/user/nickname/route.ts | New API endpoint for PATCH requests to update user nickname with validation (2-20 characters) |
| src/app/setting/page.tsx | Complete settings page implementation with ProfileSection and UserInfoSection components |
| src/features/setting/ | New feature module containing UI components, hooks, APIs, and utilities for settings functionality |
| src/shared/components/ui/skeleton.tsx | New reusable Skeleton component for loading states |
| src/shared/constants/router-path.ts | Updated route constant from SETTINGS to SETTING (plural to singular) |
| src/shared/hooks/useSelectChange.ts | Updated to use renamed SETTING route constant |
| src/shared/components/features/header/Header.tsx | Updated navigation link to use SETTING path and label |
| src/shared/provider/AppProvider.tsx | Enhanced Toaster with richColors and top-center positioning |
| .gitignore | Added GitHub Copilot instructions file to ignore list |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| const onClickEditButton = () => { | ||
| if (fieldState === "edit") { | ||
| if (inputValue.trim() === userNickname) { |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Inconsistent trimming logic. The comparison trims inputValue but compares it to untrimmed userNickname. If the original nickname has leading/trailing spaces, this comparison could behave unexpectedly. Consider trimming both values or ensuring the comparison matches the actual validation logic used in the API.
| if (inputValue.trim() === userNickname) { | |
| if (inputValue.trim() === userNickname.trim()) { |
|
|
||
| return ( | ||
| <div className='flex items-center gap-4'> | ||
| <Label className='text-text-primary w-20'>마지막 접속일</Label> |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Inconsistent label widths across form fields. The email and nickname fields use w-16 while the last login field uses w-20. Consider using consistent widths across all labels (e.g., w-20 for all) to maintain visual alignment.
| <Label className='text-text-primary w-20'>마지막 접속일</Label> | |
| <Label className='text-text-primary w-16'>마지막 접속일</Label> |
| <Button | ||
| size='icon' | ||
| variant='secondary' | ||
| className='hover:bg-surface-light absolute right-0 bottom-0 size-10 rounded-full shadow-lg' |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing accessibility attributes for profile picture upload button. The camera button should include an aria-label to describe its purpose for screen readers (e.g., aria-label="프로필 사진 업로드" or aria-label="Upload profile picture").
| className='hover:bg-surface-light absolute right-0 bottom-0 size-10 rounded-full shadow-lg' | |
| className='hover:bg-surface-light absolute right-0 bottom-0 size-10 rounded-full shadow-lg' | |
| aria-label="프로필 사진 업로드" |
| const error = await response.json(); | ||
| throw new Error(error.error || "닉네임 변경에 실패했습니다."); |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing error handling for JSON parsing. If the error response is not valid JSON, await response.json() on line 21 will throw an exception. Consider wrapping this in a try-catch block or handling cases where the response body is not JSON (e.g., network errors, server errors without JSON body).
| const error = await response.json(); | |
| throw new Error(error.error || "닉네임 변경에 실패했습니다."); | |
| let errorMessage = "닉네임 변경에 실패했습니다."; | |
| try { | |
| const error = await response.json(); | |
| if (error && error.error) { | |
| errorMessage = error.error; | |
| } | |
| } catch (e) { | |
| // response body is not valid JSON, keep default errorMessage | |
| } | |
| throw new Error(errorMessage); |
| /> | ||
| ) : ( | ||
| <div className='flex h-10 min-w-70 items-center rounded-md bg-background px-3 py-2'> | ||
| {!isLoaded ? <Skeleton className='h-5 w-32' /> : <p className='text-text-muted text-sm'>{userNickname}</p>} |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Hardcoded skeleton width may not match actual content width. The skeleton uses w-32 but the actual email/nickname length can vary significantly. Consider making the skeleton width match the expected content width or use a more flexible width (e.g., w-full or a percentage-based width) for better visual fidelity during loading.
| {!isLoaded ? <Skeleton className='h-5 w-32' /> : <p className='text-text-muted text-sm'>{userNickname}</p>} | |
| {!isLoaded ? <Skeleton className='h-5 w-full' /> : <p className='text-text-muted text-sm'>{userNickname}</p>} |
|
|
||
| return { | ||
| fieldState, | ||
| inputValue, | ||
| onChangeNickname, | ||
| onClickEditButton, |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Missing cancel functionality when in edit mode. When a user clicks the edit button and enters edit mode but wants to discard changes, there's no way to cancel and revert to the original value without saving. Consider adding a separate "취소" (Cancel) button or allowing the edit button to cancel when the value hasn't changed (the current check at line 45 only works if the value matches the original).
| return { | |
| fieldState, | |
| inputValue, | |
| onChangeNickname, | |
| onClickEditButton, | |
| const onCancelEdit = () => { | |
| setInputValue(userNickname); | |
| setFieldState("view"); | |
| }; | |
| return { | |
| fieldState, | |
| inputValue, | |
| onChangeNickname, | |
| onClickEditButton, | |
| onCancelEdit, |
| const onClickEditButton = () => { | ||
| if (fieldState === "edit") { | ||
| if (inputValue.trim() === userNickname) { | ||
| setFieldState("view"); | ||
| return; | ||
| } | ||
| changeNickname(); | ||
| } else { | ||
| setFieldState("edit"); | ||
| } | ||
| }; |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Missing client-side validation before API call. The nickname validation (length check, trim, etc.) is only done on the server side. Consider adding client-side validation in the onClickEditButton function to provide immediate feedback to users before making the API request. For example, check if inputValue.trim().length < 2 or > 20 and show an error toast.
| <Label className='text-md text-text-primary w-16'>닉네임</Label> | ||
| <div className='flex items-center gap-4'> | ||
| {fieldState === "edit" ? ( | ||
| <Input | ||
| className='min-w-70' | ||
| value={inputValue} | ||
| onChange={onChangeNickname} | ||
| disabled={isPending} | ||
| placeholder='닉네임을 입력하세요' | ||
| /> |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing proper label association for the nickname input field. The Label component on line 16 is not associated with the Input component on line 19 using the htmlFor attribute. Add an id prop to the Input and a corresponding htmlFor prop to the Label to properly associate them for accessibility (e.g., <Label htmlFor="nickname" ...> and <Input id="nickname" ...>).
| <Label className='text-md text-text-primary w-16'>닉네임</Label> | |
| <div className='flex items-center gap-4'> | |
| {fieldState === "edit" ? ( | |
| <Input | |
| className='min-w-70' | |
| value={inputValue} | |
| onChange={onChangeNickname} | |
| disabled={isPending} | |
| placeholder='닉네임을 입력하세요' | |
| /> | |
| <Label htmlFor="nickname" className='text-md text-text-primary w-16'>닉네임</Label> | |
| <div className='flex items-center gap-4'> | |
| {fieldState === "edit" ? ( | |
| <Input | |
| id="nickname" | |
| className='min-w-70' | |
| value={inputValue} | |
| onChange={onChangeNickname} | |
| disabled={isPending} | |
| placeholder='닉네임을 입력하세요' |
| const year = date ? date.getFullYear() : ""; | ||
| const month = date ? String(date.getMonth() + 1).padStart(2, "0") : ""; | ||
| const day = date ? String(date.getDate()).padStart(2, "0") : ""; | ||
|
|
||
| const Hour = date ? String(date.getHours()).padStart(2, "0") : ""; | ||
| const Minutes = date ? String(date.getMinutes()).padStart(2, "0") : ""; | ||
| const Seconds = date ? String(date.getSeconds()).padStart(2, "0") : ""; | ||
|
|
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Potential issue with formatted date output when dateString is empty. When an empty string is passed, the function returns "년 월 일 ::" which may not be user-friendly. Consider returning a more meaningful message like "정보 없음" or "N/A" when the date is invalid or empty.
| const year = date ? date.getFullYear() : ""; | |
| const month = date ? String(date.getMonth() + 1).padStart(2, "0") : ""; | |
| const day = date ? String(date.getDate()).padStart(2, "0") : ""; | |
| const Hour = date ? String(date.getHours()).padStart(2, "0") : ""; | |
| const Minutes = date ? String(date.getMinutes()).padStart(2, "0") : ""; | |
| const Seconds = date ? String(date.getSeconds()).padStart(2, "0") : ""; | |
| // Check for empty, null, or invalid date | |
| if (!dateString || !date || isNaN(date.getTime())) { | |
| return "정보 없음"; | |
| } | |
| const year = date.getFullYear(); | |
| const month = String(date.getMonth() + 1).padStart(2, "0"); | |
| const day = String(date.getDate()).padStart(2, "0"); | |
| const Hour = String(date.getHours()).padStart(2, "0"); | |
| const Minutes = String(date.getMinutes()).padStart(2, "0"); | |
| const Seconds = String(date.getSeconds()).padStart(2, "0"); |
| // 닉네임 검증 | ||
| if (!nickname || typeof nickname !== "string") { | ||
| return NextResponse.json({ status: "error", data: null, error: "닉네임을 입력해주세요." }, { status: 400 }); | ||
| } | ||
|
|
||
| if (nickname.length < 2 || nickname.length > 20) { | ||
| return NextResponse.json( | ||
| { status: "error", data: null, error: "닉네임은 2자 이상 20자 이하로 입력해주세요." }, | ||
| { status: 400 }, | ||
| ); | ||
| } |
Copilot
AI
Nov 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Consider adding input sanitization for the nickname. While length validation is present, consider trimming whitespace before validation and storage to prevent nicknames with only spaces or leading/trailing whitespace. Apply .trim() to the nickname before the validation checks on line 19.
📝 요약 (Summary)
사용자 프로필 설정 페이지를 개선하고, 닉네임 변경 기능을 구현했습니다. 더 나은 사용자 경험을 위해 스켈레톤 로딩 상태를 추가하고, 세션 관리 최적화를 통해 페이지 새로고침 없이 실시간으로 변경사항이 반영되도록 개선했습니다.
✅ 주요 변경 사항 (Key Changes)
💻 상세 구현 내용 (Implementation Details)
1. 닉네임 변경 API 구현
Backend API Route
주요 기능
PATCHsupabase.auth.getUser()로 로그인 상태 확인user_metadata업데이트APIResponse<T>형식으로 응답Client API Wrapper
useEffect동기화2. 세션 관리 최적화
문제 상황
닉네임 변경 후 Supabase에는 반영되지만 UI가 업데이트되지 않는 문제 발생
원인 분석
window.location.reload()는 사용자 경험이 좋지 않음해결 방법
AuthProvider 활용
refreshSession()패턴결과
🚀 트러블 슈팅 (Trouble Shooting)
1. 수정 버튼 클릭 시 Input 창이 비어있는 문제
문제
useUserInfo에서userNickname을undefined반환 가능하게 변경useChangeNickname의 초기inputValue가undefined로 설정됨해결
2. 닉네임 변경 후 UI가 업데이트되지 않는 문제
문제
refreshSession()사용-> 페이지 새로고침 없이 부드럽게 업데이트
분석
updateUser()만으로는 로컬 세션 캐시가 갱신되지 않음refreshSession()은 서버에서 최신 세션 데이터를 가져옴onAuthStateChange가 이를 감지하여 Zustand 스토어 업데이트useUserInfo가 Zustand를 구독하므로 자동 리렌더링구현되지 않은 기능
프로필 이미지 업로드
POST /api/user/avatar엔드포인트닉네임 취소 버튼
기술적 개선 사항
닉네임 중복 검사
낙관적 업데이트 (Optimistic Update)
에러 경계 (Error Boundary)
성능 고려사항
refreshSession()호출은 네트워크 요청을 수반하므로 필요한 경우에만 사용디자인 시스템
bg-surface-dark,text-text-primary등)📸 스크린샷 (Screenshots)
#️⃣ 관련 이슈 (Related Issues)