티스토리 뷰

이번 여행의 이유 프로젝트에서 게시글 CRUD를 구현하면서 이미지 업로드 관련한 다양한 이슈가 발생했다. 

평소에 이미지 업로드를 꼭 구현해보고 싶었는데, 이번 프로젝트에서 다양한 이슈를 해결하면서

이미지 업로드에 대한 방법들을 자세히 익힐 수 있었다. 정리하고 복기해 보자!

 

GitHub - Here-You/here-you-backend: 여행의 이유: Here You - backend server

여행의 이유: Here You - backend server. Contribute to Here-You/here-you-backend development by creating an account on GitHub.

github.com


[0] S3 Bucket 초기 코드 세팅

우선 AWS Bucket을 생성하고 생성한 버킷의 설정 정보(endpoint, aws region, 키값 등)를. env 파일에 저장해 둔다.

# S3 Config
S3_ENDPOINT= {$YOUR_S3_ENDPOINT}
S3_ACCESS_KEY= {$YOUR_S3_ACCESS_KEY}
S3_SECRET_ACCESS_KEY= {$YOUR_S3_SECRET_ACCESS_KEY}
S3_BUCKET_NAME=hereyou

# It can be CDN url or public accessible bucket url
S3_PUBLIC_URL= {$YOUR_S3_PUBLIC_URL}

 

이제 S3 버킷 업로드 기능을 수행하는 S3UtilService 클래스를 하나 생성하고, 거기에 s3 멤버를 생성하고, base64 이미지를 업로드하는 메서드와 이미지키를 랜덤으로 생성하는 메서드를 작성한다.

@Injectable()
export class S3UtilService {
  private readonly s3 = new S3({
    signatureVersion: 'v4',
    endpoint: process.env.S3_ENDPOINT,
    region: process.env.AWS_REGION,
    credentials: {
      accessKeyId: process.env.S3_ACCESS_KEY,
      secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
    },
  });
  
  public async putObjectFromBase64(key: string, body: string) {	//base64 이미지 업로드
    return this.s3
      .putObject({
        Bucket: process.env.S3_BUCKET_NAME,
        Key: key,
        Body: Buffer.from(body, 'base64'),
      })
      .promise();
  }
  
  public generateRandomImageKey(originalName: string) {	// 랜덤 이미지키 생성
    const ext = originalName.split('.').pop();
    console.log(ext);
    const uuid = uuidv4();

    return `${uuid}.${ext}`;
  }
  
 }

 

생성한 이미지키를 DB에 저장해 두고, 이미지를 조회할 때는 내 버킷의 Public url에 이미지를 붙이면 조회할 수 있다. 이미지를 볼 때 필요한 열쇠인 셈이다. 이미지를 조회할 때 사용하는 메서드는 아래와 같다.

  public async getImageUrl(key: string) {
    return `${process.env.S3_PUBLIC_URL}${key}`;
  }

 

 

이제 본격적인 이미지 업로드 과정을 자세히 살펴보자

[1] 프론트엔드로부터 사용자 게시글 DTO를 Request Body에 담은 POST 요청을 받는다.

{
	"title": "새로운 시그니처 제목",
	"pages": [
		{
			"content": "오늘은 광교 호수 공원을 산책했습니다",
			"location": "광교 호수 공원",
			"page": 1,
			"image": "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAADMElEQVR4nOzVwQnAIBQFQYXff81RUkQCOyDj1YOPnbXWPmeTRef+/3O/OyBjzh3CD95BfqICMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMO0TAAD//2Anhf4QtqobAAAAAElFTkSuQmCC"
		},
		{
			"content": "저는 원래 호수 산책을 참 좋아하는데요",
			"location": "동백 호수 공원",
			"page": 2,
			"image": "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAADMElEQVR4nOzVwQnAIBQFQYXff81RUkQCOyDj1YOPnbXWPmeTRef+/3O/OyBjzh3CD95BfqICMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMO0TAAD//2Anhf4QtqobAAAAAElFTkSuQmCC"
		},
		{
			"content": "동탄 호수 공원은 주위에 놀거리가 많습니다",
			"location": "동탄 호수 공원",
			"page": 3,
			"image": "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAADMElEQVR4nOzVwQnAIBQFQYXff81RUkQCOyDj1YOPnbXWPmeTRef+/3O/OyBjzh3CD95BfqICMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMK0CMO0TAAD//2Anhf4QtqobAAAAAElFTkSuQmCC"
		}
	]
}

