From ce2464a88e38a66be0409f5e67daa1a264cadff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9A=B0=EB=94=94?= <38103085+EunjiShin@users.noreply.github.com> Date: Mon, 26 Aug 2024 22:40:50 +0900 Subject: [PATCH 1/7] =?UTF-8?q?[BSVR-239]=20=EB=A1=9C=EC=BB=AC/=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=EC=84=9C=20redis=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=84=B8=ED=8C=85=20(#160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: embedded redis dependency 추가 * feat: embedded redis 설정 파일 작성 * refactor: OS 종속적이지 않게 코드 수정 --- .../src/main/resources/application.yaml | 2 +- infrastructure/build.gradle.kts | 4 ++ .../redis/EmbeddedRedisConfig.java | 63 +++++++++++++++++++ .../{RedisConfig.java => RedissonConfig.java} | 2 +- versions.properties | 2 + 5 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/EmbeddedRedisConfig.java rename infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/{RedisConfig.java => RedissonConfig.java} (96%) diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml index f26a91b6..1847e269 100644 --- a/application/src/main/resources/application.yaml +++ b/application/src/main/resources/application.yaml @@ -5,7 +5,7 @@ server: spring: # 서브모듈 profile profiles: - active: dev + active: local group: local: - jpa diff --git a/infrastructure/build.gradle.kts b/infrastructure/build.gradle.kts index fdb8a7c7..9391e6d1 100644 --- a/infrastructure/build.gradle.kts +++ b/infrastructure/build.gradle.kts @@ -27,6 +27,10 @@ dependencies { // redis implementation("org.springframework.boot:spring-boot-starter-data-redis:_") implementation("org.redisson:redisson-spring-boot-starter:_") + implementation("it.ozimov:embedded-redis:_") { + exclude(group = "org.slf4j", module = "slf4j-simple") + because("테스트 환경에서 사용할 embedded-redis") + } // webflux (HTTP 요청에 사용) implementation("org.springframework.boot:spring-boot-starter-webflux") diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/EmbeddedRedisConfig.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/EmbeddedRedisConfig.java new file mode 100644 index 00000000..c1006035 --- /dev/null +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/EmbeddedRedisConfig.java @@ -0,0 +1,63 @@ +package org.depromeet.spot.infrastructure.redis; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; + +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import redis.embedded.RedisServer; + +@Slf4j +@Configuration +@Profile("local | test") +@RequiredArgsConstructor +public class EmbeddedRedisConfig { + + private final RedisProperties redisProperties; + + private RedisServer redisServer; + + @PostConstruct + public void redisServer() { + int port = isRedisPortAvailable() ? findAvailablePort() : redisProperties.port(); + log.info("embedded redis port = {}", port); + redisServer = new RedisServer(port); + redisServer.start(); + } + + @PreDestroy + public void stopRedis() { + if (redisServer != null) { + redisServer.stop(); + } + } + + private boolean isRedisPortAvailable() { + return isAvailablePort(redisProperties.port()); + } + + private boolean isAvailablePort(final int port) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress("localhost", port), 200); + return true; + } catch (IOException e) { + return false; + } + } + + private int findAvailablePort() { + for (int port = 10000; port <= 65535; port++) { + if (!isAvailablePort(port)) { + return port; + } + } + throw new IllegalArgumentException("10000 ~ 65535 사이에서 사용 가능한 포트가 없습니다."); + } +} diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedisConfig.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedissonConfig.java similarity index 96% rename from infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedisConfig.java rename to infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedissonConfig.java index 8e004ed7..1dea480c 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedisConfig.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedissonConfig.java @@ -10,7 +10,7 @@ @Configuration @RequiredArgsConstructor -public class RedisConfig { +public class RedissonConfig { private static final String REDISSON_HOST_PREFIX = "redis://"; private final RedisProperties redisProperties; diff --git a/versions.properties b/versions.properties index b16ad27e..ff21ba79 100644 --- a/versions.properties +++ b/versions.properties @@ -58,3 +58,5 @@ version.com.github.loki4j..loki-logback-appender=1.4.2 version.io.micrometer..micrometer-registry-prometheus=1.12.4 version.com.github.ben-manes.caffeine..caffeine=3.1.8 + +version.it.ozimov..embedded-redis=0.7.3 From 866914be9f26f7ff6e4fc5acfbf6fc6d09523ea9 Mon Sep 17 00:00:00 2001 From: junwon <67488973+wjdwnsdnjs13@users.noreply.github.com> Date: Mon, 26 Aug 2024 23:24:55 +0900 Subject: [PATCH 2/7] =?UTF-8?q?[BSVR-208]=20=EA=B5=AC=EA=B8=80=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=B6=94=EA=B0=80,=20(=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C)=20accessToken=20=EB=B0=9C=EA=B8=89=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EB=B3=80=EA=B2=BD=20(#150)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor : 포괄적으로 application-kakao -> oauth로 네이밍 변경 * refactor : 포괄적으로 application-kakao -> oauth로 네이밍 변경 * refactor : Oauth -> KAKAO로 네이밍 변경 * feat : v2 member, 구글 로그인 화이트 리스트 추가 * feat : 구글 로그인 화이트 리스트 추가 * feat : 회원가입 v2에 사용되는 RequestDto 구현 * feat : 구글 로그인 구현 * refactor : 잘못된 파라미터명 수정 * feat : sns 구글 추가 * feat : 한 API로 여러 SNS(카카오, 구글) 로그인할 수 있도록 기능 구현 * refactor : monitoring 추가 * refactor : 구글 로그인에서는 authToken을 사용하도록 변경 --- .github/workflows/manual-prod-deploy.yaml | 2 +- .gitignore | 2 +- .../common/config/SecurityConfig.java | 3 +- .../common/jwt/JwtAuthenticationFilter.java | 5 +- .../member/controller/OauthController.java | 76 ++++++++ .../member/dto/request/RegisterV2Req.java | 35 ++++ .../src/main/resources/application.yaml | 6 +- .../spot/domain/member/enums/SnsProvider.java | 3 +- .../jpa/oauth/OauthRepositoryImpl.java | 164 ++++++++++++++++-- .../jpa/oauth/entity/GoogleTokenEntity.java | 36 ++++ .../oauth/entity/GoogleUserInfoEntity.java | 52 ++++++ .../jpa/oauth/entity/KakaoUserInfoEntity.java | 2 +- .../usecase/port/in/member/MemberUsecase.java | 2 +- .../usecase/port/in/oauth/OauthUsecase.java | 62 +++++++ .../port/out/oauth/OauthRepository.java | 13 +- .../usecase/service/member/MemberService.java | 2 +- .../usecase/service/oauth/OauthService.java | 62 +++++++ 17 files changed, 495 insertions(+), 32 deletions(-) create mode 100644 application/src/main/java/org/depromeet/spot/application/member/controller/OauthController.java create mode 100644 application/src/main/java/org/depromeet/spot/application/member/dto/request/RegisterV2Req.java create mode 100644 infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleTokenEntity.java create mode 100644 infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleUserInfoEntity.java create mode 100644 usecase/src/main/java/org/depromeet/spot/usecase/port/in/oauth/OauthUsecase.java create mode 100644 usecase/src/main/java/org/depromeet/spot/usecase/service/oauth/OauthService.java diff --git a/.github/workflows/manual-prod-deploy.yaml b/.github/workflows/manual-prod-deploy.yaml index 945007c7..2d7087aa 100644 --- a/.github/workflows/manual-prod-deploy.yaml +++ b/.github/workflows/manual-prod-deploy.yaml @@ -54,7 +54,7 @@ jobs: -e SPRING_DATASOURCE_USERNAME=${{ secrets.PROD_DB_USERNAME }} \ -e SPRING_DATASOURCE_PASSWORD=${{ secrets.PROD_DB_PASSWORD }} \ -e SPRING_JWT_SECRET=${{ secrets.JWT_SECRET }} \ - -e OAUTH_CLIENTID=${{ secrets.KAKAO_CLIENT_ID }} \ + -e KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }} \ -e OAUTH_KAUTHTOKENURLHOST=${{ secrets.KAUTH_TOKEN_URL_HOST }} \ -e OAUTH_KAUTHUSERURLHOST=${{ secrets.KAUTH_USER_URL_HOST }} \ -e SPRING_JPA_HIBERNATE_DDL_AUTO=validate \ diff --git a/.gitignore b/.gitignore index 459202df..bfd8acf2 100644 --- a/.gitignore +++ b/.gitignore @@ -387,6 +387,6 @@ gradle-app.setting *.application-jwt.yml *.application-monitoring.yml application-jwt.yml -application-kakao.yml +application-oauth.yml application-sentry.yml application-aws.yaml diff --git a/application/src/main/java/org/depromeet/spot/application/common/config/SecurityConfig.java b/application/src/main/java/org/depromeet/spot/application/common/config/SecurityConfig.java index 6b4e41fc..bed6077c 100644 --- a/application/src/main/java/org/depromeet/spot/application/common/config/SecurityConfig.java +++ b/application/src/main/java/org/depromeet/spot/application/common/config/SecurityConfig.java @@ -31,7 +31,8 @@ public class SecurityConfig { "/swagger-ui.html", "/favicon.ico/**", "/api/v1/members/**", - "/actuator/**" + "/actuator/**", + "/login/oauth2/code/google/**", }; @Bean diff --git a/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtAuthenticationFilter.java b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtAuthenticationFilter.java index 7fa27c12..fa75d8cc 100644 --- a/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtAuthenticationFilter.java +++ b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtAuthenticationFilter.java @@ -47,6 +47,10 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { Map.of( "/api/v1/members", Set.of("GET", "POST"), + "/api/v2/members", + Set.of("GET", "POST"), + "/login/oauth2/code/google", + Set.of("GET"), "/api/v1/members/delete", Set.of("DELETE"), "/api/v1/baseball-teams", @@ -65,7 +69,6 @@ protected void doFilterInternal( filterChain.doFilter(request, response); return; } - // header가 null이거나 빈 문자열이면 안됨. if (header == null || header.isEmpty()) { throw new CustomJwtException(JwtErrorCode.NONEXISTENT_TOKEN); diff --git a/application/src/main/java/org/depromeet/spot/application/member/controller/OauthController.java b/application/src/main/java/org/depromeet/spot/application/member/controller/OauthController.java new file mode 100644 index 00000000..c4fca739 --- /dev/null +++ b/application/src/main/java/org/depromeet/spot/application/member/controller/OauthController.java @@ -0,0 +1,76 @@ +package org.depromeet.spot.application.member.controller; + +import jakarta.validation.Valid; + +import org.depromeet.spot.application.common.jwt.JwtTokenUtil; +import org.depromeet.spot.application.member.dto.request.RegisterV2Req; +import org.depromeet.spot.application.member.dto.response.JwtTokenResponse; +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.usecase.port.in.oauth.OauthUsecase; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@Slf4j +@Tag(name = "(v2) Oauth") +public class OauthController { + + private final OauthUsecase oauthUsecase; + + private final JwtTokenUtil jwtTokenUtil; + + @PostMapping("/api/v2/members") + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Member 회원가입 API") + public JwtTokenResponse create(@RequestBody @Valid RegisterV2Req request) { + + Member member = request.toDomain(); + Member memberResult = oauthUsecase.create(request.accessToken(), member); + + return new JwtTokenResponse(jwtTokenUtil.getJWTToken(memberResult)); + } + + @GetMapping("/api/v2/members/{snsProvider}/{token}") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "Member 로그인 API") + public JwtTokenResponse login( + @PathVariable("snsProvider") + @Parameter(name = "snsProvider", description = "KAKAO/GOOGLE", required = true) + SnsProvider snsProvider, + @PathVariable("token") + @Parameter( + name = "token", + description = "sns 카카오는 accessToken, 구글은 authToken", + required = true) + String token) { + + Member member = oauthUsecase.login(snsProvider, token); + return new JwtTokenResponse(jwtTokenUtil.getJWTToken(member)); + } + + // TODO : /api/v2/members를 RequestMapping으로 빼면 구글 로그인에서 4xx Exception 발생 + @GetMapping("/api/v2/members/authorization/{snsProvider}") + @ResponseStatus(HttpStatus.OK) + @Operation(summary = "(백엔드용)accessToken을 받아오기 위한 API") + public String getAccessToken2( + @PathVariable("snsProvider") SnsProvider snsProvider, @RequestParam String code) { + String token = oauthUsecase.getOauthAccessToken(snsProvider, code); + log.info("snsProvider : {}", snsProvider); + log.info("token : \n{}", token); + return token; + } +} diff --git a/application/src/main/java/org/depromeet/spot/application/member/dto/request/RegisterV2Req.java b/application/src/main/java/org/depromeet/spot/application/member/dto/request/RegisterV2Req.java new file mode 100644 index 00000000..28f895b5 --- /dev/null +++ b/application/src/main/java/org/depromeet/spot/application/member/dto/request/RegisterV2Req.java @@ -0,0 +1,35 @@ +package org.depromeet.spot.application.member.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.Range; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record RegisterV2Req( + @NotNull(message = "인가 accessToken는 필수 값입니다.") @Schema(description = "카카오 인증 accessToken") + String accessToken, + @NotNull(message = "닉네임 값은 필수입니다.") + @Schema(description = "설정하려는 닉네임") + @Length(min = 2, max = 10, message = "닉네임은 2글자에서 10글자 사이여야합니다.") + @Pattern( + regexp = "^[a-zA-Z0-9가-힣]*$", + message = "닉네임은 알파벳 대소문자, 숫자, 한글만 허용하며, 공백은 불가능합니다.") + String nickname, + @Schema(description = "응원 팀 pk") + @Range( + min = 1, + max = 10, + message = "응원 팀은 null(모두 응원), 1번(두산 베어스)부터 10번(NC 다이노스)까지 입니다.") + Long teamId, + @NotNull(message = "SNS Provider는 필수 값입니다.") @Schema(description = "KAKAO/GOOGLE") + SnsProvider snsProvider) { + + public Member toDomain() { + return Member.builder().nickname(nickname).teamId(teamId).snsProvider(snsProvider).build(); + } +} diff --git a/application/src/main/resources/application.yaml b/application/src/main/resources/application.yaml index 1847e269..42d7e2c6 100644 --- a/application/src/main/resources/application.yaml +++ b/application/src/main/resources/application.yaml @@ -11,18 +11,18 @@ spring: - jpa - aws - jwt - - kakao + - oauth dev: - jpa - aws - jwt - - kakao + - oauth - monitoring prod: - jpa - aws - jwt - - kakao + - oauth - sentry - monitoring servlet: diff --git a/domain/src/main/java/org/depromeet/spot/domain/member/enums/SnsProvider.java b/domain/src/main/java/org/depromeet/spot/domain/member/enums/SnsProvider.java index f5cc1349..a1c32614 100644 --- a/domain/src/main/java/org/depromeet/spot/domain/member/enums/SnsProvider.java +++ b/domain/src/main/java/org/depromeet/spot/domain/member/enums/SnsProvider.java @@ -6,7 +6,8 @@ @Getter @AllArgsConstructor public enum SnsProvider { - KAKAO("KAKAO"); + KAKAO("KAKAO"), + GOOGLE("GOOGLE"); private final String value; } diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/OauthRepositoryImpl.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/OauthRepositoryImpl.java index a3d7fd70..87deb796 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/OauthRepositoryImpl.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/OauthRepositoryImpl.java @@ -3,6 +3,9 @@ import org.depromeet.spot.common.exception.oauth.OauthException.InternalOauthServerException; import org.depromeet.spot.common.exception.oauth.OauthException.InvalidAcessTokenException; import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.infrastructure.jpa.oauth.entity.GoogleTokenEntity; +import org.depromeet.spot.infrastructure.jpa.oauth.entity.GoogleUserInfoEntity; import org.depromeet.spot.infrastructure.jpa.oauth.entity.KakaoTokenEntity; import org.depromeet.spot.infrastructure.jpa.oauth.entity.KakaoUserInfoEntity; import org.depromeet.spot.usecase.port.out.oauth.OauthRepository; @@ -22,31 +25,51 @@ public class OauthRepositoryImpl implements OauthRepository { private final String BEARER = "Bearer"; + private final String AUTHORIZATION_CODE = "authorization_code"; + + @Value("${oauth.kakaoRedirectUrl}") + private String KAKAO_REDIRECT_URL; + + @Value("${oauth.googleRedirectUrl}") + private String GOOGLE_REDIRECT_URL; + // kakao에서 발급 받은 clientID - @Value("${oauth.clientId}") - private String CLIENT_ID; + @Value("${oauth.kakaoClientId}") + private String KAKAO_CLIENT_ID; + + @Value("${oauth.googleClientId}") + private String GOOGLE_CLIENT_ID; - @Value("${oauth.kauthTokenUrlHost}") - private String KAUTH_TOKEN_URL_HOST; + @Value("${oauth.googleClientSecret}") + private String GOOGLE_CLIENT_SECRET; + + @Value("${oauth.kakaoAuthTokenUrlHost}") + private String KAKAO_AUTH_TOKEN_URL_HOST; + + @Value("${oauth.googleAuthTokenUrlHost}") + private String GOOGLE_AUTH_TOKEN_URL_HOST; // 엑세스 토큰으로 카카오에서 유저 정보 받아오기 - @Value("${oauth.kauthUserUrlHost}") - private String KAUTH_USER_URL_HOST; + @Value("${oauth.kakaoAuthUserUrlHost}") + private String KAKAO_AUTH_USER_URL_HOST; + + @Value("${oauth.googleUserUrlHost}") + private String GOOGLE_AUTH_USER_URL_HOST; @Override - public String getKakaoAccessToken(String idCode) { + public String getKakaoAccessToken(String authorizationCode) { // Webflux의 WebClient KakaoTokenEntity kakaoTokenEntity = - WebClient.create(KAUTH_TOKEN_URL_HOST) + WebClient.create(KAKAO_AUTH_TOKEN_URL_HOST) .post() .uri( uriBuilder -> uriBuilder .scheme("https") .path("/oauth/token") - .queryParam("grant_type", "authorization_code") - .queryParam("client_id", CLIENT_ID) - .queryParam("code", idCode) + .queryParam("grant_type", AUTHORIZATION_CODE) + .queryParam("client_id", KAKAO_CLIENT_ID) + .queryParam("code", authorizationCode) .build(true)) .header( HttpHeaders.CONTENT_TYPE, @@ -71,26 +94,103 @@ public String getKakaoAccessToken(String idCode) { } @Override - public Member getRegisterUserInfo(String accessToken, Member member) { - KakaoUserInfoEntity userInfo = getUserInfo(accessToken); + public String getOauthAccessToken(SnsProvider snsProvider, String authorizationCode) { + String authTokenUrlHost; + + switch (snsProvider) { + case KAKAO: + authTokenUrlHost = KAKAO_AUTH_TOKEN_URL_HOST; + break; + default: + authTokenUrlHost = GOOGLE_AUTH_TOKEN_URL_HOST; + break; + } + + String accessToken = + WebClient.create(authTokenUrlHost) + .post() + .uri( + uriBuilder -> { + switch (snsProvider) { + case KAKAO: + return uriBuilder + .scheme("https") + .path("/oauth/token") + .queryParam("grant_type", AUTHORIZATION_CODE) + .queryParam("client_id", KAKAO_CLIENT_ID) + .queryParam("redirect_uri", KAKAO_REDIRECT_URL) + .queryParam("code", authorizationCode) + .build(true); + default: // 기본적으로 GOOGLE 처리 + return uriBuilder + .scheme("https") + .path("/token") + .queryParam("grant_type", AUTHORIZATION_CODE) + .queryParam("client_id", GOOGLE_CLIENT_ID) + .queryParam( + "client_secret", GOOGLE_CLIENT_SECRET) + .queryParam("redirect_uri", GOOGLE_REDIRECT_URL) + .queryParam("code", authorizationCode) + .build(true); + } + }) + .header( + HttpHeaders.CONTENT_TYPE, + HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) + .retrieve() + .onStatus( + HttpStatusCode::is4xxClientError, + clientResponse -> Mono.error(new InvalidAcessTokenException())) + .onStatus( + HttpStatusCode::is5xxServerError, + clientResponse -> Mono.error(new InternalOauthServerException())) + .bodyToMono(GoogleTokenEntity.class) + .block() + .getAccessToken(); + + return accessToken; + } + + @Override + public Member getKakaoRegisterUserInfo(String accessToken, Member member) { + KakaoUserInfoEntity userInfo = getKakaoUserInfo(accessToken); // 회원가입 시 받은 정보를 바탕으로 member로 변환해서 리턴. return userInfo.toKakaoDomain(member); } + @Override + public Member getOauthRegisterUserInfo(String accessToken, Member member) { + switch (member.getSnsProvider()) { + case KAKAO: + return getKakaoUserInfo(accessToken).toKakaoDomain(member); + default: + return getGoogleUserInfo(accessToken).toGoogleDomain(member); + } + } + @Override public Member getLoginUserInfo(String accesstoken) { - KakaoUserInfoEntity userInfo = getUserInfo(accesstoken); + KakaoUserInfoEntity userInfo = getKakaoUserInfo(accesstoken); - // TODO : idToken이 변경 될 수 있음. 등록된 email도 변경될 수 있기에 추 후 논의가 필요. // 기존 유저와 비교를 위해선 idToken만 필요함. // 앱에서는 accessToken을 반환해주기에 accessToken으로 로직 처리 return userInfo.toLoginDomain(); } - public KakaoUserInfoEntity getUserInfo(String accessToken) { + @Override + public Member getOauthLoginUserInfo(SnsProvider snsProvider, String accesstoken) { + switch (snsProvider) { + case KAKAO: + return getKakaoUserInfo(accesstoken).toLoginDomain(); + default: + return getGoogleUserInfo(accesstoken).toLoginDomain(); + } + } + + public KakaoUserInfoEntity getKakaoUserInfo(String accessToken) { KakaoUserInfoEntity userInfo = - WebClient.create(KAUTH_USER_URL_HOST) + WebClient.create(KAKAO_AUTH_USER_URL_HOST) .get() .uri( uriBuilder -> @@ -102,7 +202,6 @@ public KakaoUserInfoEntity getUserInfo(String accessToken) { HttpHeaders.CONTENT_TYPE, HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) .retrieve() - // TODO : Custom Exception .onStatus( HttpStatusCode::is4xxClientError, clientResponse -> Mono.error(new InvalidAcessTokenException())) @@ -114,4 +213,33 @@ public KakaoUserInfoEntity getUserInfo(String accessToken) { return userInfo; } + + public GoogleUserInfoEntity getGoogleUserInfo(String accessToken) { + GoogleUserInfoEntity userInfo = + WebClient.create(GOOGLE_AUTH_USER_URL_HOST) + .get() + .uri( + uriBuilder -> + uriBuilder + .scheme("https") + .path("/oauth2/v3/userinfo") + .build(true)) + .header( + HttpHeaders.AUTHORIZATION, + BEARER + " " + accessToken) // access token 인가 + .header( + HttpHeaders.CONTENT_TYPE, + HttpHeaderValues.APPLICATION_X_WWW_FORM_URLENCODED.toString()) + .retrieve() + .onStatus( + HttpStatusCode::is4xxClientError, + clientResponse -> Mono.error(new InvalidAcessTokenException())) + .onStatus( + HttpStatusCode::is5xxServerError, + clientResponse -> Mono.error(new InternalOauthServerException())) + .bodyToMono(GoogleUserInfoEntity.class) + .block(); + + return userInfo; + } } diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleTokenEntity.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleTokenEntity.java new file mode 100644 index 00000000..222945c3 --- /dev/null +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleTokenEntity.java @@ -0,0 +1,36 @@ +package org.depromeet.spot.infrastructure.jpa.oauth.entity; + +import org.depromeet.spot.infrastructure.jpa.common.entity.BaseEntity; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor // 역직렬화를 위한 기본 생성자 +@JsonIgnoreProperties(ignoreUnknown = true) +public class GoogleTokenEntity extends BaseEntity { + + @JsonProperty("token_type") + public String tokenType; + + @JsonProperty("access_token") + public String accessToken; + + @JsonProperty("id_token") + public String idToken; + + @JsonProperty("expires_in") + public Integer expiresIn; + + @JsonProperty("refresh_token") + public String refreshToken; + + @JsonProperty("refresh_token_expires_in") + public Integer refreshTokenExpiresIn; + + @JsonProperty("scope") + public String scope; +} diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleUserInfoEntity.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleUserInfoEntity.java new file mode 100644 index 00000000..f81d7b87 --- /dev/null +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleUserInfoEntity.java @@ -0,0 +1,52 @@ +package org.depromeet.spot.infrastructure.jpa.oauth.entity; + +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.MemberRole; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.infrastructure.jpa.common.entity.BaseEntity; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor // 역직렬화를 위한 기본 생성자 +@JsonIgnoreProperties(ignoreUnknown = true) +public class GoogleUserInfoEntity extends BaseEntity { + + // 구글 로그인은 sub라는 이름으로 id값을 줌. + // 구글의 sub 값은 Long 타입을 넘어감. + // BigInteger로 처리하거나 String으로 처리해야함. + @JsonProperty("sub") + public String idToken; + + // 풀네임(닉네임) + @JsonProperty("name") + public String nickname; + + // 구글 이메일 + @JsonProperty("email") + public String email; + + // 프로필사진 + @JsonProperty("picture") + public String profileImageUrl; + + public Member toGoogleDomain(Member member) { + return Member.builder() + .email(email) + .nickname(member.getNickname()) + .profileImage(profileImageUrl) + .snsProvider(SnsProvider.GOOGLE) + .idToken(idToken) + .role(MemberRole.ROLE_USER) + .teamId(member.getTeamId()) + .build(); + } + + public Member toLoginDomain() { + return Member.builder().idToken(idToken).build(); + } +} diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/KakaoUserInfoEntity.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/KakaoUserInfoEntity.java index de6d0db0..c7ffb4b5 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/KakaoUserInfoEntity.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/KakaoUserInfoEntity.java @@ -82,7 +82,7 @@ public class Profile { // 닉네임 @JsonProperty("nickname") - public String nickName; + public String nickname; // 프로필 미리보기 이미지 URL @JsonProperty("thumbnail_image_url") diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java index 52a55a09..b5cabe43 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/member/MemberUsecase.java @@ -11,7 +11,7 @@ public interface MemberUsecase { Member create(String accessToken, Member member); - Member login(String idCode); + Member login(String accessToken); boolean duplicatedNickname(String nickname); diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/oauth/OauthUsecase.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/oauth/OauthUsecase.java new file mode 100644 index 00000000..ac45b362 --- /dev/null +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/oauth/OauthUsecase.java @@ -0,0 +1,62 @@ +package org.depromeet.spot.usecase.port.in.oauth; + +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.domain.team.BaseballTeam; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +public interface OauthUsecase { + + Member create(String accessToken, Member member); + + Member login(SnsProvider snsProvider, String token); + + String getOauthAccessToken(SnsProvider snsProvider, String authorizationCode); + + @Getter + @Builder + @AllArgsConstructor + class MemberInfo { + private final String nickname; + private final String profileImageUrl; + private final int level; + private final String levelTitle; + private final String mascotImageUrl; + private String teamImageUrl; + private final Long teamId; + private final String teamName; + private final Long reviewCntToLevelUp; + + public static MemberInfo of(Member member, Long reviewCntToLevelUp) { + return MemberInfo.builder() + .nickname(member.getNickname()) + .profileImageUrl(member.getProfileImage()) + .level(member.getLevel().getValue()) + .levelTitle(member.getLevel().getTitle()) + .mascotImageUrl(member.getLevel().getMascotImageUrl()) + .teamImageUrl(null) + .teamId(null) + .teamName(null) + .reviewCntToLevelUp(reviewCntToLevelUp) + .build(); + } + + public static MemberInfo of( + Member member, BaseballTeam baseballTeam, Long reviewCntToLevelUp) { + return MemberInfo.builder() + .nickname(member.getNickname()) + .profileImageUrl(member.getProfileImage()) + .level(member.getLevel().getValue()) + .levelTitle(member.getLevel().getTitle()) + .mascotImageUrl(member.getLevel().getMascotImageUrl()) + .teamImageUrl(baseballTeam.getLogo()) + .teamId(baseballTeam.getId()) + .teamName(baseballTeam.getName()) + .reviewCntToLevelUp(reviewCntToLevelUp) + .build(); + } + } +} diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/out/oauth/OauthRepository.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/oauth/OauthRepository.java index 95543e08..b2c42a35 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/port/out/oauth/OauthRepository.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/out/oauth/OauthRepository.java @@ -1,12 +1,19 @@ package org.depromeet.spot.usecase.port.out.oauth; import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.SnsProvider; public interface OauthRepository { - String getKakaoAccessToken(String idCode); + String getKakaoAccessToken(String authorizationCode); - Member getRegisterUserInfo(String accesstoken, Member member); + String getOauthAccessToken(SnsProvider snsProvider, String authorizationCode); - Member getLoginUserInfo(String accesstoken); + Member getKakaoRegisterUserInfo(String accessToken, Member member); + + Member getOauthRegisterUserInfo(String accessToken, Member member); + + Member getLoginUserInfo(String accessToken); + + Member getOauthLoginUserInfo(SnsProvider snsProvider, String accessToken); } diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java index a33f48c7..e2c5ec3c 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/member/MemberService.java @@ -37,7 +37,7 @@ public Member create(String accessToken, Member member) { if (memberRepository.existsByNickname(member.getNickname())) { throw new MemberNicknameConflictException(); } - Member memberResult = oauthRepository.getRegisterUserInfo(accessToken, member); + Member memberResult = oauthRepository.getKakaoRegisterUserInfo(accessToken, member); Level initialLevel = readLevelUsecase.findInitialLevel(); // 이미 있는 유저를 검증할 필요 없음 -> 최초 시도가 로그인먼저 들어오기 때문. return memberRepository.save(memberResult, initialLevel); diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/oauth/OauthService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/oauth/OauthService.java new file mode 100644 index 00000000..3a18126e --- /dev/null +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/oauth/OauthService.java @@ -0,0 +1,62 @@ +package org.depromeet.spot.usecase.service.oauth; + +import org.depromeet.spot.common.exception.member.MemberException.InactiveMemberException; +import org.depromeet.spot.common.exception.member.MemberException.MemberNicknameConflictException; +import org.depromeet.spot.domain.member.Level; +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.usecase.port.in.member.level.ReadLevelUsecase; +import org.depromeet.spot.usecase.port.in.oauth.OauthUsecase; +import org.depromeet.spot.usecase.port.out.member.MemberRepository; +import org.depromeet.spot.usecase.port.out.oauth.OauthRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional +@RequiredArgsConstructor +public class OauthService implements OauthUsecase { + + private final OauthRepository oauthRepository; + private final MemberRepository memberRepository; + private final ReadLevelUsecase readLevelUsecase; + + @Override + public Member create(String accessToken, Member member) { + if (memberRepository.existsByNickname(member.getNickname())) { + throw new MemberNicknameConflictException(); + } + Member memberResult = oauthRepository.getOauthRegisterUserInfo(accessToken, member); + Level initialLevel = readLevelUsecase.findInitialLevel(); + + return memberRepository.save(memberResult, initialLevel); + } + + @Override + public Member login(SnsProvider snsProvider, String token) { + String accessToken; + switch (snsProvider) { + case KAKAO: + accessToken = token; + break; + default: + accessToken = oauthRepository.getOauthAccessToken(snsProvider, token); + break; + } + Member memberResult = oauthRepository.getOauthLoginUserInfo(snsProvider, accessToken); + Member existedMember = memberRepository.findByIdToken(memberResult.getIdToken()); + + // 회원 탈퇴 유저일 경우 재가입 + if (existedMember.getDeletedAt() != null) { + throw new InactiveMemberException(); + } + return existedMember; + } + + @Override + public String getOauthAccessToken(SnsProvider snsProvider, String authorizationCode) { + return oauthRepository.getOauthAccessToken(snsProvider, authorizationCode); + } +} From 87d7a33c1f2a0dcf6f04b3b61792e837166787a3 Mon Sep 17 00:00:00 2001 From: junwon <67488973+wjdwnsdnjs13@users.noreply.github.com> Date: Mon, 26 Aug 2024 23:52:41 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor=20:=20oauth=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=20=EB=B3=80=EC=88=98=20=EC=B6=94=EA=B0=80=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-build-and-deploy.yaml | 9 +++++++++ .github/workflows/manual-prod-deploy.yaml | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/.github/workflows/dev-build-and-deploy.yaml b/.github/workflows/dev-build-and-deploy.yaml index d5cd33e5..f117acc7 100644 --- a/.github/workflows/dev-build-and-deploy.yaml +++ b/.github/workflows/dev-build-and-deploy.yaml @@ -120,6 +120,15 @@ jobs: -e OAUTH_CLIENTID=${{ secrets.KAKAO_CLIENT_ID }} \ -e OAUTH_KAUTHTOKENURLHOST=${{ secrets.KAUTH_TOKEN_URL_HOST }} \ -e OAUTH_KAUTHUSERURLHOST=${{ secrets.KAUTH_USER_URL_HOST }} \ + -e OAUTH_KAKAOCLIENTID=${{ secrets.OAUTH_KAKAOCLIENTID }} \ + -e OAUTH_KAKAOAUTHTOKENURLHOST=${{ secrets.KAKAOAUTHTOKENURLHOST }} \ + -e OAUTH_KAKAOAUTHUSERURLHOST=${{ secrets.KAKAOAUTHUSERURLHOST }} \ + -e OAUTH_KAKAOREDIRECTURL=${{ secrets.KAKAOREDIRECTURL }} \ + -e OAUTH_GOOGLECLIENTID=${{ secrets.GOOGLECLIENTID }} \ + -e OAUTH_GOOGLECLIENTSECRET=${{ secrets.GOOGLECLIENTSECRET }} \ + -e OAUTH_GOOGLEREDIRECTURL=${{ secrets.GOOGLEREDIRECTURL }} \ + -e OAUTH_GOOGLEAUTHTOKENURLHOST=${{ secrets.GOOGLEAUTHTOKENURLHOST }} \ + -e OAUTH_GOOGLEUSERURLHOST=${{ secrets.GOOGLEUSERURLHOST }} \ -e SPRING_JPA_HIBERNATE_DDL_AUTO=validate \ -e AWS_S3_ACCESS_KEY=${{ secrets.AWS_S3_ACCESS_KEY }} \ -e AWS_S3_SECRET_KEY=${{ secrets.AWS_S3_SECRET_KEY }} \ diff --git a/.github/workflows/manual-prod-deploy.yaml b/.github/workflows/manual-prod-deploy.yaml index 2d7087aa..c5bd26f6 100644 --- a/.github/workflows/manual-prod-deploy.yaml +++ b/.github/workflows/manual-prod-deploy.yaml @@ -57,6 +57,15 @@ jobs: -e KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }} \ -e OAUTH_KAUTHTOKENURLHOST=${{ secrets.KAUTH_TOKEN_URL_HOST }} \ -e OAUTH_KAUTHUSERURLHOST=${{ secrets.KAUTH_USER_URL_HOST }} \ + -e OAUTH_KAKAOCLIENTID=${{ secrets.OAUTH_KAKAOCLIENTID }} \ + -e OAUTH_KAKAOAUTHTOKENURLHOST=${{ secrets.KAKAOAUTHTOKENURLHOST }} \ + -e OAUTH_KAKAOAUTHUSERURLHOST=${{ secrets.KAKAOAUTHUSERURLHOST }} \ + -e OAUTH_KAKAOREDIRECTURL=${{ secrets.KAKAOREDIRECTURL }} \ + -e OAUTH_GOOGLECLIENTID=${{ secrets.GOOGLECLIENTID }} \ + -e OAUTH_GOOGLECLIENTSECRET=${{ secrets.GOOGLECLIENTSECRET }} \ + -e OAUTH_GOOGLEREDIRECTURL=${{ secrets.GOOGLEREDIRECTURL }} \ + -e OAUTH_GOOGLEAUTHTOKENURLHOST=${{ secrets.GOOGLEAUTHTOKENURLHOST }} \ + -e OAUTH_GOOGLEUSERURLHOST=${{ secrets.GOOGLEUSERURLHOST }} \ -e SPRING_JPA_HIBERNATE_DDL_AUTO=validate \ -e AWS_S3_ACCESS_KEY=${{ secrets.AWS_S3_ACCESS_KEY }} \ -e AWS_S3_SECRET_KEY=${{ secrets.AWS_S3_SECRET_KEY }} \ From 4ad65cfd913b727c89786fc7624fa5fb27a26270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9A=B0=EB=94=94?= <38103085+EunjiShin@users.noreply.github.com> Date: Tue, 27 Aug 2024 00:51:19 +0900 Subject: [PATCH 4/7] =?UTF-8?q?[BSVR-217]=20=EB=A6=AC=EB=B7=B0=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=90=EC=97=90=20=EB=B6=84=EC=82=B0=EB=9D=BD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: embedded redis dependency 추가 * feat: embedded redis 설정 파일 작성 * refactor: OS 종속적이지 않게 수정 * feat: 분산락 어노테이션 추가 * build: common 모듈에 aop dependency 추가 * feat: 분산락 AOP 구현 * feat: 리뷰 공감 메서드에 분산락 적용 * test: FakeReviewLikeRepository 생성 * test: reviewLikeService 생성 * test: 동시성 테스트 유틸 추가 * refactor: sout -> log로 변경 * test: 리뷰 공감 테스트코드 작성 * test: 리뷰 공감에 필요한 repository 구현 * build: testContainer 의존성 설정 * refactor: @Value를 Properties로 대체 * feat: test용 프로필 생성 * test: 리뷰 공감 테스트에 필요한 데이터 * fix: ColumnDefault 수정 * test: 리뷰 공감 테스트 추가 * feat: 불필요한 코드 삭제 * feat: 불필요한 코드 삭제 * test: 통합 테스트로 바꾸면서 불필요해진 fake 삭제 * test: reviewLikeRepository fake 보완 * feat: oauth property 추가 * refactor: 불필요한 fake 삭제 --- application/build.gradle.kts | 6 + .../common/config/SpotApplicationConfig.java | 6 +- .../application/common/jwt/JwtProperties.java | 6 + .../application/common/jwt/JwtTokenUtil.java | 12 +- .../application/ReviewLikeServiceTest.java | 119 ++++++++++++++++++ .../src/test/resources/application-test.yml | 45 +++++++ .../sql/delete-data-after-review-like.sql | 10 ++ .../sql/review-like-service-data.sql | 41 ++++++ .../common/annotation/DistributedLock.java | 20 +++ infrastructure/build.gradle.kts | 2 + .../aws/config/ObjectStorageConfig.java | 2 - .../common/aop/DistributedLockAop.java | 70 +++++++++++ .../common/aop/TransactionAop.java | 15 +++ .../common/util/SpringELParser.java | 19 +++ .../jpa/oauth/OauthRepositoryImpl.java | 64 ++++------ .../jpa/oauth/config/OauthProperties.java | 15 +++ .../jpa/review/entity/ReviewEntity.java | 2 +- .../in/review/like/ReviewLikeUsecase.java | 2 +- .../service/review/ReadReviewService.java | 2 + .../service/review/UpdateReviewService.java | 2 + .../review/like/ReviewLikeService.java | 7 +- .../service/common/ConcurrencyTest.java | 47 +++++++ versions.properties | 8 ++ 23 files changed, 467 insertions(+), 55 deletions(-) create mode 100644 application/src/main/java/org/depromeet/spot/application/common/jwt/JwtProperties.java create mode 100644 application/src/test/java/org/depromeet/spot/application/ReviewLikeServiceTest.java create mode 100644 application/src/test/resources/application-test.yml create mode 100644 application/src/test/resources/sql/delete-data-after-review-like.sql create mode 100644 application/src/test/resources/sql/review-like-service-data.sql create mode 100644 common/src/main/java/org/depromeet/spot/common/annotation/DistributedLock.java create mode 100644 infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/aop/DistributedLockAop.java create mode 100644 infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/aop/TransactionAop.java create mode 100644 infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/util/SpringELParser.java create mode 100644 infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/config/OauthProperties.java create mode 100644 usecase/src/test/java/org/depromeet/spot/usecase/service/common/ConcurrencyTest.java diff --git a/application/build.gradle.kts b/application/build.gradle.kts index d9f19dc8..fefe2aa9 100644 --- a/application/build.gradle.kts +++ b/application/build.gradle.kts @@ -32,6 +32,12 @@ dependencies { // aop implementation("org.springframework.boot:spring-boot-starter-aop") + // test container + testImplementation("org.testcontainers:testcontainers:_") + testImplementation("org.testcontainers:junit-jupiter:_") + testImplementation("org.testcontainers:mysql:_") + testImplementation("org.testcontainers:jdbc:_") + } // spring boot main application이므로 실행 가능한 jar를 생성한다. diff --git a/application/src/main/java/org/depromeet/spot/application/common/config/SpotApplicationConfig.java b/application/src/main/java/org/depromeet/spot/application/common/config/SpotApplicationConfig.java index 52d2b327..a2fac1a1 100644 --- a/application/src/main/java/org/depromeet/spot/application/common/config/SpotApplicationConfig.java +++ b/application/src/main/java/org/depromeet/spot/application/common/config/SpotApplicationConfig.java @@ -2,11 +2,15 @@ import org.depromeet.spot.infrastructure.InfrastructureConfig; import org.depromeet.spot.usecase.config.UsecaseConfig; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; -@ComponentScan(basePackages = {"org.depromeet.spot.application"}) @Configuration +@EnableConfigurationProperties +@ComponentScan(basePackages = {"org.depromeet.spot.application"}) +@ConfigurationPropertiesScan(basePackages = {"org.depromeet.spot.application"}) @Import(value = {UsecaseConfig.class, SwaggerConfig.class, InfrastructureConfig.class}) public class SpotApplicationConfig {} diff --git a/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtProperties.java b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtProperties.java new file mode 100644 index 00000000..28f76925 --- /dev/null +++ b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtProperties.java @@ -0,0 +1,6 @@ +package org.depromeet.spot.application.common.jwt; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "spring.jwt") +public record JwtProperties(String secret) {} diff --git a/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtTokenUtil.java b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtTokenUtil.java index 97b787f0..8cf058cc 100644 --- a/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtTokenUtil.java +++ b/application/src/main/java/org/depromeet/spot/application/common/jwt/JwtTokenUtil.java @@ -16,7 +16,6 @@ import org.depromeet.spot.application.common.exception.JwtErrorCode; import org.depromeet.spot.domain.member.Member; import org.depromeet.spot.domain.member.enums.MemberRole; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import io.jsonwebtoken.Claims; @@ -37,8 +36,7 @@ public class JwtTokenUtil { // JWT를 생성하고 관리하는 클래스 // 토큰에 사용되는 시크릿 키 - @Value("${spring.jwt.secret}") - private String SECRETKEY; + private final JwtProperties properties; public String getJWTToken(Member member) { // TODO 토큰 구현하기. @@ -61,13 +59,13 @@ public String generateToken(Long memberId, MemberRole memberRole) { .setClaims(createClaims(memberId, memberRole)) .setIssuedAt(current) .setExpiration(expiredAt) - .signWith(SignatureAlgorithm.HS256, SECRETKEY.getBytes()) + .signWith(SignatureAlgorithm.HS256, properties.secret().getBytes()) .compact(); } public Long getIdFromJWT(String token) { return Jwts.parser() - .setSigningKey(SECRETKEY.getBytes()) + .setSigningKey(properties.secret().getBytes()) .parseClaimsJws(token) .getBody() .get("memberId", Long.class); @@ -75,7 +73,7 @@ public Long getIdFromJWT(String token) { public String getRoleFromJWT(String token) { return Jwts.parser() - .setSigningKey(SECRETKEY.getBytes()) + .setSigningKey(properties.secret().getBytes()) .parseClaimsJws(token) .getBody() .get("role", String.class); @@ -124,7 +122,7 @@ private Map createClaims(Long memberId, MemberRole role) { } private Key createSignature() { - byte[] apiKeySecretBytes = SECRETKEY.getBytes(); + byte[] apiKeySecretBytes = properties.secret().getBytes(); return new SecretKeySpec(apiKeySecretBytes, SignatureAlgorithm.HS256.getJcaName()); } diff --git a/application/src/test/java/org/depromeet/spot/application/ReviewLikeServiceTest.java b/application/src/test/java/org/depromeet/spot/application/ReviewLikeServiceTest.java new file mode 100644 index 00000000..e9d99d9a --- /dev/null +++ b/application/src/test/java/org/depromeet/spot/application/ReviewLikeServiceTest.java @@ -0,0 +1,119 @@ +package org.depromeet.spot.application; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; + +import org.depromeet.spot.domain.member.Level; +import org.depromeet.spot.domain.member.Member; +import org.depromeet.spot.domain.member.enums.MemberRole; +import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.domain.review.Review; +import org.depromeet.spot.usecase.port.in.review.ReadReviewUsecase; +import org.depromeet.spot.usecase.port.out.member.LevelRepository; +import org.depromeet.spot.usecase.port.out.member.MemberRepository; +import org.depromeet.spot.usecase.service.review.like.ReviewLikeService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.Sql.ExecutionPhase; +import org.springframework.test.context.jdbc.SqlGroup; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.junit.jupiter.Testcontainers; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@SpringBootTest +@Testcontainers +@ActiveProfiles("test") +@TestPropertySource("classpath:application-test.yml") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@SqlGroup({ + @Sql( + value = "/sql/delete-data-after-review-like.sql", + executionPhase = ExecutionPhase.AFTER_TEST_METHOD), + @Sql( + value = "/sql/review-like-service-data.sql", + executionPhase = ExecutionPhase.BEFORE_TEST_METHOD), +}) +class ReviewLikeServiceTest { + + @Autowired private ReviewLikeService reviewLikeService; + + @Autowired private ReadReviewUsecase readReviewUsecase; + + @Autowired private MemberRepository memberRepository; + + @Autowired private LevelRepository levelRepository; + + private static final int NUMBER_OF_THREAD = 100; + + @BeforeEach + @Transactional + void init() { + Level level = levelRepository.findByValue(0); + AtomicLong memberIdGenerator = new AtomicLong(1); + + for (int i = 0; i < NUMBER_OF_THREAD; i++) { + long memberId = memberIdGenerator.getAndIncrement(); + memberRepository.save( + Member.builder() + .id(memberId) + .snsProvider(SnsProvider.KAKAO) + .teamId(1L) + .role(MemberRole.ROLE_ADMIN) + .idToken("idToken" + memberId) + .nickname(String.valueOf(memberId)) + .phoneNumber(String.valueOf(memberId)) + .email("email" + memberId) + .build(), + level); + } + } + + @Test + void 멀티_스레드_환경에서_리뷰_공감_수를_정상적으로_증가시킬_수_있다() throws InterruptedException { + // given + final long reviewId = 1L; + AtomicLong memberIdGenerator = new AtomicLong(1); + final ExecutorService executorService = Executors.newFixedThreadPool(NUMBER_OF_THREAD); + final CountDownLatch latch = new CountDownLatch(NUMBER_OF_THREAD); + + // when + for (int i = 0; i < NUMBER_OF_THREAD; i++) { + long memberId = memberIdGenerator.getAndIncrement(); + executorService.execute( + () -> { + try { + reviewLikeService.toggleLike(memberId, reviewId); + System.out.println( + "Thread " + Thread.currentThread().getId() + " - 성공"); + } catch (Throwable e) { + System.out.println( + "Thread " + + Thread.currentThread().getId() + + " - 실패" + + e.getClass().getName()); + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + + // then + Review review = readReviewUsecase.findById(reviewId); + assertEquals(100, review.getLikesCount()); + } +} diff --git a/application/src/test/resources/application-test.yml b/application/src/test/resources/application-test.yml new file mode 100644 index 00000000..2850ede1 --- /dev/null +++ b/application/src/test/resources/application-test.yml @@ -0,0 +1,45 @@ +loki: + url: ${LOKI_URL} + +aws: + s3: + accessKey: ${AWS_S3_ACCESS_KEY} + secretKey: ${AWS_S3_SECRET_KEY} + bucketName: ${AWS_S3_BUCKET_NAME} + redis: + host: localhost + port: 6379 + +oauth: + kakaoClientId: ${KAKAO_CLIENT_ID} + kakaoAuthTokenUrlHost: ${KAKAO_AUTH_TOKEN_URL_HOST} + kakaoAuthUserUrlHost: ${KAKAO_AUTH_USER_URL_HOST} + kakaoRedirectUrl: ${KAKAO_REDIRECT_URL} + googleClientId: ${GOOGLE_CLIENT_ID} + googleClientSecret: ${GOOGLE_CLIENT_SECRET} + googleRedirectUrl: ${GOOGLE_REDIRECT_URL} + googleAuthTokenUrlHost: ${GOOGLE_AUTH_TOKEN_URL_HOST} + googleUserUrlHost: ${GOOGLE_USER_URL_HOST} + +spring: + datasource: + url: jdbc:tc:mysql:8.0.36:///testdb + username: testuser + password: testpassword + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + hikari: + connection-timeout: 100000 + maximum-pool-size: 300 + max-lifetime: 100000 + jpa: + database: mysql + hibernate: + ddl-auto: create + database-platform: org.hibernate.dialect.MySQL8Dialect + defer-datasource-initialization: true + jwt: + secret: ${JWT_SECRETKEY} + + +server: + port: 8080 \ No newline at end of file diff --git a/application/src/test/resources/sql/delete-data-after-review-like.sql b/application/src/test/resources/sql/delete-data-after-review-like.sql new file mode 100644 index 00000000..7639a790 --- /dev/null +++ b/application/src/test/resources/sql/delete-data-after-review-like.sql @@ -0,0 +1,10 @@ +delete from members where id > 0; +delete from reviews where id > 0; +delete from review_likes where id > 0; +delete from levels where id > 0; +delete from stadiums where id > 0; +delete from baseball_teams where id > 0; +delete from sections where id > 0; +delete from blocks where id > 0; +delete from block_rows where id > 0; +delete from seats where id > 0; \ No newline at end of file diff --git a/application/src/test/resources/sql/review-like-service-data.sql b/application/src/test/resources/sql/review-like-service-data.sql new file mode 100644 index 00000000..18de5f2c --- /dev/null +++ b/application/src/test/resources/sql/review-like-service-data.sql @@ -0,0 +1,41 @@ +-- levels +INSERT INTO levels (id, value, title, mascot_image_url, created_at, updated_at, deleted_at) +VALUES (1, 0, '직관 꿈나무', null, null, null, null), + (2, 1, '직관 첫 걸음', null, null, null, null), + (3, 2, '경기장 탐험가', null, null, null, null), + (4, 3, '직관의 여유', null, null, null, null), + (5, 4, '응원 단장', null, null, null, null), + (6, 5, '야구장 VIP', null, null, null, null), + (7, 6, '전설의 직관러', null, null, null, null); + +-- Stadiums +INSERT INTO stadiums (id, name, main_image, seating_chart_image, labeled_seating_chart_image, + is_active) +VALUES (1, '잠실 야구 경기장', 'main_image_a.jpg', 'seating_chart_a.jpg', 'labeled_seating_chart_a.jpg', + 1); + +-- Baseball Teams +INSERT INTO baseball_teams (id, name, alias, logo, label_font_color) +VALUES (1, 'Team A', 'A', 'logo_a.png', '#FFFFFF'); + +-- Stadium Sections +INSERT INTO sections (id, stadium_id, name, alias) +VALUES (1, 1, '오렌지석', '응원석'); + +-- Block +INSERT INTO blocks (id, stadium_id, section_id, code, max_rows) +VALUES (1, 1, 1, "207", 3); + +-- Row +INSERT INTO block_rows (id, block_id, number, max_seats) +VALUES (1, 1, 1, 3); + +-- Seats +INSERT INTO seats (id, stadium_id, section_id, block_id, row_id, seat_number) +VALUES (1, 1, 1, 1, 1, 1); + +-- reviews +INSERT INTO reviews (id, member_id, stadium_id, section_id, block_id, row_id, seat_id, date_time, content, likes_count, scraps_count, review_type) +VALUES + (1, 1, 1, 1, 1, 1, 1, '2023-06-01 19:00:00', '좋은 경기였습니다!', 0, 0, 'VIEW'), + (2, 1, 1, 1, 1, 1, 1, '2023-06-01 19:00:00', '좋은 경기였습니다!', 0, 0, 'VIEW'); \ No newline at end of file diff --git a/common/src/main/java/org/depromeet/spot/common/annotation/DistributedLock.java b/common/src/main/java/org/depromeet/spot/common/annotation/DistributedLock.java new file mode 100644 index 00000000..d0fa047f --- /dev/null +++ b/common/src/main/java/org/depromeet/spot/common/annotation/DistributedLock.java @@ -0,0 +1,20 @@ +package org.depromeet.spot.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DistributedLock { + + String key(); + + TimeUnit timeUnit() default TimeUnit.SECONDS; + + long leaseTime() default 5L; + + long waitTime() default 5L; +} diff --git a/infrastructure/build.gradle.kts b/infrastructure/build.gradle.kts index 9391e6d1..04303df5 100644 --- a/infrastructure/build.gradle.kts +++ b/infrastructure/build.gradle.kts @@ -9,6 +9,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-cache") implementation("org.springframework.boot:spring-boot-starter-data-jpa:_") + + // mysql runtimeOnly("com.mysql:mysql-connector-j") diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/aws/config/ObjectStorageConfig.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/aws/config/ObjectStorageConfig.java index c46aaaac..a36cd98f 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/aws/config/ObjectStorageConfig.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/aws/config/ObjectStorageConfig.java @@ -3,7 +3,6 @@ import org.depromeet.spot.infrastructure.aws.property.ObjectStorageProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; import com.amazonaws.auth.AWSStaticCredentialsProvider; import com.amazonaws.auth.BasicAWSCredentials; @@ -14,7 +13,6 @@ import lombok.RequiredArgsConstructor; @Configuration -@Profile("!test") @RequiredArgsConstructor public class ObjectStorageConfig { diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/aop/DistributedLockAop.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/aop/DistributedLockAop.java new file mode 100644 index 00000000..8fa11054 --- /dev/null +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/aop/DistributedLockAop.java @@ -0,0 +1,70 @@ +package org.depromeet.spot.infrastructure.common.aop; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.depromeet.spot.common.annotation.DistributedLock; +import org.depromeet.spot.infrastructure.common.util.SpringELParser; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class DistributedLockAop { + + private final RedissonClient redissonClient; + private final TransactionAop aopForTransaction; + + private static final String REDISSON_LOCK_PREFIX = "LOCK:"; + + @Around("@annotation(org.depromeet.spot.common.annotation.DistributedLock)") + public Object lock(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + DistributedLock distributedLock = method.getAnnotation(DistributedLock.class); + + String lockKey = generateLockKey(signature, joinPoint.getArgs(), distributedLock); + RLock rLock = redissonClient.getLock(lockKey); + + try { + if (!acquireLock(rLock, distributedLock)) { + return false; + } + return aopForTransaction.proceed(joinPoint); + } catch (InterruptedException e) { + log.error(Arrays.toString(e.getStackTrace())); + throw e; + } finally { + releaseLock(rLock); + } + } + + private String generateLockKey( + MethodSignature signature, Object[] args, DistributedLock distributeLock) { + return REDISSON_LOCK_PREFIX + + SpringELParser.getDynamicValue( + signature.getParameterNames(), args, distributeLock.key()); + } + + private boolean acquireLock(RLock rLock, DistributedLock distributeLock) + throws InterruptedException { + return rLock.tryLock( + distributeLock.waitTime(), distributeLock.leaseTime(), distributeLock.timeUnit()); + } + + private void releaseLock(RLock rLock) { + if (rLock != null && rLock.isHeldByCurrentThread()) { + rLock.unlock(); + } + } +} diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/aop/TransactionAop.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/aop/TransactionAop.java new file mode 100644 index 00000000..eb977092 --- /dev/null +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/aop/TransactionAop.java @@ -0,0 +1,15 @@ +package org.depromeet.spot.infrastructure.common.aop; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Component +public class TransactionAop { + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } +} diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/util/SpringELParser.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/util/SpringELParser.java new file mode 100644 index 00000000..cd3a6d88 --- /dev/null +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/common/util/SpringELParser.java @@ -0,0 +1,19 @@ +package org.depromeet.spot.infrastructure.common.util; + +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +public class SpringELParser { + + public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) { + ExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + return parser.parseExpression(key).getValue(context, Object.class); + } +} diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/OauthRepositoryImpl.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/OauthRepositoryImpl.java index 87deb796..628ef0de 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/OauthRepositoryImpl.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/OauthRepositoryImpl.java @@ -4,63 +4,37 @@ import org.depromeet.spot.common.exception.oauth.OauthException.InvalidAcessTokenException; import org.depromeet.spot.domain.member.Member; import org.depromeet.spot.domain.member.enums.SnsProvider; +import org.depromeet.spot.infrastructure.jpa.oauth.config.OauthProperties; import org.depromeet.spot.infrastructure.jpa.oauth.entity.GoogleTokenEntity; import org.depromeet.spot.infrastructure.jpa.oauth.entity.GoogleUserInfoEntity; import org.depromeet.spot.infrastructure.jpa.oauth.entity.KakaoTokenEntity; import org.depromeet.spot.infrastructure.jpa.oauth.entity.KakaoUserInfoEntity; import org.depromeet.spot.usecase.port.out.oauth.OauthRepository; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatusCode; import org.springframework.stereotype.Repository; import org.springframework.web.reactive.function.client.WebClient; import io.netty.handler.codec.http.HttpHeaderValues; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; @Slf4j @Repository +@RequiredArgsConstructor public class OauthRepositoryImpl implements OauthRepository { private final String BEARER = "Bearer"; + private final OauthProperties properties; private final String AUTHORIZATION_CODE = "authorization_code"; - @Value("${oauth.kakaoRedirectUrl}") - private String KAKAO_REDIRECT_URL; - - @Value("${oauth.googleRedirectUrl}") - private String GOOGLE_REDIRECT_URL; - - // kakao에서 발급 받은 clientID - @Value("${oauth.kakaoClientId}") - private String KAKAO_CLIENT_ID; - - @Value("${oauth.googleClientId}") - private String GOOGLE_CLIENT_ID; - - @Value("${oauth.googleClientSecret}") - private String GOOGLE_CLIENT_SECRET; - - @Value("${oauth.kakaoAuthTokenUrlHost}") - private String KAKAO_AUTH_TOKEN_URL_HOST; - - @Value("${oauth.googleAuthTokenUrlHost}") - private String GOOGLE_AUTH_TOKEN_URL_HOST; - - // 엑세스 토큰으로 카카오에서 유저 정보 받아오기 - @Value("${oauth.kakaoAuthUserUrlHost}") - private String KAKAO_AUTH_USER_URL_HOST; - - @Value("${oauth.googleUserUrlHost}") - private String GOOGLE_AUTH_USER_URL_HOST; - @Override public String getKakaoAccessToken(String authorizationCode) { // Webflux의 WebClient KakaoTokenEntity kakaoTokenEntity = - WebClient.create(KAKAO_AUTH_TOKEN_URL_HOST) + WebClient.create(properties.kakaoAuthTokenUrlHost()) .post() .uri( uriBuilder -> @@ -68,7 +42,7 @@ public String getKakaoAccessToken(String authorizationCode) { .scheme("https") .path("/oauth/token") .queryParam("grant_type", AUTHORIZATION_CODE) - .queryParam("client_id", KAKAO_CLIENT_ID) + .queryParam("client_id", properties.kakaoClientId()) .queryParam("code", authorizationCode) .build(true)) .header( @@ -99,10 +73,10 @@ public String getOauthAccessToken(SnsProvider snsProvider, String authorizationC switch (snsProvider) { case KAKAO: - authTokenUrlHost = KAKAO_AUTH_TOKEN_URL_HOST; + authTokenUrlHost = properties.kakaoAuthTokenUrlHost(); break; default: - authTokenUrlHost = GOOGLE_AUTH_TOKEN_URL_HOST; + authTokenUrlHost = properties.googleAuthTokenUrlHost(); break; } @@ -117,8 +91,11 @@ public String getOauthAccessToken(SnsProvider snsProvider, String authorizationC .scheme("https") .path("/oauth/token") .queryParam("grant_type", AUTHORIZATION_CODE) - .queryParam("client_id", KAKAO_CLIENT_ID) - .queryParam("redirect_uri", KAKAO_REDIRECT_URL) + .queryParam( + "client_id", properties.kakaoClientId()) + .queryParam( + "redirect_uri", + properties.kakaoRedirectUrl()) .queryParam("code", authorizationCode) .build(true); default: // 기본적으로 GOOGLE 처리 @@ -126,10 +103,15 @@ public String getOauthAccessToken(SnsProvider snsProvider, String authorizationC .scheme("https") .path("/token") .queryParam("grant_type", AUTHORIZATION_CODE) - .queryParam("client_id", GOOGLE_CLIENT_ID) .queryParam( - "client_secret", GOOGLE_CLIENT_SECRET) - .queryParam("redirect_uri", GOOGLE_REDIRECT_URL) + "client_id", + properties.googleClientId()) + .queryParam( + "client_secret", + properties.googleClientSecret()) + .queryParam( + "redirect_uri", + properties.googleRedirectUrl()) .queryParam("code", authorizationCode) .build(true); } @@ -190,7 +172,7 @@ public Member getOauthLoginUserInfo(SnsProvider snsProvider, String accesstoken) public KakaoUserInfoEntity getKakaoUserInfo(String accessToken) { KakaoUserInfoEntity userInfo = - WebClient.create(KAKAO_AUTH_USER_URL_HOST) + WebClient.create(properties.kakaoAuthUserUrlHost()) .get() .uri( uriBuilder -> @@ -216,7 +198,7 @@ public KakaoUserInfoEntity getKakaoUserInfo(String accessToken) { public GoogleUserInfoEntity getGoogleUserInfo(String accessToken) { GoogleUserInfoEntity userInfo = - WebClient.create(GOOGLE_AUTH_USER_URL_HOST) + WebClient.create(properties.googleUserUrlHost()) .get() .uri( uriBuilder -> diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/config/OauthProperties.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/config/OauthProperties.java new file mode 100644 index 00000000..0371f122 --- /dev/null +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/config/OauthProperties.java @@ -0,0 +1,15 @@ +package org.depromeet.spot.infrastructure.jpa.oauth.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth") +public record OauthProperties( + String kakaoClientId, + String kakaoAuthTokenUrlHost, + String kakaoAuthUserUrlHost, + String kakaoRedirectUrl, + String googleClientId, + String googleClientSecret, + String googleRedirectUrl, + String googleAuthTokenUrlHost, + String googleUserUrlHost) {} diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/entity/ReviewEntity.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/entity/ReviewEntity.java index f9eacd7a..6e97237e 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/entity/ReviewEntity.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/review/entity/ReviewEntity.java @@ -105,7 +105,7 @@ public class ReviewEntity extends BaseEntity { private Integer scrapsCount; @Enumerated(EnumType.STRING) - @ColumnDefault("VIEW") + @ColumnDefault("'VIEW'") @Column(name = "review_type", nullable = false) private ReviewType reviewType; diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/review/like/ReviewLikeUsecase.java b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/review/like/ReviewLikeUsecase.java index e4335f49..105dc1fb 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/port/in/review/like/ReviewLikeUsecase.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/port/in/review/like/ReviewLikeUsecase.java @@ -1,5 +1,5 @@ package org.depromeet.spot.usecase.port.in.review.like; public interface ReviewLikeUsecase { - void toggleLike(long memberId, long reviewId); + void toggleLike(Long memberId, long reviewId); } diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/ReadReviewService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/ReadReviewService.java index 40e2420f..4a554c6e 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/ReadReviewService.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/ReadReviewService.java @@ -27,9 +27,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.Builder; import lombok.RequiredArgsConstructor; @Service +@Builder @RequiredArgsConstructor @Transactional(readOnly = true) public class ReadReviewService implements ReadReviewUsecase { diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/UpdateReviewService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/UpdateReviewService.java index 7d9d0e9e..44c58844 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/UpdateReviewService.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/UpdateReviewService.java @@ -21,10 +21,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.Builder; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @Slf4j +@Builder @Service @RequiredArgsConstructor @Transactional diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/like/ReviewLikeService.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/like/ReviewLikeService.java index e20d558c..973418b4 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/like/ReviewLikeService.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/like/ReviewLikeService.java @@ -1,5 +1,6 @@ package org.depromeet.spot.usecase.service.review.like; +import org.depromeet.spot.common.annotation.DistributedLock; import org.depromeet.spot.domain.review.Review; import org.depromeet.spot.domain.review.like.ReviewLike; import org.depromeet.spot.usecase.port.in.review.ReadReviewUsecase; @@ -9,9 +10,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import lombok.Builder; import lombok.RequiredArgsConstructor; @Service +@Builder @Transactional @RequiredArgsConstructor public class ReviewLikeService implements ReviewLikeUsecase { @@ -20,9 +23,9 @@ public class ReviewLikeService implements ReviewLikeUsecase { private final UpdateReviewUsecase updateReviewUsecase; private final ReviewLikeRepository reviewLikeRepository; - // TODO: 분산락 적용 예정 @Override - public void toggleLike(final long memberId, final long reviewId) { + @DistributedLock(key = "#reviewId") + public void toggleLike(final Long memberId, final long reviewId) { Review review = readReviewUsecase.findById(reviewId); if (reviewLikeRepository.existsBy(memberId, reviewId)) { diff --git a/usecase/src/test/java/org/depromeet/spot/usecase/service/common/ConcurrencyTest.java b/usecase/src/test/java/org/depromeet/spot/usecase/service/common/ConcurrencyTest.java new file mode 100644 index 00000000..3ade3431 --- /dev/null +++ b/usecase/src/test/java/org/depromeet/spot/usecase/service/common/ConcurrencyTest.java @@ -0,0 +1,47 @@ +package org.depromeet.spot.usecase.service.common; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.function.Executable; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ConcurrencyTest { + + private final int numberOfThread; + + private ExecutorService executorService; + private CountDownLatch latch; + + public ConcurrencyTest(final int numberOfThread) { + this.numberOfThread = numberOfThread; + } + + public AtomicLong execute(Executable executable) throws InterruptedException { + this.executorService = Executors.newFixedThreadPool(numberOfThread); + this.latch = new CountDownLatch(numberOfThread); + + AtomicLong exceptionCount = new AtomicLong(); + for (int i = 0; i < numberOfThread; i++) { + executorService.execute( + () -> { + try { + executable.execute(); + log.info("Thread " + Thread.currentThread().getId() + " - 성공"); + } catch (Throwable e) { + exceptionCount.getAndIncrement(); + log.info("Thread " + Thread.currentThread().getId() + " - 실패"); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + executorService.shutdown(); + return exceptionCount; + } +} diff --git a/versions.properties b/versions.properties index ff21ba79..22b11398 100644 --- a/versions.properties +++ b/versions.properties @@ -60,3 +60,11 @@ version.io.micrometer..micrometer-registry-prometheus=1.12.4 version.com.github.ben-manes.caffeine..caffeine=3.1.8 version.it.ozimov..embedded-redis=0.7.3 + +version.org.testcontainers..testcontainers=1.20.1 + +version.org.testcontainers..junit-jupiter=1.20.1 + +version.org.testcontainers..mysql=1.20.1 + +version.org.testcontainers..jdbc=1.20.1 From 163e145543355c9deb2e07d4a62e40880fd6de94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9A=B0=EB=94=94?= <38103085+EunjiShin@users.noreply.github.com> Date: Tue, 27 Aug 2024 21:57:00 +0900 Subject: [PATCH 5/7] =?UTF-8?q?[fix/NO=5FJIRA]=20local=20&=20test=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=97=90=EC=84=9C=20embedded=20redis?= =?UTF-8?q?=EB=A5=BC=20=EC=9D=B4=EC=9A=A9=ED=95=B4=20redisson=20=EC=93=B0?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#172)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 동작과 일치하게 네이밍 수정 * fix: local, test일 때 redisson이 embedded redis 사용하도록 수정 * feat: local datasource 수정 * fix: m1에서 embedded redis 사용 위한 분기점 추가 및 바이너리 파일 생성 * fix: redis 바이너리 파일 gitignore 추가 --------- Co-authored-by: Minseong Park --- .gitignore | 3 + .../redis/EmbeddedRedisConfig.java | 68 ++++++++++++++++--- .../infrastructure/redis/RedissonConfig.java | 2 + 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index bfd8acf2..13e81ca8 100644 --- a/.gitignore +++ b/.gitignore @@ -390,3 +390,6 @@ application-jwt.yml application-oauth.yml application-sentry.yml application-aws.yaml + +# 민성 레디스 바이너리 파일 +redis-server-7.2.3-mac-arm64 \ No newline at end of file diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/EmbeddedRedisConfig.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/EmbeddedRedisConfig.java index c1006035..73385e03 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/EmbeddedRedisConfig.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/EmbeddedRedisConfig.java @@ -1,35 +1,59 @@ package org.depromeet.spot.infrastructure.redis; +import java.io.File; import java.io.IOException; import java.net.InetSocketAddress; import java.net.Socket; +import java.util.Objects; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import redis.embedded.RedisServer; @Slf4j @Configuration @Profile("local | test") -@RequiredArgsConstructor public class EmbeddedRedisConfig { - private final RedisProperties redisProperties; + private static final String REDISSON_HOST_PREFIX = "redis://localhost:"; + private static final int REDIS_DEFAULT_PORT = 6379; private RedisServer redisServer; + private final int embeddedRedisPort; + + public EmbeddedRedisConfig() { + this.embeddedRedisPort = + isPortInUse(REDIS_DEFAULT_PORT) ? findAvailablePort() : REDIS_DEFAULT_PORT; + log.info("embedded redis port = {}", embeddedRedisPort); + } @PostConstruct public void redisServer() { - int port = isRedisPortAvailable() ? findAvailablePort() : redisProperties.port(); - log.info("embedded redis port = {}", port); - redisServer = new RedisServer(port); - redisServer.start(); + // redisServer = new RedisServer(embeddedRedisPort); + if (isArmMac()) { + redisServer = new RedisServer(getRedisFileForArcMac(), embeddedRedisPort); + } else { + redisServer = + RedisServer.builder() + .port(embeddedRedisPort) + .setting("maxmemory 128M") // maxheap 128M + .build(); + } + try { + redisServer.start(); + } catch (Exception e) { + e.printStackTrace(); + } } @PreDestroy @@ -39,11 +63,33 @@ public void stopRedis() { } } - private boolean isRedisPortAvailable() { - return isAvailablePort(redisProperties.port()); + @Bean + public RedissonClient redissonClient() { + Config redissonConfig = new Config(); + redissonConfig.useSingleServer().setAddress(REDISSON_HOST_PREFIX + embeddedRedisPort); + return Redisson.create(redissonConfig); + } + + /** + * 현재 시스템이 ARM 아키텍처를 사용하는 MAC인지 확인 System.getProperty("os.arch") : JVM이 실행되는 시스템 아키텍처 반환 + * System.getProperty("os.name") : 시스템 이름 반환 + */ + private boolean isArmMac() { + return Objects.equals(System.getProperty("os.arch"), "aarch64") + && Objects.equals(System.getProperty("os.name"), "Mac OS X"); + } + + /** ARM 아키텍처를 사용하는 Mac에서 실행할 수 있는 Redis 바이너리 파일을 반환 */ + private File getRedisFileForArcMac() { + try { + return new ClassPathResource("binary/redis/redis-server-7.2.3-mac-arm64").getFile(); + } catch (Exception e) { + e.printStackTrace(); + return null; + } } - private boolean isAvailablePort(final int port) { + private boolean isPortInUse(final int port) { try (Socket socket = new Socket()) { socket.connect(new InetSocketAddress("localhost", port), 200); return true; @@ -54,7 +100,7 @@ private boolean isAvailablePort(final int port) { private int findAvailablePort() { for (int port = 10000; port <= 65535; port++) { - if (!isAvailablePort(port)) { + if (!isPortInUse(port)) { return port; } } diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedissonConfig.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedissonConfig.java index 1dea480c..fd7e74f8 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedissonConfig.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/redis/RedissonConfig.java @@ -5,10 +5,12 @@ import org.redisson.config.Config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import lombok.RequiredArgsConstructor; @Configuration +@Profile("dev | prod") @RequiredArgsConstructor public class RedissonConfig { From 76a36066f77b64fa27fe2c0159628ffda2096d7d Mon Sep 17 00:00:00 2001 From: Minseong Park <52368015+pminsung12@users.noreply.github.com> Date: Wed, 28 Aug 2024 00:23:16 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20ReviewCreationProcessorImpl=EC=97=90?= =?UTF-8?q?=20=EB=A6=AC=EB=B7=B0=20builder=EC=97=90=20=EB=94=94=ED=8F=B4?= =?UTF-8?q?=ED=8A=B8=20reviewType=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#174)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/review/processor/ReviewCreationProcessorImpl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/processor/ReviewCreationProcessorImpl.java b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/processor/ReviewCreationProcessorImpl.java index b398e9bd..e1ddb0a9 100644 --- a/usecase/src/main/java/org/depromeet/spot/usecase/service/review/processor/ReviewCreationProcessorImpl.java +++ b/usecase/src/main/java/org/depromeet/spot/usecase/service/review/processor/ReviewCreationProcessorImpl.java @@ -84,6 +84,7 @@ public Review createAdminReview( .seat(seat) .dateTime(command.dateTime()) .content(command.content()) + .reviewType(ReviewType.VIEW) .build(); } } From 5c1b3582f0649571d770bff819731b1bff95d7b1 Mon Sep 17 00:00:00 2001 From: junwon <67488973+wjdwnsdnjs13@users.noreply.github.com> Date: Thu, 29 Aug 2024 14:44:16 +0900 Subject: [PATCH 7/7] =?UTF-8?q?[NO=5FJIRA]=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#176)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor : 기본 프로필 이미지 변경 * refactor : 기본 프로필 이미지 환경 변수로 처리 --- .github/workflows/dev-build-and-deploy.yaml | 1 + .github/workflows/manual-prod-deploy.yaml | 1 + .../jpa/oauth/entity/GoogleUserInfoEntity.java | 6 +++++- .../jpa/oauth/entity/KakaoUserInfoEntity.java | 6 +++++- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev-build-and-deploy.yaml b/.github/workflows/dev-build-and-deploy.yaml index f117acc7..45d4ccd2 100644 --- a/.github/workflows/dev-build-and-deploy.yaml +++ b/.github/workflows/dev-build-and-deploy.yaml @@ -133,6 +133,7 @@ jobs: -e AWS_S3_ACCESS_KEY=${{ secrets.AWS_S3_ACCESS_KEY }} \ -e AWS_S3_SECRET_KEY=${{ secrets.AWS_S3_SECRET_KEY }} \ -e AWS_S3_BUCKET_NAME=${{ secrets.DEV_AWS_S3_BUCKET_NAME }} \ + -e AWS_S3_BASICPROFILEIMAGEURL=${{ secrets.BASICPROFILEIMAGEURL }} \ -e TZ=Asia/Seoul \ -e SENTRY_DSN=${{ secrets.SENTRY_DSN }} \ -e SENTRY_ENABLE_TRACING=true \ diff --git a/.github/workflows/manual-prod-deploy.yaml b/.github/workflows/manual-prod-deploy.yaml index c5bd26f6..ec62a71a 100644 --- a/.github/workflows/manual-prod-deploy.yaml +++ b/.github/workflows/manual-prod-deploy.yaml @@ -70,6 +70,7 @@ jobs: -e AWS_S3_ACCESS_KEY=${{ secrets.AWS_S3_ACCESS_KEY }} \ -e AWS_S3_SECRET_KEY=${{ secrets.AWS_S3_SECRET_KEY }} \ -e AWS_S3_BUCKET_NAME=${{ secrets.PROD_AWS_S3_BUCKET_NAME }} \ + -e AWS_S3_BASICPROFILEIMAGEURL=${{ secrets.BASICPROFILEIMAGEURL }} \ -e TZ=Asia/Seoul \ -e SENTRY_DSN=${{ secrets.SENTRY_DSN }} \ -e SENTRY_ENABLE_TRACING=true \ diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleUserInfoEntity.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleUserInfoEntity.java index f81d7b87..07d2b20d 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleUserInfoEntity.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/GoogleUserInfoEntity.java @@ -4,6 +4,7 @@ import org.depromeet.spot.domain.member.enums.MemberRole; import org.depromeet.spot.domain.member.enums.SnsProvider; import org.depromeet.spot.infrastructure.jpa.common.entity.BaseEntity; +import org.springframework.beans.factory.annotation.Value; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @@ -16,6 +17,9 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class GoogleUserInfoEntity extends BaseEntity { + @Value("${aws.s3.basicProfileImageUrl}") + private String BASIC_PROFILE_IMAGE_URL; + // 구글 로그인은 sub라는 이름으로 id값을 줌. // 구글의 sub 값은 Long 타입을 넘어감. // BigInteger로 처리하거나 String으로 처리해야함. @@ -38,7 +42,7 @@ public Member toGoogleDomain(Member member) { return Member.builder() .email(email) .nickname(member.getNickname()) - .profileImage(profileImageUrl) + .profileImage(BASIC_PROFILE_IMAGE_URL) .snsProvider(SnsProvider.GOOGLE) .idToken(idToken) .role(MemberRole.ROLE_USER) diff --git a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/KakaoUserInfoEntity.java b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/KakaoUserInfoEntity.java index c7ffb4b5..1708a1d1 100644 --- a/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/KakaoUserInfoEntity.java +++ b/infrastructure/src/main/java/org/depromeet/spot/infrastructure/jpa/oauth/entity/KakaoUserInfoEntity.java @@ -8,6 +8,7 @@ import org.depromeet.spot.domain.member.enums.MemberRole; import org.depromeet.spot.domain.member.enums.SnsProvider; import org.depromeet.spot.infrastructure.jpa.common.entity.BaseEntity; +import org.springframework.beans.factory.annotation.Value; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; @@ -21,6 +22,9 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class KakaoUserInfoEntity extends BaseEntity { + @Value("${aws.s3.basicProfileImageUrl}") + private String BASIC_PROFILE_IMAGE_URL; + // 서비스에 연결 완료된 시각. UTC @JsonProperty("connected_at") public Date connectedAt; @@ -100,7 +104,7 @@ public Member toKakaoDomain(Member member) { .name(kakaoAccount.name) .nickname(member.getNickname()) .phoneNumber(kakaoAccount.phoneNumber) - .profileImage(kakaoAccount.profile.profileImageUrl) + .profileImage(BASIC_PROFILE_IMAGE_URL) .snsProvider(SnsProvider.KAKAO) .idToken(getId().toString()) .role(MemberRole.ROLE_USER)