🔍 개요
이번 글에서는 우리가 개발한 실시간 식당 예약 관리 시스템 `table-now`에서
사용자 프로필 이미지와 식당 이미지를 어떻게 효율적으로 업로드할 수 있을지 고민했고,
그 결과 도입한 Presigned URL 방식의 설계 및 구현 과정을 이번 글에서 공유합니다.
AWS S3에 이미지를 업로드할 때 일반적으로 사용하는 두 가지 방식은 다음과 같습니다:
🔹 MultipartFile 방식
- 서버가 클라이언트로부터 MultipartFile을 받고, 이를 S3로 직접 업로드
- 장점: 간단한 구현
- 단점:
- 서버 트래픽 부담 증가
- 대용량 파일의 경우 서버 리소스 과다 사용
- 업로드 처리 시간이 서버 응답 시간에 포함됨
🔹 Presigned URL 방식
- 클라이언트가 S3에 직접 파일을 업로드하고, 서버는 그 URL만 발급
- 장점:
- 서버 부하 감소
- 대용량 파일 업로드에 적합
- 단점:
- Presigned URL이 외부에 유출될 경우 보안 이슈 발생 가능
- 이를 방지하기 위해 URL 만료 시간 설정이 필수
우리 서비스에서는 사용자 프로필 이미지 및 식당 이미지 업로드 기능이 포함되어 있으며, 업로드된 이미지는 사용자와 일반 방문자가 바로 확인할 수 있어야 하기 때문에 Presigned URL 방식으로 업로드를 처리하고, 다운로드는 퍼블릭 URL 방식으로 접근 가능하도록 설정하였습니다.
✅ 전체 흐름 요약
🟢 1단계: 클라이언트 → 서버
클라이언트: “S3에 업로드할 URL 주세요!”

- 도메인(user 또는 store)은 path variable로 전달
- 서버는 사용자 ID와 함께 파일 경로를 안전하게 생성
🟡 2단계: 서버 → 클라이언트
서버: “이 URL로 S3에 업로드하세요. 업로드되면 이 주소로 접근 가능해요.”

- `uploadUrl`: 프론트가 PUT 요청으로 파일을 업로드할 수 있는 URL (Presigned)
- `fileUrl`: 업로드가 완료되면 이 주소로 이미지 접근 가능함
- 이건 백엔드가 미리 구성한 경로: `디렉토리/사용자ID/UUID.확장자`
- 업로드가 완료된 후 접근하게 될 예상 경로. presigned URL은 아님!
🔵 3단계: 클라이언트 → S3
클라이언트: “uploadUrl에 이미지를 PUT 요청으로 전송할게요.”


