diff --git a/Group 483718@2x.png b/Group 483718@2x.png
new file mode 100644
index 0000000..27be1e6
Binary files /dev/null and b/Group 483718@2x.png differ
diff --git a/README.md b/README.md
index d661561..1863f9e 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
| | | | |
|---------|--------------|----------|-------------|
-| 담당 역할 |
Github Actions + AWS CodeDeploy CI/CD ERD 및 엔티티 설계 예외처리 로그인 + 회원가입 API 상점 도메인 API카카오 로그인(개발중) | API 명세서성지순례 도메인 API위치 기반 인증(웹 소켓 - 개발중)마이페이지 도메인 API| ERD 및 엔티티 설계홈 화면 API커뮤니티 도메인 API이미지 업로드 로직(GCP)FCM 푸시 알림(개발중)대댓글 API |
+| 담당 역할 | Github Actions + AWS CodeDeploy CI/CD ERD 및 엔티티 설계 예외처리 로그인 + 회원가입 API 상점 도메인 API카카오 로그인AWS S3 이미지 업로드 | API 명세서성지순례 도메인 API위치 기반 인증(STOMP)마이페이지 도메인 API| ERD 및 엔티티 설계홈 화면 API커뮤니티 도메인 APIFCM 푸시 알림대댓글 API |
@@ -34,4 +34,5 @@
- `develop`: 프로덕트 배포 전 기능을 개발하는 브랜치
- `feature`: 단위 기능을 개발하는 브랜치로 단위 기능 개발이 완료되면 develop 브랜치에 merge ex) feat/#이슈번호
-## 💖 8월 말 ~ 9월 초 릴리즈 예정
+
+## 💖 2024년 하반기 릴리즈 예정!
diff --git a/build.gradle b/build.gradle
index d4bceb2..179dd2c 100644
--- a/build.gradle
+++ b/build.gradle
@@ -60,6 +60,7 @@ dependencies {
//image
implementation 'com.google.cloud:google-cloud-storage:2.20.1'
implementation 'org.springframework.cloud:spring-cloud-gcp-starter-storage:1.2.8.RELEASE'
+ implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
implementation 'net.coobird:thumbnailator:0.4.14'
// FCM
diff --git a/src/main/java/com/favoriteplace/app/dto/member/MemberDto.java b/src/main/java/com/favoriteplace/app/dto/member/MemberDto.java
index 73656b1..4be10d6 100644
--- a/src/main/java/com/favoriteplace/app/dto/member/MemberDto.java
+++ b/src/main/java/com/favoriteplace/app/dto/member/MemberDto.java
@@ -8,6 +8,7 @@
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@@ -33,7 +34,7 @@ public Member toEntity(String encodedPassword, String profileImg, Item titleItem
.password(encodedPassword)
.alarmAllowance(snsAllow)
.description(introduction)
- .profileImageUrl(profileImg == null ? null : ConvertUuidToUrl.convertUuidToUrl(profileImg))
+ .profileImageUrl(profileImg)
.point(0L)
.loginType(LoginType.SELF)
.profileTitle(titleItem)
@@ -73,8 +74,11 @@ public static class EmailSendReqDto {
* 2)@기호를 기준으로 이메일 주소를 이루는 로컬호스트와 도메인 파트가 존재해야 한다.
* 3)도메인 파트는 최소하나의 점과 그 뒤에 최소한 2개의 알파벳을 가진다를 검증
*/
- @Email
- @NotEmpty(message = "이메일을 입력해주세요")
+ @NotEmpty(message = "이메일 입력은 필수 입니다.")
+ @Pattern(
+ regexp = "^[\\w\\.-]+@[\\w\\.-]+\\.[a-zA-Z]{2,}$",
+ message = "이메일 형식에 맞지 않습니다."
+ )
private String email;
}
@@ -95,8 +99,12 @@ public static class EmailDuplicateResDto {
@Getter
@NoArgsConstructor
public static class EmailCheckReqDto {
- @Email
+
@NotEmpty(message = "이메일을 입력해 주세요")
+ @Pattern(
+ regexp = "^[\\w\\.-]+@[\\w\\.-]+\\.[a-zA-Z]{2,}$",
+ message = "이메일 형식에 맞지 않습니다."
+ )
private String email;
@NotNull(message = "인증 번호를 입력해 주세요")
diff --git a/src/main/java/com/favoriteplace/app/service/MemberService.java b/src/main/java/com/favoriteplace/app/service/MemberService.java
index 0873c4b..c30da63 100644
--- a/src/main/java/com/favoriteplace/app/service/MemberService.java
+++ b/src/main/java/com/favoriteplace/app/service/MemberService.java
@@ -13,9 +13,13 @@
import com.favoriteplace.app.repository.MemberRepository;
import com.favoriteplace.global.exception.RestApiException;
import com.favoriteplace.global.gcpImage.UploadImage;
+import com.favoriteplace.global.s3Image.AmazonS3ImageManager;
import com.favoriteplace.global.security.kakao.KakaoClient;
import com.favoriteplace.global.security.provider.JwtTokenProvider;
import com.favoriteplace.global.util.SecurityUtil;
+
+import java.util.ArrayList;
+import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
@@ -38,8 +42,8 @@ public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
- public final SecurityUtil securityUtil;
- private final UploadImage uploadImage;
+ private final SecurityUtil securityUtil;
+ private final AmazonS3ImageManager amazonS3ImageManager;
private final ItemRepository itemRepository;
private final RedisTemplate redisTemplate;
private final KakaoClient kakaoClient;
@@ -77,6 +81,8 @@ public MemberDto.MemberSignUpResDto kakaoSignUp(final String token, final KaKaoS
@Transactional
public MemberDto.MemberSignUpResDto signup(MemberSignUpReqDto memberSignUpReqDto, List images)
throws IOException {
+ List> futures = new ArrayList<>();
+
memberRepository.findByEmail(memberSignUpReqDto.getEmail())
.ifPresent(
existingMember -> {
@@ -88,7 +94,7 @@ public MemberDto.MemberSignUpResDto signup(MemberSignUpReqDto memberSignUpReqDto
String password = passwordEncoder.encode(memberSignUpReqDto.getPassword());
if (images != null && !images.get(0).isEmpty()) {
- profileImageUrl = uploadImage.uploadImageToCloud(images.get(0));
+ profileImageUrl = amazonS3ImageManager.upload(images.get(0)).join();
}
Item titleItem = itemRepository.findByName("새싹회원").get();
diff --git a/src/main/java/com/favoriteplace/global/config/AsyncConfig.java b/src/main/java/com/favoriteplace/global/config/AsyncConfig.java
new file mode 100644
index 0000000..0d7b71b
--- /dev/null
+++ b/src/main/java/com/favoriteplace/global/config/AsyncConfig.java
@@ -0,0 +1,29 @@
+package com.favoriteplace.global.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Executor;
+
+@EnableAsync
+@Configuration
+public class AsyncConfig {
+
+ /**
+ corePoolSize = CPU 코어수 * CPU 사용률 * (1 + I/O입력시간대기효율)
+ */
+ @Bean(name = "S3imageUploadExecutor")
+ public Executor getAsyncExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+
+ int numOfCores = Runtime.getRuntime().availableProcessors();
+ float targetCpuUtilization = 0.3f; // CPU 사용률
+ float blockingCoefficient = 0.1f; // I/O 입력시간 대기효율
+ int corePoolSize = (int) (numOfCores * targetCpuUtilization * (1 + blockingCoefficient));
+ executor.setCorePoolSize(corePoolSize);
+ executor.initialize();
+ return executor;
+ }
+}
diff --git a/src/main/java/com/favoriteplace/global/config/S3Config.java b/src/main/java/com/favoriteplace/global/config/S3Config.java
new file mode 100644
index 0000000..95d6860
--- /dev/null
+++ b/src/main/java/com/favoriteplace/global/config/S3Config.java
@@ -0,0 +1,51 @@
+package com.favoriteplace.global.config;
+
+import com.amazonaws.auth.AWSCredentials;
+import com.amazonaws.auth.AWSStaticCredentialsProvider;
+import com.amazonaws.auth.BasicAWSCredentials;
+import com.amazonaws.services.s3.AmazonS3Client;
+import com.amazonaws.services.s3.AmazonS3ClientBuilder;
+import lombok.Getter;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.beans.factory.annotation.Value;
+
+import javax.annotation.PostConstruct;
+
+
+@Configuration
+@Getter
+public class S3Config {
+
+ private AWSCredentials awsCredentials;
+
+ @Value("${cloud.aws.s3.bucket}")
+ private String bucket;
+
+ @Value("${cloud.aws.credentials.accessKey}")
+ private String accessKey;
+
+ @Value("${cloud.aws.credentials.secretKey}")
+ private String secretKey;
+
+ @Value("${cloud.aws.region.static}")
+ private String region;
+
+ @Value("${cloud.aws.s3.path}")
+ private String filePath;
+
+ @PostConstruct
+ public void init() {
+ this.awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
+ }
+
+ @Bean
+ public AmazonS3Client amazonS3Client() {
+ AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey);
+
+ return (AmazonS3Client) AmazonS3ClientBuilder.standard()
+ .withRegion(region)
+ .withCredentials(new AWSStaticCredentialsProvider(awsCredentials))
+ .build();
+ }
+}
diff --git a/src/main/java/com/favoriteplace/global/exception/ExceptionHandlerAdvice.java b/src/main/java/com/favoriteplace/global/exception/ExceptionHandlerAdvice.java
index 27069da..208d359 100644
--- a/src/main/java/com/favoriteplace/global/exception/ExceptionHandlerAdvice.java
+++ b/src/main/java/com/favoriteplace/global/exception/ExceptionHandlerAdvice.java
@@ -5,6 +5,7 @@
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.core.NestedExceptionUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -15,9 +16,11 @@
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
+import java.lang.reflect.Method;
+
@Slf4j
@RestControllerAdvice
-public class ExceptionHandlerAdvice {
+public class ExceptionHandlerAdvice implements AsyncUncaughtExceptionHandler {
//모든 에러 -> 하위 에러에서 못받을 때
@ExceptionHandler(Exception.class)
@@ -89,4 +92,10 @@ public ResponseEntity handleMaxUploadSizeException(MaxUploadSizeExceededExceptio
ErrorResponse errorResponse = ErrorResponse.of(errorCode.getHttpStatus(), errorCode.getCode(), errorCode.getMessage());
return ResponseEntity.status(errorResponse.getHttpStatus()).body(errorResponse.getMessage());
}
+
+ // 비동기 메서드 예외 처리를 위해
+ @Override
+ public void handleUncaughtException(Throwable ex, Method method, Object... params) {
+ log.error("[ASYNC-ERROR] method: {} exception: {}", method.getName(), ex);
+ }
}
\ No newline at end of file
diff --git a/src/main/java/com/favoriteplace/global/s3Image/AmazonS3ImageManager.java b/src/main/java/com/favoriteplace/global/s3Image/AmazonS3ImageManager.java
new file mode 100644
index 0000000..2ea12ca
--- /dev/null
+++ b/src/main/java/com/favoriteplace/global/s3Image/AmazonS3ImageManager.java
@@ -0,0 +1,91 @@
+package com.favoriteplace.global.s3Image;
+
+import com.amazonaws.services.s3.AmazonS3Client;
+import com.amazonaws.services.s3.model.CannedAccessControlList;
+import com.amazonaws.services.s3.model.PutObjectRequest;
+import com.favoriteplace.global.config.S3Config;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.*;
+import java.util.concurrent.CompletableFuture;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AmazonS3ImageManager {
+
+ private final AmazonS3Client amazonS3Client;
+ private final S3Config s3Config;
+
+ @Async("S3imageUploadExecutor")
+ public CompletableFuture upload(MultipartFile multipartFile) throws IOException {
+ CompletableFuture future = new CompletableFuture<>();
+
+ String s3FileName = UUID.randomUUID().toString().substring(0, 10) + multipartFile.getOriginalFilename();
+
+ File uploadFile = convert(multipartFile)
+ .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));
+
+ future.complete(upload(uploadFile, s3FileName));
+
+ return future;
+ }
+
+ @Async("S3imageUploadExecutor")
+ public List>upload(List multipartFiles) throws IOException {
+ List> futures = new ArrayList>();
+ CompletableFuture future = new CompletableFuture<>();
+
+ for (MultipartFile file : multipartFiles) {
+ String s3FileName = UUID.randomUUID().toString().substring(0, 10) + file.getOriginalFilename();
+
+ File uploadFile = convert(file)
+ .orElseThrow(() -> new IllegalArgumentException("MultipartFile -> File 전환 실패"));
+ future.complete((upload(uploadFile, s3FileName)));
+ }
+ futures.add(future);
+
+ return futures;
+ }
+
+ private String upload(File uploadFile, String s3FileName) {
+ String fileName = s3Config.getFilePath()+ "/" + s3FileName + uploadFile.getName();
+ String uploadImageUrl = putS3(uploadFile, fileName);
+ removeNewFile(uploadFile); // 로컬에 생성된 File 삭제 (MultipartFile -> File 전환 하며 로컬에 파일 생성됨)
+ return uploadImageUrl; // 업로드된 파일의 S3 URL 주소 반환
+ }
+
+ private String putS3(File uploadFile, String fileName) {
+ amazonS3Client.putObject(
+ new PutObjectRequest(s3Config.getBucket(), fileName, uploadFile)
+ .withCannedAcl(CannedAccessControlList.PublicRead) // PublicRead 권한으로 업로드 됨
+ );
+ return amazonS3Client.getUrl(s3Config.getBucket(), fileName).toString();
+ }
+
+ private void removeNewFile(File targetFile) {
+ if (targetFile.delete()) {
+ log.info("파일이 삭제되었습니다.");
+ } else {
+ log.info("파일이 삭제되지 못했습니다.");
+ }
+ }
+
+ private Optional convert(MultipartFile file) throws IOException {
+ File convertFile = new File(Objects.requireNonNull(file.getOriginalFilename()));
+ if (convertFile.createNewFile()) {
+ try (FileOutputStream fos = new FileOutputStream(convertFile)) {
+ fos.write(file.getBytes());
+ }
+ return Optional.of(convertFile);
+ }
+ return Optional.empty();
+ }
+}
diff --git a/src/main/java/com/favoriteplace/global/websocket/WebSocketConfig.java b/src/main/java/com/favoriteplace/global/websocket/WebSocketConfig.java
index 9c6755a..1c4aa33 100644
--- a/src/main/java/com/favoriteplace/global/websocket/WebSocketConfig.java
+++ b/src/main/java/com/favoriteplace/global/websocket/WebSocketConfig.java
@@ -23,8 +23,7 @@ public void configureMessageBroker(MessageBrokerRegistry registry) {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
- .setAllowedOriginPatterns("*")
- .withSockJS();
+ .setAllowedOriginPatterns("*");
}
@Override