현재 진행 중인 프로젝트에서 사용자가 이름, 성별, 나이 등의 정보 외에 '프로필 이미지'를 설정할 수 있도록 하고 싶다.
(프론트엔드는 React를, 백엔드는 Node.js의 Koa를 사용하고 있음)
사용자가 이미지 파일을 업로드하면 로컬 경로에 저장한 다음, 해당 경로를 사용자의 프로필 이미지 경로로 설정하도록 하면 될 것이다.
사용자 스키마에 업로드된 프로필 이미지의 경로를 가리키는 필드를 추가한다.
const UserSchema: Schema<IUserDocument> = new Schema({
username: { type: String, required: true },
hashedPassword: { type: String, default: '' },
profileImage: { type: String, default: '' },
...
});
사용자가 업로드한 이미지 파일을 저장할 경로를 설정한다. 현재 디렉토리에서 uploads 폴더를 사용할 것이다.
import fs from 'fs';
try {
fs.readdirSync('uploads');
} catch (error) {
console.error('uploads folder created.');
fs.mkdirSync('uploads');
}
form-data 형태로 전송된 파일을 처리할 수 있는 패키지인 @koa/multer
를 설치하고 미들웨어를 작성한다. (koa-multer
는 deprecated 됨.)
export const upload = multer({
storage: multer.diskStorage({
destination(req, file, cb) {
cb(null, 'uploads/');
},
filename(req, file, cb) {
const ext = path.extname(file.originalname);
cb(null, path.basename(file.originalname, ext) + Date.now() + ext);
},
}),
limits: { fileSize: 3 * 1024 * 1024 },
});
destination은 전송된 파일을 저장할 경로를, filename은 전송된 파일의 이름을 어떻게 바꿀지를 정의한다. limits는 파일의 최대 크기 등을 제한할 수 있다.
업로드된 파일의 경로가 주어지면 사용자의 profileImage
필드에 저장하는 역할을 하는 함수이다.
export const setProfileImageSrc = async (ctx: DefaultContext) => {
const inputSchema = Joi.object().keys({
username: Joi.string().alphanum().min(5).max(20).required(),
src: Joi.string().allow(''),
});
const result = inputSchema.validate(ctx.request.body);
if (result.error) {
ctx.status = 400;
ctx.body = result.error;
return;
}
const { username, src } = ctx.request.body;
try {
const user = await User.findByUsername(username);
if (!user) {
ctx.status = 401;
return;
}
user.profileImage = src;
await user.save();
ctx.status = 200;
} catch (e) {
ctx.throw(500, e as Error);
}
};
위 3, 4에서 작성한 함수를 프론트엔드가 요청할 수 있도록 적절히 라우팅한다.
user.post('/image', userCtrl.upload.single('image'), (ctx) => {
ctx.body = { url: `/img/${ctx.request.file.filename}` };
});
user.post('/curimage', userCtrl.setProfileImageSrc);
multer 미들웨어가 이미지를 폴더에 저장한 후 그 경로를 다시 클라이언트에게 응답하도록 했다. 이후 클라이언트가 사용자와 이미지 경로를 보내주면 그것을 해당 사용자의 프로필 이미지 경로로 설정하도록 한다.
클라이언트의 정적 파일 요청에 가상 경로를 이용하여 응답하도록 한다. /img 경로로 요청이 들어오면 실제로는 /uploads 경로로 바뀌어 처리된다. (실제 경로를 숨겨 보안성을 높임)
import serve from 'koa-static';
import mount from 'koa-mount';
...
app.use(mount('/img', serve('uploads')));
...
백엔드의 스키마와 일치하도록 사용자 스키마를 수정한다.
export type User = {
username: string;
nickname: string;
profileImage?: string;
...
};
백엔드에서 정의한 라우팅 경로에 맞게 적절한 API 함수를 작성한다.
export const uploadProfileImage = (image: FormData) =>
client.post('/user/image', image);
export const setProfileImageSrc = (username: string, src: string) =>
client.post('/user/curimage', { username, src });
이 프로젝트는 redux-toolkit
을 사용 중이고 비동기 액션을 createAsyncThunk
으로 처리하고 있다.
export const setProfileImage = createAsyncThunk(
'SET_PROFILE_IMAGE',
async ({ username, image }: { username: string; image: FormData }) => {
const response = await api.uploadProfileImage(image);
await api.setProfileImageSrc(username, response.data.url);
return response.data.url;
},
);
...
export const userSlice = createSlice({
name: 'users',
initialState,
reducers: {
...
},
extraReducers: (builder) => {
...
builder
.addCase(setProfileImage.pending, (state) => {
state.loading = true;
})
.addCase(setProfileImage.fulfilled, (state, action) => {
state.loading = false;
state.error = null;
state.user.profileImage = action.payload;
})
.addCase(setProfileImage.rejected, (state, action) => {
state.loading = false;
state.error = action.error;
});
...
},
})
Thunk에서 이미지 업로드 API로 요청을 보내면 응답으로 업로드한 이미지의 경로를 받아오고, 이 경로를 사용자 아이디와 함께 다시 프로필 설정 API로 요청을 보낸다.
모든 요청이 성공하면 후속 처리로 Redux의 사용자 상태 또한 바꿔준다. (백엔드와 프론트엔드의 데이터 일치)
이렇게 작성한 액션을 실제로 dispatch할 컴포넌트 내부 함수를 작성하고 이미지를 보여줄/요청을 보낼 HTML 요소도 만들어준다.
const onSubmitImage = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault();
if (!e.target.files) return;
const formData = new FormData();
formData.append('image', e.target.files[0]);
dispatch(setProfileImage({ username: user.username, image: formData }));
};
return (
<>
<ProfileBlock>
<ImageBlock htmlFor="profileImage">
<input
id="profileImage"
type="file"
accept="image/*"
onChange={(e) => onSubmitImage(e)}
/>
{user.profileImage ? (
<img src={user.profileImage} alt="profile_image" />
) : (
<b>사진 등록</b>
)}
</ImageBlock>
...
);
};
따로 submit 버튼 없이 이미지를 업로드하면(onChange) 즉시 파일 업로드/사진 변경을 요청하도록 했다.
파일 크기 초과 알림, 이미지 압축, DB 최적화 등 조금 더 응용할 수 있는 부분이 남아있다. 차근차근 시도해 봐야겠다.
참조