개요

월요일 아침에 회사에 일찍 출근하여 마무리를 하고 글에 정리한다, 주말 내내 이 문제 하나 붙잡고 있느라 하루종일 공부만 했다.

이번 문제를 해결하면서 트랜젝션과 영속성 컨텍스트에 대해서 부족한 부분을 공부하게 되었고 아직 공부하지 못했던 트랜젝션 격리 수준, 낙관적락과 비관적락, 데드락 발생시 디버깅 방법등을 알게 되었다. 이 부분들은 다른 글에서 자세히 정리하도록 하겠다.

일요일 저녁 9시 다음날 출근인데 잠을 안 자겠다고 선언

근본적인 원인

일단 한줄로 간단하게 원인을 말하자면 중복 값이 저장되면 안되는 컬럼에 유니크 제약조건을 걸어놓지 않았던 것이 문제였다.

어어 나가지말고 끝까지 들어보십쇼, '근본적인' 문제는 핵심은 정확히 같은 시간, 데이터로 요청할때 생기는 동시성 문제였다.

공부 제대로 안한거 티내버렸다 ㅋ..

사실 특별하게 멀티 쓰레딩을 사용한적이 없다, 하물며 parallelStream조차 사용하지 않았다, 돌아보면 이런 생각을 하는 내가 바보같다.

그래서 내가 현재 사용하는 Spring, Servlet, Tomcat이 동시에 오는 요청에 대해서 어떻게 처리하는지 다시 공부해보기로 했다.

Spring Webflux를 사용하는 경우에는 Reactive와 Netty를 사용하기 때문에 꼭 헷갈리지 말고둘이 구분해서 파악해야한다.

SpringServlet이 http 요청을 처리하는 법

아차 싶었다, 아는 내용인데 왜 까먹고 있었지 싶었다

SpringServlet은 http 요청을 각자 다른 쓰레드에서 처리하게 된다, 한마디로 동시에 여러 쓰레드가 활동 할 수 있다는것이다.

조금 더 정확히 말하자면 Spring이 처리하는것이 아닌 내장되어 있는 Tomcat의 Servlet Container에서 여러개를 처리하는것이다.

 

이런 관점에서 생각하면 두개의 쓰레드가 동시에 데이터베이스 커넥션을 얻어서 접근하고 데이터를 조회할 수 있게 되는것이다.

기존 회원 중복확인 로직과 문제점

기존에는 회원가입 요청이 발생하면 데이터베이스에 그 아이디가 저장된 컬럼이 있는지 조회를 하고 결과값이 없을 시 회원가입을 시켜줬다

여기서 문제가 발생하는 이유를 살짝 예상할 수 있다, 만약 두 쿼리가 동시에 select where id를 하게 되면 당연히 없다고 결과는 반환된다.

동시에 없다는 결과를 반환받게 되고 동시에 insert가 되면서 중복 가입이 되는것이다. unique를 걸어뒀으면 예외가 터졌을것이다.

결론적으로 문제가 되는 시나리오는 위와 같았다, 동시에 같은 바디로 http 요청을 했을때 각자 쓰레드를 배정받고 동시에 처리하니 이런 문제가 생긴것이였다. 동시에 같은 아이디로 회원가입이 요청될 확률은 매우 낮지만 성능 테스트 도중 이런 케이스를 발견해서 정말 좋았다.

해결방법

시나리오에 대한 테스트 코드 작성

일단 매번 서버를 재시작하고 성능 테스트를 하여 재현하기엔 매우 번거롭기 때문에 위 상황을 재현할 수 있는 테스트 코드를 만들었다

@Test
@DisplayName("동시에 같은 id를 가진 회원가입 요청")
 public void duplicateMemberJoin() throws InterruptedException {
        //given
        int threadNum = 2;
        ExecutorService service = Executors.newFixedThreadPool(threadNum);
        String randomId = UUID.randomUUID().toString(); // 성능 테스트 환경과 똑같이 랜덤 아이디 생성
        MemberForm.Join joinBody1 = new MemberForm.Join(randomId, "testpassword", "abcName1", "010-0000-0000", Gender.MALE, "1234");
        MemberForm.Join joinBody2 = new MemberForm.Join(randomId, "testpassword", "abcName2", "010-1111-1111", Gender.MALE, "1234");
        //when
        Future<?> submit1 = service.submit(() -> {
            memberController.join(joinBody1);
        });

        Future<?> submit2 = service.submit(() -> {
            memberController.join(joinBody2);
        });

        //then
        service.shutdown();

        boolean exceptionOccurred = false;

        try {
            submit1.get();
        } catch (ExecutionException e) {
            if (e.getCause() instanceof ExistMemberException) {
                exceptionOccurred = true;
            }
        }

        try {
            submit2.get();
        } catch (ExecutionException e) {
            if (e.getCause() instanceof ExistMemberException) {
                exceptionOccurred = true;
            }
        }

        assertTrue(exceptionOccurred, "적어도 둘 중의 한개의 요청에선 회원이 존재한다는 예외가 반환되어야 한다.");
        service.awaitTermination(30, TimeUnit.SECONDS);
    }

