본문으로 건너뛰기

AWS 탈탈 털리고, Amazon Lightsail을 이용해 안전하게 파일 업로드 해보기 (Spring Boot)

2025년 12월 24일에 작성되었고, 블로그를 이전하며 옮겨온 포스트입니다.
여기에서 원본을 확인할 수 있습니다.

얼마 전, AWS에서 보내주는 경고 이메일을 열심히 무시하다가 계정이 잠겼다는 이메일을 보고 후다닥 AWS Console로 들어가 보았는데, 엄청난 과금이 되고 있다는 것을 발견했다.. 계정 자체가 털린 것은 아니지만, 프로젝트를 배포하는 과정에서 흘린 콘솔 액세스 키가 탈취되어 액세스 키를 활용해 다른 사람이 계정의 모든 지역에 (고가의) 인스턴스를 무작위로 생성해 과금을 유발하고 있었던 것이였다.. (다행히 킹왕짱 AWS 측에서 요금 조정을 해줌)

액세스 키를 왜 발급했었냐면,,

단지, 프로젝트의 기능 중 하나인 프로필 사진 업로드 기능을 구현하는데, Amazon S3 서비스를 이용하려고 액세스 키를 발급했다. 지금 생각해 보면 IAM 권한을 제한하거나 해당 EC2 인스턴스에서만 접근할 수 있도록 조치했어야 하는데, 아무런 대책 없이 액세스 키를 발급하여 서비스에 사용한 것이 문제였다.

이 글에서는,

  • Lightsail Instance
  • Lightsail Storage
  • Spring Boot

위 서비스, 프레임워크를 이용해 액세스 키를 배포에 사용하지 않고, 파일을 안전하게 업로드하고 업로드한 파일을 삭제하는 과정을 담아볼 예정이다.

1. Storage 생성하기

먼저, Amazon Lightsail 콘솔에서 왼쪽 사이드 바에 있는 Storage 메뉴를 선택한다. 이후 Create bucket 버튼을 선택하면 아래와 같은 화면이 표시된다.

본인의 서비스에 맞게 사용할 버킷의 지역을 선택하고, 요금제를 선택하고, 버킷 이름을 지정하고 Create bucket 버튼을 탭하면 모든 과정이 끝난다!

  • 최대 5GB Storage, 25GB의 대역폭은 1년(12개월) 동안 무료로 제공됩니다.

버킷이 생성되면 위 화면과 같이 생성된 버킷이 표시되는데요, Permissions 메뉴를 탭하고, Bucket access permissions에서 Change permissions 버튼을 탭해준다.

그러면 위와 같은 화면이 표시되는데, All objects are public and read-only 를 선택하고, 오른쪽 아래에 있는 Save 버튼을 클릭한다. 이 설정은 업로드 한 파일이 외부에 공개되도록 설정하는 과정이다.

이 설정이 되어있어야 사용자들이 외부에서 인증 없이 파일의 URL로 접근할 수 있게된다. 내부에서만 이 파일을 이용한다면 해당 설정을 하지 않는게 좋다.

이제 아래의 Resource access 부분을 설정해 줄 차례이다.

Select an instance from Seoul (ap-northeast-2)에서 Lightsail에서 생성한 인스턴스를 선택하고 Attach 버튼을 클릭한다. 이 설정을 해야 Lightsail Instance에 배포된 Spring Boot 프로젝트에서 액세스 키 ID와 액세스 키 없이 파일을 올리고 삭제할 수 있다. 이 설정 과정에서 눈치채셨겠지만, 배포된 해당 인스턴스 안에서는 인증 없이 Lightsail에서 생성한 버킷에 접근할 수 있다는 것이다.

다음으로 Access keys를 발급할 차례이다. 그러면 ‘액세스 키 없이 한다면서 왜 액세스 키를 발급하는가?’ 바로 위 단계에서 언급했지만, 인증 없이 파일을 업로드하고, 삭제하는 과정은 Lightsail Instance 내부에서만 가능하다. 우리의 로컬 환경에서 테스트하려면 액세스 키를 등록해야 로컬 환경에서 원활하게 테스트 해볼 수 있다. 로컬 환경에서 테스트가 완료되었다면 이 액세스 키를 삭제하면 된다.

2. Spring Boot에서 기능 구현하기

먼저, 로컬 개발 환경에서 테스트하기 위해 터미널에서 AWS 인증을 진행해 줘야한다. 이 인증 과정에서는 위 단계에서 사용한 액세스 키가 사용될 예정이다. Homebrew를 사용해 awscli를 설치한다.

 brew install awscli

