banner
  1. About
  2. Blog
  3. Contact
프로그래밍 · 

파일 업로드 기능 구현 방법과 주의해야 할 사항들(+S3 Presigned URL)


person using computer on table
이미지 출처: Sigmund on Unsplash


우선 주의사항부터 살펴봅시다. 이 주의사항들은 웹사이트를 만들든 모바일 앱을 만들든, 리액트를 쓰든 자바를 쓰든, 프론트엔드든 백엔드든 상관 없이 공통적으로 해당됩니다.


1. 파일을 업로드할 때 크기 정보를 함께 저장해야 한다.


이러한 지침은 CLS(Cumulative Layout Shift) 문제와 관련이 있습니다. CLS 문제는 다운로드가 필요한 레이아웃 요소를 불러왔을 때 일정한 공간을 새로 차지하면서 기존 레이아웃이 밀리는 현상을 말합니다. 이러한 현상은 성능을 저하시킬 뿐만 아니라 갑작스럽게 화면을 바꿔 놓기 때문에 사용자 경험을 해치기 쉽습니다.


예컨대 다음과 같은 HTML 코드를 보면 CLS 문제가 발생하는 원인을 알 수 있습니다.


<img src="/logo.png" alt="logo" />


브라우저는 서버가 전달한 HTML을 최초에 렌더링할 때 실제 이미지를 함께 렌더링할 수 없습니다. 왜냐하면 이미지와 관련해서 주어진 HTML이 제공하는 정보는 img 태그에서 src가 표현하는 주소밖에 없기 때문입니다.


브라우저는 src 주소를 통해 실제 바이너리를 다운로드하고 난 다음에야 이미지가 가진 픽셀값과 사이즈를 알 수 있습니다. 다시 말해 다운로드하기 전까지는 img 태그가 차지하는 공간을 확정할 수 없다는 이야기지요.


그런 이유로 처음 렌더링할 때는 이미지 없이 레이아웃이 정해지고 잠시 후 이미지가 다운로드 되었을 때 다시 레이아웃이 정해지면서 CLS 문제가 발생하는 것입니다.


이건 웹에서 사용하는 HTML뿐만 아니라 모바일 앱에서 사용하는 프레임워크에서도 마찬가지입니다.


좌우지간 그래서 CLS 문제를 해결하기 위해서는 다운로드가 필요한 요소에게 사이즈 정보를 최초에 함께 제공해야 하겠습니다. 수정한 코드는 다음과 같습니다.


<img src="logo.png" width="200" height="100" />


만약 이미지가 반응형으로 가로 넓이에 따라 사이즈가 함께 변할 때에는 css를 이용해 요소가 차지하는 공간을 미리 지정할 수 있습니다.


<img src="logo.png" style="aspect-ratio: 200/100; max-width: 200px; width: 100%;" />


그런데 개발자가 소유한 정적 파일의 사이즈 정보를 제공하기는 어렵지 않습니다. 하지만 유저가 업로드한 파일의 사이즈 정보를 제공하려면 어떻게 해야할까요?


결론부터 말하자면 유저가 업로드한 파일의 사이즈 정보를 최초 렌더링할 때 제공하기 위해서는 유저가 파일을 업로드할 때 미리 사이즈 정보를 함께 받아서 DB에 저장해야 합니다.


사전에 저장한 사이즈 정보 없이 파일 크기를 알기 위해서는 어쩔 수 없이 바이너리 일부를 읽어들어야 합니다. 여기서 시간이 걸리죠. 물론 최초 렌더링할 HTML을 서버에서 생성할 때 해당 파일이 가진 메타데이터를 읽어들여도 되지만, 매번 해당 작업에서 시간을 잡아먹게 됩니다.


그러므로 파일을 업로드하는 API는 타입에 따라 width, height 메타데이터를, 비디오라면 duration 메타데이터까지 필수 인자로 지정해 줍니다. 물론 Content Type도 받아 놓으면 여러모로 유용합니다. 저는 보통 Content Type, 메타데이터와 함께 파일 이름과 크기 정보도 함께 받아 놓습니다.


2. S3에 저장하는 경우 PutObject보다 Presigned URL을 이용한다.


흔히 유저가 업로드하는 이미지나 비디오 같은 파일은 공간을 많이 차지하기 때문에 웹 애플리케이션을 운영하는 서버에 저장하지 않습니다. S3 같은 오브젝트 스토리지를 따로 이용하고 웹 애플리케이션 서버를 경량화 및 독립시켜서 운영하는 편입니다.


이런 경우에 보안 관점에서 S3 자격증명을 클라이언트에 놓을 수 없기 때문에 유저로부터 서버로 파일 데이터를 넘겨받고 서버가 파일 데이터를 다시 S3에 업로드(Put Object)하곤 하는데 바람직하지는 않아 보입니다. 왜냐하면


1. 10MB 이상 큰 데이터를 불필요하게 들여오고 내보내기 때문입니다.

2. 웹서버 설정에서 파일 크기 제한에 걸릴 수 있기 때문입니다. 물론 이 문제는 따로 크기 제한을 높여 주면 되지만 번거롭기도 하고 내 경우에는 종종 웹서버 접근 권한이 부족한 때도 있었습니다.


