Skip to content

Commit a2c53b7

Browse files
committed
fix: 회원가입 프로필 이미지 업로드 확정 처리
1 parent 2f1c86c commit a2c53b7

5 files changed

Lines changed: 108 additions & 16 deletions

File tree

src/app/auth/signup/profile/page.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ import { useAuthSignupStore } from "@/stores/authSignupStore";
1616

1717
export default function ProfilePage() {
1818
const router = useRouter();
19-
const { nickname, bio, setNickname, setBio, setProfileImage } = useAuthSignupStore();
19+
const { nickname, bio, profileImage, setNickname, setBio, setProfileImage } =
20+
useAuthSignupStore();
2021

21-
const [errors, setErrors] = useState<{ nickname?: string; bio?: string }>({});
22+
const [errors, setErrors] = useState<{ nickname?: string; bio?: string; profileImage?: string }>(
23+
{}
24+
);
2225
const nicknameCheckMutation = useMutation({
2326
mutationFn: checkNickname,
2427
onSuccess: (data, checkedNickname) => {
@@ -86,6 +89,10 @@ export default function ProfilePage() {
8689
}
8790
};
8891

92+
const handleProfileImageErrorChange = (message: string | null) => {
93+
setErrors(prev => ({ ...prev, profileImage: message ?? undefined }));
94+
};
95+
8996
return (
9097
<AuthLayout
9198
title="프로필 설정하기"
@@ -95,7 +102,12 @@ export default function ProfilePage() {
95102
onBack={handleBack}
96103
>
97104
<div className="space-y-3">
98-
<ProfileAvatarInput onImageChange={setProfileImage} />
105+
<ProfileAvatarInput
106+
file={profileImage}
107+
error={errors.profileImage}
108+
onImageChange={setProfileImage}
109+
onErrorChange={handleProfileImageErrorChange}
110+
/>
99111

100112
<AuthTextField
101113
label="닉네임 (최대 10자)"

src/app/auth/signup/role/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import AuthButton from "@/components/auth/AuthButton";
99
import AuthLayout from "@/components/auth/AuthLayout";
1010
import RoleSelect from "@/components/auth/RoleSelect";
1111
import { ApiError } from "@/services/apiClient";
12-
import { issueImageUploadUrl, uploadImageToStorage } from "@/services/imageApi";
12+
import { confirmImageUpload, issueImageUploadUrl, uploadImageToStorage } from "@/services/imageApi";
1313
import { completeWelcome } from "@/services/userApi";
1414
import { useAuthSignupStore } from "@/stores/authSignupStore";
1515

@@ -38,6 +38,8 @@ export default function RolePage() {
3838
throw new Error("프로필 이미지 업로드 중 오류가 발생했습니다.");
3939
}
4040

41+
await confirmImageUpload(uploadInfo.imageId);
42+
4143
profileImageId = uploadInfo.imageId;
4244
}
4345

src/app/mypage/settings/profile/page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { PROFILE_VALIDATION_MESSAGES } from "@/constants/profile";
1313
import { useRequireAuth } from "@/hooks/useRequireAuth";
1414
import { ApiError } from "@/services/apiClient";
1515
import { getMe } from "@/services/authApi";
16-
import { issueImageUploadUrl, uploadImageToStorage } from "@/services/imageApi";
16+
import { confirmImageUpload, issueImageUploadUrl, uploadImageToStorage } from "@/services/imageApi";
1717
import {
1818
checkNickname,
1919
getNicknamePolicy,
@@ -128,7 +128,9 @@ export default function MyPageProfileSettingsPage() {
128128
throw new Error("프로필 이미지 업로드 중 오류가 발생했습니다.");
129129
}
130130

131-
profileBody.profileImageUrl = uploadInfo.imageUrl;
131+
const confirmedImage = await confirmImageUpload(uploadInfo.imageId);
132+
133+
profileBody.profileImageUrl = confirmedImage.imageUrl;
132134
}
133135

134136
return updateProfile(profileBody);

src/components/auth/ProfileAvatarInput.tsx

Lines changed: 75 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,79 @@
1-
import React, { useState } from "react";
1+
import { useEffect, useRef, useState, type ChangeEvent } from "react";
22

33
import Image from "next/image";
44

5+
const ALLOWED_PROFILE_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
6+
const MAX_PROFILE_IMAGE_SIZE = 10 * 1024 * 1024;
7+
58
interface ProfileAvatarInputProps {
9+
file?: File | null;
10+
error?: string;
611
onImageChange?: (file: File | null) => void;
12+
onErrorChange?: (message: string | null) => void;
13+
}
14+
15+
function validateProfileImage(file: File) {
16+
if (!ALLOWED_PROFILE_IMAGE_TYPES.has(file.type)) {
17+
return "프로필 이미지는 JPG, PNG, WEBP 형식만 업로드할 수 있습니다.";
18+
}
19+
20+
if (file.size > MAX_PROFILE_IMAGE_SIZE) {
21+
return "프로필 이미지는 10MB 이하만 업로드할 수 있습니다.";
22+
}
23+
24+
return null;
725
}
826

9-
export default function ProfileAvatarInput({ onImageChange }: ProfileAvatarInputProps) {
10-
const [imageUrl, setImageUrl] = useState<string | null>(null);
27+
export default function ProfileAvatarInput({
28+
file = null,
29+
error,
30+
onImageChange,
31+
onErrorChange,
32+
}: ProfileAvatarInputProps) {
33+
const [imageUrl, setImageUrl] = useState<string | null>(() =>
34+
file ? URL.createObjectURL(file) : null
35+
);
36+
const imageUrlRef = useRef(imageUrl);
37+
38+
const replaceImageUrl = (nextImageUrl: string | null) => {
39+
if (imageUrlRef.current) {
40+
URL.revokeObjectURL(imageUrlRef.current);
41+
}
42+
43+
imageUrlRef.current = nextImageUrl;
44+
setImageUrl(nextImageUrl);
45+
};
46+
47+
useEffect(() => {
48+
return () => {
49+
if (imageUrlRef.current) {
50+
URL.revokeObjectURL(imageUrlRef.current);
51+
}
52+
};
53+
}, []);
1154

12-
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
55+
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
1356
const file = e.target.files?.[0];
14-
if (file) {
15-
const url = URL.createObjectURL(file);
16-
setImageUrl(url);
17-
onImageChange?.(file);
57+
e.target.value = "";
58+
59+
if (!file) return;
60+
61+
const validationMessage = validateProfileImage(file);
62+
if (validationMessage) {
63+
replaceImageUrl(null);
64+
onImageChange?.(null);
65+
onErrorChange?.(validationMessage);
66+
return;
1867
}
68+
69+
replaceImageUrl(URL.createObjectURL(file));
70+
onErrorChange?.(null);
71+
onImageChange?.(file);
1972
};
2073

2174
const handleRemove = () => {
22-
setImageUrl(null);
75+
replaceImageUrl(null);
76+
onErrorChange?.(null);
2377
onImageChange?.(null);
2478
};
2579

@@ -42,7 +96,12 @@ export default function ProfileAvatarInput({ onImageChange }: ProfileAvatarInput
4296
</div>
4397

4498
<label className="absolute inset-0 cursor-pointer" aria-label="프로필 이미지 변경">
45-
<input type="file" accept="image/*" onChange={handleFileChange} className="hidden" />
99+
<input
100+
type="file"
101+
accept="image/jpeg,image/png,image/webp"
102+
onChange={handleFileChange}
103+
className="hidden"
104+
/>
46105
</label>
47106
</div>
48107

@@ -55,6 +114,12 @@ export default function ProfileAvatarInput({ onImageChange }: ProfileAvatarInput
55114
이미지 제거
56115
</button>
57116
)}
117+
118+
{error && (
119+
<p className="text-caption font-regular text-error-default text-center" role="alert">
120+
{error}
121+
</p>
122+
)}
58123
</div>
59124
);
60125
}

src/services/imageApi.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ export interface ImageUploadUrlResponse {
1414
expiresInSeconds: number;
1515
}
1616

17+
export interface ImageConfirmResponse {
18+
imageId: number;
19+
status: "UPLOADED";
20+
fileKey: string;
21+
imageUrl: string;
22+
uploadedAt: string;
23+
}
24+
1725
export const issueImageUploadUrl = (body: ImageUploadUrlRequest) =>
1826
apiClient.post<ImageUploadUrlResponse>("/api/v1/images/upload-url", body);
1927

@@ -25,3 +33,6 @@ export const uploadImageToStorage = (uploadUrl: string, file: File) =>
2533
},
2634
body: file,
2735
});
36+
37+
export const confirmImageUpload = (imageId: number) =>
38+
apiClient.post<ImageConfirmResponse>(`/api/v1/images/${imageId}/confirm`);

0 commit comments

Comments
 (0)