설치가 완료되었다면 아래의 명령어를 실행한다.

yunhwanjeon@Yunhwanui-MacBookPro kangneng-server % aws configure
AWS Access Key ID [None]: <액세스 키 ID>
AWS Secret Access Key [None]: <액세스 키>
Default region name [None]: ap-northeast-2
Default output format [None]:
  • AWS Access Key ID [None]: 위 단계에서 발급받은 액세스 키 ID 입력
  • AWS Secret Access Key [None]: 위 단계에서 발급받은 액세스 키 입력
  • Default region name [None]: 서울(ap-northeast-2) 리전 입력
  • Default output format [None]: 엔터

build.gradle에서 아래의 의존성을 추가한다. (gradle)

implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'

application.yml에서 아래의 내용을 설정한다.

spring:
  cloud:
    aws:
      s3:
        bucket: <버킷 이름>
      region:
        static: ap-northeast-2
  servlet:
    multipart:
      max-file-size: 5MB
      max-request-size: 5MB

FileService.java

// 아래 의존성을 사용해 주세요
import io.awspring.cloud.s3.ObjectMetadata;
import io.awspring.cloud.s3.S3Template;
import org.springframework.beans.factory.annotation.Value;

@Service
@RequiredArgsConstructor
public class FileService {
    private final S3Template s3Template;

		// application.yml의 bucket 이름 가져오기
    @Value("${spring.cloud.aws.s3.bucket}")
    private String bucket;

		// 파일 확장자(jpg, png, gif) 제한
    private boolean isImageFile(MultipartFile file) {
        String contentType = file.getContentType();
        if (contentType == null) return false;

        List<String> allowedTypes = List.of("image/jpeg", "image/png", "image/gif", "image/webp");

        return allowedTypes.contains(contentType.toLowerCase());
    }

		// URL에서 파일 명 추출
    private String extractFileName(String fileUrl) {
        return fileUrl.substring(fileUrl.lastIndexOf("/") + 1);
    }

		// Lightsail 버킷에 파일 업로드
    private String uploadFileToS3(MultipartFile file) throws IOException {
		    // 파일이 존재하지 않을 때
        if (file.isEmpty()) {
            throw new FileEmptyException();
        }

				// 이미지 파일이 아닐 때
        if (!isImageFile(file)) {
            throw new InvalidFileException();
        }

				// 아래 과정은 원본 파일 명에서 공백을 _로 변환하는 과정
        String originalName = file.getOriginalFilename();
        String cleanedName = (originalName != null) ? originalName.replaceAll("\\s", "_") : "unnamed";

				// 파일 명이 중복되어도 기존 파일이 손상되지 않도록 UUID 사용
        String fileName = UUID.randomUUID() + "_" + cleanedName;

				// 파일 업로드
        var resource = s3Template.upload(bucket, fileName, file.getInputStream(),
                ObjectMetadata.builder().contentType(file.getContentType()).build());

				// 업로드 된 파일의 URL 반환
        return resource.getURL().toString();
    }

		// 파일 삭제
    public void deleteFile(String fileUrl) {
        String fileName = extractFileName(fileUrl);
        s3Template.deleteObject(bucket, fileName);
    }

		// 파일 업로드
    public String uploadFile(MultipartFile file) {
        String fileUrl = uploadFileToS3(file);
        return fileUrl;
    }
}

FileController.java

@RestController
@RequestMapping("/api/v1/file")
@RequiredArgsConstructor
public class FileController {
    private final FileService fileService;

    @Operation(
            summary = "파잉 삭제"
    )
    @DeleteMapping("/")
    public void deleteFile(@RequestParam String url) {
        fileService.deleteFile(url);
    }

    @Operation(
            summary = "파일 업로드"
    )
    @PostMapping(value = "/", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public String uploadFile(@RequestPart("file") MultipartFile file) {
        return fileService.uploadFile(file);
    }
}

여기까지 구현하고, Swagger 또는 Postman 같은 API 테스터를 이용해 /api/v1/file/로 Post요청과 Delete 요청을 보내 파일을 업로드 하거나 삭제할 수 있다. 로컬 환경에서 테스트가 끝났고, 동일하게 액세스 키 없이 Lightsail Instance에 배포를 진행하면, 인증 과정 없이 파일을 업로드하거나 삭제할 수 있음을 확인할 수 있다. 로컬 환경에서 더 이상 테스트를 진행하지 않을 예정이라면 버킷 관리 페이지에서 발급 받은 액세스 키를 삭제해 주는 것이 좋다.