[Spring boot & java] 동시성 이슈 (feat. Redis)

DragonTiger·2023년 4월 10일
3

Issue

회사에서 프로젝트를 진행중에 있었던 동시성 이슈에 대한 포스팅이다.

spring boot 로 진행했던 프로젝트이므로 java 의 멀티 쓰레드 환경이다.

수강신청 인원에 리밋이 걸려있었는데 인원을 검증하여 제한하는 로직에 데이터가 통과되며 데이터가 들어가는 일이 발생하였다.

ex) 현재수강인원/수강인원 800/800 명을 예상했다면 801/800 .. 802/800 인원초과되는..

spring boot & java 환경에서의 동시성 문제

쉽게 말하면 2개이상의 쓰레드가 java jvm heap 영역의 객체를 동시에 공유함으로써 자원에 데이터 정합성 문제가 발생한다.

예제로 살펴보자..


예제

Course.java

CourseClass.java

CourseMember.java

Member.java

CourseMemberServiceImpl.java

@SpringBootTest
@Slf4j
class CourseServiceImplTest {

    @Autowired
    private MemberService memberService;
    @Autowired
    private MemberRepository memberRepository;
    @Autowired
    private CourseMemberRepository courseMemberRepository;
    @Autowired
    private CourseService courseService;
    @Autowired
    private CourseClassService courseClassService;
    @AutoWired
    private CourseMemberService courseMemberService;

    @Test
    @DisplayName("수강신청 테스트")
    void 수강신청_테스트() throws InterruptedException {

        //given
        ExecutorService executorService = Executors.newFixedThreadPool(100); 스레드풀에 스레드 100개 준비
        CountDownLatch countDownLatch = new CountDownLatch(100);

        Long courseSeq = courseService.save("테스트 과정");
        Long savedCourseClassSeq = courseClassService.save(courseSeq,110);

        List<Long> memberIdLists = new ArrayList<>();
        List<Long> courseMemberIdLists = new ArrayList<>();
        for (int i = 1; i < 101; i++) {
            Long savedMembers = memberService.save("DragonTiger" + i);
            memberIdLists.add(savedMembers);
        }

        List<Member> memberList = memberRepository.findAllById(memberIdLists);
        //when
        for (Member member : memberList) {
                //스레드 n개중 한개의 쓰레드 할당
                executorService.submit(() -> {
                try {
                    Long savedCourseMember = courseMemberService.save(member.getSeq(), savedCourseClassSeq);
                    courseMemberIdLists.add(savedCourseMember);
                } finally {
                    countDownLatch.countDown();
                }


            });
        }
        countDownLatch.await();
        List<CourseMember> courseMembers = courseMemberRepository.findAllById(courseMemberIdLists);

        //then
        Assertions.assertThat(courseMembers.size()).isEqualTo(11);

    }

각 Service의 Save() 는 트랜잭션 어노테이션이 걸려있습니다.

위 테스트 코드는 100명의 사용자가 동시에 수강신청을 했을때를 테스트하는 코드이며,
11명의 수강인원 제한을 걸어놓았으므로 테스트 코드를 실행해보면 db에 들어가있는 데이터(CourseMember)는 11명이 나와야할것이다... 과연?!

그러나 결과는 11명을 넘어서 18명의 CourseMember가 들어가있다.

로그를 찍어보니..

로그를 보면 동시에 서로 다른 쓰레드들이 동시에 courseMemberService.save() 메소드를 호출하며 현재의 수강신청 인원수가 0으로 나온다.

CourseMemberServiceImpl 의 save 메서드의 로직을 보면 memberSeq 와 courseClassSeq 를 받아서 Member,CourseClass 엔티티를 가져온뒤 해당 save 메소드 내에서 exceedingTheNumberOfStudents() 로 들어가보면 courseMemberList 의 총갯수를 가져와 조건비교를 하는데

여기서 동시에 현재 수강신청 인원수를 조회 하면서 0명인 인원수의 조건을 비교하는 쓰레드가 동시에 10개가 되어버리니 데이터 정합성이 맞지 않아버리게 된다..

즉, 자바의 JVM의 Heap 영역의 CourseClass 객체를 각 쓰레드들이 공유하면서 if 문의 로직을 통과해버리는 일이 일어난다.. 해서 통과한 쓰레드들이 CourseMeber를 저장할것이고 결과적으로 인원 제한을 하지못하게되는것이다.

Solution

나는 위 문제를 바로잡고자 Redis 를 활용해보고자 한다.

Redis

Redis 를 통한 분산락을 이용해서 동시성 문제를 해결하는 방법이 있다.

분산 락(Distribute Lock)
Lock: DB의 트랜잭션의 순차적 처리를 보장하기 위한 방법
여러 서버에서 동기화된 처리를 하기 위해 Database, Redis와 같은 공통된 저장소를 이용한 방법
(공통된 저장소를 사용해 여러 서버에 대한 동기화된 처리가 가능함)

lettuce

lettuce 같은 경우는 분산락 구현을 위해 스핀락으로 구현을 해야한다.

스핀락 같은경우는 락을 얻기위해 SETNX 명령어로 계속 요청을 보내야하므로 Redis의 부하를 줘서 비효율적이다.

락의 타임아웃도 구현되어있지않아서 무한루프에 빠질가능성도 많다.

* SETNX :: SET IF NOT EXIST 특정 key 값이 존재 하지않을 경우 set 하라는 명령어로 특정 키에 대해 value가 없을경우에만 값을 세팅하는 명령어

redisson

위 같은 단점을 보완한게 redisson 의 pub/sub 방식이다

Redis는 메시지 브로커 역할이 가능하다.
해서 메시지에 대한 publish 와 subscribe 기능을 지원한다.

예를 들면 동시에 10개의 쓰레드가 락 획득을 위해 경합할때 쓰레드 1번이 락을 얻고 로직을 실행 할때 나머지 스레드 2~10 번들은 락을 얻기위해 특정채널을 subscribe 하고 있다.
그리고 로직이 처리된후 락이 해제되면 해제된 메시지를 subscribe 에 대기중인 스레드에 publish 하고 이어 대기 스레드가 다시 락을 얻는다.
이 과정을 반복한다.

redisson의 tryLock 메소드 같은경우는 락을 획득했을때의 타임아웃과 락 대기 타임아웃도 구현해 놓았다.

자 그럼 Redis에서 분산락을 구현하기 위해 java Redisson 써보자

일단 의존성 설정을 해주자

Redis 를 사용할때 기본적으로 쓰는 Spring Data Redis는 기본 클라이언트로 lettuce 를 사용한다.
해서 redisson 은 의존성을 따로 설정해야한다.

redisson-spring-boot-starter는 Spring Data Redis의 기능들을 포함하고 있기 때문에, 굳이 spring-boot-starter-data-redis를 implementation 할 필요가 없다.

추가로 Spring Boot 2 를 사용하고 계신다면 org.redisson:redisson-spring-boot-starter 를 3.17. 버전 이하를 사용하셔야 됩니다.

Redisson 버전확인

implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'

일단 코드로 알아보자

@SpringBootTest
@Slf4j
class CourseServiceImplTest {

