9osari.log
← All posts

[DevBid - AWS] S3 파일업로드

October 27, 2025 · 4 min read · #devbid#aws#spring-boot#aws-s3#file-upload#myproject

📚 파일 업로드 시리즈:

  1. AWS S3 파일업로드 (현재 글)
  2. [DevBid - AWS] 파일업로드 Presigned URL 리펙토링

개요

개인 프로젝트 DevBid 경매 플랫폼에서 상품 이미지 업로드 기능을 구현했다. 판매자가 경매 등록 시 대표 이미지 1장과 추가 이미지 최대 4장까지 업로드할 수 있도록 했다.

초기에는 서버를 경유하는 방식으로 구현했고, 이후 Presigned URL 방식으로 개선했다. 이 글에서는 기본 구현 방식과 그 과정에서 발견한 문제점들을 다룬다.

왜 AWS S3인가?

파일 저장 방식을 선택할 때 몇 가지 옵션이 있었다:

1. 로컬 파일 시스템

장점: 추가 비용 없음, 구현 간단
단점: 서버 용량 제한, 백업 어려움, 확장 불가

2. Database BLOB

장점: 트랜잭션 보장, 별도 인프라 불필요
단점: DB 부하, 용량 한계, 성능 저하

3. AWS S3

장점: 
- 무제한 용량
- 99.999999999% 내구성
- CDN 연동 가능 (CloudFront)
- 객체 스토리지 최적화

단점:
- 추가 비용 발생
- AWS 종속성

DevBid는 이미지가 핵심 자산이므로 안정성과 확장성이 중요했다. 따라서 AWS S3를 선택했다.

파일 업로드의 흐름

전체 아키텍처

[브라우저]                [Spring Boot]              [AWS S3]
    │                         │                         │
    │  1. 파일 선택             │                         │
    │  (multipart/form-data)  │                         │
    │                         │                         │
    │ ──2. HTTP POST ────────→│                         │
    │    (이미지 파일 전송)   │                          │
    │                         │                         │
    │                         │  3. MultipartFile 처리   │
    │                         │     (메모리 로드)        │
    │                         │                         │
    │                         │ ──4. S3 업로드 ────────→ │
    │                         │    (PutObjectRequest)   │
    │                         │                         │
    │                         │ ←─5. URL 반환 ────────── │
    │                         │                         │
    │                         │  6. DB 저장              │
    │                         │     (파일 URL)          │
    │                         │                         │
    │ ←─7. 응답 ────────────── │                         │
    │    (성공/실패)          │                          │

주요 처리 흐름

  1. 사용자가 파일 선택 및 업로드
  2. Spring이 MultipartFile로 파일 수신 (메모리 로드)
  3. S3에 파일 업로드
  4. S3로부터 파일 URL 획득
  5. DB에 파일 메타데이터 저장
  6. 사용자에게 결과 응답

구현

1단계 - AWS 설정

application.yml

cloud:
  aws:
    s3:
      bucket: ${AWS_S3_BUCKET:}
    credentials:
      access-key: ${AWS_ACCESS_KEY:}
      secret-key: ${AWS_SECRET_KEY:}
    region:
      static: ${AWS_REGION:ap-northeast-2}
    stack:
      auto: false

AwsProperties 클래스

@Getter
@ConfigurationProperties(prefix = "cloud.aws")
public class AwsProperties {
    private final S3 s3;
    private final Credentials credentials;
    private final Region region;

    public AwsProperties(S3 s3, Credentials credentials, Region region) {
        this.s3 = s3;
        this.credentials = credentials;
        this.region = region;
    }

    @Getter
    public static class S3 {
        private final String bucket;

        public S3(String bucket) {
            this.bucket = bucket;
        }
    }

    @Getter
    public static class Credentials {
        private final String accessKey;
        private final String secretKey;

        public Credentials(String accessKey, String secretKey) {
            this.accessKey = accessKey;
            this.secretKey = secretKey;
        }
    }

    @Getter
    public static class Region {
        private final String staticRegion;

        public Region(String staticRegion) {
            this.staticRegion = staticRegion;
        }
    }
}

S3Config 클래스

@Configuration
@EnableConfigurationProperties({AwsProperties.class})
public class S3Config {
    @Bean
    public S3Client s3Client(AwsProperties awsProperties) {
        // AWS SDK v2의 S3Client는 Builder 패턴을 사용
        return S3Client.builder()
                .region(Region.of(awsProperties.getRegion().getStaticRegion()))
                .credentialsProvider(StaticCredentialsProvider.create(
                        AwsBasicCredentials.create(
                                awsProperties.getCredentials().getAccessKey(),
                                awsProperties.getCredentials().getSecretKey()
                        )
                ))
                .build();
    }
}

2단계 - HTML Form 설정

일반 텍스트가 아닌 파일을 전송하려면 enctype="multipart/form-data" 속성이 필요하다.

