Skip to content

Commit

Permalink
Feat: S3를 활용하여 파일 업로드 기능 구현 (#84)
Browse files Browse the repository at this point in the history
* #82 - chore: 단순 패키지 정리

* #82 - feat: 파일 업로드를 위한 설정 추가

* #82 - feat: deploy 파일 변경

임시 파일 저장 경로를 설정하기 위해

* #82 - feat: S3에 저장, 조회, 삭제 로직 구현

* #82 - feat: 멤버의 프로필 저장 로직 구현

* #82 - feat: 에러 핸들러 추가

* #82 - feat: 파일 최대 사이즈 수정
  • Loading branch information
GGHDMS authored Feb 8, 2024
1 parent febcb42 commit e1494f1
Show file tree
Hide file tree
Showing 13 changed files with 229 additions and 3 deletions.
1 change: 1 addition & 0 deletions .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
auth.jwt.secretKey: ${{ secrets.JWT_SECRET }}
api.aladin.key: ${{ secrets.ALADIN_KEY }}
spring.jpa.hibernate.ddl-auto: ${{ secrets.DDL_AUTO }}
spring.servlet.multipart.location: ${{ secrets.LOCATION }}

- name: Grant execute permission for gradlew
run: chmod +x ./gradlew
Expand Down
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ dependencies {
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

implementation platform('software.amazon.awssdk:bom:2.20.56')
implementation 'software.amazon.awssdk:s3'

compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.multipart.MultipartException;

import java.time.LocalDateTime;

Expand Down Expand Up @@ -76,4 +77,28 @@ public ResponseEntity<ErrorResponse> handleAccessDeniedException(AccessDeniedExc
);
return new ResponseEntity<>(errorResponse, HttpStatus.FORBIDDEN);
}

@ExceptionHandler(MultipartException.class)
public ResponseEntity<ErrorResponse> handleMultipartException(MultipartException ex, HttpServletRequest request) {
log.error("handleMultipartException occurred: {}", ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(
LocalDateTime.now(),
HttpStatus.BAD_REQUEST,
ex.getMessage(),
request.getRequestURI()
);
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}

@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRunTimeException(RuntimeException ex, HttpServletRequest request) {
log.error("handleRuntimeException occurred: {}", ex.getMessage());
ErrorResponse errorResponse = new ErrorResponse(
LocalDateTime.now(),
HttpStatus.BAD_REQUEST,
ex.getMessage(),
request.getRequestURI()
);
return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST);
}
}
34 changes: 34 additions & 0 deletions src/main/java/cotato/bookitlist/config/AwsS3Config.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package cotato.bookitlist.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.AwsCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;

