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
  • 커뮤니티 도메인 API
  • FCM 푸시 알림
  • 대댓글 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