이렇게 Request Body에 게시글 제목과 각 페이지 내용, 페이지 넘버, 장소, 그리고 base 64로 인코딩한 이미지를 String 타입으로 받는다.

 

[2] 이제 받은 이미지를 업로드해 보자. 각 페이지를 DB에 저장하는 기능을 담당하는 saveSignaturePage 메서드에서 이미지 업로드를 구현하고 있다.

1) 일단 랜덤 이미지 키를 생성한다. 이미지 이름이 중복되는 것도 막고 보안적인 측면도 있다.

이때 이미지 key는 디렉토리와 같은 구조로, 기능에 따라 이미지를 같은 디렉토리에 분류하기 위해서 내가 구현하는 기능인 signature를 앞에 두고 그 뒤에 랜덤 이미지키를 생성해 이어 붙였다.

 

2) 그다음엔 생성한 이미지키와 base64를 사용해서 버킷에 이미지를 업로드한다.

async saveSignaturePage(
    pageSignatureDto: PageSignatureDto,
    signature: SignatureEntity,
  ) {
    const signaturePage: SignaturePageEntity = new SignaturePageEntity();

    signaturePage.signature = signature;
    signaturePage.content = pageSignatureDto.content;
    signaturePage.location = pageSignatureDto.location;
    signaturePage.page = pageSignatureDto.page;

    // 랜덤 이미지 키 생성
    const key = `signature/${this.s3Service.generateRandomImageKey(
      'signaturePage.png',
    )}`;

    // Base64 이미지 업로드
    const uploadedImage = await this.s3Service.putObjectFromBase64(
      key,
      pageSignatureDto.image,
    );
    console.log(uploadedImage);

    signaturePage.image = key;

    await signaturePage.save();
  }

 

이렇게 1차 구현 완료


 

이렇게 구현 완료 후 포스트맨 테스트까지 완료해 프론트엔드 팀원에게 전달했다. 그리고 돌아온 답변은 프론트에서 테스트할 때는 이미지 업로드가 실패한다는 것..! 

크롬 개발자 모드에서 조회했을 때 결과는 'Payload Too Large'였다.

 

내가 포스트맨으로 테스트할 때는 작은 용량의 이미지였는데, 프론트에서 테스트할 때 고화질의 크기가 큰 이미지를 업로드하려고 하니 base64 길이가 너무 길어서 오류가 발생한 것이다. 당연히 실제 유저가 사용할 때는 고화질의 이미지를 사용할 것이니 이미지 오류를 반드시 해결해야 했다.

 

그래서 이미지 업로드를 구현하는 다른 방법들을 찾아보았다.


새로운 방법 1. Form-Data 형식

먼저 첫 번째 방법은 버퍼 타입의 이미지 자체를 업로드하는 방법이다. 기존에 base64로 인코딩한 이미지를 받는 방식과 거의 비슷하다. 

public async putObject(key: string, body: Buffer) {
    return this.s3
      .putObject({
        Bucket: process.env.S3_BUCKET_NAME,
        Key: key,
        Body: body,
      })
      .promise();
  }

 

새로운 방법 2. Presigned-URL 형식

이번 방법은 서버에서 이미지를 업로드할 url을 넘겨주면, 프론트엔드 측에서 직접 이미지를 업로드하는 방식인 presigned-url 방식이다.

첫인상은 로직이 단순하지 않아서 어려워 보여서 이해하기 어려웠는데 공식 문서와 팀원들의 설명을 들으니 이해할 수 있었다.

우선 로직은 아래와 같다.

1. 클라이언트가 이미지 업로드 시도 시 프론트엔드에서 서버에 presignedURL 요청

2. 서버에서 AWS S3에 presignedURL 요청

3. AWS에서 presignedURL 리턴

4. 서버는 프론트엔드 측에 presignedURL 리턴, 프론트엔드는 해당 URL에 PUT 요청을 보내 이미지 업로드

5. 서버에 해당 요청 종료 되었음을 알린다.

 

그럼 해당 로직을 코드로 구현해 보자 

  public async getPresignedUrl(key: string) {
    return this.s3.getSignedUrlPromise('putObject', { // AWS S3에 presignedURL 요청+받기
      Bucket: process.env.S3_BUCKET_NAME,
      Key: key,
      Expires: 60,
    });
  }