이럴 경우에는 S3 Presigned URL을 이용하면 되겠습니다. S3 Presigned URL 방식은 다음과 같은 과정을 거치게 됩니다.


1. 서버측에서 파일을 업로드할 수 있는 S3 Presigned URL을 생성합니다.

2. 클라이언트에게 URL을 전달합니다.

3. 클라이언트가 직접 해당 URL로 파일을 업로드합니다.


이렇게 하면 보안을 유지하면서 서버가 직접 데이터를 들여오고 내보낼 필요 없이 파일을 저장할 수 있습니다.


구현하기


이제 두 가지 주의 사항을 고려해서 직접 구현하는 예제를 살펴보겠습니다. express로 진행합니다. DB ORM은 drizzle을 사용했습니다.


S3 Presigned Url을 사용하기 위해서는 @aws-sdk/client-s3 말고도 @aws-sdk/s3-request-presigner를 설치해야 합니다.


npm i @aws-sdk/s3-request-presigner 


다음은 서버측 API 예제입니다.


const s3 = new S3Client();

app.post("/api/files", async (req, res) => {
  const { name, type, size, metadata } = req.body;

  const id = uuid.v4();

  const key = `users/${id}/${name}`;

  const command = new PutObjectCommand({
    Bucket: "my-bucket",
    Key: key,
    ContentType: type,
  });

  const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 1800 });

  const [file] = await db
    .insert(fileTable)
    .values({
      id,
      name,
      type,      
      size,
      metadata,
      key,
      presignedUrl,
    })
    .returning();

  res.send(file);
})


서버측에서는 /api/files 엔드포인트로 name, type, size, metadata 등 인자를 넘겨 받습니다. 여기서 metadata는 type에 따라 이미지라면 width, height 데이터를, 비디오라면 duration 데이터를 포함한 객체로 받습니다.


key는 S3에 저장할 위치를 가리키는데 `users/${id}/${name}`로 정의해 주었습니다. id를 통해서 식별할 수 있게 하되 name으로 파일이 가진 본래 이름을 확장자와 함께 저장해서 그대로 다운로드받을 수 있게끔 하기 위해서입니다.


다음은 key를 이용해 @aws-sdk/s3-request-presigner가 제공하는 getSignedUrl 함수로 S3 Presigned URL을 발급합니다.


마지막으로 S3 Presigned URL과 전달받은 파일 정보, 그리고 key를 모두 함께 DB에 저장합니다. 이렇게 하면 추후 DB로부터 파일 데이터를 읽어들일 때 즉시 파일 경로와 함께 크기 정보를 가질 수 있습니다.


참고로, 파일 경로를 s3 origin + key 형태의 완성된 URL로 저장하지 않은 이유는 추후 CDN을 이용하면 경로가 달라지기 때문입니다. 서버는 AWS SDK를 이용해 S3에 접근하여 key만 가지고 파일을 다룰 때가 많지만, 보통 클라이언트는 CDN origin + key 형태 주소를 통해서 접근하는 경우가 많습니다. CDN origin은 달라지는 경우가 있으므로 클라이언트 측 설정으로 남겨두는 편이 더 유연합니다.


클라이언트 측 코드는 아래와 같습니다.


type Result = {
  id: string;
  name: string;
  type: string;
  size: number;
  metadata: Record<string, unknown>;
  key: string;
  presignedUrl: string;
}

const upload = (file: File): Result => {
  const metadata = await getMetadata(file);

  const presign = await fetch("/api/files", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",    
    },
    body: JSON.stringify({
      name: file.name,
      type: file.type,
      size: file.size,
      metadata,
    }),
  });

  if (!presign.ok) {
    throw new Error("presign failed")
  }

  const result = presign.json();

  const put = await fetch(result.presignedUrl, {
    method: "PUT",
    body: file,  
  });

  if (!put.ok) {
    throw new Error("Upload failed");
  }

  return result;
}

const getMetadata = async (file: File) => {
  return new Promise<Record<string, unknown>>((resolve, reject) => {
    if (file.type.startsWith("image/")) {
      const img = new Image();

      img.src = URL.createObjectURL(blob);

      img.onload = () => {
        resolve({
          ...metadata,
          width: img.width,
          height: img.height,
        });
      };

      img.onerror = reject;

      return;
    }

    // TODO: Get video metadata

    resolve({});
  });
}


주어진 코드처럼 S3 Presigned URL을 이용할 때에는 클라이언트가 타입에 따라 width, height 정보를 미리 알아내서 서버에 넘겨 주어야 합니다. 더 이상 서버 측에서 파일 데이터를 받지 않기 때문에 해당 메타데이터를 알아낼 수 있는 방법이 없기 때문입니다.


이렇게 업로드를 마친 후, 최종적으로 파일을 읽어서 렌더링하는 코드는 다음과 같습니다. React Server Component를 사용한 예제입니다.


export default async function MyImageComponent({ fileId }: { fileId: string }) {
  const file = await db.query.fileTable.findFirst({
    where(t, { eq }) {
      return eq(t.id, fileId);
    }
  });

  if (!file) {
    return null;
  }

  return <img src={`http://cdn.com/${file.key}`} width={file.metadata.width} height={file.metadata.height} />
}
댓글 0

프로그래밍 카테고리 다른 글