Infra

PresignedUrl 활용하여 업로드 API 개선 및 서버 부하 줄이기

Joonfluence 2024. 10. 22.

서론 (들어가기 앞서)

  • 현재 제가 재직 중인 회사는 온라인 강의 서비스커뮤니티 서비스를 운영합니다. 저희 고객 분들은 온라인으로 줌 강의를 수강하고, 모임 인증 사진을 커뮤니티에 사진을 업로드하곤 합니다. 문제는 모임이 끝나는 시점에 여러 명의 사용자가 동시 다발적으로, 동시에 업로드를 진행한다는 점입니다. 이로 인해 짧은 시간에 서버로 부하가 몰렸습니다.
  • 현재 AWS S3 서비스를 이용 중인데, 기존에는 S3로 파일을 업로드할 때 WAS를 경유하는 구조였습니다. 특히 요청이 밀리다보면 뒤 요청은 TimeOut 에러로 인해, 아예 처리되지 않는 문제가 발생했습니다. 프론트엔드 서버에서부터 백엔드 서버, S3로 업로드 하는 각 단계에서 네트워크 송/수신을 처리하기 위한 시간이 들며, 업로드를 처리하는 평균 속도가 1초 정도로 느린 편이었습니다. 이로 인해 해당 이벤트가 있는 날, 별도의 백엔드 서버(WAS) 증설을 해야 했습니다.

파일 업로드 API 호출 횟수 및 응답 시간

실제 이벤트가 있었던 날의 데이터

7/18(목) 기준 

- 호출횟수 : 4500번 호출
- 소요시간 : 평균 1초 소요, 최대 100초 소요
- 합계 처리시간 : 92분 소요
  • 이를 PresignedUrl을 활용하여 WAS를 거치지 않고 S3 파일을 직접 업로드함으로써 서버 부하도 줄이고, 서버에서 S3로 통신하는 과정을 줄임으로써, 업로드 API의 성능도 개선할 수 있었습니다. 오늘은 이 PresignedUrl에 무엇이며, 위 문제를 해결할 수 있었던 배경에 대해서 자세하게 알아보도록 하겠습니다.

본론

PresignedUrl 이란

Presigned URL은 특정 사용자에게 제한된 시간 동안 유효한 URL을 제공하여, 클라이언트가 해당 URL을 통해 파일을 직접 클라우드 스토리지에 업로드하거나 다운로드할 수 있도록 하는 방식입니다. 일반적으로 서버를 거치지 않고 클라이언트와 스토리지 간 직접적인 파일 전송이 이루어지기 때문에 서버의 부담을 줄일 수 있습니다.

장점

  • 서버 부하 감소 : 파일을 클라이언트가 직접 S3와 같은 클라우드 스토리지로 전송하기 때문에 서버 리소스 사용을 최소화할 수 있습니다.
  • 보안 : Presigned URL에는 유효 기간이 있고, 이 기간이 지나면 URL이 만료되어 더 이상 접근할 수 없습니다. 또한, 특정 권한을 설정할 수 있어 필요한 작업에만 제한적으로 사용됩니다.
  • 효율성 : 파일을 업로드할 때, WAS를 거치지 않고 직접 스토리지 서비스로 전송하다보니, 네트워크 자원을 최적으로 사용할 수 있습니다. 특히 대용량 파일을 처리할 때 유용합니다.

사용 사례

주요한 사용 사례는 다음과 같습니다.

동시 요청자 수가 많은 어플리케이션의 미디어 파일 업로드

Instagram과 같이, 동시 접속자가 많은 소셜 미디어 앱에서도 presigned URL을 적극 활용 할 수 있습니다. 클라이언트가 사진이나 동영상을 클라우드 서버에 직접 업로드함으로써, 동시 요청 처리량을 높일 수 있습니다. 2가지 측면에서 장점이 있습니다. 첫번째론 API 서버 측면, 두번째론 클라우드 스토리지 측면입니다. 전자에선 서버가 presigned URL 생성에만 관여하기 때문에, 부하가 크지 않습니다. 후자에선 AWS S3와 같은 스토리지 서비스에선 수백만 건의 동시 파일 업로드를 처리할 수 있는 자동 확장성을 제공하므로, 이론적으로 동시 요청의 한계가 거의 없습니다.

대용량 파일 업로드

