-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[FEAT/#76] PreSigned URL 획득 API 구현 #81
Changes from 9 commits
666cf05
665ae86
876a86a
48aab6a
889423c
655edba
da3ee47
27d0e5d
84ceb8f
ac2ffb7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
package com.acon.server.global.external.s3; | ||
|
||
import com.acon.server.global.exception.BusinessException; | ||
import com.acon.server.global.exception.ErrorType; | ||
import com.amazonaws.HttpMethod; | ||
import com.amazonaws.services.s3.AmazonS3; | ||
import com.amazonaws.services.s3.Headers; | ||
import com.amazonaws.services.s3.model.CannedAccessControlList; | ||
import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; | ||
import java.net.URL; | ||
import java.time.Duration; | ||
import java.time.Instant; | ||
import java.util.Date; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.stereotype.Service; | ||
|
||
@Service | ||
@RequiredArgsConstructor | ||
// TODO: 추후 AWS SDK v2를 사용하는 방식으로 변경 | ||
public class S3Adapter { | ||
|
||
@Value("${cloud.aws.s3.bucket}") | ||
private String bucket; | ||
|
||
@Value("${cloud.aws.s3.path.profileImage}") | ||
private String profileImagePath; | ||
|
||
@Value("${cloud.aws.s3.path.reviewImage}") | ||
private String reviewImagePath; | ||
|
||
@Value("${cloud.aws.s3.path.spotImage}") | ||
private String spotImagePath; | ||
|
||
private final AmazonS3 amazonS3; | ||
|
||
public String getPreSignedUrlForProfileImage(String fileName) { | ||
return getPreSignedUrl(profileImagePath, fileName); | ||
} | ||
|
||
// TODO: spotId 별로 directory 구분하도록 path 수정 | ||
public String getPreSignedUrlForReviewImage(String fileName) { | ||
return getPreSignedUrl(reviewImagePath, fileName); | ||
} | ||
|
||
public String getPreSignedUrlForSpotImage(String fileName) { | ||
return getPreSignedUrl(spotImagePath, fileName); | ||
} | ||
|
||
private String getPreSignedUrl(String path, String fileName) { | ||
if (!path.isEmpty()) { | ||
fileName = path + "/" + fileName; | ||
} | ||
Comment on lines
+51
to
+53
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Q1: path가 Empty일 땐 따로 처리를 할 필요가 없나요 !? 에러를 뱉거나, 이후 로직들을 실행하지 않게 하거나 ... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
어차피 |
||
|
||
try { | ||
GeneratePresignedUrlRequest generatePresignedUrlRequest = getGeneratePresignedUrlRequest(fileName); | ||
URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest); | ||
|
||
return url.toString(); | ||
} catch (Exception e) { | ||
throw new BusinessException(ErrorType.FAILED_GET_PRE_SIGNED_URL_ERROR); | ||
} | ||
} | ||
|
||
private GeneratePresignedUrlRequest getGeneratePresignedUrlRequest(String fileName) { | ||
GeneratePresignedUrlRequest generatePresignedUrlRequest = new GeneratePresignedUrlRequest(bucket, fileName) | ||
.withMethod(HttpMethod.PUT) | ||
.withExpiration(getPreSignedUrlExpTime()); | ||
|
||
generatePresignedUrlRequest.addRequestParameter( | ||
Headers.S3_CANNED_ACL, | ||
CannedAccessControlList.PublicRead.toString() | ||
); | ||
|
||
return generatePresignedUrlRequest; | ||
} | ||
|
||
private Date getPreSignedUrlExpTime() { | ||
// TODO: 매직 넘버 yml 처리 | ||
Instant expirationTime = Instant.now().plus(Duration.ofMinutes(2)); | ||
|
||
return Date.from(expirationTime); | ||
} | ||
|
||
public String getImageUrl(String fileName) { | ||
return amazonS3.getUrl(bucket, profileImagePath + "/" + fileName).toString(); | ||
} | ||
|
||
// TODO: S3에서 파일 삭제하는 로직 추가 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
package com.acon.server.global.external.s3; | ||
|
||
import com.amazonaws.auth.AWSStaticCredentialsProvider; | ||
import com.amazonaws.auth.BasicAWSCredentials; | ||
import com.amazonaws.services.s3.AmazonS3; | ||
import com.amazonaws.services.s3.AmazonS3ClientBuilder; | ||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
|
||
@Configuration | ||
public class S3Config { | ||
|
||
@Value("${cloud.aws.region.static}") | ||
private String region; | ||
|
||
@Value("${cloud.aws.credentials.accessKey}") | ||
private String accessKey; | ||
|
||
@Value("${cloud.aws.credentials.secretKey}") | ||
private String secretKey; | ||
|
||
@Bean | ||
public AmazonS3 amazonS3() { | ||
BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); | ||
|
||
return AmazonS3ClientBuilder.standard() | ||
.withRegion(region) | ||
.withCredentials(new AWSStaticCredentialsProvider(credentials)) | ||
.build(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,10 +7,12 @@ | |
import com.acon.server.member.api.response.AcornCountResponse; | ||
import com.acon.server.member.api.response.LoginResponse; | ||
import com.acon.server.member.api.response.MemberAreaResponse; | ||
import com.acon.server.member.api.response.PreSignedUrlResponse; | ||
import com.acon.server.member.application.service.MemberService; | ||
import com.acon.server.member.domain.enums.Cuisine; | ||
import com.acon.server.member.domain.enums.DislikeFood; | ||
import com.acon.server.member.domain.enums.FavoriteSpot; | ||
import com.acon.server.member.domain.enums.ImageType; | ||
import com.acon.server.member.domain.enums.SocialType; | ||
import com.acon.server.member.domain.enums.SpotStyle; | ||
import com.acon.server.spot.domain.enums.SpotType; | ||
|
@@ -109,4 +111,15 @@ public ResponseEntity<AcornCountResponse> getAcornCount() { | |
memberService.fetchAcornCount() | ||
); | ||
} | ||
|
||
@GetMapping(path = "/images/presigned-url", produces = MediaType.APPLICATION_JSON_VALUE) | ||
public ResponseEntity<PreSignedUrlResponse> getPreSignedUrl( | ||
Comment on lines
+127
to
+128
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Q1: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저도 그렇게 생각해서 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 엇 아니요!수정의 의미로 말씀드린 것보다 PreSigned처럼 pre와 signed 두 단어를 분리한 부분도 있고 /presigned-url처럼 두 단어를 분리하지 않은 부분이 있어서 따로 이유가 있는지 여쭤보고 싶었습니다~! |
||
@RequestParam(name = "imageType") final String imageTypeString | ||
) { | ||
ImageType imageType = ImageType.fromValue(imageTypeString); | ||
|
||
return ResponseEntity.ok( | ||
memberService.fetchPreSignedUrl(imageType) | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
package com.acon.server.member.api.response; | ||
|
||
public record PreSignedUrlResponse( | ||
String fileName, | ||
String preSignedUrl | ||
) { | ||
|
||
public static PreSignedUrlResponse of(String fileName, String preSignedUrl) { | ||
return new PreSignedUrlResponse(fileName, preSignedUrl); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,9 +5,11 @@ | |
import com.acon.server.global.auth.jwt.JwtTokenProvider; | ||
import com.acon.server.global.exception.BusinessException; | ||
import com.acon.server.global.exception.ErrorType; | ||
import com.acon.server.global.external.NaverMapsAdapter; | ||
import com.acon.server.global.external.maps.NaverMapsAdapter; | ||
import com.acon.server.global.external.s3.S3Adapter; | ||
import com.acon.server.member.api.response.AcornCountResponse; | ||
import com.acon.server.member.api.response.LoginResponse; | ||
import com.acon.server.member.api.response.PreSignedUrlResponse; | ||
import com.acon.server.member.application.mapper.GuidedSpotMapper; | ||
import com.acon.server.member.application.mapper.MemberMapper; | ||
import com.acon.server.member.application.mapper.PreferenceMapper; | ||
|
@@ -19,6 +21,7 @@ | |
import com.acon.server.member.domain.enums.Cuisine; | ||
import com.acon.server.member.domain.enums.DislikeFood; | ||
import com.acon.server.member.domain.enums.FavoriteSpot; | ||
import com.acon.server.member.domain.enums.ImageType; | ||
import com.acon.server.member.domain.enums.SocialType; | ||
import com.acon.server.member.domain.enums.SpotStyle; | ||
import com.acon.server.member.infra.entity.GuidedSpotEntity; | ||
|
@@ -37,6 +40,7 @@ | |
import java.util.Collections; | ||
import java.util.List; | ||
import java.util.Optional; | ||
import java.util.UUID; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Service; | ||
import org.springframework.transaction.annotation.Transactional; | ||
|
@@ -58,11 +62,14 @@ public class MemberService { | |
|
||
private final JwtTokenProvider jwtTokenProvider; | ||
private final PrincipalHandler principalHandler; | ||
// TODO: 네이밍 변경 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Q2: 네이밍 변경이 필요하다고 느끼신 이유가 어떤걸까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 외부 API에 연결하는 클래스들은 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 오 좋네요! 그러면 캐시에 리프레시 |
||
private final GoogleSocialService googleSocialService; | ||
private final AppleAuthAdapter appleAuthService; | ||
|
||
private final NaverMapsAdapter naverMapsAdapter; | ||
|
||
private final S3Adapter s3Adapter; | ||
|
||
// TODO: 메서드 순서 정리, TRANSACTION 설정, mapper 사용 | ||
// TODO: @Valid 거친 건 원시타입으로 받기 | ||
|
||
|
@@ -216,5 +223,19 @@ public AcornCountResponse fetchAcornCount() { | |
return new AcornCountResponse(acornCount); | ||
} | ||
|
||
public PreSignedUrlResponse fetchPreSignedUrl(ImageType imageType) { | ||
// TODO: 확장자 방식 고민하기 | ||
String fileName = UUID.randomUUID() + ".jpg"; | ||
|
||
String preSignedUrl = switch (imageType) { | ||
case PROFILE -> s3Adapter.getPreSignedUrlForProfileImage(fileName); | ||
// case REVIEW -> s3Adapter.getPreSignedUrlForReviewImage(fileName); | ||
// case SPOT -> s3Adapter.getPreSignedUrlForSpotImage(fileName); | ||
default -> throw new BusinessException(ErrorType.INVALID_IMAGE_TYPE_ERROR); | ||
}; | ||
|
||
return PreSignedUrlResponse.of(fileName, preSignedUrl); | ||
} | ||
|
||
// TODO: 최근 길 안내 장소 지우는 스케줄러 추가 | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
package com.acon.server.member.domain.enums; | ||
|
||
import com.acon.server.global.exception.BusinessException; | ||
import com.acon.server.global.exception.ErrorType; | ||
import java.util.HashMap; | ||
import java.util.Map; | ||
import lombok.AccessLevel; | ||
import lombok.RequiredArgsConstructor; | ||
|
||
@RequiredArgsConstructor(access = AccessLevel.PRIVATE) | ||
public enum ImageType { | ||
|
||
PROFILE, | ||
REVIEW, | ||
SPOT, | ||
; | ||
|
||
private static final Map<String, ImageType> IMAGE_TYPE_MAP = new HashMap<>(); | ||
|
||
static { | ||
for (ImageType imageType : ImageType.values()) { | ||
IMAGE_TYPE_MAP.put(imageType.name(), imageType); | ||
} | ||
} | ||
|
||
public static ImageType fromValue(String value) { | ||
ImageType imageType = IMAGE_TYPE_MAP.get(value); | ||
|
||
if (imageType == null) { | ||
throw new BusinessException(ErrorType.INVALID_IMAGE_TYPE_ERROR); | ||
} | ||
|
||
return imageType; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -33,5 +33,4 @@ public static FilterCategory fromValue(String value) { | |
|
||
return category; | ||
} | ||
|
||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Q1: 현재는 어떤 방식으로 진행하는데 어떤 문제 때문에 변경을 생각하시는건가용?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
현재는
AWS SDK v1
을 사용하는 방식이고, 큰 문제는 없지만v2
를 사용하는 방식으로 마이그레이션하게 되면 비동기 처리 등 성능적으로 더 우수한 면이 있어 추후 리팩토링하면 좋을 것 같아TODO
처리했습니다 !