[Next] api router를 활용한 S3 이미지 관리

강동욱·2024년 6월 30일
0

S3를 프론트에서 관리하는 이유

nextjs를 활용해서 S3버킷에다가 이미지를 프론트에서 직접 관리하게 되었다.
S3를 프론트에서 관리하면 좀더 빠른 응답을 만들 수 있다. 그 이유는 무엇일까 생각해보자면 다음과 같다.

  • 클라이언트에서 백엔드로 이미지 데이터를 던져주고 서버에서 그걸 받고 aws에 넘겨주고 클라이언트한테 링크를 넘겨줘야한다. 서버가 바쁘면 이미지를 가공해서 주는 시간이 길다는 뜻이다. 서버가 바쁘니 덜 바쁜 프론트에서 대신 aws에 업로드를 해주는 것이다.

  • 클라이언트에서 Img업로드를 S3버킷에 해주고 s3 url을 서버한테 보내야하는데 이때 react-query를 사용해서 optimistic UI를 적용해주면 좀 더 빠르게 적용된 이미지를 볼 수 있다.

단점으로는 브라우저에서 우리의 코드를 확인할 수 있어 보안이 좋지 않을 수 있는데 현재 내 상황은 nexjs api router를 사용하기 때문에 브라우저에서 코드를 확인하지 못해 이런 단점은 해결된거라고 생각이 된다.

S3를 설정 할 때 알아야 할 것들

기본적으로 s3를 설정하는 방법은 인터넷에 다 나와있을테니 이번에 s3를 설정하면서 중요하게 생각했던 것들을 정리해 보려고 한다.

권한 설정 종류

  • 사용자를 생성하고 사용자의 버킷 권한 액세스를 관리하는 IAM
  • 권한 있는 사용자에 대해 간단한 개별 객체(오브젝트)를 액세스 가능하게 만드는 액세스 제어 목록(ACL)
  • 단일 S3 버킷 내 모든 객체에 대한 권한을 세부적으로 구성하는 버킷 정책(bocket policy)
  • 임시 URL을 사용하여 다른 사용자에게 기간 제한(임시 권한) 액세스를 부여하는 쿼리 문자열 인증(pre-signed URL)

ACL

ACL은 버킷이나 객체에 대해 요청자의 권한 허용 범위를 어디까지 설정할 것인가에 대해 간단하게 설정할 수 있다.

요청자는 일반 퍼블릭한 사용자, 계정 주인, 리소스 그룹, 특정 사용자가 될 수 있다.

각각의 버킷과 그 속에 포함된 객체는 ACL로 연동되므로 ACL로 객체 접근을 제어하는게 가능하다

버킷정책

Bucket Policy는 버킷을 사용할 권한을 가진 여러 명의 사용자 별로 각각의 행위에 대한 권한 범위를 설정할 수 있다. 누군가는 읽기만 가능하고 누군가는 읽기, 쓰기 모두 가능한 상태로 설정할 수 있다.

acl과 버킷정책 차이

ACL이나 버킷 정책이나 둘다 버킷에 대한 엑세스를 제한하거나 허용하는 권한 설정이다. 버킷정책은 버킷에대해서만 권한을 설정할 수 있지만, ACL은 버킷 뿐만 아니라 개별 객체에도 가능하다. 버킷정책은 JSON을 통해 세분화된 권한을 설정할 수 있지만, ACL은 버킷 정책만큼 세분화된 엑세스 모드를 제공하지 않는다.

액세스 차단

  • 새 ACL(액세스 제어 목록)을 통해 부여된 버킷 및 객체에 대한 퍼블릭 액세스 차단→ 지정된 ACL이 퍼블릭이거나, 요청에 퍼블릭 ACL이 포함되어 있으면 PUT 요청을 거절한다.

  • 임의의 ACL(액세스 제어 목록)을 통해 부여된 버킷 및 객체에 대한 퍼블릭 액세스 차단→ 버킷의 모든 퍼블릭 ACL과 그 안에 포함된 모든 Object를 무시하고, 퍼블릭 ACL를 포함하는 PUT 요청은 허용한다.

  • 새 퍼블릭 버킷 또는 액세스 지점 정책을 통해 부여된 버킷 및 객체에 대한 퍼블릭 액세스 차단→ 지정된 버킷 정책이 퍼블릭이면 PUT 요청을 거절한다. 이 설정을 체크하면 버킷 및 객체에 대한 퍼블릭 액세스를 차단하고 사용자가 버킷 정책을 관리할 수 있으며, 이 설정 활성화는 기존 버킷 정책에 영향을 주지 않는다.

  • 임의의 퍼블릭 버킷 또는 액세스 지점 정책을 통해 부여된 버킷 및 객체에 대한 퍼블릭 액세스 차단→ 퍼블릭 정책이 있는 버킷에 대한 액세스가 권한이 있는 사용자와 AWS 서비스로만 제한되며, 이 설정 활성화는 기존 버킷 정책에 영향을 주지 않는다.

api router 설정 코드

const s3Client = new S3Client({
  region: process.env.S3_IMAGE_UPLOAD_REGION as string,
  credentials: {
    accessKeyId: process.env.S3_IMAGE_UPLOAD_ACCESS_KEY as string,
    secretAccessKey: process.env.S3_IMAGE_UPLOAD_SECRET_ACCESS_KEY as string,
  },
})

