본문 바로가기

프로그래밍 - 활용/Front-end

이미지 업로드 최적화하기

0. 배경

위치 추적 게임 서비스, 어데고?! 의 핵심 부분인 콘텐츠 부분을 전담하고 있다. 사용자가 이미지를 업로드한 후, 미리 보기로 업로드한 이미지들을 보여주는 것부터, 올린 전체 장소 조회, 게임에서 콘텐츠로 활용 등 서비스 전체적으로 이미지가 필수적으로 존재해야 한다.

1차 MVP에선 사용자가 업로드한 이미지를 그대로 S3에 저장하고, url로 해당 이미지를 불러와 사용했다. 크기가 작은 이미지를 저장하고 사용하는 것에 문제가 되지 않았으나, 크기가 큰 이미지를 저장하고 불러올 때 서비스에 지연 시간이 생기는 것을 확인했다. 이러한 지연은 사용자에게 예상보다 큰 불편함을 느끼게 하고, 서비스가 안정적이지 못하다는 인상을 남기게 되는 악영향을 초래하기에 심각성을 인지하고 곧바로 수리에 돌입했다.

 

문제의 원인은 이미지의 크기가 크다는 것이었고, 이미지를 업로드할 때 이미지를 최적화한다면 모든 조회가 매끄러워질 것이라는 판단을 내렸다.

 

이미지 최적화를 위해 Next/image와 sharp 라이브러리를 모두 사용하여, 할 수 있는 최대한 최적화를 진행하고자 했다.

 

1. 이미지 업로드 최적화 방법

  • Next/image 사용
  • sharp 라이브러리 사용
    • 이미지 압축: webp로 변환
    • 이미지 크기 제한: 800px 이하로 제한

 

2. Next/image 사용

NextJS의 경우, img 태그가 아닌 Next/image를 활용한 Image 태그를 사용한다면 일차적으로 이미지를 최적화할 수 있다. 해당 태그를 사용했을 때 서버가 동작하게 되면, /_next/image라는 라우트를 만들어 이미지 최적화 모듈을 사용해 자동으로 이미지를 최적화하게 된다.

하지만 NextJS가 모든 경우의 이미지를 최적화해주는 것은 불가능하고 동적으로 최적화를 진행해야 하는 경우, sharp를 통해 이차적으로 최적화를 진행할 수 있다.

import Image from 'next/image';

<Image src={file} alt="Preview Image" fill style={{ objectFit: 'cover' }} />;

 

3. sharp 라이브러리 사용

(0) 환경

sharp 라이브러리의 경우, node 기반의 라이브러리이기에 클라이언트에서 사용할 수 없다. 그렇기에 Next의 Server에서 이미지 최적화를 진행하게 된다.

 

(1) 기본 구조

import { NextResponse } from 'next/server';
import sharp from 'sharp';

export async function POST(req: Request) {
  try {
    const formData = await req.formData();
    const file = formData.get('file') as Blob;


	/* **************** */
	/* 로직이 처리될 부분 */
	/* **************** */
    
    
    return new Response(compressedBuffer, {
      headers: {
        'Content-Type': 'image/webp',
      },
    });
  } catch (error) {
    console.error('이미지 처리 중 에러:', error);
    return NextResponse.json({ error: '이미지 처리 실패' }, { status: 500 });
  }
}

 

(2) 이미지 압축하기

const compressedBuffer = await sharp(buffer)
  .rotate()
  .withMetadata()
  .webp({ quality: 80 })
  .toBuffer();

 

(3) 이미지 크기 제한하기

  • 가로/세로에 따른 크기 제한 옵션 설정
    • 가로가 세로보다 긴 경우, 가로를 800px로 제한
    • 세로가 가로보다 긴 경우, 세로를 800px로 제한
const metadata = await sharp(buffer).rotate().metadata();
let resizeOptions = {};
if (metadata.width && metadata.height) {
  if (metadata.width > metadata.height) {
    // 가로가 세로보다 길 경우
    resizeOptions = { width: 800, withoutEnlargement: true };
  } else {
    // 세로가 가로보다 길 경우
    resizeOptions = { height: 800, withoutEnlargement: true };
  }
}
  • sharp에 크기 제한 옵션 적용