    @Autowired
    private MemberService memberService;
    @Autowired
    private MemberRepository memberRepository;
    @Autowired
    private CourseMemberRepository courseMemberRepository;
    @Autowired
    private CourseService courseService;
    @Autowired
    private CourseClassService courseClassService;
    @Autowired
    private CourseLockUtils courseLockUtils;

    @Test
    @DisplayName("수강신청 테스트")
    void 수강신청_테스트() throws InterruptedException {

        //given
        ExecutorService executorService = Executors.newFixedThreadPool(100); //스레드풀에 스레드 100개 준비
        CountDownLatch countDownLatch = new CountDownLatch(100);

        Long courseSeq = courseService.save("테스트 과정");
        Long savedCourseClassSeq = courseClassService.save(courseSeq,11);

        List<Long> memberIdLists = new ArrayList<>();
        List<Long> courseMemberIdLists = new ArrayList<>();
        for (int i = 1; i < 101; i++) {
            Long savedMembers = memberService.save("DragonTiger" + i);
            memberIdLists.add(savedMembers);
        }

        List<Member> memberList = memberRepository.findAllById(memberIdLists);
        //when
        for (Member member : memberList) {
                //스레드 n개중 한개의 쓰레드 할당
                executorService.submit(() -> {
                try {
                    Long savedLockCourseMember = courseLockUtils.saveLock(member.getSeq(), savedCourseClassSeq);
                    courseMemberIdLists.add(savedLockCourseMember);
                }
                catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                finally {
                    countDownLatch.countDown();
                }


            });
        }
        countDownLatch.await();
        List<CourseMember> courseMembers = courseMemberRepository.findAllById(courseMemberIdLists);

        //then
        Assertions.assertThat(courseMembers.size()).isEqualTo(11);

    }

}

@Slf4j
@Component
@RequiredArgsConstructor
public class CourseLockUtils {

    private final RedissonClient redissonClient;
    private final CourseMemberService courseMemberService;

    public Long saveLock(Long memberSeq, Long courseClassSeq) throws InterruptedException {

            RLock rLock = redissonClient.getLock("courseMemberLock");
        try {
            boolean success = rLock.tryLock(5000,1000, TimeUnit.MILLISECONDS);
            if (!success) {
                log.info("락 획득 실패");
                throw new IllegalArgumentException();
            }
            return courseMemberService.save(memberSeq,courseClassSeq);
        } finally {
            rLock.unlock();
        }
    }
}

CourseLockUtils 클래스로 courseMemberService 클래스의 @Transaction이 붙은 save 메소드를 감싸주고있다.

@Transaction이 원리가 aop 이므로
save 메소드를 둘러 싸는 트랜잭션을 처리하는 프록시가 동작한다.
여기서 알아둬야할점은 트랜잭션 안에서 lock 획득을 처리해버리면 안된다는것이다.
예를 들어 쓰레드 1, 2 가 경합시 스레드 1의 락 해체 후 트랜잭션이 커밋되는 시점에
스레드 2가 락을 얻는다. 데이터 베이스상으로 락이 존재 하지 않아서 데이터를 읽고 쓰레드 1의 변경내용은 유실된다 해서 트랜잭션 어노테이션의 범위보다 넓게 락 Utils 클래스를 만들어 주었다.

redisson의 tryLock 메서드

waitTime 같은 경우는 잠금을 얻기위한 대기시간이고, leaseTime 같은경우는 락을 획득하고 임대하는 시간이다.

참고로 쓰레드가 많아 지면 많아질수록 waitTime 이 길어져야한다.

자 테스트 코드를 돌려보자.

위 로그 처럼 수강신청 정원을 체크하는 로직도 정상동작하고,

처음에는 18명의 CourseMember 가 들어가 있었다면, 현재는 보다시피 11명의 제한된 인원만 들어가있다.

고도화

위 코드를 각각의 Utils 클래스를 만드는것보단 스프링의 강력한기능인 AOP 를 적용하여

적용하는게 알맞다고 생각한다.

해서 다음번 포스팅은 AOP 를 적용해서 Redis를 써봐야겠다.


참고한 사이트
ohzzi

profile
take the bull by the horns

0개의 댓글