실제 저희 고객 분들은 부동산 임장을 다녀 온 후, 30-50 페이지에 달하는 임장보고서를 작성하여 업로드 하는 경우가 있습니다. 이 때, 파일 용량은 1GB에 달합니다. 이 때, Presigned URL을 사용해 파일을 S3 같은 스토리지에 직접 업로드하게 하여, 서버는 파일 자체를 처리하지 않고 URL 생성과 인증만 담당합니다. 서버 부하가 줄어들고 클라이언트가 직접 스토리지에 파일을 전송할 수 있어 성능이 향상될 수 있습니다. 실제, 저희 회사 외에도 Youtube나 Vimeo 같은 서비스에서는 특히, 큰 용량의 비디오 파일을 처리해야 하므로, 스토리지 서버에 직접 업로드 하는 것이 효율적일 것 입니다.

제한된 시간 동안의 안전한 파일 공유

저희 서비스에서도 강의를 결제한 고객들만 볼 수 있는 자료들이 있습니다. 이러한 자료들은 Public bucket 안에 보관하지 않고 Private bucket에 보관합니다. 이 때, PresignedUrl을 활용하여, 제한된 시간 동안 파일에 접근 가능한 링크를 제공할 수 있습니다. 일정 시간이 지나면, 링크가 더 이상 유효하지 않기 때문에, 보안을 높일 수 있습니다. 그 외에도 법률 문서, 의료 기록, 계약서와 같은 민감한 데이터를 클라이언트와 한정된 시간 동안만 공유하고 싶을 때, 활용할 수 있습니다.

파일 업로드 기본 흐름

Presigned URL을 활용한 파일 업로드의 흐름은 다음과 같습니다.

  1. 클라이언트가 파일 업로드 요청: 사용자가 파일을 업로드하려는 요청을 서버에 보냅니다.
  2. 서버에서 Presigned URL 생성: 서버는 클라이언트의 요청을 받아 AWS S3와 같은 스토리지에 파일을 업로드할 수 있는 Presigned URL을 생성합니다.
  3. 클라이언트에 Presigned URL 반환: 서버는 생성된 URL을 클라이언트에 반환합니다.
  4. 클라이언트가 URL을 통해 파일 업로드: 클라이언트는 해당 URL을 이용해 직접 S3에 파일을 업로드합니다.
  5. 업로드 완료 후 확인 작업: 클라이언트는 업로드가 성공적으로 완료되었는지 확인하고, 필요한 후속 처리를 합니다.

실습

이론만 다루면 아쉬우니, 서버는 Java 환경, 클라이언트는 JavaScript 환경에서, 위 흐름대로 직접 서비스를 구현한 코드 예시를 보여 드리며, 직접 실습하여 봅시다. Spring 환경 설정 관련해서는 [Framework/Spring] - [Spring] Spring Boot 환경설정 가이드 을 참고하시길 바랍니다. 코드 상에서 S3를 활용하기 위해선, 아래와 같은 작업들이 수반되어야 합니다.

  • AWS 계정 생성
  • IAM Role 또는 Access Key & Secret Key 생성
  • AWS S3 버킷 생성
  • S3 버킷 CORS 설정 (파일 업로드를 위해)

위와 관련된 부분은 본 게시글의 범위를 넘어가므로, 생략합니다.

  • 환경설정
aws:
  s3:
      access: your-access-key # 수정 필요
      secret: your-secret-key # 수정 필요
      region: ap-northeast-2 # 서울 아닐 시, 수정 필요

놓치기 쉬운 부분이, 환경설정하는 부분입니다. 설정을 돕기 위해, 관련된 코드를 첨부합니다. 이와 같은 설정이 없으면, S3 접근 권한이 없습니다.

@Configuration
public class AwsFileConfig {

    @Value("${aws.s3.access}")
    private String accessKey;

    @Value("${aws.s3.secret}")
    private String secretKey;

    @Value("${aws.s3.region}")
    private String region;

    @Bean
    public AmazonS3 amazonS3() {
       return AmazonS3ClientBuilder.standard()
          .withRegion(region)
          .withCredentials(new AWSStaticCredentialsProvider(amazonAWSCredentials()))
          .build();
    }

    @Bean
    public AWSCredentials amazonAWSCredentials() {
       return new BasicAWSCredentials(accessKey, secretKey);
    }
}
  • 클라이언트가 파일 업로드 요청 (JavaScript)

아래는 클라이언트에서 사용자가 파일을 업로드하려고 할 때 서버로 요청을 보내는 예시 코드입니다. 참고로 업로드할 파일의 메타 정보나 이름을 포함할 수 있도록 body 값에 담아 보내줍니다.

async function requestPresignedUrl(fileName) {
  // 서버에 presigned URL 요청
  const response = await fetch('/generate-presigned-url', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ fileName: fileName })
  });

  if (response.ok) {
    const { presignedUrl } = await response.json();
    return presignedUrl;
  } else {
    throw new Error('Failed to fetch presigned URL');
  }
}

