이번에는 저번 글에 이어 이미지 업로드에서 발생했던 문제점에 대한 해결 방안에 대해 한 번 알아보겠습니다!
먼저 저번 시간 문제점에 대해 다시 한번 살펴보자면,
사용자가 글 작성 도중 페이지를 나가거나 이미지를 삭제해도 여전히 서버에 이미지가 남는다는 문제점이 있었습니다.
이 문제점을 해결하기 위해 흔히 많이 사용하는 방법으로
- 임시 폴더(Working Directory)를 생성해둔다.
- 이미지를 임시 폴더에 저장해두다가 게시글이 올라가지면 임시 폴더에 있던 파일들을 영구 폴더로 옮긴다.
- 임시 폴더는 삭제한다.
이 같은 방법이 있습니다.
현재 프로젝트에 위 프로세스를 적용하면 다음과 같습니다.
- 이미지를 업로드하면 서버에서 이미지 파일을 받아 temp 폴더 안에 사용자 ID 폴더를 생성하여 그 안에 임시로 저장합니다.
- temp 폴더 안에 사용자 ID 폴더로 구분시키는 이유는 여러 사용자가 글을 작성하고 있는 상황이 있을 수 있기 때문입니다. 즉, 사용자 ID가 Key 역할을 하는 셈입니다.
<img>
태그의 src에는 temp폴더의 이미지 파일을 가리키도록 합니다.
- 만약 사용자가 해당 페이지를 벗어난다면(React 생명 주기가 Unmount) 이벤트를 감지하여 temp 폴더내의 해당 사용자 ID 폴더를 삭제합니다.
- 사용자가 글을 제출하면 서버에서 글 내용안에
<img>
태그 내의 src를 추출하여 해당 주소에 있는 파일들을 영구 폴더/[postID] 디렉토리로 옮깁니다. 그리고 글 내용 안의 src은 영구 폴더를 가리키도록 변경한 뒤, temp 내의 파일들은 삭제시킵니다.
위 프로세스에 따라 수정 작업을 진행해보겠습니다.
먼저 Express 서버에서 파일이 업로드될 경로부터 다음과 같이 수정합니다.
- 기존 경로 : __dirname/images
- 수정 경로 : __dirname/images/temp/${사용자ID}
const fs = require("fs-extra"); // (1)
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const path = `./images/temp/${req.params.userId}`; // (2)
!fs.existsSync(path) && fs.mkdirSync(path, (err) => { // (3)
if(err) cb(err);
});
cb(null, `images/temp/${req.params.userId}`);
},
filename: (req, file, cb) => {
cb(null, `${uuid()}.${mime.extension(file.mimetype)}`);
}
});
(1) fs-extra
: 폴더 내의 파일들을 쉽게 다룰 수 있게 도와주는 라이브러리입니다. 기존 파일 시스템을 다룰 때 흔히 사용되던 fs
라이브러리 상위 버전이라 생각하시면 될 것 같습니다.
(2) temp 임시 폴더 내에 사용자 ID 폴더를 생성하기 위해 클라이언트로부터 사용자ID를 param으로 받아옵니다. 사용자 ID는 굉장히 많이 활용되므로 저같은 경우 Redux를 사용하여 전역 변수로 사용자 ID 값을 관리하고있어 쉽게 받아올 수 있었습니다.
(3) 이전 글에서 언급했듯이 파일 업로드 목적지를 지정해주기 전에 반드시 해당 디렉토리가 먼저 생성되있어야 합니다. 만약 사용자 ID 폴더가 존재하지 않는다면 폴더를 먼저 생성해주도록 합니다.
- mkdirSync는 동기적으로 폴더를 생성합니다.
- 만약 상위 폴더까지 한꺼번에 생성하고 싶다면 두 번째 인자로 recursive:true 옵션을 주시면 됩니다.
클라이언트 쪽으로 와서 업로드 어댑터 내 API 요청 부분과 <img>
태그 내의 소스 부분을 수정해줍니다.
const imgLink = "http://localhost:5000/images"
const customUploadAdapter = (loader) => {
return {
upload(){
return new Promise ((resolve, reject) => {
const data = new FormData();
loader.file.then( (file) => {
data.append("name", file.name);
data.append("file", file);
axios.post(`/api/upload/${userId}`, data) // 수정 (1)
.then((res) => {
if(!flag){
setFlag(true);
setImage(res.data.filename);
}
resolve({
// 수정 (2)
default: `${imgLink}/temp/${userId}/${res.data.filename}`
});
})
.catch((err)=>reject(err));
})
})
}
}
}
이제 순서 (1), (2) 과정이 끝마쳤고, (3) 과정을 진행해보겠습니다.
사용자가 글 작성 도중 페이지를 나가거나, 브라우저를 닫으면 temp 폴더에 있던 이미지 파일들도 삭제되도록 해야합니다. 이 과정에 대해 2가지 방법 정도 고민해보았습니다.
- 라우팅을 담당하는 컴포넌트(App.js)에서
UseLocation
훅을 통해 현재 URL을 담는 상태 값을 선언합니다. 그리고 해당 URL 값을 글 작성 컴포넌트로 넘겨받아UseEffect
훅에서 URL 변경이 감지될 때마다 temp 폴더 삭제 요청을 전송합니다.
- 글 작성 컴포넌트 생명주기가 Unmount(사라질 때)될 때 temp 폴더 삭제 요청을 전송합니다.
저는 좀 더 리액트스러운(?) 방법 2로 택하였습니다.
글 작성 컴포넌트(Editor.jsx 상위 컴포넌트)에 다음 코드를 추가해줍니다. (Unmount 시 수행할 작업)
useEffect(() => {
return async () => {
await axios.delete(`/api/upload/${userId}`)
}
}, [])
다시 서버로 돌아와서 임시 폴더 삭제 API를 추가해줍니다.
app.delete("/api/upload/:userId", (req, res) => {
const path = `./images/temp/${req.params.userId}`;
try {
console.log("디렉토리 삭제")
fs.existsSync(path) && fs.removeSync(path); // (1)
res.status(200).json("디렉토리를 성공적으로 삭제하였습니다.")
} catch (err) {
throw new Error(err.message);
}
})
(1) 만약 temp 폴더 내에 사용자ID 폴더가 존재하면 해당 폴더를 삭제시킵니다. 따로 콜백함수를 받지 않아(동기식) try-catch문으로 감싸서 에러 처리를 해주었습니다.
이제 (3)과정을 끝마쳤고, (4) 과정을 진행해보겠습니다.
(4) 사용자가 글 작성을 하게되면, 서버에서 글 내용안
<img>
태그 내의 src를 추출하여 추출한 주소에 있는 파일들을 영구폴더로 옮겨야 합니다.
그럼 글 작성 API를 살펴보겠습니다.
- 글 작성 관련 API들은 따로 라우팅이 되있는 점 참고하시면 될 것 같습니다.
MongoDB
사용을 위해mongoose
라이브러리를 사용하였습니다.
//REGISTER POST
router.post("/register", async (req, res) => {
try {
let newPost = await new Post(req.body).save(); // (1)
const {_id:postId, userId, desc} = newPost;
const oldPath = `./images/temp/${userId}`; // (2)
const newPath = `./images/posts/${postId}`;
// 삭제를 대비해 폴더 생성
!fs.existsSync(newPath) && fs.mkdirSync(newPath); // (3)
const imgSrcReg = /(<img[^>]*src\s*=\s*[\"']?([^>\"']+)[\"']?[^>]*>)/g; // (4)
while(imgSrcReg.test(desc)){
let src = RegExp.$2.trim(); // (5)
let imgName = src.substr(src.indexOf(userId) + userId.length + 1); // (6)
let tmpImgPath = oldPath + `/${imgName}`;
let postImgPath = newPath + `/${imgName}`;
// 만약 temp 폴더에 desc 이미지가 존재하면
fs.existsSync(tmpImgPath) && fs.rename(tmpImgPath, postImgPath, (err) => { // (7)
if(err) throw new Error(err);
console.log("성공적으로 이미지 옮김");
})
}
const newDesc = desc.replaceAll(`temp/${userId}`, `posts/${postId}`); // (8)
newPost = await Post.findOneAndUpdate({_id:postId}, {desc:newDesc}); // (9)
res.status(200).json(newPost);
} catch (error) {
console.log(error);
res.status(500).json(error);
}
});
(1) 이미지 파일들을 영구 폴더로 옮긴다면 나중 글 수정이나 삭제를 위해 postId 폴더 별로 구분해둘 필요가 있습니다. 이를 위해 먼저 DB에 post를 생성하여 고유의 ObjectID 값을 받습니다.
(2) 현재 파일이 저장된 임시 폴더 경로와 새로 옮길 경로를 정의해줍니다.
(3) 만약 새 경로에 postId 폴더가 존재하지 않는다면 동기식으로 폴더를 생성합니다.
(4) 글 내용 (html 형식)에서 <img>
태그 내의 src를 추출하기 위한 정규식입니다.
(5) 글 내용에서 모든 <img>
태그를 순회하며, src 태그를 뽑습니다.
(6) 현재 이미지 주소 형식은 temp/[userId]/[이미지 이름]
입니다. 여기서 이미지 이름만을 추출하기위해 [userId]
까지 문자열을 잘라냅니다.
(7) 만약 temp 폴더 내에 src 내의 이미지가 존재한다면 해당 이미지 파일을 새폴더의 경로로 이름을 바꿔 복사시킵니다. 복사는 비동기 식으로 처리해주었습니다.
(8) 글 내용 안 이미지들을 보이게 하려면 새 경로를 가리키도록 해야되므로 정규식을 통해 새 경로로 변경해줍니다.
(9) 새 경로로 수정한 글 내용을 DB에 업데이트 합니다.
마지막으로 글 작성이 완료되면 포스팅된 페이지로 이동 -> 자동으로 temp 폴더가 삭제되도록 구현했으므로 이에 대한 코드를 따로 작성할 필요는 없습니다.
이제 모든 과정을 끝마쳤고, 사용자가 글 작성 도중에 이미지를 지우거나 페이지 이동이나 브라우저가 닫혀도 서버에는 이미지가 남지 않게 되었습니다.
그리고 글을 성공적으로 작성하면 이미지 파일들을 영구 폴더로 이동시키고 임시 폴더를 삭제하는 데까지 구현해보았습니다.
이제 남은 일을 생각해보면...
나중 사용자 수가 많아지면 파일들을 물리적인 서버에 담는데 한계가 있습니다. 보통 이런 경우 S3와 같은 스토리지 서비스로 대행합니다. 다음에는 현재 영구 폴더를 AWS S3로 이전 해보는 시간을 가져보겠습니다. 감사합니다 ㅎㅎ