일단 특별한 케이스들에 대한 테스트여서 단위 테스트, 유닛 테스트 패키지와 같은 경로에 SpecialCaseTest라는 패키지를 만들어서 관리하기로 했다, 앞으로 성능 테스트를 진행하면서 이런 문제들을 더 만날 수 있다고 생각했기 때문이다.

 

일단 given 파트부터 보면 테스트의 재현을 위해 멀티 쓰레드 환경을 준비한다. threadNum으로 쓰레드풀의 숫자를 정하고 자바에서 제공하는 ExecutorService를 이용하여 값이 2개로 고정된 쓰레드풀을 생성하여 service라는 변수에 할당한다.

그 다음 성능 테스트 스크립트와 똑같이 랜덤 아이디를 생성하고 똑같은 아이디를 사용하는 회원가입 바디를 두개 생성한다.

 

when에서 submit1과 submit2에서 각각의 바디로 회원가입 요청을 serivce 쓰레드풀에 요청하고 Future를 이용해서 결과를 받는다.

 

then에서 예외가 발생되었는지 확인하는 변수인 exceptionOccured를 거짓으로 초기화하여 생성한다. 이런 방법을 사용하는 이유는 멀티 쓰레드 환경에서는 순서가 보장되지 않아 submit중 어떤 쓰레드가 먼저 실행할지 예상할 수 없기 때문이다. 원래 submit2에서 예외를 예상하는 코드로 짯는데 로그로 확인했을때 쓰레드가 시작되는 순서와 디비에 요청하는 순서가 매번 같음을 보장되지 않기 때문에 이러한 방법을 채택하였다.

 

그리고 둘 다 submit.get()을 이용해서 요청이 문제 없이 잘 실행되었는지 확인하고 만약 ExistMemberException이 발생했다면 exceptionOccured의 값을 참으로 설정해준다. 그리고 assertTrue를 이용하여 exceptionOccured의 값이 참이 아니라면 적어도 둘중의 한개의 요청에선 회원이 존재한다는 예외가 반환되어야 한다 라는 메세지를 보여주면서 실패하게 된다.

 

이렇게 문제를 재현할 수 있는 테스트 코드를 간단하게 구성하였다.

해결 방법 선택

일단 해결을 위해서 이러한 동시성을 어떻게 처리하는지 best pratice에 대해서 국내/외 블로그들을 마구잡이로 뒤지기 시작했다.

많은 기술블로그들이 나와 같은 문제를 겪고 있었고 작성 되어 있는 해결방법 예시들을 정리해보자면 다음과 같았다

  1. java에서 syncronized 사용
  2. 트랜잭션 격리 수준을 serializible로 조정
  3. 트랜잭션 격리 수준을 REPEATABLE-READ로 조정
  4. 유니크 키를 설정하고 예외를 핸들링하여 해결
  5. 비관적락을 사용하여 해결

일단 위 네가지 방법중에서 1번과 2번은 처음부터 사용하지 않기로 했다, 왜냐하면 둘 다 모든 요청을 무조건 순차적으로 처리하도록 하여 큰 성능저하와 병목지점이 될 가능성이 높고 시나리오 자체가 흔하지 않기 때문에 이 정도 엄격한 조치가 필요 없다고 판단하였다.

 

3번인 REPEATABLE-READ로 조정하는것은 국내 블로그에서 보게 되었는데 막상 트랜잭션 격리 수준에 대해서 공부하니 지금 이 상황에 적절하지 않은 방법이라고 판단되었다. MVCC를 이용하여 데이터가 트랜잭션 도중에 변경되더라도 읽기에서 같은 결과를 보장하는거라고 공부했는데 지금 이 상황에선 동시에 없는 데이터에 대해서 읽기 요청을 해서 발생하는 문제기 때문에 적용할 수 있는 방법이 아니라고 판단했다. 그리고 확인해보니 이미 데이터베이스의 트랜잭션 격리 수준이 REPEATABLE-READ였다..

 

사실 4번이 가장 적절한 방법이라고 판단하였다, 무조건 0.1초라도 요청 순서에 따라 처리가 되어야는 경우도 아니고 그냥 유니크 중복 삽입으로 인한 예외를 처리해서 아무나 먼저 insert 하는 사람이 임자~ 라고 하는 방법이 성능상에도 병목지점도 없고 좋기도 하다.

 

하지만 나는 5번 비관적락을 사용하여 해결을 선택했다. 일단 낙관적 락과 비관적 락에 대해서 항상 자세히 공부하고 싶기도 했고 이번 이슈를 계기로 직접 사용해보고 싶기도 하였기 때문이다. 매번 이론과 단순 예시로만은 채울 수 없었던 실전 활용 욕심?이란 것이 있었다. 

 

당연히 이런 방법으로 해결한다면 동시에 같은 데이터로 온 요청들에 대해서 락 때문에 처리가 늦어지는 요청들이 있을것이고 성능 테스트시에 평균 TPS에도 영향이 갈것이라고 생각한다. 그래서 일단 비관적 락으로 문제를 해결하고 성능 테스트를 진행하고 후에 유니크 키에 대한 예외를 처리하는 방법으로 마이그레이션 하였을때 성능이 어느정도 개선되는지까지도 보고 싶기도 했다. 

