Skip to content

Commit 561d26d

Browse files
authored
feat(IN-316): 마이페이지 프로필 변경 기능 추가
/me/profie 페이지에서 카메라 버튼 클릭 시 파일 선택 → S3 직접 업로드 → 백엔드 objectKey 전달 순서로 프로필 이미지 변경 기능 구현 Presigned URL 발급 → S3 PUT → 백엔드 PUT 3단계 업로드 플로우 구성
2 parents 74614bb + ee8b97a commit 561d26d

7 files changed

Lines changed: 157 additions & 2 deletions

File tree

next.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const nextConfig: NextConfig = {
66
{ protocol: 'https', hostname: 'i.ytimg.com' },
77
{ protocol: 'https', hostname: 'yt3.ggpht.com' },
88
{ protocol: 'https', hostname: 'lh3.googleusercontent.com' },
9+
{ protocol: 'https', hostname: '*.s3.ap-northeast-2.amazonaws.com' },
910
],
1011
},
1112
/* Cross-Origin-Opener-Policy로 인해 팝업이 닫히지 않는 문제 해결 */

src/features/me/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export {
77
export type { WithdrawalReason } from './model/withdrawalReason'
88
export { useMyProfile } from './profile/model/useMyProfile'
99
export { useEditPreferencesModal } from './profile/model/useEditPreferencesModal'
10+
export { useEditProfileImage } from './profile/model/useEditProfileImage'
1011
export { EditPreferencesModal } from './profile/ui/EditPreferencesModal'
1112
export type {
1213
MyProfileDto,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { ApiResponse } from '@/shared/api/types'
2+
import { axiosInstance } from '@/shared/api'
3+
import type {
4+
ProfileImageUploadUrlRequest,
5+
ProfileImageUploadUrlResponse,
6+
ProfileImageUpdateRequest,
7+
MyProfileDto,
8+
} from '../types'
9+
10+
export async function postProfileImageUploadUrl(
11+
payload: ProfileImageUploadUrlRequest,
12+
): Promise<ProfileImageUploadUrlResponse> {
13+
const response = await axiosInstance.post<
14+
ApiResponse<ProfileImageUploadUrlResponse>
15+
>('/user/profile-image/upload-url', payload)
16+
return response.data.responseDto
17+
}
18+
19+
export async function uploadFileToS3(
20+
uploadUrl: string,
21+
file: File,
22+
): Promise<void> {
23+
const controller = new AbortController()
24+
const timeout = setTimeout(() => controller.abort(), 30000)
25+
try {
26+
const response = await fetch(uploadUrl, {
27+
method: 'PUT',
28+
body: file,
29+
headers: {
30+
'Content-Type': file.type,
31+
'Cache-Control': 'public, max-age=31536000, immutable',
32+
},
33+
signal: controller.signal,
34+
})
35+
if (!response.ok) {
36+
throw new Error(`S3 업로드 실패: ${response.status}`)
37+
}
38+
} catch (error) {
39+
if (error instanceof DOMException && error.name === 'AbortError') {
40+
throw new Error('S3 업로드 타임아웃: 30초 초과')
41+
}
42+
throw error
43+
} finally {
44+
clearTimeout(timeout)
45+
}
46+
}
47+
48+
export async function putProfileImage(
49+
payload: ProfileImageUpdateRequest,
50+
): Promise<MyProfileDto> {
51+
const response = await axiosInstance.put<ApiResponse<MyProfileDto>>(
52+
'/user/profile-image',
53+
payload,
54+
)
55+
return response.data.responseDto
56+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useMutation, useQueryClient } from '@tanstack/react-query'
2+
import {
3+
postProfileImageUploadUrl,
4+
uploadFileToS3,
5+
putProfileImage,
6+
} from '../api/profileImageApi'
7+
8+
export function useEditProfileImage() {
9+
const queryClient = useQueryClient()
10+
11+
return useMutation({
12+
mutationFn: async (file: File) => {
13+
const { uploadUrl, objectKey } = await postProfileImageUploadUrl({
14+
contentType: file.type,
15+
fileSize: file.size,
16+
})
17+
18+
await uploadFileToS3(uploadUrl, file)
19+
20+
return putProfileImage({ objectKey })
21+
},
22+
onSuccess: (updatedProfile) => {
23+
queryClient.setQueryData(['myProfile'], updatedProfile)
24+
},
25+
})
26+
}

src/features/me/profile/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,19 @@ export interface MyProfileDto {
1818
account: MyProfileAccountDto
1919
preferences: MyProfilePreferencesDto
2020
}
21+
22+
export interface ProfileImageUploadUrlRequest {
23+
contentType: string
24+
fileSize: number
25+
}
26+
27+
export interface ProfileImageUploadUrlResponse {
28+
uploadUrl: string
29+
objectKey: string
30+
publicUrl: string
31+
expiresInSeconds: number
32+
}
33+
34+
export interface ProfileImageUpdateRequest {
35+
objectKey: string
36+
}

src/shared/api/msw/handlers/myProfileHandlers.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,35 @@ export const myProfileHandlers = [
3131
error: null,
3232
})
3333
}),
34+
35+
http.post(`${process.env.NEXT_PUBLIC_API_URL}/user/profile-image/upload-url`, () => {
36+
return HttpResponse.json({
37+
success: true,
38+
responseDto: {
39+
uploadUrl: 'https://mock-s3.example.com/upload?signature=mock',
40+
objectKey: 'profiles/mock-object-key.jpg',
41+
publicUrl: 'https://mock-s3.example.com/profiles/mock-object-key.jpg',
42+
expiresInSeconds: 300,
43+
},
44+
error: null,
45+
})
46+
}),
47+
48+
http.put(`${process.env.NEXT_PUBLIC_API_URL}/user/profile-image`, async ({ request }) => {
49+
const body = await request.json() as { objectKey: string }
50+
51+
currentProfile = {
52+
...currentProfile,
53+
account: {
54+
...currentProfile.account,
55+
profileImageUrl: `https://mock-s3.example.com/${body.objectKey}`,
56+
},
57+
}
58+
59+
return HttpResponse.json({
60+
success: true,
61+
responseDto: currentProfile,
62+
error: null,
63+
})
64+
}),
3465
]

