diff --git a/.gitignore b/.gitignore index 33551f4..92d03e5 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ out/ .DS_Store ### Yml ### -doorip-api/src/main/resources/application.yml \ No newline at end of file +doorip-api/src/main/resources/application.yml +doorip-api/src/test/resources/application.yml \ No newline at end of file diff --git a/build.gradle b/build.gradle index e51d3c2..62ccc77 100644 --- a/build.gradle +++ b/build.gradle @@ -32,6 +32,8 @@ subprojects { // test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' } tasks.named('test') { diff --git a/doorip-api/src/main/java/org/doorip/common/Constants.java b/doorip-api/src/main/java/org/doorip/common/Constants.java index 09d0e9c..cfa2595 100644 --- a/doorip-api/src/main/java/org/doorip/common/Constants.java +++ b/doorip-api/src/main/java/org/doorip/common/Constants.java @@ -13,4 +13,5 @@ public abstract class Constants { public static final int TODO_OWNER_POSITION = 0; public static final int START_STYLE_POS = 0; public static final int END_STYLE_POS = 5; + public static final String SIGN_UP_LOCK = "signup"; } diff --git a/doorip-api/src/main/java/org/doorip/user/api/UserApiController.java b/doorip-api/src/main/java/org/doorip/user/api/UserApiController.java index 0cc194f..7cccf06 100644 --- a/doorip-api/src/main/java/org/doorip/user/api/UserApiController.java +++ b/doorip-api/src/main/java/org/doorip/user/api/UserApiController.java @@ -2,18 +2,14 @@ import lombok.RequiredArgsConstructor; import org.doorip.auth.UserId; -import org.doorip.common.BaseResponse; import org.doorip.common.ApiResponseUtil; +import org.doorip.common.BaseResponse; import org.doorip.message.SuccessMessage; -import org.doorip.user.dto.request.ResultUpdateRequest; -import org.doorip.user.dto.request.UserReissueRequest; -import org.doorip.user.dto.request.UserSignInRequest; -import org.doorip.user.dto.request.UserSignUpRequest; -import org.doorip.user.dto.request.ProfileUpdateRequest; +import org.doorip.user.dto.request.*; import org.doorip.user.dto.response.ProfileGetResponse; -import org.doorip.user.dto.response.UserSignUpResponse; import org.doorip.user.dto.response.UserSignInResponse; -import org.doorip.user.service.UserService; +import org.doorip.user.dto.response.UserSignUpResponse; +import org.doorip.user.facade.UserFacade; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; @@ -24,12 +20,12 @@ @RequestMapping("/api/users") @Controller public class UserApiController implements UserApi { - private final UserService userService; + private final UserFacade userFacade; @GetMapping("/splash") @Override public ResponseEntity> splash(@UserId final Long userId) { - userService.splash(userId); + userFacade.splash(userId); return ApiResponseUtil.success(SuccessMessage.OK); } @@ -37,7 +33,7 @@ public ResponseEntity> splash(@UserId final Long userId) { @Override public ResponseEntity> signIn(@RequestHeader(AUTHORIZATION) final String token, @RequestBody final UserSignInRequest request) { - final UserSignInResponse response = userService.signIn(token, request); + final UserSignInResponse response = userFacade.signIn(token, request); return ApiResponseUtil.success(SuccessMessage.OK, response); } @@ -45,21 +41,21 @@ public ResponseEntity> signIn(@RequestHeader(AUTHORIZATION) fina @Override public ResponseEntity> signUp(@RequestHeader(AUTHORIZATION) final String token, @RequestBody final UserSignUpRequest request) { - final UserSignUpResponse response = userService.signUp(token, request); + final UserSignUpResponse response = userFacade.signUp(token, request); return ApiResponseUtil.success(SuccessMessage.CREATED, response); } @PatchMapping("/signout") @Override public ResponseEntity> signOut(@UserId final Long userId) { - userService.signOut(userId); + userFacade.signOut(userId); return ApiResponseUtil.success(SuccessMessage.OK); } @DeleteMapping("/withdraw") @Override public ResponseEntity> withdraw(@UserId final Long userId) { - userService.withdraw(userId); + userFacade.withdraw(userId); return ApiResponseUtil.success(SuccessMessage.OK); } @@ -67,14 +63,14 @@ public ResponseEntity> withdraw(@UserId final Long userId) { @Override public ResponseEntity> reissue(@RequestHeader(AUTHORIZATION) final String refreshtoken, @RequestBody final UserReissueRequest request) { - final UserSignUpResponse response = userService.reissue(refreshtoken, request); + final UserSignUpResponse response = userFacade.reissue(refreshtoken, request); return ApiResponseUtil.success(SuccessMessage.OK, response); } @GetMapping("/profile") @Override public ResponseEntity> getProfile(@UserId final Long userId) { - final ProfileGetResponse response = userService.getProfile(userId); + final ProfileGetResponse response = userFacade.getProfile(userId); return ApiResponseUtil.success(SuccessMessage.OK, response); } @@ -82,14 +78,14 @@ public ResponseEntity> getProfile(@UserId final Long userId) { @Override public ResponseEntity> updateResult(@UserId final Long userId, @RequestBody final ResultUpdateRequest request) { - userService.updateResult(userId, request); + userFacade.updateResult(userId, request); return ApiResponseUtil.success(SuccessMessage.OK); } @PatchMapping("/profile") public ResponseEntity> updateProfile(@UserId final Long userId, @RequestBody final ProfileUpdateRequest request) { - userService.updateProfile(userId, request); + userFacade.updateProfile(userId, request); return ApiResponseUtil.success(SuccessMessage.OK); } } diff --git a/doorip-api/src/main/java/org/doorip/user/facade/UserFacade.java b/doorip-api/src/main/java/org/doorip/user/facade/UserFacade.java new file mode 100644 index 0000000..fb64ce2 --- /dev/null +++ b/doorip-api/src/main/java/org/doorip/user/facade/UserFacade.java @@ -0,0 +1,65 @@ +package org.doorip.user.facade; + +import lombok.RequiredArgsConstructor; +import org.doorip.common.Constants; +import org.doorip.user.dto.request.*; +import org.doorip.user.dto.response.ProfileGetResponse; +import org.doorip.user.dto.response.UserSignInResponse; +import org.doorip.user.dto.response.UserSignUpResponse; +import org.doorip.user.repository.LettuceLockRepository; +import org.doorip.user.service.UserService; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class UserFacade { + private final UserService userService; + private final LettuceLockRepository lettuceLockRepository; + + public void splash(Long userId) { + userService.splash(userId); + } + + public UserSignInResponse signIn(String token, UserSignInRequest request) { + return userService.signIn(token, request); + } + + public UserSignUpResponse signUp(String token, UserSignUpRequest request) { + while (!lettuceLockRepository.lock(token, Constants.SIGN_UP_LOCK)) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + try { + return userService.signUp(token, request); + } finally { + lettuceLockRepository.unlock(token); + } + } + + public void signOut(Long userId) { + userService.signOut(userId); + } + + public void withdraw(Long userId) { + userService.withdraw(userId); + } + + public UserSignUpResponse reissue(String refreshToken, UserReissueRequest request) { + return userService.reissue(refreshToken, request); + } + + public ProfileGetResponse getProfile(Long userId) { + return userService.getProfile(userId); + } + + public void updateResult(Long userId, ResultUpdateRequest request) { + userService.updateResult(userId, request); + } + + public void updateProfile(Long userId, ProfileUpdateRequest request) { + userService.updateProfile(userId, request); + } +} diff --git a/doorip-api/src/test/java/org/doorip/user/facade/UserFacadeTest.java b/doorip-api/src/test/java/org/doorip/user/facade/UserFacadeTest.java new file mode 100644 index 0000000..02c9365 --- /dev/null +++ b/doorip-api/src/test/java/org/doorip/user/facade/UserFacadeTest.java @@ -0,0 +1,77 @@ +package org.doorip.user.facade; + +import lombok.extern.slf4j.Slf4j; +import org.doorip.exception.ConflictException; +import org.doorip.exception.EntityNotFoundException; +import org.doorip.message.ErrorMessage; +import org.doorip.user.dto.request.UserSignInRequest; +import org.doorip.user.dto.request.UserSignUpRequest; +import org.doorip.user.dto.response.UserSignInResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Slf4j +@SpringBootTest +class UserFacadeTest { + @Autowired + UserFacade userFacade; + @Value("${oauth.kakao.test}") + String accessToken; + + @AfterEach + void afterEach() { + UserSignInRequest request = new UserSignInRequest("kakao"); + try { + UserSignInResponse response = userFacade.signIn(accessToken, request); + userFacade.withdraw(response.userId()); + } catch (EntityNotFoundException e) { + log.error("After Each Error: ", e); + throw e; + } + } + + @DisplayName("동일한 회원의 회원가입 요청이 동시에 여러 개 들어오는 경우 정상적으로 회원가입된다.") + @Test + void 동일한_회원의_회원가입_요청이_동시에_여러_개_들어오는_경우_정상적으로_회원가입된다() throws InterruptedException { + // given + int threadCount = 20; + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(threadCount); + UserSignUpRequest signUpRequest = new UserSignUpRequest("개발자", "동시성 테스트", "kakao"); + UserSignInRequest signInRequest = new UserSignInRequest("kakao"); + + // when + IntStream.range(0, threadCount) + .forEach(i -> + executorService.submit(() -> { + try { + userFacade.signUp(accessToken, signUpRequest); + } catch (ConflictException e) { + log.error("Sub Task Thread Error: ", e); + throw e; + } finally { + latch.countDown(); + } + })); + latch.await(); + + // then + assertThatThrownBy(() -> userFacade.signUp(accessToken, signUpRequest)) + .isInstanceOf(ConflictException.class) + .hasMessage(ErrorMessage.DUPLICATE_USER.getMessage()); + UserSignInResponse response = userFacade.signIn(accessToken, signInRequest); + assertThat(response.userId()).isNotNull(); + } +} \ No newline at end of file diff --git a/doorip-domain/src/main/java/org/doorip/user/repository/LettuceLockRepository.java b/doorip-domain/src/main/java/org/doorip/user/repository/LettuceLockRepository.java new file mode 100644 index 0000000..6e95956 --- /dev/null +++ b/doorip-domain/src/main/java/org/doorip/user/repository/LettuceLockRepository.java @@ -0,0 +1,23 @@ +package org.doorip.user.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; + +@RequiredArgsConstructor +@Repository +public class LettuceLockRepository { + private final RedisTemplate redisTemplate; + + public Boolean lock(String token, String lockType) { + return redisTemplate + .opsForValue() + .setIfAbsent(token, lockType, Duration.ofSeconds(3L)); + } + + public void unlock(String token) { + redisTemplate.delete(token); + } +}