Jaeilit

AWS S3를 활용한 프로필 이미지 업로드 구현하기 본문

로또헌터

AWS S3를 활용한 프로필 이미지 업로드 구현하기

Jaeilit 2025. 2. 21. 16:10
728x90

AWS S3를 활용한 프로필 이미지 업로드 구현하기

배경

프로필 사진 업로드 관련해서 이미지를 저장할 S3 버킷을 생성하고 업로드하려고 합니다. 처음에는 S3 버킷도 무료가 아니기 때문에 도입을 망설였습니다. 요금이 크진 않겠지만 전송량, 저장용량, 조회, 업로드 등 버킷에 대한 요금이 발생할 수 있기 때문입니다.

 

하지만 Next.js에서 next/image를 활용하려면 외부 이미지에 대한 remotePattern이나 domains를 next.config.js에서 설정해주어야 합니다. 외부 도메인에 대한 URL이 랜덤하다면 도메인을 특정할 수 없으니 설정할 수 없고, 이미지 최적화 기능을 사용할 수 없게 됩니다.

 

다른 방법으로 next/image를 사용하지 않고 일반 HTML img 태그로 렌더링해도 됩니다. 최적화가 필요한 만큼의 큰 사이즈의 이미지도 아니고 게시글이나 상품 정보를 나타내는 특별히 중요한 메세지가 담긴 이미지가 아니기 때문에 이 방법으로 해도 사실 문제가 될 거라고 생각하진 않습니다. 하지만 편의를 위해 이것저것 배제하다 보면 실제로는 프로젝트를 하는 의미가 무색해지지 않나 싶습니다.

 

S3 이미지 업로드 전체적인 흐름

  1. 사용자가 클라이언트에서 업로드 할 이미지 파일을 선택합니다.
  2. 프론트엔드는 파일 유효성을 검사하고 미리보기를 생성합니다.
  3. 프론트엔드는 백엔드 API에 presigned URL을 요청합니다.
  4. 백엔드는 S3에 대한 presigned URL을 생성하여 반환합니다.
  5. 프론트엔드는 presigned URL로 직접 파일을 업로드합니다.
  6. 업로드 완료 후, 프론트엔드는 백엔드 API를 호출하여 사용자 프로필을 업데이트합니다.

1~2 클라이언트에서 이미지 미리보기 생성 & 유효성 검사

// page.tsx
  <div className={styles.img_wrapper}>
      <Avatar src={preview || data.profileImg} size="lg" />
      <input
        id="profile-upload"
        type="file"
        accept=".jpg, .jpeg, .png"
        className={styles.upload}
        onChange={handleImageChange}
      />
      <label htmlFor="profile-upload" className={styles.icon_bg}>
        <Image
          src={uploadIcon}
          width={24}
          height={24}
          alt="프로필 이미지 변경"
        />
      </label>
</div>
    
    
// hooks
'use client';

import { ChangeEvent, useState } from 'react';

const useImageUpload = () => {
  const [preview, setPreview] = useState('');
  const [file, setFiles] = useState<File | null>(null);

  const handleImageChange = (e: ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    const fileType = ['image/jpg', 'image/jpeg', 'image/png'];

    if (!file) return;

    const filesSize = 2 * 1024 * 1024;

    if (file.size > filesSize) {
      alert('파일 용량 초과');
      e.target.value = '';
      return;
    }

    if (!fileType.includes(file.type)) {
      alert('파일 형식이 맞지 않습니다.');
      e.target.value = '';
      return;
    }

    const render = new FileReader();
    render.onload = () => {
      setPreview(render.result as string);
      setFiles(file);
    };
    render.readAsDataURL(file);
  };

  return { preview, file, handleImageChange };
};

export default useImageUpload;

label 태그와 input을 id로 연결시켜주었고 label 아래로 업로드 아이콘을 만들었습니다. 업로드 아이콘을 누르면 label까지 이벤트가 전달되어 연결된 input의 file 선택창이 뜰 것이고, accept 속성으로 이미지 확장자에 대한 규칙을 정해두었습니다.

 

이미지 업로드 훅을 만들어 preview와 file을 담을 state를 만들고 이미지 업로드 시 유효성 검사를 수행합니다. 문제가 없다면 FileReader로 파일을 읽고 base64로 변환된 dataURL 결과물을 preview로 전달하여 클라이언트에 미리보기로 보여주도록 했습니다.

 

 

 

3~4. 프론트엔드는 백엔드 API에 presigned URL을 요청하고 백엔드에서 presigned URL 리턴하기

환경변수 설정

  • AWS_REGION
  • AWS_ACCESS_KEY_ID
  • AWS_SECRET_ACCESS_KEY
  • S3_BUCKET_NAME

