Develop(개발)/트러블슈팅

[Spring] Transaction으로 인한 중복 아이디 회원 생성 문제

seongmik 2024. 2. 12. 10:28
728x90
반응형

회원가입 엔드포인트로 빠르게 연속으로 요청 시 중복 회원 생성 문제

회원가입 엔드포인트로 빠르게 연속해서 같은 회원가입 요청을 보내면 생성 중인 유저에 대한 존재 유무가 체크되지 못해서 중복된 회원이 생성되는 문제가 발견됐습니다.
프론트엔드에서 회원가입 버튼을 연타하지 못하게 비활성화한다면 일시적으로는 괜찮겠지만 근본적으로 서버에서 이런 현상이 가능하다는 것이 문제이기 때문에 서버에서 문제를 방지하는 해결책이 필요합니다.

문제 원인 분석

이 문제는 백엔드에서 유저 생성이 Transaction 하게 처리되기 때문에 발생하는 문제입니다.
유저의 정보가 모두 생성되는 시점에서야 DB에 유저의 정보가 반영되기 때문에 유저의 정보가 생성되는 시간 동안 똑같은 이름의 유저를 생성하는 요청을 서버에 보내더라도 DB에 존재하지 않는 유저로 인식되어 똑같은 유저 생성 프로세스를 진행하게 됩니다.
유저 최초 생성시 유저의 정보를 외부 서버에서 받아오는 과정이 있기 때문에 유저 생성 전체 과정은 10초가량의 시간이 걸립니다.
그렇다면 한 유저가 생성되는 과정 중에 있다면 그 전체 정보가 아직 반영되지 않았더라도 다른 유저가 그 상황을 알 수 있는 방법이 무엇이 있을지에 대한 고민이 생겼습니다.

이 문제를 해결하기 위해 직관적으로 다음과 같은 해결법을 생각해 보았습니다.

  1. Transaction의 범위를 쪼개서 유저 생성 프로세스의 시작에 유저의 이름만 DB에 선 등록하고, 유저 등록 실패 시에 삭제한다.
  2. 유저 생성을 담당하는 서비스 Bean에 static 한 유저 생성 HashSet을 만들어서 유저 생성 시작 시에 이 HashSet을 통해 서로의 생성 유무를 확인하는 과정을 추가한다.

본격적으로 이 문제를 해결하기에 앞서, 이 문제 상황을 재현하는 테스트 코드를 먼저 작성하도록 하겠습니다.

Red Cycle - 빠르게 동시에 진행되는 유저생성 재현

동시에 여러 유저의 생성 요청이 발생 상황을 가상으로 재현하기 위해서 멀티 쓰레딩 기법을 이용하도록 하겠습니다.
병렬적으로 요청이 처리되는 스레드의 특성을 이용하여 유저 생성 요청이 서버에 동시다발적으로 처리되는 상황을 재현할 수 있기 때문입니다.

Java에서는 멀티 쓰레딩을 구현하기 위해서 Thread 클래스와 Runnable 인터페이스를 지원합니다.
Thread와 Runnable의 사용상의 주된 차이점은 Java의 특성에서 기인합니다.
Java에서는 다중 상속을 허용하지 않기 때문에 Thread 클래스를 상속받아 구현한다면 Thread 이외의 클래스를 함께 상속받을 수 없습니다.
하지만 Runnable 인터페이스를 구현한다면 다른 클래스와 인터페이스를 함께 상속, 구현할 수 있습니다.
이러한 차이점으로 인해 스레드를 구현해야 하는 대부분의 상황에서 Runnable이 선호됩니다.

Runnable을 다음과 같이 static으로 선언했습니다.

static class UserSaveTestThread implements Runnable {  

    private final TestContainer testContainer;  

    private final UserSave userSave;  

    UserSaveTestThread(TestContainer testContainer, UserSave userSave) {  
        this.testContainer = testContainer;  
        this.userSave = userSave;  
    }  