public async GetPresignedUrlForSignature(): Promise<S3PresignedUrlDto> {
    const s3PresignedUrlDto: S3PresignedUrlDto = new S3PresignedUrlDto();

    // 이미지 키 생성: DB에 저장되는 값
    s3PresignedUrlDto.key = `signature/${this.generateRandomImageKey(
      'signature.png',
    )}`;

    // 프론트에서 이미지를 업로드할 presignedUrl
    s3PresignedUrlDto.url = await this.getPresignedUrl(s3PresignedUrlDto.key);

    return s3PresignedUrlDto;
  }

 

우선 첫 번째 getPresignedURL 메서드에서는 AWS 서버로부터 presignedURL을 요청하고 응답받는다.

여기서 초반에 계속 오류가 났는데 getSignedUrlPromised의 첫 번째 인자를 'getObject'로 했었던 게 원인이었다.

putObject로 바꿔주고, 버킷 이름, 생성한 키, 그리고 해당 presignedURL의 유효 기간을 지정해 주면 (60초) S3 서버로부터 presignedURL을 받을 수 있다.

 

다음 GetPresignedUrlForSignature() 메서드는 내 기능(시그니처의 이미지 업로드)을 관련해서 구현한 메서드이다.

먼저 키 값을 signature 디렉토리 하위에 저장하도록 키 값을 signature/{$랜덤키}로 생성해 준다. 

그렇게 생성한 키와 첫 번째 메서드에서 받은  presignedURL을 함께 DTO에 담아 프론트에 전송해 준다.

// S3.presignedUrl.dto.ts

export class S3PresignedUrlDto {
  key: string;
  url: string;
}

 

그럼 프론트에서는 응답받는 url에 'PUT'메서드로 이미지 키값과 이미지를 보내면 업로드할 수 있는 것이다. 

포스트맨 테스트결과는 다음과 같다.

 

1. 프론트엔드 측에서 GET 메서드로 presignedURL을 요청을 보내면 응답으로 key, url을 보내준다.

 

2. 그럼 프론트는 응답받는 url에 PUT매서드로 이미지 파일을 전송한다. 성공하면 200 OK!

 


 

이렇게 이미지 업로드 오류를 해결하기 위한 대체 방법을 여러 개 구현해서 프론트엔드 측에 제공했다. 

그리고 결국 해결한 방법은 < 기존 base64 형식의 이미지를 압축해서 전송 + 서버 Request 용량 늘리기 >였다.

비록 내가 새로 구현한 방법들은 채택되지 않았지만 적극적으로 해결을 위해 여러 방법들을 찾고 구현해서 전달하니, 협업 분위기가 매우 활발해져서 뿌듯했다. 개인적으로 다음 프로젝트에서는 꼭 presignedURL 방식으로 이미지 업로드를 구현하고 싶다!

 

사실 Payload Too Large 오류는 해결 과정 중 단순히 서버 Request 용량을 늘렸을 때 해결 됐는데, 여기서 조금이라도 서버의 과부하를 줄이기 위해서 이미지 압축 방식을 추가했다.

 

압축으로 얻을 수 있는 성능을 체크해 보기 위해 같은 이미지를 갖고 실험해 보았다.

https://hereyou-cdn.kaaang.dev/signature/420807e2-cc09-4ab9-8c6d-99d5390e62bf.png

https://hereyou-cdn.kaaang.dev/signature/45a5a1f3-c183-42bb-a92e-c2894dfeed54.png

위의 두 이미지는 같은 이미지인데 위의 것은 압축하지 않은 버전, 아래 것은 압축한 버전이다.

 

이 둘의 차이가 얼마나 있나 보니 첫 번째 이미지가 626KB, 두 번째 이미지가 283KB였다. 약 2.2배 정도 차이가 나는 것이다.

내가 구현하는 시그니처(게시글)는 페이지 수가 최대 10개로 최대 10장의 이미지가 올라갈 수 있다. 그러니까 2.2배씩 10장이 된다면 22배나 성능이 향상되는 것이다! 이런 식으로 이미지를 통으로 서버에 전달한다면 압축이 필수일 것 같다. 또한 용량이 너무 클 경우 프론트엔드에서 로드 시간이나 데이터 소모가 커질 수 있으므로 이 부분을 고려해서 lazy-load를 적용하는 등의 방안을 사용하는 것도 좋다.

 

 

 

🔗 Reference

 

AWS S3 presignedURL을 이용해서 image Upload 하기

presigned URL을 이용한 S3 image 동적 업로드 가이드입니다. 😁

velog.io

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/09   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30
글 보관함