비관적 락 사용하기

회원가입 과정 중 회원중복 가입 확인을 위하여 existsById라는 함수를 사용하고 있었다.

public boolean existsById(String id) {
    Member member = entityManager.createQuery("SELECT m FROM Member m WHERE m.id = :id", Member.class)
            .setParameter("id", id)
            .getResultList().stream().findFirst().orElse(null);
    return member != null;
}

 

하나의 id를 가진 데이터는 무조건 하나밖에 없어야한다는 전제하에 회원을 조회하고 데이터가 없으면 거짓 있다면 참을 보내는 함수이다.

간단하게 아래의 코드를 .setParameter 이후에 추가하면서 비관적 락을 사용할 수 있게 되었다. 

.setLockMode(LockModeType.PESSIMISTIC_WRITE)

 

공부할때 PESSIMISTIC_WRITE와 PESSIMISTIC_READ의 차이점이 살짝 헷갈려서 다시 한번 정리하고 넘어가겠다

 

PESSIMISTIC_WRITE으로 lock mode를 설정하게 되면 쿼리가 for update로 나가게 된다, 다른 트랜잭션에서 조회 및 수정에 대해서 둘 다 락을 거는 쿼리로 영어로 해석해보자면 업데이트를 위해서 조회를 한것이고 변경될 예정이니 이 데이터에 대해서는 조회도 수정도 하면 안된다 라는 의미로 만들어진 쿼리 같았다. 

 

PESSIMISTIC_WRITE으로 lock mode를 설정하게 되면 쿼리가 for share로 나가게 된다, 다른 트랜잭션에서 조회는 가능하지만 수정은 불가능한 쿼리로 영어로 해석해보자면 공유를 위한 쿼리고 변경이 될 예정은 아니니 조회는 해도 된다, 하지만 공유 중 데이터 정합성이 깨지면 안되니 수정은 하지 말아라 라는 의미로 만들어진 쿼리 같았다.

 

지금 우리의 문제는 근본적으로 다른 트랜잭션에서 조회를 동시에 했을때 발생하는 문제기 때문에 PESSIMISTIC_WRITE을 사용하는것이 적절하다고 판단하였다.

 

이렇게 간단한 일이였지만 사실 주말 내내 이해가 잘 안되서 회원 가입과 검증 트랜잭션을 분리도 해보고 이렇게 저렇게 설정해보다가 데드락도 엄청 걸리고 많은 고생을 했었다.

 

이런 경험이 옛날부터 많은데 완벽하게 이해가 되면 이상하게도 그런것이 왜 문제가 되었지?라고 생각이 든다.

고등학생때 도커를 처음 사용하던 시절에 컨테이너에 포트를 포트포워딩하면 되는데 네트워크를 브릿지하고 어쩌고 쌩쇼를 했는데 지금 생각하면 왜 그런 문제가 발생했는지 왜 나는 그렇게 사용했을까 이해가 안되는 경험이 있었다.

 

글을 쓰고 있는 지금도 비슷한 느낌이 든다, 불과 어제였는데 말이다. 자기전까지 생각하고 아침에 씻으면서 생각하고 출근하며 생각하다 보니 생각정리가 좀 되고 머리속에 알아서 착착 정리가 되어서 그런것 같다.

데드락까지 터지고 이해가 안되서 중간에 생긴 광기의 영역..

나는 머리속으로 현상을 가상화 하는것에 한계가 있고 분명 복잡한 조건들이 동반되었을때는 놓치는 부분들이 있을거라고 생각해서 그림으로 그리거나 파워포인트로 플로우 차트를 그리는것을 굉장히 좋아한다. 어려운 부분들에는 이해가 안되서 생긴 광기의 영역이 가끔 있다.

마무리하며

우리 껌딱지 개발팀 위키에 위 이슈를 정리하면서 마무리하게 되었다.

 

주말을 통째로 게임도 못하고 영상시청도 못하고 독서도 못하고 이것 하나에 올인하며 보냈지만 정말 보람찬 주말이였다.

마치 헬스를 할때 힘들다가 집 가서 뿌듯한것처럼 주말 내내 너무 머리아프고 어질어질 했지만 지금 돌아보면 알았던 부분들의 디테일에 대해서도 더 깊숙하게 알게 되었고 모르는 부분들에 대해서도 새롭게 알게 되어서 너무 좋았다. 

 

항상 프로덕션 레벨의 관점에서 개발을 진행하다보면 이런 문제점을 마주하게 되고 그때마다 드는 의문점은 과연 개발 문화가 좋고 사용자들에게 강력한 서비스를 제공하고 있는 네카라쿠배당토 같은 회사들은 이런 문제를 어떻게 처리하는지 궁금하다. 기술블로그로 살짝 엿볼수 있지만 나도 빨리 그곳에 합류해서 이런 문제의 best practice에 대해서 알아가고 토론하면서 성장하고 싶다.

 

다음에는 성능 테스트 결과와 성능 개선기에 대해서 글을 작성하도록 하겠다.

반응형
복사했습니다!