Skip to content
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

Merged
merged 10 commits into from
Feb 15, 2025
9 changes: 6 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,10 @@ dependencies {
annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3'
testAnnotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3'

// TODO: 버전 확인하기
// Google
implementation 'com.google.api-client:google-api-client:2.7.0'
implementation 'com.google.api-client:google-api-client:2.7.1'

// TODO: 0.12.6으로 버전업 하기
// JWT
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
Expand All @@ -66,10 +66,13 @@ dependencies {

// PostGIS
implementation 'org.hibernate:hibernate-spatial:6.6.4.Final'
implementation 'org.locationtech.jts:jts-core:1.19.0' // TODO: 버전 확인하기
implementation 'org.locationtech.jts:jts-core:1.20.0'

// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'

// AWS
implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.4.4'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,11 @@ public enum ErrorType {
INVALID_FAVORITE_SPOT_ERROR(HttpStatus.BAD_REQUEST, 40017, "유효하지 않은 favoriteSpot입니다."),
INVALID_FAVORITE_SPOT_RANK_SIZE_ERROR(HttpStatus.BAD_REQUEST, 40030, "favoriteSpotRank의 사이즈가 잘못되었습니다."),
INVALID_FAVORITE_CUISINE_RANK_SIZE_ERROR(HttpStatus.BAD_REQUEST, 40031, "favoriteCuisineRank의 사이즈가 잘못되었습니다."),
INVALID_IMAGE_TYPE_ERROR(HttpStatus.BAD_REQUEST, 40045, "유효하지 않은 imageType입니다."),

/* 500 Internal Server Error */
FAILED_DOWNLOAD_GOOGLE_PUBLIC_KEY_ERROR(HttpStatus.BAD_REQUEST, 50002, "구글 공개키 다운로드에 실패하였습니다."),
FAILED_GET_PRE_SIGNED_URL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 50005, "PreSigned URL 획득에 실패하였습니다."),

/* Review Error */
/* 400 Bad Request */
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.acon.server.global.external;
package com.acon.server.global.external.maps;

public record GeoCodingResponse(
String latitude,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.acon.server.global.external;
package com.acon.server.global.external.maps;

import com.acon.server.global.exception.BusinessException;
import com.acon.server.global.exception.ErrorType;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.acon.server.global.external;
package com.acon.server.global.external.maps;

import java.util.Map;
import org.springframework.cloud.openfeign.FeignClient;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.acon.server.global.external;
package com.acon.server.global.external.maps;

import com.acon.server.global.config.properties.NaverMapsProperties;
import feign.RequestInterceptor;
Expand Down
90 changes: 90 additions & 0 deletions src/main/java/com/acon/server/global/external/s3/S3Adapter.java
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를 사용하는 방식으로 변경
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q1: 현재는 어떤 방식으로 진행하는데 어떤 문제 때문에 변경을 생각하시는건가용?

Copy link
Member Author

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 처리했습니다 !

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q1: path가 Empty일 땐 따로 처리를 할 필요가 없나요 !? 에러를 뱉거나, 이후 로직들을 실행하지 않게 하거나 ...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty일 때는 버킷 루트 경로에 파일이 저장되는 걸 생각하고 이렇게 로직을 짰는데요 ~!

어차피 getPreSignedUrl 메서드는 private으로 선언하고 getPreSignedUrlForProfileImage 형태로 외부에서 접근하도록 막아놔서 따로 처리할 필요가 없을 것 같긴 합니다 !


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에서 파일 삭제하는 로직 추가
}
32 changes: 32 additions & 0 deletions src/main/java/com/acon/server/global/external/s3/S3Config.java
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
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q1: Presigned -> PreSigned 로 변경하신 이유가 있나요?!
단어를 분리해서 생각하셔서 변경하신 것 같아요! 그렇다면 엔드포인트도 pre-signed-url이 맞지 않을까요?
저는 개인적으로 Presigned가 더 적합한 것 같아용

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 그렇게 생각해서 Presigned로 사용하고 있었는데 인텔리제이에서 맞춤법 수정 띄우길래 찾아보니까 일단은 정식 명칭이 Pre-signed Url인 것 같더라구요 ?! 그래서 바꿔봤는데 별로인가요 . . . 🥹

Copy link
Collaborator

Choose a reason for hiding this comment

The 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
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -58,11 +62,14 @@ public class MemberService {

private final JwtTokenProvider jwtTokenProvider;
private final PrincipalHandler principalHandler;
// TODO: 네이밍 변경
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q2: 네이밍 변경이 필요하다고 느끼신 이유가 어떤걸까요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

외부 API에 연결하는 클래스들은 Service 대신 Adapter로 컨벤션 통일하는 게 좋을 것 같아서요 !

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 좋네요! 그러면 캐시에 리프레시
토큰 저장하는 로직들도 Adatpter로 분리하는 거 어떠신가요?

private final GoogleSocialService googleSocialService;
private final AppleAuthAdapter appleAuthService;

private final NaverMapsAdapter naverMapsAdapter;

private final S3Adapter s3Adapter;

// TODO: 메서드 순서 정리, TRANSACTION 설정, mapper 사용
// TODO: @Valid 거친 건 원시타입으로 받기

Expand Down Expand Up @@ -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
Expand Up @@ -7,8 +7,8 @@
import com.acon.server.global.auth.PrincipalHandler;
import com.acon.server.global.exception.BusinessException;
import com.acon.server.global.exception.ErrorType;
import com.acon.server.global.external.GeoCodingResponse;
import com.acon.server.global.external.NaverMapsAdapter;
import com.acon.server.global.external.maps.GeoCodingResponse;
import com.acon.server.global.external.maps.NaverMapsAdapter;
import com.acon.server.member.domain.enums.Cuisine;
import com.acon.server.member.domain.enums.DislikeFood;
import com.acon.server.member.domain.enums.FavoriteSpot;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,4 @@ public static FilterCategory fromValue(String value) {

return category;
}

}