- 백엔드랑 상관없이 프론트가 직접 S3에 업로드
- uploadUrl은 유효시간(예: 3분)이 지나면 못 쓰게 됨
- `PUT` 요청을 위한 URL이므로, `GET`으로 접근하거나 다른 용도로는 사용할 수 없
🟣 4단계: 클라이언트 → 서버
클라이언트: “S3에 이미지 업로드 다 했어요! 이 URL을 제 프로필로 저장해주세요.”
PATCH /api/v1/users
Content-Type: application/json
{
"imageUrl": "https://your-bucket.s3.amazonaws.com/profile/42/uuid.png"
}
- 이 요청을 통해, 최종적으로 DB의 user.imageUrl 필드가 채워짐
⚙️ Presigned URL 발급 구현 코드
1. build.gradle S3 의존성 추가
// S3
implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.3.0")
implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3'
2. .env 환경 설정
AWS_ACCESS_KEY=...
AWS_SECRET_KEY=...
AWS_REGION=ap-northeast-2
AWS_S3_BUCKET=your-bucket-name
AWS_S3_PRESIGNED_EXPIRATION=3600
3. application.yml
cloud:
aws:
credentials:
accessKey: ${AWS_ACCESS_KEY}
secretKey: ${AWS_SECRET_KEY}
region:
static: ${AWS_REGION}
s3:
bucket: ${AWS_S3_BUCKET}
presigned-url-expiration: ${AWS_S3_PRESIGNED_EXPIRATION}
4. 컨트롤러 (ImageController)
@PostMapping("/v1/images/upload/{domain}")
public ResponseEntity<PresignedUrlResponse> generatePresignedUrl(
@AuthenticationPrincipal AuthUser authUser,
@PathVariable("domain") String domain,
@Valid @RequestBody PresignedUrlRequest request
) {
ImageDomain imageDomain = ImageDomain.of(domain);
return ResponseEntity.ok(imageService.generatePresignedUrl(authUser, imageDomain, request));
}
5. Presigned URL 생성 (ImageService)
@Slf4j
@RequiredArgsConstructor
@Service
public class ImageService {
private final S3Presigner presigner;
private final S3Client s3Client;
private final S3Properties s3Properties;
private static final String S3_URL_PREFIX = "https://";
private static final String S3_URL_SUFFIX = ".s3.amazonaws.com/";
public PresignedUrlResponse generatePresignedUrl(AuthUser authUser, ImageDomain imageDomain, PresignedUrlRequest request) {
try {
Long userId = authUser.getId();
String fileExtension = extractExtension(request.getFileName());
String key = generateKey(imageDomain, userId, fileExtension);
PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(s3Properties.getBucket())
.key(key)
.contentType(request.getFileType().getMimeType())
.build();
PresignedPutObjectRequest presignedRequest = presigner.presignPutObject(
PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(s3Properties.getPresignedUrlExpiration()))
.putObjectRequest(putObjectRequest)
.build()
);
String uploadUrl = presignedRequest.url().toString();
String fileUrl = getFileUrl(key);
return PresignedUrlResponse.builder()
.uploadUrl(uploadUrl)
.fileUrl(fileUrl)
.build();
} catch (S3Exception | SdkClientException | IllegalArgumentException e) {
log.error("[S3] Presigned URL 생성 중 예외 발생", e);
throw new HandledException(ErrorCode.S3_PRESIGNED_URL_CREATION_FAILED);
}
}
public void delete(String objectPath) {
try {
DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
.bucket(s3Properties.getBucket())
.key(getImageKey(objectPath))
.build();
s3Client.deleteObject(deleteObjectRequest);
} catch (SdkServiceException e) {
log.error(String.valueOf(e.getCause()));
}
}
private String generateKey(ImageDomain imageDomain, Long userId, String fileExtension) {
return imageDomain.name().toLowerCase() + "/" + userId + "/" + UUID.randomUUID() + fileExtension;
}
private String getFileUrl(String key) {
return S3_URL_PREFIX + s3Properties.getBucket() + S3_URL_SUFFIX + key;
}
private String extractExtension(String fileName) {
return fileName.substring(fileName.lastIndexOf('.')); // 예: ".png"
}
private String getImageKey(String objectPath) {
try {
URL url = new URL(objectPath);
return url.getPath().substring(1);
} catch (MalformedURLException e) {
throw new HandledException(ErrorCode.INVALID_IMAGE_URL);
}
}
}
S3 퍼블릭 접근 설정
파일 접근(다운로드)을 위해 S3 객체는 퍼블릭 읽기(Public Read) 권한을 가져야 한다.
{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::your-bucket-name/*"
}
- 공개 가능한 자료에만 적용
- ❗ 퍼블릭 설정은 민감 정보(예: 주민등록증, 내부 문서 등)에는 사용해서는 안 되며, 외부 공개가 의도된 이미지(예: 프로필/썸네일)에만 적용해야 함.
구현 코드 상세
https://github.com/spring-team-7/table-now/pull/66
[feat] 이미지 업로드 기능 구현 by mannaKim · Pull Request #66 · spring-team-7/table-now
🔗 Issue Number close #61 📝 작업 내역 S3 의존성 추가 Presigned PUT Url 발급 API 구현 💡 PR 특이사항 spring-cloud-aws 버전 결정 참고 내용 중: Spring Cloud AWS 3.3.0 brings compatibility with Spring Boot 3.4 업로드 대상
github.com
https://github.com/spring-team-7/table-now/pull/80
[feat] 유저 프로필 이미지 저장 및 삭제 기능 추가 by mannaKim · Pull Request #80 · spring-team-7/table-now
🔗 Issue Number close #69 close #76 📝 작업 내역 presigned URL 로직 리팩토링 프로필 이미지 저장 및 삭제 기능 추가 💡 PR 특이사항 ImageService의 bucketName, expirationMinutes는 환경 변수(@Value) 주입 대상이라 f
github.com
📝 마무리
우리 프로젝트에서는 사용자 프로필 이미지와 식당 이미지를 업로드하는 목적에 맞춰 이 방식을 선택했습니다.
사용자와 고객이 직접 이미지를 확인할 수 있어야 하므로,
- 업로드는 Presigned URL 방식으로 처리하고
- 다운로드는 퍼블릭 URL 방식으로 구성하여
실시간 접근성과 서버 자원 효율이라는 두 가지 목표를 모두 충족할 수 있었습니다.
Presigned URL은 특히 대용량 파일 업로드나 클라이언트-서버 간 책임 분리가 필요한 상황에서 유용하며,
퍼블릭 URL 접근은 공개 가능한 리소스에 대해서만 신중하게 적용해야 합니다.
'Dev Projects' 카테고리의 다른 글
| [table-now] Spring Security의 oauth2Login() 대신 WebClient를 선택한 이유 (3) | 2025.05.28 |
|---|---|
| [table-now] 의사결정 기록 - 외부 연동 시스템 예외 처리 강화 리팩토링 (0) | 2025.05.23 |
| [table-now] AccessToken 블랙리스트 기반 토큰 무효화 기능 구현 (0) | 2025.04.22 |
| [table-now] Refresh Token 저장소를 Redis로 교체하며 인증 구조 고도화하기 (0) | 2025.04.21 |
| [table-now] 소셜 로그인(Kakao, Naver) 통합 구현기 (1) | 2025.04.21 |