이전 글에서는 Presigned URL을 활용하여 이미지를 조회하는 기능을 구현했습니다.
Presigned URL로 S3 프라이빗 버킷 이미지 조회하기 (SDK v2)
프로메사 프로젝트를 진행하던 중, 원래 계획에는 없었던 Presigned URL을 도입하게 되었습니다. 이 글에서는 해당 기술을 왜 선택하게 되었는지 먼저 설명하고 이어서 Presigned URL의 개념을 간단히
devyeonee911.tistory.com
이번 글에서는 `Presigned URL`을 활용하여 S3에 이미지를 업로드하고, 추가적으로 S3 버킷의 이미지를 삭제하는 기능까지 구현해보겠습니다.
직접 구현하는 과정에서는 `Presigned URL`을 사용하기 위한 기본 세팅 (ex. 의존성 주입, S3Config 등)에 대한 내용은 포함하지 않으므로 이전 글에서 작성한 설정 부분을 참고하시길 바랍니다.
🍒파일을 업로드하는 방법
이미지, 즉 파일을 S3에 업로드하는 방법에는 여러 가지가 있습니다. 가장 많이 사용하는 방식은 `MultipartFile`을 활용하는 방법일 것입니다.
Spring Boot에서 S3에 파일을 업로드하는 데는 세 가지 방법이 있습니다.
- Stream 업로드
- MultipartFile 업로드
- AWS Multipart 업로드
이러한 방식들은 모두 서버, 즉 백엔드에서 파일을 직접 S3에 업로드하는 구조입니다.
반면 `Presigned URL`을 활용한 방식은 서버가 아닌 클라이언트(프론트엔드)에서 파일을 업로드하는 구조입니다.
Presigned URL을 사용할 때의 파일 업로드 흐름과 이 방식의 장점, 그리고 특징에 대해 알아보겠습니다.
🍒Presigned URL을 통해 객체 업로드
🍀객체 업로드 흐름
`Presigned URL`은 Amazon S3 버킷에 대한 권한 정보를 담고 있어서 다른 사용자가 해당 URL을 통해 버킷의 객체를 조회하거나 수정할 수 있도록 허용합니다. 즉 상대방에게 AWS 보안 자격 증명이나 권한이 없어도 URL을 통해 파일을 업로드할 수 있습니다.
서버에 클라이언트 측으로부터 이미지 업로드 요청이 왔을 때, 서버는 S3 버킷에 업로드할 수 있는 권한을 담고 있는 `Presigned URL`을 클라이언트에 발급합니다. 이후 클라이언트는 해당 URL을 사용해 직접 S3에 이미지를 업로드합니다.
🍀장점 및 사용 이유
그렇다면, 서버(Spring boot)를 거치지 않고 클라이언트에서 업로드하는 방식은 무엇이 좋을까요?
클라이언트에서 업로드가 이루어지므로 서버의 부하를 줄일 수 있습니다. 또 이미지 업로드 속도가 빨라져 사용자 경험을 향상됩니다.
이미지 파일은 용량이 매우 크기 때문에 일반 API 요청에 비해 서버에 많은 부하를 줍니다. 만약 백엔드 서버를 거쳐 이미지를 업로드하면 백엔드 서버가 금방 과부하에 걸릴 수 있고, 이를 방지하기 위해 동시 업로드 요청 수를 제한해야 할 수도 있습니다. 이는 사용자 입장에서 불편함을 초래할 수 있습니다.
프론트엔드에서 백엔드를 거쳐 Spring Boot를 이용해 파일을 업로드 했던 것은 보안 때문입니다. S3 버킷의 객체를 아무나 수정하면 안되기 때문에 업로드하고자 하는 파일을 백엔드로 넘겨, 백엔드가 S3에 대한 보안 절차를 거치고 S3에 이미지를 업로드하는 것이었습니다. 그러나 S3버킷의 퍼블릭 액세스를 차단하고 `Presigned URL`을 이용함으로써 필요한 경우에만 접근을 허용함으로써 보안 문제를 해결할 수 있습니다. 백엔드는 `Presigned URL`을 발급하여 보안 작업을, 프론트는 해당 URL을 통해 이미지를 업로드하는 구조로 작업을 분리시켰습니다.
🍀특징
- 클라이언트가 URL을 사용하여 객체를 업로드하는 경우 Amazon S3는 지정된 버킷에 객체를 생성합니다.
- `Presigned URL`에 지정된 것과 동일한 키(key)를 사용하는 객체가 이미 버킷에 있다면 Amazon S3는 업로드된 객체로 기존 객체를 덮어씌웁니다.
- 업로드 후에는 버킷 소유자가 객체를 소유하게 됩니다.
따라서 의도치 않게 이미지가 덮어씌워지는 것을 방지하기 위해, 고유한 key를 생성하여 사용하는 것이 좋습니다.
이후 구현 과정에서 고유한 key를 생성하는 방법도 함께 다뤄보겠습니다.
🍒이미지 업로드 구현하기
공식문서에서 제공하는 소스 코드를 활용하여 프로젝트에 맞게 수정해 사용하는 방식으로 진행하겠습니다.
그리고 프론트엔드 구현 없이 발급받은 URL을 통해 실제로 업로드가 되는지 확인하는 방법을 소개하고 글을 마무리하겠습니다.
아래는 원문 소스코드 `createPresignedUrl`입니다.
/* Create a presigned URL to use in a subsequent PUT request */
public String createPresignedUrl(String bucketName, String keyName, Map<String, String> metadata) {
try (S3Presigner presigner = S3Presigner.create()) {
PutObjectRequest objectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(keyName)
.metadata(metadata)
.build();
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(10)) // The URL expires in 10 minutes.
.putObjectRequest(objectRequest)
.build();
PresignedPutObjectRequest presignedRequest = presigner.presignPutObject(presignRequest);
String myURL = presignedRequest.url().toString();
logger.info("Presigned URL to upload a file to: [{}]", myURL);
logger.info("HTTP method: [{}]", presignedRequest.httpRequest().method());
return presignedRequest.url().toExternalForm();
}
}
이전 글에서 구현한 `createPresignedGetUrl` 메서드와 동일한 방식으로 초기 작업을 진행하겠습니다.
- `S3Service`클래스에 해당 메서드를 작성하고 `S3Presigner` 객체는 빈으로 등록하여 사용하겠습니다.
- `S3Presigner` 인스턴스는 생성자 주입 방식으로 주입받겠습니다.
- 로그 출력을 위해 `Slf4j` 어노테이션을 활용하겠습니다.
- `try-catch`문을 활용해 커스터 예외 처리를 적용하겠습니다.
- `@Value` 어노테이션을 활용하여 만료 시간을 외부 설정 파일에서 주입받아 사용하겠습니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {
private final S3Presigner s3Presigner;
private final S3Client s3Client;
@Value("${aws.s3.presigned.expire-minutes}")
private long expireMinutes;
public String createPresignedPutUrl(String bucketName, String keyName, Map<String, String> metadata) {
try {
PutObjectRequest objectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(keyName)
.metadata(metadata)
.build();
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(expireMinutes))
.putObjectRequest(objectRequest)
.build();
PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest);
String myURL = presignedRequest.url().toString();
log.info("Presigned URL to upload a file to: [{}]", myURL);
log.info("HTTP method: [{}]", presignedRequest.httpRequest().method());
return presignedRequest.url().toExternalForm();
} catch (Exception e) {
log.error("Presigned URL 생성 실패: {}/{}", bucketName, keyName, e);
throw InternalServerError.EXCEPTION;
}
}
}
그리고 이 코드에는 한 가지 치명적인 문제점과 한 가지 개선사항이 존재합니다.
🔧개선사항 1 : key 중복 시 덮어쓰기 문제
앞서 살펴본 `Presigned URL`의 특징의 경우, 동일한 key가 이미 S3 버킷에 존재할 경우 기존의 객체를 덮어쓴다는 점입니다. 다음과 같은 시나리오를 가정할 수 있습니다.
user1이 분홍색 컵을 구매 후 cup.png 파일을 업로드하여 리뷰를 남겼습니다. 이때 s3 버킷에 키가 'cup'으로 올라갔다고 가정하겠습니다.
그 후 user2가 파란색 컵을 구매 후 cup.png 파일을 업로드하여 리뷰를 남겼습니다. 이때도 s3 버킷에 키가 'cup'으로 올라갔다고 가정하면, 이미 s3에 존재하던 key가 있으므로 해당 키를 덮어쓰게 됩니다. 즉 분홍색 컵의 이미지는 파란색 컵의 이미지로 덧씌워집니다.
이후 리뷰를 조회해보면 분홍색 컵에 대한 리뷰 화면에는 파란색 컵의 이미지가 나타나는 문제가 발생합니다.
이와 같은 상황을 방지하기 위해서는 key값이 중복되지 않도록 고유한 key를 생성해주는 것이 중요합니다. 이는 UUID를 활용하여 해결할 수 있습니다.
UUID는 'Universally Unique Identifier'의 약자로 128-bit의 고유 식별자입니다. 다른 고유 ID 생성 방법과 달리 UUID는 중앙 시스템에 등록하고 발급하는 과정이 없어서 상대적으로 더 빠르고 간단하게 만들 수 있다는 장점이 있습니다.
RFC 4122 문서에 정의된 UUID 버전 4 표준 규약을 따르면 1조 개의 UUID 중에 중복이 일어날 확률은 10억 분의 1로, 고유하지 않을 가능성도 존재합니다.
UUID의 또 다른 장점은 작은 크기입니다. 다른 고유 식별자에 비해 정렬, 차수, 해싱 등 다양한 알고리즘에 사용하기 쉽고 데이터베이스에 보관하기도 용이합니다.

