Skip to content

Commit

Permalink
Merge pull request #157 from Team-Going/feature/156
Browse files Browse the repository at this point in the history
[fix] 중복 회원가입 동시성 문제 해결
  • Loading branch information
SunwoongH authored Apr 10, 2024
2 parents 77ed7cb + a137009 commit 064c566
Show file tree
Hide file tree
Showing 7 changed files with 184 additions and 19 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ out/
.DS_Store

### Yml ###
doorip-api/src/main/resources/application.yml
doorip-api/src/main/resources/application.yml
doorip-api/src/test/resources/application.yml
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
1 change: 1 addition & 0 deletions doorip-api/src/main/java/org/doorip/common/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
32 changes: 14 additions & 18 deletions doorip-api/src/main/java/org/doorip/user/api/UserApiController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand All @@ -24,72 +20,72 @@
@RequestMapping("/api/users")
@Controller
public class UserApiController implements UserApi {
private final UserService userService;
private final UserFacade userFacade;

@GetMapping("/splash")
@Override
public ResponseEntity<BaseResponse<?>> splash(@UserId final Long userId) {
userService.splash(userId);
userFacade.splash(userId);
return ApiResponseUtil.success(SuccessMessage.OK);
}

@PostMapping("/signin")
@Override
public ResponseEntity<BaseResponse<?>> 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);
}

@PostMapping("/signup")
@Override
public ResponseEntity<BaseResponse<?>> 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<BaseResponse<?>> signOut(@UserId final Long userId) {
userService.signOut(userId);
userFacade.signOut(userId);
return ApiResponseUtil.success(SuccessMessage.OK);
}

@DeleteMapping("/withdraw")
@Override
public ResponseEntity<BaseResponse<?>> withdraw(@UserId final Long userId) {
userService.withdraw(userId);
userFacade.withdraw(userId);
return ApiResponseUtil.success(SuccessMessage.OK);
}

@PostMapping("/reissue")
@Override
public ResponseEntity<BaseResponse<?>> 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<BaseResponse<?>> getProfile(@UserId final Long userId) {
final ProfileGetResponse response = userService.getProfile(userId);
final ProfileGetResponse response = userFacade.getProfile(userId);
return ApiResponseUtil.success(SuccessMessage.OK, response);
}

@PatchMapping("/test")
@Override
public ResponseEntity<BaseResponse<?>> 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<BaseResponse<?>> updateProfile(@UserId final Long userId,
@RequestBody final ProfileUpdateRequest request) {
userService.updateProfile(userId, request);
userFacade.updateProfile(userId, request);
return ApiResponseUtil.success(SuccessMessage.OK);
}
}
65 changes: 65 additions & 0 deletions doorip-api/src/main/java/org/doorip/user/facade/UserFacade.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<String, String> 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);
}
}

0 comments on commit 064c566

Please sign in to comment.