    @Override  
    public void run() throws EntityExistsException {  
        try {  
            testContainer.userService.save(userSave);  
        } catch (JsonProcessingException e) {  
            throw new RuntimeException(e);  
        }  
    }  
}

이를 이용하여 아래와 같이 2개의 유저 생성 Thread를 실행시키는 코드를 작성했습니다.

@Test  
@DisplayName("save로 유저를 생성하는데 아직 유저 정보에 대한 Tracsaction이 반영되지 않았을 때, 중복된 bojHandle의 유저를 생성하면 에러를 던진다")  
public void save로_유저를_생성할_때_빠르게_연속으로_중복된_bojHandle의_유저를_생성하면_에러를_던진다() throws JsonProcessingException {  
    // given  
    UserScrapingInfoDto userScrapingInfoDto = UserScrapingInfoDto.builder()  
            .tier(15)  
            .profileImg("https://static.solved.ac/uploads/profile/64x64/fin-picture-1665752455693.png")  
            .currentStreak(252)  
            .totalSolved(1067)  
            .isTodaySolved(true)  
            .todaySolvedProblemCount(1)  
            .build();  
    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();  
    TestContainer testContainer = TestContainer.builder()  
            .parser(new FakeParserImpl())  
            .solvedacParser(new FakeSolvedacParserImpl(userScrapingInfoDto))  
            .passwordEncoder(passwordEncoder)  
            .build();  
    UserSave userSave = UserSave.builder()  
            .bojHandle("fin")  
            .password("q1w2e3r4!")  
            .notionId("성민")  
            .manager(1L)  
            .emoji("🛠️")  
            .build();  
    Thread userSaveProcess1 = new Thread(new UserSaveTestThread(testContainer, userSave));  
    Thread userSaveProcess2 = new Thread(new UserSaveTestThread(testContainer, userSave));  

    // when & then  
    assertThatThrownBy(() -> {  
        userSaveProcess1.start();  
        userSaveProcess2.start();  
        userSaveProcess1.join();  
        userSaveProcess2.join();  
    }).isInstanceOf(EntityExistsException.class);  

}

이제 UserInfo를 크롤링하는 FakeSolvedacParserImpl Mocking객체에 Thread.sleep을 이용하여 10초의 크롤링 시간 딜레이를 구현하겠습니다.

@Override  
public UserScrapingInfoDto getSolvedUserInfo(String bojHandle) throws JsonProcessingException, InterruptedException {  
    Thread.sleep(1000 * 10);  
    return userScrapingInfoDto;  
}

이제 테스트 코드를 실행해 보면 예외가 던져지지 않아서 테스트가 실패하는 것을 확인할 수 있습니다.

문제 발생

하지만 전체 테스트를 실행시켜 보니 문제가 발생했습니다.
FakeSolvedacParserImpl Mocking객체에 Thead.sleep을 이용한 10초의 크롤링 딜레이를 적용했더니 관련한 메서드를 호출하는 모든 메서드에 딜레이가 적용돼서 연관된 모든 테스트 코드의 작동에 10초의 딜레이가 생겼습니다.
이 문제를 해결하기 위해서 FakeSolvedacDelayedParserImpl라는 Delay를 적용한 Mocking객체를 따로 만들어서 딜레이가 필요한 테스트에만 사용해 주도록 하겠습니다.

public class FakeSolvedacDelayedParserImpl  implements SolvedacParser {  

    private UserScrapingInfoDto userScrapingInfoDto;  

    ...

    @Override  
    public UserScrapingInfoDto getSolvedUserInfo(String bojHandle) throws JsonProcessingException {  
        try {  
            Thread.sleep(1000 * 10);  
        } catch (InterruptedException e) {  
            throw new RuntimeException("error occurred while waiting for 10 seconds.");  
        }  
        return userScrapingInfoDto;  
    }  
}

위와 같이 Thread를 10초 sleep 시키는 부분만 다른 새로운 Fake 객체를 만들었습니다.