UUID는 128-bit의 숫자 문자열이고 총 길이는 36자리입니다. 32개의 16진수 숫자가 4개의 하이픈으로 나누어진 `8-4-4-4-12` 형태입니다.
하이픈 사이에 있는 16진수 숫자들은 하나의 필드로 구성됩니다. 각 필드는 정수로 취급되며 가장 중요한 숫자가 앞에 나옵니다. 예를 들어 위의 예시에서 세번째 필드 `4bad`의 첫 숫자 `4`는 버전 4 UUID를 의미합니다. 따라서 어떤 UUID를 보더라도 세 번째 필드의 첫 숫자를 통해 버전 정보를 확인할 수 있습니다.
UUID를 활용하여 기존 이미지 파일명을 key로 사용하는 대신, `UUID + key` 형태로 고유한 key를 생성하겠습니다.
private String generateKey(ImageType imageType, Long referenceId, String originalFileName) {
String uuid = UUID.randomUUID().toString();
return switch (imageType) {
case REVIEW -> "reviews/" + referenceId + "/" + uuid + "-" + originalFileName;
case PROFILE -> "profiles/" + referenceId + "/" + uuid + "-" + originalFileName;
case ITEM -> "items/" + referenceId + "/" + uuid + "-" + originalFileName;
};
}
UUID를 발급하고 발급받은 UUID를 기존 key에 붙여 고유한 key값을 생성하는 `generateKey`메서드입니다.
매개변수를 하나씩 살펴보겠습니다.
- `ImageType imageType` : 해당 이미지의 업로드 목적
- REVIEW : 리뷰 작성 시 이미지 등록을 위함
- PROFILE : 프로필 등록을 위함
- ITEM : 상품 설명 이미지 등록을 위함
- `Long referenceId` : 해당 엔티티의 Id
- `imgaeType`이...
- REVIEW 일 경우 : `itemId`
- PROFILE 일 경우 : `memverId`
- ITEM 일 경우 : `itemId`
- `String originalFileName` : 클라이언트에서 전달받은 원본 파일명
그리고 key를 생성하는 곳에 슬래시(`/`)를 붙이는 이유는 S3 버킷에서 key 내의 슬래시는 폴더 경로를 의미하기 때문입니다. 따라서 슬래시를 적절히 이용하여 원하는 구조로 이미지를 정리하고 쉽게 관리할 수 있습니다.
만약, 상품ID가 1번인 상품에 `cup.png`라는 리뷰 이미지를 등록한 경우 S3 버킷 내 구조는 아래와 같습니다.
├─📂items
├─📂profiles
└─📂reviews
└─📂1
└─📜98ddafb3-efe6-4678-b3e4-b2f2ff2e400d-cup.png
리뷰에 등록한 이미지이므로 `/reviews` 폴더에, `itemId`가 1번인 상품에 대한 리뷰이므로 `/1` 폴더에, 그리고 UUID와 파일명을 합친 고유한 파일명이 들어가 있습니다.
이제 기존에 작성한 `createPresignedPutUrl`메서드에 방금 구현한 `generateKey`메서드를 내부 호출하여 이용해보겠습니다.
그리고 새롭게 고유한 key값을 생성했으니, 클라이언트에 응답을 내려줄 때 url뿐만 아니라 생성된 key값을 함께 넘겨주면 좋을 것 같습니다. `String` 타입으로 반환하던 구조에서 Response DTO 객체를 생성하여 반환하도록 수정하겠습니다.
package com.promesa.promesa.common.dto.s3;
public record PresignedUrlResponse (
String key,
String url
){}
"key": "reviews/1/98ddafb3-efe6-4678-b3e4-b2f2ff2e400d-cat.png",
"url": "https://ceos-promesa.s3.ap-northeast-2.amazonaws.com/reviews/1/98ddafb3-efe6-4678-b3e4-b2f2ff2e400d-cat.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20250614T070535Z&X-Amz-SignedHeaders=host%3Bx-amz-meta-uploadby&X-Amz-Expires=900&X-Amz-Credential=AKIA6CGYKEJU2OIBLQSX%2F20250614%2Fap-northeast-2%2Fs3%2Faws4_request&X-Amz-Signature=d80ca0b8492c7b1c4d6bbeaa7cf9e866e0020d2bb1b5d54727aacb6957845ec6"
반환 예시입니다.
그리고 `createPresignedPutUrl`의 반환형식 및 파라미터를 수정하고 내부에서 `generateKey` 메서드를 호출합니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service{
private final S3Presigner s3Presigner;
private final S3Client s3Client;
@Value("${aws.s3.presigned.expire-minutes}")
private long expireMinutes;
public PresignedUrlResponse createPresignedPutUrl( // ✔️변경!
String bucketName,
ImageType imageType, // ➕추가!
Long referenceId, // ➕추가!
String originalFileName, // ✔️변경!
Map<String, String> metadata
) {
try {
String key = generateKey(imageType, referenceId, originalFileName); // ➕추가!
PutObjectRequest objectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(key) // ✔️변경!
.metadata(metadata)
.build();
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(expireMinutes))
.putObjectRequest(objectRequest)
.build();
PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest);
return new PresignedUrlResponse(key, presignedRequest.url().toExternalForm()); // ✔️변경!
} catch (Exception e) {
log.error("Presigned URL 생성 실패", e); // ✔️변경!
throw InternalServerError.EXCEPTION;
}
}
private String generateKey(ImageType imageType, Long referenceId, String originalFileName) {
String uuid = UUID.randomUUID().toString();
return switch (imageType) {
case REVIEW -> "reviews/" + referenceId + "/" + uuid + "-" + originalFileName;
case PROFILE -> "profiles/" + referenceId + "/" + uuid + "-" + originalFileName;
case ITEM -> "items/" + referenceId + "/" + uuid + "-" + originalFileName;
};
}
}
🔧개선사항 2 : 단일 업로드 요청만 처리 문제
마지막 개선사항으로는 여러 이미지를 한 번에 처리하는 기능입니다. 현재는 단일 이미지 업로드 요청만 처리 가능한데, 사용자는 여러 장의 이미지를 업로드하기 위해서 이미지 한 장씩 개별 요청해야 하는 번거로움이 생깁니다. 이는 사용자 경험을 저해시킬 수 있습니다.
여러 장의 이미지에 대한 업로드 요청이 들어온 경우, 발급한 Presigned URL을 묶어서 한 번에 반환하도록 구조를 개선하겠습니다.
우선 request의 형식부터 수정하바니다.
public record PresignedUrlRequest (
ImageType imageType,
Long referenceId,
List<String> fileNames,
Map<String, String> metadata
){}
반환 타입 `PresignedUrlResponse`를 List로 묶어서 반환하는 형태로 수정합니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class S3Service {
private final S3Presigner s3Presigner;
private final S3Client s3Client;
@Value("${aws.s3.presigned.expire-minutes}")
private long expireMinutes;
public List<PresignedUrlResponse> createPresignedPutUrl(
String bucketName,
ImageType imageType,
Long referenceId,
List<String> fileNames, // ✔️변경!
Map<String, String> metadata
) {
try {
return fileNames.stream() // ➕추가!
.map(originalFileName -> { // ➕추가!
try {
String key = generateKey(imageType, referenceId, originalFileName);
PutObjectRequest objectRequest = PutObjectRequest.builder()
.bucket(bucketName)
.key(key)
.metadata(metadata)
.build();
PutObjectPresignRequest presignRequest = PutObjectPresignRequest.builder()
.signatureDuration(Duration.ofMinutes(expireMinutes))
.putObjectRequest(objectRequest)
.build();
PresignedPutObjectRequest presignedRequest = s3Presigner.presignPutObject(presignRequest);
log.info("Presigned URL 생성: {}", presignedRequest.url());
return new PresignedUrlResponse(key, presignedRequest.url().toExternalForm());
} catch (Exception e) {
log.error("Presigned URL 생성 실패", e);
throw InternalServerError.EXCEPTION;
}
})
.toList(); // ➕추가!
} catch (Exception e) {
log.error("Presigned URL 생성 실패", e);
throw InternalServerError.EXCEPTION;
}
}
private String generateKey(ImageType imageType, Long referenceId, String originalFileName) {
String uuid = UUID.randomUUID().toString();
return switch (imageType) {
case REVIEW -> "reviews/" + referenceId + "/" + uuid + "-" + originalFileName;
case PROFILE -> "profiles/" + referenceId + "/" + uuid + "-" + originalFileName;
case ITEM -> "items/" + referenceId + "/" + uuid + "-" + originalFileName;
};
}
}
업로드 요청 이미지들을 List의 형태로 반환한 다음, 해당 리스트를 순회하면서 PresignedUrl 발급을 N번 해줍니다.
🍒터미널을 이용해 이미지 업로드 기능 확인하기
`Presigned URL`을 통해 이미지를 업로드하는 기능은 백엔드에서 url을 발급해주면 프론트엔드가 업로드한다고 설명했습니다. 그러나 테스트 단계에서는 프론트엔드 구현은 어렵기 때문에, 터미널을 이용해 발급받은 url을 통해서 직접 이미지를 업로드하는 방법을 소개하겠습니다.
우선 swagger, postman 등을 이용해 `Presigned URL`을 발급받는 API 요청을 보내고 Response를 받아옵니다.
Request
{
"imageType": "REVIEW",
"referenceId": 1,
"fileNames": [
"cat.png"
],
"metadata": {
"uploadBy": "1"
}
}
Response
{
"success": true,
"status": 200,
"timeStamp": "2025-06-14T16:05:36.0252927",
"data": [
{
"key": "reviews/1/98ddafb3-efe6-4678-b3e4-b2f2ff2e400d-cat.png",
"url": "https://ceos-promesa.s3.ap-northeast-2.amazonaws.com/reviews/1/98ddafb3-efe6-4678-b3e4-b2f2ff2e400d-cat.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20250614T070535Z&X-Amz-SignedHeaders=host%3Bx-amz-meta-uploadby&X-Amz-Expires=900&X-Amz-Credential=AKIA6CGYKEJU2OIBLQSX%2F20250614%2Fap-northeast-2%2Fs3%2Faws4_request&X-Amz-Signature=d80ca0b8492c7b1c4d6bbeaa7cf9e866e0020d2bb1b5d54727aacb6957845ec6"
}
]
}
응답 포맷을 통일되도록 커스텀 처리를 했기 때문에 반환 형식은 다를 수 있습니다.
응답에서 확인해야 할 부분은 key와 url입니다. 구현한대로 uuid가 잘 붙어있는 것을 확인할 수 있습니다.
그리고 터미널을 켜서 아래 명령어를 입력합니다.
curl -x PUT "발급받은_Presigend_URL" --upload-file "로컬_이미지_경로" -H "x-amz-meta-{키}: {값}"]
실제 응답을 적용시켜 보면 아래 명령어가 됩니다.
curl -X PUT "https://ceos-promesa.s3.ap-northeast-2.amazonaws.com/reviews/1/98ddafb3-efe6-4678-b3e4-b2f2ff2e400d-cat.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Date=20250614T070535Z&X-Amz-SignedHeaders=host%3Bx-amz-meta-uploadby&X-Amz-Expires=900&X-Amz-Credential=AKIA6CGYKEJU2OIBLQSX%2F20250614%2Fap-northeast-2%2Fs3%2Faws4_request&X-Amz-Signature=d80ca0b8492c7b1c4d6bbeaa7cf9e866e0020d2bb1b5d54727aacb6957845ec6" --upload-file "C:\Users\amily\Downloads\cat.png" -H "x-amz-meta-uploadby: 1"
🔎S3에서 확인