const compressedBuffer = await sharp(buffer)
  .rotate()
  .resize(resizeOptions)
  .withMetadata()
  .toBuffer();

 

 

4. 활용

(0) 이미지 최적화 적용 화면 및 PR

 

Feat/#17: 이미지 최적화 구현(이미지 업로드하는 경우) by minjeongss · Pull Request #31 · urdego/Urdego_Front

#️⃣ 연관된 이슈 #17 📝 작업 내용 컨텐츠 크기 제한 가로가 세로보다 긴 경우, 가로를 800px로 제한 세로가 가로보다 긴 경우, 세로를 800px로 제한 컨텐츠 확장자 webp로 변환 📸 성능 향상 이미

github.com

  • 이미지 최적화 활용 화면

 

(1) 사용자가 이미지 업로드할 때, Next Server로 이미지 최적화 요청 및 활용

  • 코드 위치: hooks/useRegisterFiles.ts
const compressFile = async (fileList: File[]) => {
  const compressedFileList: File[] = [];

  for (const file of fileList) {
    const formData = new FormData();
    formData.append('file', file);

    const response = await fetch('/api/content/compress', {
      method: 'POST',
      body: formData,
    });

    if (!response.ok) {
      throw new Error('파일 압축 실패');
    }

    const compressedBlob = await response.blob();
    const fileNameToWebp = file.name.split('.')[0] + '.webp';
    const compressedFile = new File([compressedBlob], fileNameToWebp, {
      type: 'image/webp',
    });
    compressedFileList.push(compressedFile);
  }

  return compressedFileList;
};

 

(2) Next Server에서 이미지 최적화 진행

  • 코드 위치: app/api/content/compress/route.ts
import { NextResponse } from 'next/server';
import sharp from 'sharp';

export async function POST(req: Request) {
  try {
    const formData = await req.formData();
    const file = formData.get('file') as Blob;

    // file 객체를 Buffer로 변환
    const buffer = Buffer.from(await file.arrayBuffer());

    // 가로/세로에 따른 옵션 설정
    const metadata = await sharp(buffer).rotate().metadata();
    let resizeOptions = {};
    if (metadata.width && metadata.height) {
      if (metadata.width > metadata.height) {
        // 가로가 세로보다 길 경우
        resizeOptions = { width: 800, withoutEnlargement: true };
      } else {
        // 세로가 가로보다 길 경우
        resizeOptions = { height: 800, withoutEnlargement: true };
      }
    }

    // Sharp로 이미지 압축
    const compressedBuffer = await sharp(buffer)
      .rotate()
      .resize(resizeOptions)
      .withMetadata()
      .webp({ quality: 80 })
      .toBuffer();

    return new Response(compressedBuffer, {
      headers: {
        'Content-Type': 'image/webp',
      },
    });
  } catch (error) {
    console.error('이미지 처리 중 에러:', error);
    return NextResponse.json({ error: '이미지 처리 실패' }, { status: 500 });
  }
}

 

5. 성능 개선 결과

(1) 이미지의 크기: 20배 감소

  • 적용 전: 2,500,000Byte
  • 적용 후: 120,000Byte

 

(2) 이미지 불러오는 시간: 40배 감소

시크릿 창에서 캐시 사용 중지를 설정하고 테스트한 환경이다.

  • 적용 전: 44ms
  • 적용 후: 2ms

 

6. 결론

Next/image와 sharp 라이브러리를 총동원하여 클라이언트에서 사용자가 이미지를 업로드했을 때의 최적화를 진행했다. 예상보다 더 뛰어난 성능 개선을 이룰 수 있어 뿌듯했다. 배포된 서비스를 사용할 때 이미지를 조회하는 로직이 빨라짐을 체감하며, 서비스의 질을 높이는데 기여했다는 생각이 들었다.

 

이미지를 최적화하는 방법은 각 서비스에서 집중하고 있는 부분이 무엇인지와 불편함을 느끼는 것의 원인이 무엇인지를 분석하는 것에 있다고 생각한다. 원인을 깊이 파고들어, 진정한 문제를 찾아 최적화를 진행하는 것이 올바른 접근 방법이 아닐까 싶다. 😀👍

 

7. 참고한 블로그