@Test  
@DisplayName("save로 유저를 생성하는데 아직 유저 정보에 대한 Tracsaction이 반영되지 않았을 때, 중복된 bojHandle의 유저를 생성하면 에러를 던진다")  
public void save로_유저를_생성할_때_빠르게_연속으로_중복된_bojHandle의_유저를_생성하면_에러를_던진다() throws JsonProcessingException {  
    // given  

    ...

    TestContainer testContainer = TestContainer.builder()  

            .parser(new FakeParserImpl())  
            .solvedacParser(new FakeSolvedacDelayedParserImpl(userScrapingInfoDto))  
            .passwordEncoder(passwordEncoder)  
            .build();  

    ...
}

그 뒤 위와 같이 TestContainer에 주입되는 solvedacParser 객체를 변경해 주었습니다.

다른 테스트들의 속도는 다시 정상으로 빨라진 것을 확인할 수 있습니다.

Green Cycle - Static HashMap을 이용한 생성 중인 유저 명부 관리

이제 스레드들이 만들어질 때, HashMap에 유저 생성을 명시하고, 끝날 때는 유저 생성을 HashMap에서 삭제하는 식으로 동기화에 드는 오버헤드를 최소화시켜 보도록 하겠습니다.

private static HashMap<String, Boolean> userSaveProcessSet = new HashMap<>();

static으로 HashMap을 UserService에 멤버변수로 생성해 주었습니다.

static으로 생성한 이유는 해쉬맵의 상태가 유지되어야 하고, UserService 객체에서만 사용되는 정보이기 때문입니다.

@Transactional  
public User save(UserSave userSave) throws JsonProcessingException {  
    User existUser = userRepository.findByBojHandle(userSave.getBojHandle()).orElse(null);  
    synchronized (this) {  
        if (existUser != null || userSaveProcessSet.get(userSave.getBojHandle()) != null) {  
            throw new EntityExistsException("이미 존재하는 유저는 생성할 수 없습니다.");  
        } else {  
            // Process HashMap에 추가  
            userSaveProcessSet.put(userSave.getBojHandle(), true);  
        }  
    }

    ...

    // Process HashMap에서 삭제  
    userSaveProcessSet.remove(userSave.getBojHandle());  
    return user;  
}

그 뒤, 위와 같이 synchronized를 이용해 HashMap에서 이미 생성 중인지 유저를 찾고, HashMap에 없다면 등록하는 부분을 임계영역으로 만들어주었습니다.

다만 주의할 점은 synchronized 자체가 monitor를 이용한 lock방식이기 때문에 동기화에 드는 오버헤드가 매우 적지는 않으므로 남발해서는 안됩니다.

전체 유저 생성 구간 전체를 임계영역으로 만들었다면 한 번에 한 명의 유저밖에 생성하지 못하므로 매우 비효율적이었을 것입니다.
하지만 명부에 write 할 때와 read 할 때만 동기화시켜주는 방법을 통해 임계영역을 최소화해 줄 수 있었습니다.

그 뒤, 테스트를 다시 확인해 보았더니 테스트가 실패했습니다..!!

파생 스레드가 던진 Exception을 메인 스레드에서 캐치를 못하고 있습니다.

알고 보니 위에서 사용한 Runable을 이용한 스레드 구현 방법은 쓰레드의 Exception을 테스트하기에는 부적절했습니다.

구글링을 통해 파생 스레드가 던진 예외를 메인 스레드에서 받기 위해서는 다른 방법을 통해서 처리를 해주어야 한다는 것을 알았습니다.

아래와 같은 3개의 방법이 대표적인 해결 방법입니다.

  1. Future.get()을 사용한다.
  2. CompletableFuture의 예외 처리 가능 메서드들(exceptionally(), whenComplete(), handle() 등)을 사용한다.
  3. UncaughtExceptionHandler 구현체를 스레드 핸들러로 등록한다.
    위와 같은 3개의 방법이 있다고 합니다.