Access Key와 Secret Access Key는 IAM을 만들 때 발급받을 수 있는데, Secret Key는 발급 당시에 딱 1회만 공개되기 때문에 잃어버리게 되면 재발급 받아야 합니다. 저도 전에 쓰던 맥북에 저장되어있어서 다시 찾으려고 깃 레포 환경변수에 등록되어 있는 걸 출력해봐도 마스킹되어 표시되어서 찾을 수가 없어 포기하고 재발급 받았습니다.

버킷 만들고 권한 정책 설정

버켓 만드는 것은 어렵지 않습니다만 구조를 확실히 해두는 것이 향후에도 도움이 많이 된다고 생각합니다.

lotto-hunter-storage
  ├── /private
  ├── /public
  │    └── /profiles
  │         └── /thumbnails
  │              └── /{userId}
  └── /temp

지금 사용할 프로필 업로드는 public/profiles/thumbnails/ 경로에 저장하도록 했습니다. 더 세분화하자면 thumbnails 밑으로 small/medium/large 등 사이즈를 나누는 경우도 있습니다.

 

정책 설정 및 IAM 권한 부여

Resource를 루트에서 와일드카드(/*) 설정하면 public과 private 모두 접근이 가능해지므로, public 폴더만 접근 가능하도록 설정했습니다

 

정책 변경이 IAM 권한 때문에 안 되는 경우가 있는데, 그럴 땐 AmazonS3FullAccess 권한을 부여해주면 됩니다.

{
    "Version": "2012-10-17",
    "Id": "Policy1738300337433",
    "Statement": [
        {
            "Sid": "PublicReadForPublicFolder",
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::lotto-hunter-storage/public/*"
        }
    ]
}

3-3 백엔드에서 presigned URL 생성하기

// modules/aws/s3/upload/upload.controller.ts
@ApiTags('업로드 API')
@Controller('upload')
export class UploadController {
  constructor(private readonly uploadService: UploadService) {}

  @ApiResponseType(PresignedUrlOutputDto)
  @UseGuards(AuthGuard)
  @Post('presigned-url')
  async getPresignedUrl(
    @Req() req: Request,
    @Body() { contentType }: PresignedUrlInputDto,
  ) {
    if (!contentType.startsWith('image/')) {
      throw new BadRequestException('이미지 타입이 잘못되었습니다.');
    }

    return this.uploadService.generatePresignedUrl(
      req.user['sub'],
      contentType,
    );
  }
}


// modules/aws/s3/upload/upload.service.ts
@Injectable()
export class UploadService {
  private readonly s3Client: S3Client;
  private readonly bucket: string;

  constructor(private readonly config: ConfigService) {
    this.s3Client = new S3Client({
      region: this.config.get('AWS_REGION'),
      credentials: {
        accessKeyId: this.config.get('AWS_ACCESS_KEY_ID'),
        secretAccessKey: this.config.get('AWS_SECRET_ACCESS_KEY'),
      },
    });

    this.bucket = this.config.get('S3_BUCKET_NAME');
  }

  async generatePresignedUrl(
    userId: string,
    contentType: string,
  ): Promise<PresignedUrlOutputDto> {
    const key = this.generateProfileImageKey(userId);

    const commend = new PutObjectCommand({
      Bucket: this.bucket,
      Key: key,
      ContentType: contentType,
    });

    const presignedUrl = await getSignedUrl(this.s3Client, commend, {
      expiresIn: 300, // presignedUrl 유효기간 5분
    });

    const fileUrl = `https://${this.bucket}.s3.${this.config.get(
      'AWS_REGION',
    )}.amazonaws.com/${key}`;

    return { presignedUrl, fileUrl };
  }

  private generateProfileImageKey(userId: string): string {
    const timeStamp = Date.now();
    const uniqueId = uuid();
    const filename = `${timeStamp}-${uniqueId}`; // 중복방지

    return `public/profiles/thumbnails/${userId}/${filename}`;
  }
}


// 그외 모듈 추가  등..

발급받은 presigned Url

발급받은 presigned URL과 fileURL의 차이점:

  • fileUrl: 실제로 이미지가 저장될 위치의 URL입니다.
  • presignedURL: S3에서 발급받은 이미지 업로드 권한이 담긴 임시 URL입니다. 실체가 없는 가상의 주소이기 때문에 브라우저에서 열어보면 Access Denied 에러가 발생합니다.
  • 아직 업로드하지 않았기 때문에 두 URL 모두 접근하면 파일이 없습니다.

5. 프론트엔드는 presigned URL로 직접 파일을 업로드합니다.

백엔드에서 받은 presigned URL을 활용하여 프론트엔드에서 직접 S3 버킷에 이미지를 업로드합니다. 이 방식의 가장 큰 장점은 파일이 백엔드 서버를 거치지 않고 클라이언트에서 S3로 직접 전송되어 서버 부하를 줄이고 업로드 속도를 높일 수 있다는 점입니다.

//hooks
  const handleS3Upload = async (file: File) => {
    try {
      const {
        data: { presignedUrl, fileUrl },
      } = await api().upload.uploadControllerGetPresignedUrl({
        contentType: file.type,
      });

      const uploadResult = await fetch(presignedUrl, {
        method: 'PUT',
        body: file, // 이미지 파일 전달
        headers: {
          'Content-Type': file.type,
        },
      });

      if (!uploadResult.ok) {
        throw new Error('이미지 업로드에 실패했습니다.');
      }

      return fileUrl;
    } catch (error) {
      console.error(error, '이미지 업로드 실패');
      throw new Error('이미지 업로드 실패');
    }
  };
  
  // pages.tsx
  
  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    if (!data.socialId) return;

    let fileUrl = data.profileImg;

    if (file) {
      fileUrl = await handleS3Upload(file);
    }

    try {
      const updateUser = await api().user.userControllerUpdateProfile(
        data.socialId,
        {
          nickname: formState.nickname.value,
          profileImg: fileUrl,
        },
      );

      mutate(API_KEY.AUTH.ME, updateUser.data); // me api 재검증 profile 즉시 변경적용
    } catch (error) {
      console.error('프로필 변경 실패', error);
      throw new Error('프로필 변경 실패');
    }
  };

 

CORS 설정 문제

저장된 이미지를 불러올 때 S3 버킷에 대한 CORS 설정 이슈가 있어 아래와 같이 버킷에 설정을 해주었습니다. CORS 에러가 발생한다면 다음과 같이 설정하면 됩니다

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "PUT",
            "POST",
            "DELETE",
            "GET"
        ],
        "AllowedOrigins": [
            "http://localhost:3000",
            // 허용 도메인
        ],
        "ExposeHeaders": [
            "ETag"
        ]
    }
]

에러 처리

S3 업로드 과정에서는 다양한 에러가 발생할 수 있습니다:

  • 네트워크 오류
  • presigned URL 만료
  • 권한 부족
  • CORS 설정 문제

따라서 적절한 에러 처리와 사용자 피드백이 중요합니다.

파일 업로드 후 처리

성공적으로 파일이 업로드되면 반환된 fileUrl을 사용하여 사용자 프로필 정보를 업데이트합니다.

중요 포인트

  1. presigned URL은 유효 기간이 있으므로(아까 백엔드에서 설정한 5분) 발급 후 최대한 빨리 사용해야 합니다.
  2. 직접 업로드 방식에서는 반드시 CORS 설정이 올바르게 되어 있어야 합니다.
  3. Content-Type 헤더가 presigned URL 생성 시 지정한 것과 정확히 일치해야 합니다.

이 방식을 활용하면 파일을 효율적으로 업로드할 수 있으며, 실제로 서버에 파일을 보내는 것이 아니기 때문에 부하를 크게 줄일 수 있습니다.

 

6. 기타 추가 고려사항

  • 버켓 저장용량을 고려한 생명주기
  • 이미지 리사이징
  • 사용자에게 알려줄 모달 / Toast 메세지

버킷 저장용량을 고려한 생명주기 설정

S3 버킷의 스토리지 비용을 최적화하기 위해 생명주기 정책을 설정할 수 있습니다. 예를 들어, 임시 파일을 30일 후 자동 삭제하거나, 오래된 파일을 저비용 스토리지 클래스로 전환할 수 있습니다.

{
  "Rules": [
    {
      "ID": "Delete temp files",
      "Status": "Enabled",
      "Prefix": "temp/",
      "ExpirationInDays": 1
    }
  ]
}

아직은 추가적으로 설정을 해두지 않았습니다.

이미지 리사이징

이미지 최적화를 위해 사용자가 업로드한 이미지를 여러 크기로 리사이징하여 저장할 수 있습니다. 이를 위해 AWS Lambda와 S3 트리거를 사용하거나, 백엔드에서 Sharp 라이브러리를 활용할 수 있습니다. 하지만 파일을 직접적으로 백엔드로 보낸 것이 아니기 때문에 백엔드에서 이미지 리사이징 처리 할 순 없고 AWS Lamda 나 클라이언트에서 s3보내는 과정에 미들웨어로 리사이징 처리를 해야합니다

 

사용자 피드백

업로드 과정에서 사용자에게 적절한 피드백을 제공하기 위해 로딩 상태, 성공 및 에러 메시지를 표시하는 UI 컴포넌트를 구현하는 것이 좋습니다.

 

결론

AWS S3를 활용한 프로필 이미지 업로드 구현은 서버 부하를 줄이고 효율적인 파일 관리가 가능하다는 장점이 있습니다. Presigned URL을 통한 직접 업로드 방식은 백엔드 서버를 거치지 않아 더욱 효율적이며, 적절한 폴더 구조와 권한 설정을 통해 보안도 관리할 수 있습니다.

구현 시 주의할 점은 CORS 설정, presigned URL의 유효 기간, 적절한 에러 처리 등이 있으며, 추가로 이미지 최적화나 스토리지 관리를 위한 기능을 도입하면 더욱 완성도 높은 시스템을 구축할 수 있습니다.

728x90