<form action="/product/new" method="post" enctype="multipart/form-data">
    <input type="file" id="mainImage" name="mainImage" accept="image/*">
    <div style="font-size:11px; color:#666;">
        * 선택사항. JPG, PNG, GIF (최대 10MB)
    </div>

    <input type="file" id="subImages" name="subImages" accept="image/*" multiple>
    <div style="font-size:11px; color:#666;">
        * 선택사항. 최대 4장 (sortOrder: 2,3,4,5)
    </div>
</form>

3단계 - S3 업로드 서비스


@Service
@RequiredArgsConstructor
public class S3Service {
    private final S3Client s3Client;
    private final AwsProperties awsProperties;

    public String upload(MultipartFile mainImage) {
        if (mainImage == null || mainImage.isEmpty()) {
            return null;
        }

        try {
            // 1. 파일명 생성 (UUID로 중복 방지)
            String fileName = UUID.randomUUID().toString() + extractExtension(mainImage);
            String key = "products/" + fileName;

            //2. S3에 업로드 요청 생성
            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
                    .bucket(awsProperties.getS3().getBucket())
                    .key(key)
                    .contentType(mainImage.getContentType())
                    .build();

            // 3. 서버가 파일을 S3에 업로드 (메모리 경유)
            s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(
                    mainImage.getInputStream(),
                    mainImage.getSize()
            ));

            // 4. S3 URL 반환
            return buildURL(key);
        } catch (Exception e) {
            throw new RuntimeException("파일 업로드 실패 " + mainImage.getOriginalFilename(), e);
        }
    }

    private String buildURL(String key) {
        return String.format("https://%s.s3.%s.amazonaws.com/%s",
            awsProperties.getS3().getBucket(),
            awsProperties.getS3().getRegion(),
            key
        );
    }

    private String extractExtension(MultipartFile mainImage) {
        String fileName = mainImage.getOriginalFilename();
        return fileName.substring(fileName.lastIndexOf('.'));
    }

    public List<String> uploadMultiple(List<MultipartFile> subImages) {
        return subImages.stream()
                .map(this::upload)
                .collect(Collectors.toList());
    }
}

핵심 흐름:

  1. MultipartFile을 서버가 받음 (메모리 로드)
  2. UUID로 고유한 파일명 생성
  3. S3Client.putObject()로 S3에 업로드
  4. S3 URL 생성 및 반환

4단계 - DTO 설계

public record ProductRegistrationRequest(
        String productName,
        String description,
        BigDecimal price,
        Long categoryId,
        String condition,
        Long sellerId,
        String mainImageUrl,    // 파일이 아닌 URL
        List<String> subImageUrls // 파일이 아닌 URL
) {}

왜 MultipartFile을 DTO에 넣지 않았나?

5단계: Controller 구현

@PostMapping("/product/new")
public String createProduct(
        ProductRegistrationRequest request,
        @RequestParam("mainImage") MultipartFile mainImage,
        @RequestParam("subImages") List<MultipartFile> subImages,
        @AuthenticationPrincipal AuthUser authUser,
        BindingResult result,
        RedirectAttributes ra
) {
    if(result.hasErrors()) {
        return "product/productRegister";
    }
    
    // 파일을 S3에 업로드
    String mainImageUrl = s3Service.upload(mainImage);
    List<String> subImageUrls = s3Service.uploadMultiple(subImages);

    ProductRegistrationRequest registrationRequest = new ProductRegistrationRequest(
            request.productName(),
            request.description(),
            request.price(),
            request.categoryId(),
            request.condition(),
            authUser.getId(),
            mainImageUrl,
            subImageUrls
    );

    productService.registerProduct(registrationRequest);

    ra.addFlashAttribute("productName", request.productName());
    return "redirect:/productRegisterSuccess";
}

처리 순서:

  1. Controller에서 파일 업로드 처리
  2. S3 URL을 DTO에 담아 Service로 전달
  3. Service는 순수 비즈니스 로직만 처리

정리

ConfigurationProperties 패턴

@ConfigurationProperties(prefix = "cloud.aws")
// application.yml의 cloud.aws.* 값들을 타입 안전하게 매핑

S3 업로드 과정

MultipartFile → getInputStream() → PutObjectRequest → S3 업로드 → URL 생성

관심사의 분리

Controller: 파일 처리 (MultipartFile → S3 URL)
Service: 비즈니스 로직 (URL 기반 데이터 처리)

발견된 문제점

기본 구현은 완료했지만, 실제 운영 환경을 고려하면 몇 가지 문제가 있었다:

서버 메모리 부담

트랜잭션 문제

성능 문제

특히 경매 이미지처럼 대용량 파일이 빈번하게 업로드되는 서비스에서는 이러한 문제들이 치명적일 수 있다.

이를 해결하기 위해 Presigned URL 방식을 도입했고, 그 과정에서 마주한 문제와 해결 방법은 다음 글에서 다룬다.

📌 관련 글:
[DevBid - AWS] 파일업로드 Presigned URL 리펙토링

···
← PREV [DevBid - JPA] 카테고리 트리 N+1 최적화 NEXT → [DevBid - AWS] 파일업로드 Presigned URL 리펙토링