async function uploadFileToS3(file) {
  try {
    // Presigned URL 요청
    const presignedUrl = await requestPresignedUrl(file.name);

    // S3로 파일 업로드
    const response = await fetch(presignedUrl, {
      method: 'PUT',
      body: file
    });

    if (response.ok) {
      console.log('File uploaded successfully.');
    } else {
      console.error('File upload failed.', response.statusText);
    }
  } catch (error) {
    console.error('Error uploading file:', error);
  }
}

클라이언트에서 서버로 파일 이름을 전송해 presigned URL을 요청하고, 받은 URL을 사용해 AWS S3에 파일을 업로드합니다.

  • 서버에서 Presigned URL 생성 (Java)

서버는 클라이언트로부터 파일 업로드 요청을 받으면, AWS S3에 presigned URL을 생성하고 반환합니다. 여기서 중요한 부분은 presignedUrl의 접근 제한 시간을 설정하는 부분입니다. 아래 예시에선, LocalDateTime.now().plusMinutes(1L)로 설정 한 뒤, 타입 호환을 위해 자료형을 변경해 두었습니다. 또한 클라이언트에서 입력 받은 fileName을 반영하기 위해, ResponseHeaderOverrides 객체 안에 metadata 값으로 fileName을 받아줬습니다. key 값은 업로드 시, 파일 이름이 됩니다. 이는 중복이 되면 안되므로, UUID로 설정해줍니다.

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import com.amazonaws.services.s3.model.ResponseHeaderOverrides;

@RestController
@RequiredArgsConstructor
public class FileUploadController {

    private final AmazonS3 amazonS3;

    @PostMapping("/generate-presigned-url")
    public Map<String, String> generatePresignedUrl(@RequestBody Map<String, String> requestBody) {
        String fileName = requestBody.get("fileName");
        String uuidFileName = UUID.randomUUID().toString();

        // 제한 시간을 1분으로 설정
        LocalDateTime expirationTime = LocalDateTime.now().plusMinutes(1L); 
        Date expirationDate = Date.from(expirationTime.atZone(ZoneId.systemDefault()).toInstant());

        // 메타데이터 저장
        String encodedFilename = URLEncoder.encode(fileName, StandardCharsets.UTF_8);
        ResponseHeaderOverrides responseHeaders = new ResponseHeaderOverrides().withCacheControl("No-cache");
        responseHeaders.setContentDisposition("attachment; filename=\"" + encodedFilename + "\"");

        GeneratePresignedUrlRequest generatePresignedUrlRequest = new GeneratePresignedUrlRequest(bucketName, uuidFileName)
                .withMethod(HttpMethod.PUT)
                .withExpiration(expirationDate)
                .withResponseHeaders(responseHeaders);

        // Presigned URL 생성
        URL presignedUrl = amazonS3.generatePresignedUrl(generatePresignedUrlRequest);

        // 클라이언트에 URL 반환
        Map<String, String> response = new HashMap<>();
        response.put("presignedUrl", presignedUrl.toString());
        return response;
    }
}

서버 환경은 Java Spring Boot 환경이며, presigned URL을 생성하는 예제입니다. 클라이언트로부터 파일 이름을 받아 해당 파일을 업로드할 수 있는 URL을 생성해 반환합니다.

  1. 클라이언트에 Presigned URL 반환

서버는 presigned URL을 JSON 형식으로 클라이언트에 반환합니다.

{
  "presignedUrl": "https://your-bucket-name.s3.amazonaws.com/your-file.txt?X-Amz-Algorithm=..."
}
  1. 클라이언트가 URL을 통해 파일 업로드

클라이언트는 서버로부터 받은 presigned URL을 통해 AWS S3에 직접 파일을 업로드합니다. 이 단계는 JavaScript 코드에서 이미 처리되었습니다 (fetch API로 S3에 PUT 요청을 보내 파일을 업로드).

  1. 업로드 완료 후 확인 작업

파일이 성공적으로 업로드되었는지 확인하려면, S3에서 response.ok를 확인하고 그에 따라 후속 작업을 진행할 수 있습니다.

if (response.ok) {
  console.log('File uploaded successfully.');
} else {
  console.error('File upload failed.', response.statusText);
}

결론

Presigned URL을 사용한 파일 업로드 방식을 통해, 클라이언트가 서버를 거치지 않고 AWS S3와 같은 스토리지에 직접 파일을 업로드할 수 있었습니다. 클라이언트-서버 간 네트워크 부담을 줄이고, 서버에서 복잡한 파일 관리 로직을 최소화함으로써, 비즈니스에 효과적으로 대응할 수 있었습니다. 이 글을 읽는 분들께서도 비슷한 상황이라면, 고려해보시는 것을 권해봅니다.

반응형

댓글