🍒이미지 삭제 구현하기
이번에는 AWS SDK for API Reference 문서를 통해 쉽게 구현할 수 있습니다.
`S3Clinet` 클래스의 `deleteObject` 메서드를 활용하면 됩니다. 공식문서를 확인해 보면, `deleteObject` 메서드는 매개변수가 다른 두 가지 형태로 제공되고 있습니다.


두 가지 방식 모두 단일 HTTP 요청으로 버킷에서 여러 객체를 삭제할 수 있습니다. 요청에는 삭제할 키 목록을 최대 1,000개까지 포함할 수 있습니다.
버전 관리가 활성화된 버킷의 경우에는 특정 버전의 객체를 삭제할 때 버전 ID도 선택적으로 지정할 수 있습니다.
Amazon S3는 각 키에 대해 삭제 작업을 수행하고 삭제 결과(성공 또는 실패)를 응답으로 반환합니다. 요청에 지정된 객체를 찾을 수 없는 경우, Amazon S3는 삭제됨을 확인하여 결과를 "삭제됨"으로 반환합니다. 즉 버킷에 존재하지 않는 객체에 대한 요청을 보내면 예외를 발생시키는 것이 아니라 해당 객체가 삭제된 것으로 처리하여 확인하여 200 OK 응답을 받게 됩니다.
- `deleteObjects(DeleteObjectsRequest)` : 인자로 이미 생성된 `DeleteObjectReqeust`를 받습니다. 요청 객체를 재사용할 때 유리합니다.
- `deleteObjects(Consumer)` : 인자로 람다식을 받습니다. `DeleteRequest.Builder`를 조작할 수 있도록 제공합니다. 코드가 간결하다는 장점이 있습니다.
두 번째 deleteObjects(Consumer)를 이용해서 구현했습니다.
public void deleteObject(String bucketName, String key) {
try {
s3Client.deleteObject(builder -> builder
.bucket(bucketName)
.key(key)
.build()
);
log.info("S3 객체 삭제 완료: {}/{}", bucketName, key);
} catch (Exception e) {
log.error("S3 객체 삭제 실패: {}/{}", bucketName, key, e);
throw InternalServerError.EXCEPTION;
}
}
Reference
- https://docs.aws.amazon.com/ko_kr/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
- AWS Presigned url 장점 - 인프런 | 커뮤니티 질문&답변
'SPRING > HOW-TO' 카테고리의 다른 글
| HeadObject로 S3 객체 존재 여부 확인하기 (SDK 2) (2) | 2025.09.30 |
|---|---|
| SSH 터널링으로 프라이빗 RDS에 MySQL Workbench 연결하기 (3) | 2025.06.18 |
| Presigned URL로 S3 프라이빗 버킷 이미지 조회하기 (SDK v2) (2) | 2025.06.15 |
