next.js
,prisma
,tailwindcss
를 사용하는 프로젝트입니다.
이전에 AWS-S3
를 이용해서 이미지를 저장하는 경우에는 다음과 같은 순서로 처리했습니다.
1. 브라우저에서 서버로 이미지 전송
2. 서버에서 받은 이미지와 key
들을 이용해서 S3
에 이미지 업로드 요청
3. S3
에서 이미지 업로드 후 결과 반환
4. 서버에서 받은 결과를 브라우저에 전달
5. 브라우저에서 받은 결과로 업로드된 이미지를 화면에 렌더링
위와 같은 순서로 처리할 때 항상 존재한 문제들은 두 가지 있습니다.
1. next.js
의 api
는 1MB
이상의 이미지를 처리하지 못함
2. 전송하는데 많은 리소스가 낭비되는 이미지를 두 번 연속으로 처리하므로 많은 낭비가 발생
브라우저에서 바로 S3
로 이미지를 전송하면 문제가 해결되지 않냐고 생각할 수 있지만 S3
에 접근하기 위해서는 몇 가지 key
가 필요합니다. 그것을 브라우저에 저장하면 key
가 누구에게나 노출되는 문제가 발생하기 때문에 가능하지만 사용하지 않았습니다.
위의 한계를 극복할 방법을 찾다가 알게 된 방법이 preSignedUrl
입니다.
미리 서명된 URL으로 처리 로직은 아래와 같습니다.
1. 브라우저에서 이미지 업로드 요청을 서버에게 보냄 ( 이미지는 보내지 않고 요청만 보냄 )
2. 서버에서 S3
에 요청해서 일시적으로 이미지 업로드가 가능한 URL
을 받음 ( 해당 URL
을 preSignedUrl
라고 부릅니다. )
3. 서버에서 브라우저로 preSignedUrl
을 전송함
4. 브라우저에서 받은 preSignedUrl
에 이미지를 첨부해서 보냄
5. 문제없이 결과가 오면 이미지 업로드 완료이므로 이미지를 화면에 렌더링
preSignedUrl
로 얻은 이점은 이전 방식의 두 가지 문제점을 모두 해결해줍니다.
S3
의 버킷 생성과 권한 부여 등은 생략하겠습니다.
npm i aws-sdk
.env
( 환경변수 )# 백엔드에서만 사용하기 때문에 "NEXT_PUBLIC" 붙일 필요 없음
BLESHOP_AWS_REGION=ap-northeast-2 ( 본인이 설정한 지역으로 입력 )
BLESHOP_AWS_ACCESS_KEY=<직접 입력>
BLESHOP_AWS_SECRET_KEY=<직접 입력>
import AWS from "aws-sdk";
AWS.config.update({
region: process.env.BLESHOP_AWS_REGION,
accessKeyId: process.env.BLESHOP_AWS_ACCESS_KEY,
secretAccessKey: process.env.BLESHOP_AWS_SECRET_KEY,
});
// 버킷 정책에서 생성된 버전 날짜 그대로 가져와서 사용함
const S3 = new AWS.S3({ apiVersion: "2012-10-17", signatureVersion: "v4" });
/**
* "이미지.확장자"를 받아서 "경로/이미지_시간.확장자"으로 변경해주는 함수
* @param name "이미지.확장자" 형태로 전송
* @returns "경로/이미지_시간.확장자" 형태로 반환
*/
const getPhotoPath = (name: string) => {
const [filename, ext] = name.split(".");
return `photos/${process.env.NODE_ENV}/${filename}_${Date.now()}.${ext}`;
};
/**
* "preSignedURL"을 생성하는 함수
* @param name "이미지.확장자" 형태로 전송
* @returns "preSignedURL"와 "photoURL"을 반환 ( "photoURL"은 정상적으로 완료 시 이미지 url )
*/
export const getSignedURL = (name: string) => {
const photoURL = getPhotoPath(name);
const preSignedURL = S3.getSignedUrl("putObject", {
// 생성한 버킷이름 작성
Bucket: "bleshop",
// 생성할 위치 및 파일명 작성 ( 현재는 "photos/development/파일명_시간.확장자" 형태임 )
Key: photoURL,
// URL 유효기간 ( 20초 )
Expires: 20,
});
// preSignedURL과 미래에 생성될 이미지의 URL 반환
return { preSignedURL, photoURL };
};
import { useCallback, useState } from "react";
import axios, { AxiosError } from "axios";
import Image from "next/image";
// type
import type { ChangeEvent } from "react";
type ApiGetUrlRespinse = { preSignedURL: string; photoURL: string };
/**
* 현재 웹페이지의 이미지의 경로를 얻는 헬퍼 함수 ( aws-s3 )
* @param path 후반부 이미지 경로
* @returns 전체 이미지 경로
*/
declare function combinePhotoUrl(path: string): string;
const TestComponent = () => {
const [photoUrl, setPhotoUrl] = useState<string | null>(null);
const onUploadPhoto = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
try {
if (e.target.files?.length) {
const photo = e.target.files[0];
// 해당 api에서는 이전에 "preSignedURL()"를 이용해서 값을 얻고 반환
const {
data: { preSignedURL, photoURL },
} = await axios.get<ApiGetUrlRespinse>(
`/api/photo?name=${photo.name}`
);
// S3로 이미지 생성 요청
await axios.put(preSignedURL, photo, {
headers: { "Content-Type": photo.type },
});
// catch구문으로 넘어가지 않았으니 정상 작동
setPhotoUrl(photoURL);
}
} catch (error) {
console.error(error);
if (error instanceof AxiosError) {
// 예측 가능한 에러 ex) 이미지 용량 초과, 전송 시간 초과 등
}
}
},
[setPhotoUrl]
);
return (
<>
<input type="file" accept="image/*" onChange={onUploadPhoto} />
// Next.js의 "<Image>"를 사용하기 위해서는 도메인을 등록이 필수
{photoUrl && (
<figure className="w-80 h-80 relative bg-black rounded-md">
<Image
layout="fill"
priority
src={combinePhotoUrl(photoUrl)}
className="object-contain"
alt="업로드한 이미지"
/>
</figure>
)}
</>
);
};
export default TestComponent;
현재 이미지 저장 방식은 다음과 같습니다.
1. 기본 이미지 저장 형태: photos/모드/사용방식/이미지명.확장자
2. 이미지 확정 전 방식: /temporary
3. 이미지 확정 후 방식: 이미지를 사용하는 형태에 따라 다름 ( /user
, /product
등 )
예를 들어 회원가입하는 경우 회원가입 버튼을 누르기 전에는 /temporary
에 이미지를 저장하고 회원가입 버튼을 누르고 유효성 검사 후 유저 생성이 확정되면 /user
로 이미지를 옮깁니다.
이렇게 하면 임시 저장 이미지와 실제 사용하는 이미지를 구분할 수 있고, 임시 저장 이미지도 보관할 수 있습니다.
아래 코드는 위 로직을 쉽게 적용할 수 있도록 이미지 이동을 도와주는 헬퍼 함수입니다.
/**
* 2022/08/14 - S3 이미지 제거 - by 1-blue
* @param photo 이미지 파일 이름
* @returns
*/
export const deletePhoto = (photo: string) =>
S3.deleteObject(
{
Bucket: "bleshop",
Key: photo,
},
(error, data) => {
if (error) console.error("S3 이미지 제거 error >> ", error);
}
).promise();
/**
* 2022/08/14 - S3 이미지 복사 - by 1-blue
* @param originalSource: 이미지 파일 이름, location: 이미지 복사 위치
* @returns
*/
export const copyPhoto = (originalSource: string, location: PhotoKinds) => {
// 이미지 저장 형태 : photos/모드/사용방식/이미지명.확장자
// 모드: production or development
// 사용 방식: temporary or remove or product or review 등
// 따라서 두 번째 "/"를 찾아서 다음 "/"까지 내용을 바꾸면 됨 ( temporary -> product )
let Key: unknown = null;
const firstSlashIndex = originalSource.indexOf("/");
const secondSlashIndex = originalSource.indexOf("/", firstSlashIndex + 1);
switch (location) {
// 이미지 제거
case "remove":
Key =
originalSource.slice(0, secondSlashIndex) +
"/" +
location +
originalSource.slice(secondSlashIndex);
break;
// 이미지 사용 확정으로 인한 이미지 이동
default:
Key = originalSource.replace("/temporary", "");
break;
}
if (typeof Key !== "string")
return console.error("이미지 저장 위치가 올바르지 않습니다.");
return S3.copyObject(
{
Bucket: "bleshop",
CopySource: "bleshop/" + originalSource,
Key,
},
(error, data) => {
/**
* >>> 여기가 가끔씩 두 번 실행됨, 요청은 한 번으로 확인했고, callback이 두 번 실행되면서 에러가 발생함
* 하지만 첫 번째 실행에 정상작동해서 이미지 복사는 정상적으로 실행되므로 상관은 없지만 에러 로그가 남는 문제가 발생
*/
if (error) console.error("S3 이미지 이동 error >> ", error);
}
).promise();
};
/**
* 2022/08/14 - S3 이미지 이동 ( 복사 후 제거 ) - by 1-blue
* @param photo: 이미지 파일 이름, location: 이미지 복사 위치
* @returns
*/
export const movePhoto = async (photo: string, location: PhotoKinds) => {
// OAuth의 이미지를 사용하는 경우
if (photo.includes("http")) return;
try {
await copyPhoto(photo, location);
await deletePhoto(photo);
} catch (error) {
console.error("movePhoto >> ", error);
}
};
preSignedURL
로 PUT
요청을 할 때는 File
객체 자체를 그대로 넘겨줘야 하는데 처음에는 FormData
를 이용해서 넘겨줬었습니다. FormData
를 이용하면 아무 문제 없이 정상 작동을 하지만 이미지가 깨지는 건지는 모르겠는데 이미지가 불러오는 부분에서 문제가 발생합니다.
처음에 이 부분을 몰라서 문제를 찾느라 4시간 정도 삽질하면서 결국 해결했습니다.