src/widgets/me/personalInfo/ui/PersonalInfoSection.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,30 @@
11
'use client'
22

3+
import { useRef } from 'react'
34
import Image from 'next/image'
45
import { useRouter } from 'next/navigation'
56
import CameraIcon from '@/shared/assets/camera-bold.svg'
67
import FaviconIcon from '@/shared/assets/favicon.svg'
7-
import { useMyProfile } from '@/features/me'
8+
import { useMyProfile, useEditProfileImage } from '@/features/me'
89
import { formatDate } from '@/shared/lib/format'
910

1011
export function PersonalInfoSection() {
1112
const router = useRouter()
1213
const { data } = useMyProfile()
1314
const account = data?.account
15+
const { mutate: editProfileImage, isPending } = useEditProfileImage()
16+
const fileInputRef = useRef<HTMLInputElement>(null)
17+
18+
function handleCameraClick() {
19+
fileInputRef.current?.click()
20+
}
21+
22+
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
23+
const file = e.target.files?.[0]
24+
if (!file) return
25+
editProfileImage(file)
26+
e.target.value = ''
27+
}
1428

1529
return (
1630
<div className='flex h-fit w-full flex-col gap-24 rounded-16 bg-white p-32 shadow-[0px_2px_6px_0px_#0D0D0D0A]'>
@@ -37,11 +51,21 @@ export function PersonalInfoSection() {
3751
</div>
3852
)}
3953
</div>
54+
{/* 파일 인풋 (숨김) */}
55+
<input
56+
ref={fileInputRef}
57+
type='file'
58+
accept='image/*'
59+
className='hidden'
60+
onChange={handleFileChange}
61+
/>
4062
{/* 프로필 변경 버튼 */}
4163
<button
4264
type='button'
4365
aria-label='프로필 사진 변경'
44-
className='absolute top-[6.2rem] left-[6.2rem] flex size-[2.8rem] cursor-pointer items-center justify-center rounded-full border border-[#E6E6E6] bg-background-gray-default'>
66+
disabled={isPending}
67+
onClick={handleCameraClick}
68+
className='absolute top-[6.2rem] left-[6.2rem] flex size-[2.8rem] cursor-pointer items-center justify-center rounded-full border border-[#E6E6E6] bg-background-gray-default disabled:cursor-not-allowed disabled:opacity-50'>
4569
<CameraIcon className='size-[2rem]' />
4670
</button>
4771
</div>

0 commit comments

Comments
 (0)