저는 이 중 Future.get()을 이용한 방법으로 해결해 보도록 하겠습니다.

Future은 자바의 스레드풀을 간편하게 생성하고 사용할 수 있는 ExecutorService 객체의 쓰레드 실행 결과를 감싸는 객체입니다.
ExecutorService 스레드풀의 submit() 메서드를 이용해 스레드를 실행시킨 결과를 Future를 통해 메인 스레드가 받은 뒤, 그 결과를 get()을 통해 꺼내는 순간 Exception이 터져서 메인 스레드가 Exception을 감지하는 원리입니다.

다만, 이때 ExecutionException으로 한번 감싸진 Exception이 던져지기 때문에 getCause()를 이용하여 감싸지기 이전의 Exception을 던져주어야 저희가 의도한 Exception이 던져지게 됩니다.

@Test  
@DisplayName("save로 유저를 생성하는데 아직 유저 정보에 대한 Tracsaction이 반영되지 않았을 때, 중복된 bojHandle의 유저를 생성하면 에러를 던진다")  
public void save로_유저를_생성할_때_빠르게_연속으로_중복된_bojHandle의_유저를_생성하면_에러를_던진다() throws JsonProcessingException {  
    // given  

    ...

    ExecutorService pool = Executors.newFixedThreadPool(2);

    // when & then  
    assertThatThrownBy(() -> {  
        Future<?> future1 = pool.submit(()->{  
            try {  
                testContainer.userService.save(userSave);  
            } catch (JsonProcessingException e) {  
                throw new RuntimeException(e);  
            }  
        });  
        Future<?> future2 = pool.submit(()->{  
            try {  
                testContainer.userService.save(userSave);  
            } catch (JsonProcessingException e) {  
                throw new RuntimeException(e);  
            }  
        });  
        try {  
            future1.get();  
            future2.get();  
        } catch (ExecutionException ee) {  
            throw ee.getCause();  
        }  
        pool.shutdown();  
    }).isInstanceOf(EntityExistsException.class);  
}

위와 같이 2개의 스레드를 가진 쓰레드 풀을 생성한 뒤, 2개의 스레드에게 동일한 유저 생성 요청을 보내고 그 결과를 Future 객체로 받아주었습니다.
그 뒤, try-catch문에서 get()을 통해 exception을 터뜨린 뒤 catch 된 ExecutionException에 getCause()를 해줘서 원래의 exception을 던져주었습니다.


테스트가 통과하는 것을 확인할 수 있습니다.

하지만 테스트 시간이 10초로 너무 길다는 것을 알 수 있습니다.
EntityExistsException이 발생하는 부분은 유저 생성의 처음이므로 Exception이 의도한 대로 발생했다면 그 즉시 모든 스레드를 종료시키고 테스트를 성공시키면 되므로 10초를 기다릴 필요가 없는 테스트입니다.

    ExecutorService pool = Executors.newFixedThreadPool(2);  

    // when & then  
    assertThatThrownBy(() -> {  

        ...

        try {  
            future1.get();  
            future2.get();  
        } catch (ExecutionException ee) {  
            pool.shutdownNow(); // 쓰레드 풀의 모든 쓰레드 종료
            throw ee.getCause();  
        }  
        pool.shutdown();  
    }).isInstanceOf(EntityExistsException.class);  
}

따라서 위와 같이 스레드풀의 모든 스레드를 EntityExistsException이 던져지기 직전에 종료하는 코드를 추가하여 문제를 해결했습니다.


테스트 시간이 매우 짧아진 것을 확인할 수 있습니다.

위와 같은 과정을 통해 유저 중복 생성 문제를 해결했습니다.

HashMap을 이용하지 않고 임계영역에 드는 오버헤드를 더 최소화시킬 수 있는 방법을 더 고민해 봐야겠습니다.

728x90
반응형