@Configuration
public class AwsS3Config {

@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 AwsCredentials basicAWSCredentials() {
return AwsBasicCredentials.create(accessKey, secretKey);
}

@Bean
public S3Client s3Client(AwsCredentials awsCredentials) {
return S3Client.builder().region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(awsCredentials)).build();
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package cotato.bookitlist.config;
package cotato.bookitlist.config.p6spy;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package cotato.bookitlist.config;
package cotato.bookitlist.config.p6spy;

import com.p6spy.engine.common.ConnectionInformation;
import com.p6spy.engine.event.JdbcEventListener;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package cotato.bookitlist.config;
package cotato.bookitlist.config.p6spy;

import com.p6spy.engine.logging.Category;
import com.p6spy.engine.spy.appender.MessageFormattingStrategy;
Expand Down
74 changes: 74 additions & 0 deletions src/main/java/cotato/bookitlist/file/service/FileService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package cotato.bookitlist.file.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartException;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.*;

import java.io.IOException;

@Slf4j
@Service
@RequiredArgsConstructor
public class FileService {

private final S3Client s3Client;

@Value("${cloud.aws.s3.bucket}")
private String bucket;

public String uploadFileToS3(Long memberId, String fileName, MultipartFile multipartFile) {
String key = generateKey(memberId, fileName);

PutObjectRequest putObjectRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.acl(ObjectCannedACL.BUCKET_OWNER_FULL_CONTROL)
.build();

try {
s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(multipartFile.getInputStream(), multipartFile.getSize()));
} catch (IOException ex) {
log.error("파일 업로드 중 에러가 발생했습니다: {}", ex.getMessage());
throw new MultipartException("파일 업로드 중 에러가 발생했습니다.", ex);
} catch (S3Exception ex) {
log.error("S3에 파일을 업로드하는 중 에러가 발생했습니다: {}", ex.getMessage());
throw new MultipartException("S3에 파일을 업로드하는 중 에러가 발생했습니다.", ex);
}

return getS3FileUrl(key);
}

public String getS3FileUrl(String key) {
try {
return s3Client.utilities().getUrl(GetUrlRequest.builder().bucket(bucket).key(key).build()).toString();
} catch (NoSuchKeyException ex) {
log.error("해당 키의 파일이 존재하지 않습니다: {}", key, ex);
throw new RuntimeException("해당 키의 파일이 존재하지 않습니다.",ex);
} catch (S3Exception ex) {
log.error("S3에서 파일 URL을 가져오는 중 에러가 발생했습니다: {}", ex.getMessage());
throw new RuntimeException("S3에서 파일 URL을 가져오는 중 에러가 발생했습니다.", ex);
}
}

public void deleteS3File(Long memberId, String fileName) {
String key = generateKey(memberId, fileName);
try {
s3Client.deleteObject(DeleteObjectRequest.builder().bucket(bucket).key(key).build());
} catch (S3Exception ex) {
log.error("S3에서 파일 삭제 중 에러가 발생했습니다: {}", ex.getMessage());
throw new RuntimeException("S3에서 파일 삭제 중 에러가 발생했습니다.", ex);
}
}

private String generateKey(Long memberId, String fileName) {
return String.format("member%s/%s", memberId, fileName);
}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package cotato.bookitlist.member.controller;

import cotato.bookitlist.config.security.jwt.AuthDetails;
import cotato.bookitlist.member.dto.ProfileResponse;
import cotato.bookitlist.member.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

@RestController
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {

private final MemberService memberService;

@PatchMapping("/profiles")
public ResponseEntity<ProfileResponse> uploadProfile(
@RequestPart MultipartFile multipartFile,
@AuthenticationPrincipal AuthDetails details
) {
return ResponseEntity.ok(ProfileResponse.of(memberService.uploadProfile(multipartFile, details.getId())));
}
}
7 changes: 7 additions & 0 deletions src/main/java/cotato/bookitlist/member/domain/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ public class Member extends BaseEntity {
@Enumerated(EnumType.STRING)
private AuthProvider authProvider;

private String profileLink;

private int followCount = 0;

private boolean deleted = false;
Expand All @@ -53,4 +55,9 @@ public Member update(OAuth2UserInfo oAuth2UserInfo) {

return this;
}

public String updateProfileLine(String url) {
profileLink = url;
return profileLink;
}
}
10 changes: 10 additions & 0 deletions src/main/java/cotato/bookitlist/member/dto/ProfileResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package cotato.bookitlist.member.dto;

public record ProfileResponse(
String url
) {

public static ProfileResponse of(String url) {
return new ProfileResponse(url);
}
}
28 changes: 28 additions & 0 deletions src/main/java/cotato/bookitlist/member/service/MemberService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package cotato.bookitlist.member.service;

import cotato.bookitlist.file.service.FileService;
import cotato.bookitlist.member.domain.Member;
import cotato.bookitlist.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

@Service
@Transactional
@RequiredArgsConstructor
public class MemberService {

private static final String PROFILE_FILE_NAME = "profile";

private final MemberRepository memberRepository;
private final FileService fileService;

public String uploadProfile(MultipartFile profile, Long memberId) {
Member member = memberRepository.getReferenceById(memberId);

String url = fileService.uploadFileToS3(member.getId(), PROFILE_FILE_NAME, profile);

return member.updateProfileLine(url);
}
}
18 changes: 18 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ spring:
profiles:
include: oauth

servlet:
multipart:
enabled: true
file-size-threshold: 0B
location: ${LOCATION}
max-file-size: 5MB
max-request-size: 5MB

api:
aladin:
key: ${ALADIN_KEY}
Expand All @@ -31,3 +39,13 @@ auth:
secretKey: ${JWT_SECRET}
accessExp: 3600000
refreshExp: 604800000

cloud:
aws:
s3:
bucket: bookitlist-profile
credentials:
access-key: ${AWS_ACCESS_KEY}
secret-key: ${AWS_SECRET_KEY}
region:
static: ap-northeast-2

0 comments on commit e1494f1

Please sign in to comment.