Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ export interface UpdateUserRequest {
newPassword?: string;
}

// 내 정보 조회 - GET /{teamId}/users/me
// 프로필 이미지 업로드 응답 타입
export interface UploadProfileImageResponse {
profileImageUrl: string;
}

// 내 정보 조회 - GET /users/me
export const getUsersMe = async () => {
return apiFetch<UserResponse>('/users/me');
};
Expand All @@ -29,3 +34,16 @@ export async function updateMe(body: UpdateUserRequest): Promise<UserResponse> {
body,
});
}

// 프로필 이미지 업로드
export async function uploadProfileImage(
file: File
): Promise<UploadProfileImageResponse> {
const formData = new FormData();
formData.append('image', file);

return apiFetch<UploadProfileImageResponse>('/users/me/image', {
method: 'POST',
body: formData,
});
}
29 changes: 13 additions & 16 deletions src/app/(common)/(mypage)/components/SideMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,24 @@
'use client';

import Image from 'next/image';
import { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';

import { useProfileImageContext } from '../mypage/context/ProfileImageContext';

import { useGetMyInfo } from '@/app/(common)/(mypage)/mypage/hooks/useUser';
import closeIcon from '@/assets/icons/sidemenu/ic-close.svg';
import EditableProfile from '@/components/ProfileEditable';
import SideMenuNav from '@/components/SideMenu/SideMenuNav';

export default function SideMenu({ onClose }: { onClose?: () => void }) {
const [profileImage, setProfileImage] = useState<string>();

const handleImageChange = (file: File) => {
const preview = URL.createObjectURL(file);
setProfileImage(preview);
};
const pathname = usePathname();
const { data: userData } = useGetMyInfo();
const { handleImageChange } = useProfileImageContext();

useEffect(() => {
return () => {
if (profileImage) {
URL.revokeObjectURL(profileImage);
}
};
}, [profileImage]);
const isMyInfoPage = pathname === '/mypage';

return (
<aside className="w-full flex-none rounded-lg bg-white px-3.5 py-4 md:w-45 md:border md:border-gray-100 md:py-6 md:shadow-[3px_3px_20px_3px_#eee] lg:w-72.5">
{/* 모바일 닫기 버튼 */}
{onClose && (
<button
type="button"
Expand All @@ -36,7 +29,11 @@ export default function SideMenu({ onClose }: { onClose?: () => void }) {
</button>
)}
<div className="flex flex-col items-center pb-4 md:pb-6">
<EditableProfile src={profileImage} onImageChange={handleImageChange} />
<EditableProfile
src={userData?.profileImageUrl}
onImageChange={handleImageChange}
editable={isMyInfoPage}
/>
</div>
<SideMenuNav onClose={onClose} />
</aside>
Expand Down
61 changes: 33 additions & 28 deletions src/app/(common)/(mypage)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { useState } from 'react';

import SideMenu from './components/SideMenu';
import { MenuProvider } from './context/MenuContext';
import { ProfileImageProvider } from './mypage/context/ProfileImageContext';

import { useProfileImage } from '@/app/(common)/(mypage)/mypage/hooks/useProfileImage';
import { useGetMyInfo } from '@/app/(common)/(mypage)/mypage/hooks/useUser';
import { useRequireAuth } from '@/hooks/useRequireAuth';
import { cn } from '@/util/cn';

Expand All @@ -13,45 +16,47 @@ export default function MyPageLayout({
}: {
children: React.ReactNode;
}) {
//접속권한 제한
useRequireAuth();
const [isOpen, setIsOpen] = useState(false);
const openMenu = () => setIsOpen(true);
const closeMenu = () => setIsOpen(false);

const { data: userData } = useGetMyInfo();
const profileImageHook = useProfileImage(userData?.profileImageUrl);

return (
<MenuProvider value={{ openMenu, closeMenu }}>
<div className="flex min-h-[calc(100vh-(48px+85px))] w-full justify-center md:min-h-[calc(100vh-(80px+145px))]">
<div className="mypage-layout flex w-full max-w-245 pt-7.5 pb-10 md:pt-10 md:pb-15">
{/* PC / 태블릿 */}
<div className="hidden md:block">
<SideMenu />
</div>
<ProfileImageProvider value={profileImageHook}>
<MenuProvider value={{ openMenu, closeMenu }}>
<div className="flex min-h-[calc(100vh-(48px+85px))] w-full justify-center md:min-h-[calc(100vh-(80px+145px))]">
<div className="mypage-layout flex w-full max-w-245 pt-7.5 pb-10 md:pt-10 md:pb-15">
<div className="hidden md:block">
<SideMenu />
</div>

{/* 모바일 */}
<div
className={cn(
'fixed inset-0 z-40 shrink-0 shadow-[5px_0_30px_#0000004a] transition-opacity duration-300 md:hidden',
isOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0'
)}>
{/* 오버레이 */}
<div className="absolute inset-0 bg-black/40" onClick={closeMenu} />

{/* 왼쪽 슬라이드 메뉴 */}
<div
className={cn(
'absolute top-0 left-0 flex h-full w-[280px] items-center bg-white transition-transform duration-300',
isOpen ? 'translate-x-0' : '-translate-x-full'
'fixed inset-0 z-40 shrink-0 shadow-[5px_0_30px_#0000004a] transition-opacity duration-300 md:hidden',
isOpen
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0'
)}>
<SideMenu onClose={closeMenu} />
<div
className="absolute inset-0 bg-black/40"
onClick={closeMenu}
/>
<div
className={cn(
'absolute top-0 left-0 flex h-full w-70 items-center bg-white transition-transform duration-300',
isOpen ? 'translate-x-0' : '-translate-x-full'
)}>
<SideMenu onClose={closeMenu} />
</div>
</div>
</div>

{/* 메인 컨텐츠 */}
<section className="flex-1 md:ml-12.5">{children}</section>
<section className="flex-1 md:ml-12.5">{children}</section>
</div>
</div>
</div>
</MenuProvider>
</MenuProvider>
</ProfileImageProvider>
);
}
39 changes: 39 additions & 0 deletions src/app/(common)/(mypage)/mypage/context/ProfileImageContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
'use client';

import { createContext, useContext, ReactNode } from 'react';

interface ProfileImageContextValue {
profileImage: File | null;
profileImagePreview: string | null;
handleImageChange: (file: File) => void;
resetImage: () => void;
updatePreview: (url: string) => void;
}

const ProfileImageContext = createContext<ProfileImageContextValue | null>(
null
);

export function ProfileImageProvider({
children,
value,
}: {
children: ReactNode;
value: ProfileImageContextValue;
}) {
return (
<ProfileImageContext.Provider value={value}>
{children}
</ProfileImageContext.Provider>
);
}

export function useProfileImageContext() {
const context = useContext(ProfileImageContext);
if (!context) {
throw new Error(
'useProfileImageContext must be used within ProfileImageProvider'
);
}
return context;
}
129 changes: 129 additions & 0 deletions src/app/(common)/(mypage)/mypage/hooks/useErrorHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { useRouter } from 'next/navigation';
import { useCallback } from 'react';

import { ApiError } from '@/config/client';

/**
* 에러 처리 Hook
* - API 에러 핸들링
* - 401 Unauthorized 시 로그인 페이지 리다이렉트
* - 사용자 친화적 에러 메시지 표시
*/
export function useErrorHandler() {
const router = useRouter();

/**
* 일반 에러 처리
*/
const handleError = useCallback(
(error: unknown, fallbackMessage = '오류가 발생했습니다.') => {
console.error('Error:', error);

if (error instanceof ApiError) {
// 401 Unauthorized - 로그인 페이지로 리다이렉트
if (error.status === 401) {
alert('로그인이 필요합니다.');
localStorage.removeItem('accessToken');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

security-high high

localStorageaccessToken을 저장하고 관리하는 것은 XSS(Cross-Site Scripting) 공격에 취약할 수 있습니다. localStorage는 JavaScript로 쉽게 접근할 수 있기 때문입니다.

보안 강화를 위해 accessToken은 변수와 같이 메모리에 저장하고, 리프레시 토큰은 httpOnly 쿠키에 저장하는 방식을 고려해 보세요. 이는 스크립트를 통한 토큰 탈취 위험을 크게 줄여줍니다.

router.push('/login');
return;
}

// 403 Forbidden
if (error.status === 403) {
alert('접근 권한이 없습니다.');
return;
}

// 404 Not Found
if (error.status === 404) {
alert('요청한 리소스를 찾을 수 없습니다.');
return;
}

// 409 Conflict
if (error.status === 409) {
alert(error.message || '이미 존재하는 데이터입니다.');
return;
}

// 422 Validation Error
if (error.status === 422) {
alert(error.message || '입력 데이터를 확인해주세요.');
return;
}

// 500 Internal Server Error
if (error.status >= 500) {
alert('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
return;
}

// 기타 API 에러
alert(error.message || fallbackMessage);
} else if (error instanceof Error) {
// 일반 JavaScript 에러
alert(error.message || fallbackMessage);
} else {
// 알 수 없는 에러
alert(fallbackMessage);
}
},
[router]
);

/**
* 이미지 업로드 에러 처리
*/
const handleImageUploadError = useCallback(
(error: unknown) => {
console.error('Image upload error:', error);

if (error instanceof ApiError) {
if (error.status === 413) {
alert('이미지 파일 크기가 너무 큽니다. (최대 4MB)');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

이곳에서는 파일 크기 제한을 4MB로 안내하고 있지만, ProfileEditable.tsx 컴포넌트의 클라이언트 측 유효성 검사에서는 5MB(MAX_SIZE)로 설정되어 있습니다. 사용자 경험의 일관성을 위해 두 값을 일치시키는 것이 좋습니다. ProfileEditable.tsxMAX_SIZE를 서버 제한에 맞춰 4 * 1024 * 1024로 수정하는 것을 고려해 보세요.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

프로필 편집 컴포넌트에 등록된 사이즈 제한과 통일하거나 연결해서 재사용하면 좋을거 같아요
얼랏은 토스트 팝업 사용하면 좋을거 같아요

return;
}

if (error.status === 415) {
alert('지원하지 않는 이미지 형식입니다. (JPG, PNG, GIF만 가능)');
return;
}
}

handleError(error, '이미지 업로드에 실패했습니다.');
},
[handleError]
);

/**
* 프로필 업데이트 에러 처리
*/
const handleProfileUpdateError = useCallback(
(error: unknown) => {
console.error('Profile update error:', error);

if (error instanceof ApiError) {
// 닉네임 중복
if (error.status === 409) {
alert('이미 사용 중인 닉네임입니다.');
return;
}

// 비밀번호 형식 오류
if (error.status === 400 && error.message?.includes('password')) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

에러 메시지 문자열(error.message?.includes('password'))에 의존하여 로직을 분기하는 방식은 불안정합니다. 백엔드 API의 에러 메시지가 변경되면 이 코드는 오작동할 수 있습니다.

대신 백엔드에서 에러 코드나 에러가 발생한 필드명을 포함한 구조화된 에러 응답을 보내주도록 협의하고, 이를 기반으로 분기하는 것이 더 안정적인 방법입니다. 예를 들어, error.body.field === 'password' 와 같이 확인할 수 있습니다.

References
  1. For robust error handling, use API error codes or dedicated field names for branching logic instead of relying on parsing error message strings.

alert('비밀번호는 8자 이상이어야 합니다.');
return;
}
}

handleError(error, '프로필 수정에 실패했습니다.');
},
[handleError]
);

return {
handleError,
handleImageUploadError,
handleProfileUpdateError,
};
}
Loading