From d2699e4c0303f0a6b9741d87700926146d3ee63c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EB=B2=94?= Date: Fri, 22 Nov 2024 23:29:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20s3=20=EA=B4=80=EB=A0=A8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/gradle.yml | 4 + build.gradle | 3 + docker-compose.yml | 8 +- .../global/exception/BaseErrorException.java | 14 ++++ .../exception/GlobalExceptionHandler.java | 73 ++++++++++++++++++ .../exception/Response/ExceptionResponse.java | 29 ++++++++ .../hackathon/global/s3/config/S3Config.java | 35 +++++++++ .../global/s3/exception/S3ErrorException.java | 18 +++++ .../global/s3/exception/S3ErrorMessage.java | 19 +++++ .../global/s3/service/S3Service.java | 74 +++++++++++++++++++ src/main/resources/application.yml | 12 ++- 11 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/example/hackathon/global/exception/BaseErrorException.java create mode 100644 src/main/java/com/example/hackathon/global/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/example/hackathon/global/exception/Response/ExceptionResponse.java create mode 100644 src/main/java/com/example/hackathon/global/s3/config/S3Config.java create mode 100644 src/main/java/com/example/hackathon/global/s3/exception/S3ErrorException.java create mode 100644 src/main/java/com/example/hackathon/global/s3/exception/S3ErrorMessage.java create mode 100644 src/main/java/com/example/hackathon/global/s3/service/S3Service.java diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 07f0b6f..2e07e81 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -105,6 +105,10 @@ jobs: echo "DB_USERNAME=${{ secrets.DB_USERNAME }}" >> ~/deploy/.env echo "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" >> ~/deploy/.env echo "DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }}" >> ~/deploy/.env + echo "ACCESS_KEY=${{ secrets.ACCESS_KEY }}" >> ~/deploy/.env + echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> ~/deploy/.env + echo "BUCKET_NAME=${{ secrets.BUCKET_NAME }}" >> ~/deploy/.env + echo "REGION=${{ secrets.REGION }}" >> ~/deploy/.env sudo docker-compose -f ~/deploy/docker-compose.yml pull sudo docker-compose -f ~/deploy/docker-compose.yml up -d diff --git a/build.gradle b/build.gradle index d321752..65d161f 100644 --- a/build.gradle +++ b/build.gradle @@ -40,6 +40,9 @@ dependencies { //database runtimeOnly 'org.postgresql:postgresql' + // s3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' diff --git a/docker-compose.yml b/docker-compose.yml index b1e34f5..53512db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,10 +8,10 @@ services: DB_URL: ${DB_URL} DB_USERNAME: ${DB_USERNAME} DB_PASSWORD: ${DB_PASSWORD} -# ACCESS_KEY: ${ACCESS_KEY} -# SECRET_KEY: ${SECRET_KEY} -# BUCKET_NAME: ${BUCKET_NAME} -# REGION: ${REGION} + ACCESS_KEY: ${ACCESS_KEY} + SECRET_KEY: ${SECRET_KEY} + BUCKET_NAME: ${BUCKET_NAME} + REGION: ${REGION} # JWT_SECRET_KEY: ${JWT_SECRET_KEY} # MAIL_USERNAME : ${MAIL_USERNAME} # MAIL_PASSWORD : ${MAIL_PASSWORD} diff --git a/src/main/java/com/example/hackathon/global/exception/BaseErrorException.java b/src/main/java/com/example/hackathon/global/exception/BaseErrorException.java new file mode 100644 index 0000000..58c78c9 --- /dev/null +++ b/src/main/java/com/example/hackathon/global/exception/BaseErrorException.java @@ -0,0 +1,14 @@ +package com.example.hackathon.global.exception; + +import lombok.Getter; + +@Getter +public class BaseErrorException extends RuntimeException { + + private final int ErrorCode; + + public BaseErrorException(int errorCode, String message) { + super(message); + ErrorCode = errorCode; + } +} diff --git a/src/main/java/com/example/hackathon/global/exception/GlobalExceptionHandler.java b/src/main/java/com/example/hackathon/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d09c68b --- /dev/null +++ b/src/main/java/com/example/hackathon/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,73 @@ +package com.example.hackathon.global.exception; + + +import com.example.hackathon.global.exception.Response.ExceptionResponse; +import java.io.UnsupportedEncodingException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + // 로그 형식 + private static final String LOG_FORMAT = "Class : {}, Code : {}, Message : {}"; + private static final int ERROR_CODE = 400; + private static final int SERVER_ERROR_CODE = 500; + + // 사용자 정의 예외 처리 + @ExceptionHandler(BaseErrorException.class) + public ResponseEntity> handle(BaseErrorException e) { + + logWarning(e, e.getErrorCode()); + ExceptionResponse response = ExceptionResponse.fail(e.getErrorCode(), e.getMessage()); + + return ResponseEntity + .status(e.getErrorCode()) + .body(response); + } + + // @Valid 예외 처리 (@NotNull, @Size, etc...) or IllegalArgumentException + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handle(MethodArgumentNotValidException e) { + + logWarning(e, ERROR_CODE); + ExceptionResponse response = ExceptionResponse.fail(ERROR_CODE, e.getBindingResult().getAllErrors().get(0).getDefaultMessage()); + + return ResponseEntity + .status(ERROR_CODE) + .body(response); + } + + @ExceptionHandler({UnsupportedEncodingException.class}) + public ResponseEntity> handle(UnsupportedEncodingException e) { + + + logWarning(e, ERROR_CODE); + ExceptionResponse response = ExceptionResponse.fail(ERROR_CODE, e.getMessage()); + + return ResponseEntity + .status(ERROR_CODE) + .body(response); + } + + // 서버 측 에러 (이외의 에러) + @ExceptionHandler(Exception.class) + public ResponseEntity> handle(Exception e) { + + logWarning(e, SERVER_ERROR_CODE); + ExceptionResponse response = ExceptionResponse.fail(SERVER_ERROR_CODE, e.getMessage()); + + return ResponseEntity + .status(SERVER_ERROR_CODE) + .body(response); } + + // log.warn이 중복되어 리팩토링 + private void logWarning(Exception e, int errorCode) { + log.warn(e.getMessage(), e); // 전체 로그 출력, 운영 단계에서는 삭제 + log.warn(LOG_FORMAT, e.getClass().getSimpleName(), errorCode, e.getMessage()); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/hackathon/global/exception/Response/ExceptionResponse.java b/src/main/java/com/example/hackathon/global/exception/Response/ExceptionResponse.java new file mode 100644 index 0000000..74a4258 --- /dev/null +++ b/src/main/java/com/example/hackathon/global/exception/Response/ExceptionResponse.java @@ -0,0 +1,29 @@ +package com.example.hackathon.global.exception.Response; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ExceptionResponse { + + private int code; + private String message; + private T data; + + private ExceptionResponse(int code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + private ExceptionResponse(int code, String message) { + this.code = code; + this.message = message; + } + + public static ExceptionResponse fail(int code, String message) { + + return new ExceptionResponse<>(code, message); + } +} diff --git a/src/main/java/com/example/hackathon/global/s3/config/S3Config.java b/src/main/java/com/example/hackathon/global/s3/config/S3Config.java new file mode 100644 index 0000000..cc84d05 --- /dev/null +++ b/src/main/java/com/example/hackathon/global/s3/config/S3Config.java @@ -0,0 +1,35 @@ +package com.example.hackathon.global.s3.config; + +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.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3 amazonS3Client(){ + BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + + return AmazonS3ClientBuilder + .standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .build(); + } + + +} diff --git a/src/main/java/com/example/hackathon/global/s3/exception/S3ErrorException.java b/src/main/java/com/example/hackathon/global/s3/exception/S3ErrorException.java new file mode 100644 index 0000000..45cb927 --- /dev/null +++ b/src/main/java/com/example/hackathon/global/s3/exception/S3ErrorException.java @@ -0,0 +1,18 @@ +package com.example.hackathon.global.s3.exception; + +import com.example.hackathon.global.exception.BaseErrorException; + +public abstract class S3ErrorException { + + public static class S3UploadFailException extends BaseErrorException { + public S3UploadFailException() { + super(S3ErrorMessage.UPLOAD_FAIL.getErrorCode(), S3ErrorMessage.UPLOAD_FAIL.getMessage()); + } + } + + public static class S3NotExistNameException extends BaseErrorException { + public S3NotExistNameException() { + super(S3ErrorMessage.NOT_EXIST_NAME.getErrorCode(), S3ErrorMessage.NOT_EXIST_NAME.getMessage()); + } + } +} diff --git a/src/main/java/com/example/hackathon/global/s3/exception/S3ErrorMessage.java b/src/main/java/com/example/hackathon/global/s3/exception/S3ErrorMessage.java new file mode 100644 index 0000000..dc6488b --- /dev/null +++ b/src/main/java/com/example/hackathon/global/s3/exception/S3ErrorMessage.java @@ -0,0 +1,19 @@ +package com.example.hackathon.global.s3.exception; + +import lombok.Getter; + +@Getter +public enum S3ErrorMessage { + + UPLOAD_FAIL(400, "사진 업로드에 실패하였습니다."), + NOT_EXIST_NAME(404, "존재하지 않는 파일 이름입니다."); + + private final int errorCode; + private final String message; + + S3ErrorMessage(int errorCode, String message) { + this.errorCode = errorCode; + this.message = message; + } + +} diff --git a/src/main/java/com/example/hackathon/global/s3/service/S3Service.java b/src/main/java/com/example/hackathon/global/s3/service/S3Service.java new file mode 100644 index 0000000..6c4850c --- /dev/null +++ b/src/main/java/com/example/hackathon/global/s3/service/S3Service.java @@ -0,0 +1,74 @@ +package com.example.hackathon.global.s3.service; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.CannedAccessControlList; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.example.hackathon.global.s3.exception.S3ErrorException.S3NotExistNameException; +import com.example.hackathon.global.s3.exception.S3ErrorException.S3UploadFailException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +public class S3Service { + + private final AmazonS3 s3Client; + private final String bucketName; + + public S3Service(AmazonS3 s3Client, @Value("${bucket_name}") String bucketName) { + this.s3Client = s3Client; + this.bucketName = bucketName; + } + + public URL uploadImages(MultipartFile file) { + String key = file.getOriginalFilename(); + checkExistFile(key); + // MultipartFile에서 InputStream을 얻어 S3에 업로드합니다. + try (InputStream inputStream = file.getInputStream()) { + ObjectMetadata metadata = setMetaData(file); + + // PutObjectRequest 생성 시 InputStream과 ContentType을 설정합니다. + PutObjectRequest putRequest = new PutObjectRequest(bucketName, key, inputStream, metadata) + .withCannedAcl(CannedAccessControlList.PublicRead); + s3Client.putObject(putRequest); + return getImageUrl(key); + } catch (IOException e) { + throw new S3UploadFailException(); + } + } + + public URL getImageUrl(String key) { + try { + return s3Client.getUrl(bucketName, key); + } catch (Exception e) { + throw new S3NotExistNameException(); + } + } + + public void deleteImage(String key) { + try { + s3Client.deleteObject(bucketName, key); + } catch (Exception e) { + throw new S3NotExistNameException(); + } + } + + private ObjectMetadata setMetaData(MultipartFile file) { + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + metadata.setContentType(file.getContentType()); + + return metadata; + } + + private void checkExistFile(String key) { + if (s3Client.doesObjectExist(bucketName, key)) { + s3Client.deleteObject(bucketName, key); + } + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 89e1735..d8af925 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,4 +10,14 @@ spring: properties: hibernate: # show_sql: true - format_sql: true \ No newline at end of file + format_sql: true + +cloud: + aws: + credentials: + access-key: ${ACCESS_KEY} + secret-key: ${SECRET_KEY} + s3: + bucket: ${BUCKET_NAME} + region: + static: ${REGION} \ No newline at end of file