export async function POST(request: Request) {
  try {
    const formData = await request.formData()

    const file = formData.get('file') as File | null
    if (!file) {
      return NextResponse.json({ error: '파일이 필요합니다' }, { status: 400 })
    }
    const mimeType = file.type
    const buffer = Buffer.from(await file.arrayBuffer())

    const s3ImgUrl = await uploadFileToS3(buffer, file.name, mimeType)

    return NextResponse.json({ s3ImgUrl }, { status: 200 })
  } catch (err) {
    return NextResponse.json({ error: 'Error uploading file' })
  }
}

제일 먼저 IAM유저를 생성하면서 발급받은 키들을 s3client 인스턴스에 등록을 해준다.

POST메서드를 활용해 이미지 업로드를 해주고 post메서드를 통해 들어온 이미지 파일을 추출해준 다음 Buffer.from() 메서드를 활용해 이미지 파일을 Buffer 바꿔준다. 그런 다음 uploadFileToS3라는 함수를 하나 생성해주었는데 다음과 같다.

async function uploadFileToS3(file: Buffer, fileName: string, type: string) {
  const fileBuffer = file
  const params = {
    Bucket: process.env.S3_IMAGE_UPLOAD_BUCKET_NAME,
    Key: `${fileName}`,
    Body: fileBuffer,
    ContentType: type,
  }

  const command = new PutObjectCommand(params)
  await s3Client.send(command)

  const s3ImgUrl = `https://${process.env.S3_IMAGE_UPLOAD_BUCKET_NAME}.s3.${process.env.S3_IMAGE_UPLOAD_REGION}.amazonaws.com/${fileName}`

  return s3ImgUrl
}

PutObjectCommand 인스턴스를 활용해서 s3에 보내줄 커맨드 객체를 생성해주고 s3Client.send를 사용해 파일 객체를 업로드 해준다. 보통 s3 이미지 url 형식은 다음과 같다

버킷이름.s3.버킷리전.amazonaws.com/파일 이름

그래서 s3ImgUrl을 반환해서 Post 메서드의 반환값으로 전달해준다.

Optimistic UI 적용

const { mutate } = useMutation({
    mutationFn: postImgUrl,
    onMutate: async (postImgArg) => {
      await queryClient.cancelQueries({ queryKey: ['profile', email] })
      const previousData = queryClient.getQueryData<UserProfile>([
        'profile',
        email,
      ])

      queryClient.setQueryData<UserProfile>(['profile', email], (old) => {
        if (old) {
          const imgUrl =
            type === 'profile'
              ? { profileImg: postImgArg.fileImgSrc }
              : { backgroundImg: postImgArg.fileImgSrc }
          return { ...old, ...imgUrl }
        }
        return old
      })

      return { previousData }
    },
    onError: (err, postImgArg, context) => {
      queryClient.setQueryData(['profile', email], context?.previousData)
    },
   	onSuccess: (err, postImgArg, context) => {
      fetch('/api/s3-upload', {
        body: JSON.stringify({
          deleteS3ImgUrl: `${type === 'profile' ? context.previousData?.profileImg : context.previousData?.backgroundImg}`,
        }),
        method: 'DELETE',
      })
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['profile', email] })
    },
  })

mutate함수에는 이미지 url을 전달해주면 되는데 이때 이미지 url은 우리가 이전에 본 post api router 통해 얻을 수 있다.

낙관적 업데이트가 진행되면 refetchOnMount 속성을 활용해 데이터를 refetch하는데 이때 낙관적 업데이트 데이터를 옛날 데이터가 덮어 씌울 수 있는데 이것을 cancelQuries를 활용하여 방지해준다.

setQueryData를 활용해 직접 캐시 데이터를 조작 해주면 된다.

previousdata를 onmutate에서 반환해주는 이유는 만약 error가 있을 때 rollback을 해주기 위함이다.

onError에서는 롤백해주기 위한 로직을 작성한 것이고

onSuccess일때는 최근 등록된 이미지가 아니라 이전에 사용했던 이미지를 지우기 위해서 onMutate때 반환한 Previou 데이터를 context인자를 활용해서 이용할 수 있다. 그래서 이전 이지미 url을 지워주기 위해 DELETE 메서드를 사용해서 api router를 호출하면 된다.

Delete api router

export async function DELETE(request: Request) {
  const data = (await request.json()) as { deleteS3ImgUrl: string }

  const pattern = /(?<=amazonaws\.com\/)[^/]+$/

  const match = data.deleteS3ImgUrl.match(pattern)

  if (match) {
    const fileName = match[0]
    const bucketParams = {
      Bucket: process.env.S3_IMAGE_UPLOAD_BUCKET_NAME,
      Key: `${fileName}`,
    }

    const command = new DeleteObjectCommand(bucketParams)

    s3Client.send(command)
  }

  return NextResponse.json({ message: 'good delete' }, { status: 200 })
}

이전 post api router와 다른점은 DeleteObjectCommand 인스턴스를 활용하여 커맨드 객체를 만든 뒤 전달하면 된다.

profile
차근차근 개발자

0개의 댓글