동시성 이슈 해결 과정

JeongMin·2024년 7월 7일
0
post-thumbnail

서비스의 규모가 커진다면, 하나의 자원에 다수의 요청이 한 번에 들어와동시성 이슈가 발생할 수 있습니다.

데이터의 일관성을 보장하지 못한다면 금융 거래가 발생했을 때, 고객과 회사에 막대한 피해를 줄 수 있습니다.

현재 프로젝트에서 총 좋아요 개수동시성 이슈가 발생하여 해결해 보겠습니다.

문제 상황

class RedissonLockFacadeTest extends IntegrationTestSupport {

    public static final int N_THREADS = 4;

    @Autowired
    private PostLikeService postLikeService;

    @Autowired
    private PostLikeRepository postLikeRepository;

    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private BoardRepository boardRepository;

    @AfterEach
    public void clear() {
        postLikeRepository.deleteAllInBatch();
        boardRepository.deleteAllInBatch();
        memberRepository.deleteAllInBatch();
    }
    
    @DisplayName("락을 걸지 않은 상황에서 회원 100명이 한 게시글에 좋아요를 동시에 눌렀을 때, 총 좋아요 개수가 100개가 아닙니다.")
    @Test
    void likeCountTest() throws InterruptedException {
        // given
        List<Member> members = Stream.generate(this::getMember).limit(100)
                .collect(Collectors.toList());

        Board createdBoard = getBoard(members.get(0));

        memberRepository.saveAll(members);
        boardRepository.save(createdBoard);

        int threadCount = members.size();
        // 모든 스레드가 작업을 완료할 때 까지 기다리는 동기화 객체
        CountDownLatch latch = new CountDownLatch(threadCount);

		// 스레드를 관리하는 풀
        ExecutorService executorService = Executors.newFixedThreadPool(N_THREADS);

        // 회원 100명이 동시에 좋아요를 누르는 상황
        for (Member member : members) {
        // 회원 100명이 동시에 좋아요 누르는 작업을 스레드로 실행
            executorService.execute(() -> {
                try {
                    postLikeService.postLike(member.getId(), createdBoard.getId());
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                // 작업이 완료되고 카운트
                    latch.countDown();
                }
            });
        }

		// 모든 스레드가 완료될 때까지 대기
        latch.await();

        // when
        Board board = boardRepository.findById(createdBoard.getId())
                .orElseThrow(() -> new BadRequestException(NOT_FOUND_BOARD_EXCEPTION));

        // then
        assertThat(board.getTotalLikeCount()).isNotEqualTo(100);
    }

    private Member getMember() {
        return Member.builder()
                .loginAccountId("1234")
                .name("이름")
                .profileImageUrl("URL")
                .roleType(USER)
                .build();
    }

    private Board getBoard(final Member createdMember) {
        return Board.builder()
                .member(createdMember)
                .title("제목")
                .content("내용")
                .build();
    }
    
  }

100명의 회원이 동시에 좋아요를 누르고 총 좋아요 개수가 100개가 아닌 것을 확인하는 테스트입니다.

위에 작성한 테스트 코드가 통과된 것을 확인할 수 있습니다.

즉, 총 좋아요 개수정상적으로 반영되지 않았습니다.

그리고 여러 번 실행했을 때, 총 좋아요 개수가 항상 일정하지 않고 실행할 때 마다 다른 것을 확인했습니다.

해당 동시성 이슈를 해결하기 위해 낙관적 락(Optimistic Lock), 비관적 락(Pessimistic Lock), 네임드 락(Named Lock), 분산 락(Distributed Lock)을 비교해보고 적절한 해결 방법을 적용해 보겠습니다.


낙관적 락(Optimistic Lock)

낙관적 락이란??

  • DB의 Lock을 사용하지 않고 Version 관리를 통해 애플리케이션 레벨에서 처리하여 단순합니다.
  • 주로 트랜잭션 충돌이 자주 발생하지 않고 읽기 비율이 높은 환경에서 적용합니다.
  • 트랜잭션 커밋 전에는 트랜잭션 충돌을 알 수 없습니다.

주의해야 할 점

  • 낙관적 락을 적용했다면 충돌 시 예외 처리를 개발자가 직접 구현해야 합니다.

  • 충돌 시 재시도가 필요할 경우, 재시도 횟수에 따라 데이터베이스 I/O가 발생해 부하가 증가합니다.

  • 낙관적 락에서는 락을 사용하지 않아도 테이블에 외래 키 제약 조건이 정의되어 있는 경우(FK 존재) 데드락이 발생할 수 있습니다.

    • MySQL 8.0 래퍼런스
      S-Lock(공유 락)이 적용되는 이유

      If a FOREIGN KEY constraint is defined on a table, any insert, update, or delete that requires the constraint condition to be checked sets shared record-level locks on the records that it looks at to check the constraint.
      InnoDB also sets these locks in the case where the constraint fails.

    • 외래 키(FK)가 있는 테이블에서 삽입, 수정, 삭제 작업이 발생할 경우 제약 조건을 확인하기 위해 해당 레코드에 S-Lock이 설정됩니다.

      X-Lock(베타 락)이 적용되는 이유

      UPDATE … WHERE … sets an exclusive next-key lock on every record the search encounters.
      However, only an index record lock is required for statements that lock rows using a unique index to search for a unique row.

    • 수정 작업에 사용되는 모든 레코드에 X-Lock이 설정됩니다.

      낙관적 락에서 데드락이 발생하는 경우

    1. 트랜잭션 A는 외래 키가 있는 테이블에서 삽입, 수정, 삭제 작업이 발생해 S-Lock 획득
    2. 트랜잭션 B도 동일한 상황으로 S-Lock 획득
    3. 트랜잭션 B에서 수정 작업으로 X-Lock 획득을 하기 위해 대기
    4. 트랜잭션 A에서 수정 작업으로 X-Lock 획득을 하기 위해 대기
    5. S-Lock과 X-Lock은 양립이 불가능하기 때문에 데드락 발생

    해당 내용을 쉽게 설명하면 외래키가 존재하는 상황에서 자식 테이블에서 삽입, 수정, 삭제 작업이 발생했을 때 부모 테이블에 공유 락(S-Lock)이 걸린다.
    다음으로 부모 테이블에서 수정이 발생하면 베타적 락(X-Lock)이 부모 테이블에 걸리는 것을 시도하는데, 공유 락(S-Lock)베타적 락(X-Lock)은 동시에 소유할 수 없어 데드락 현상이 발생하는 것이다.


비관적 락(Pessimistic Lock)

비관적 락이란??

  • 트랜잭션이 데이터를 읽고 수정하는 동안 다른 트랜잭션이 해당 트랜잭션을 읽거나 수정하지 못하게 합니다.
  • 주로 X-Lock(배타 락)을 사용하여 데이터의 일관성을 보장합니다.
  • 트랜잭션 충돌이 자주 발생하는 환경에 적용하기 적절합니다.

주의해야 할 점

  • 일관성은 보장할 수 있지만, 비관적 락은 모든 트랜잭션에 대해 Lock을 사용하여 필요하지 않은 상황에서도 Lock을 사용하기 때문에 성능이 저하되는 문제가 발생합니다.

  • 비관적 락을 적용해도 데드락 현상이 발생합니다.

    1. 트랜잭션 A가 X테이블의 1번 데이터 row에 Lock을 건다.

    2. 트랜잭션 B가 Y테이블의 1번 데이터 row에 Lock을 건다.

    3. 트랜잭션 A가 Y테이블의 1번 테이터 row에 접근하지만, 이미 트랜잭션 B가 Lock을 걸어놔서 대기한다.

    4. 트랜잭션 B가 X테이블의 1번 데이터 row에 접근하지만, 이미 트랜잭션 A가 Lock을 걸어놔서 대기한다.

    5. 서로 다른 트랜잭션이 각자의 자원을 점유하고 서로 상대방의 자원을 얻기 위해 데드락이 발생한다.


네임드 락(Named Lock)

네임드 락은 특정 이름을 통해 락을 관리하는 방식입니다. 락 이름은 문자열 형태로, 해당 락을 요청하거나 해제할 때 이름을 사용합니다.

MySQL 에서GET_LOCK()RELEASE_LOCK()를 사용하여 락을 획득하고 해제할 수 있습니다.

네임드 락은 단순한 구현과 효과적인 동시성 제어로 동시성 이슈를 해결할 수 있습니다.

주의해야 할 점

하지만 여러 서버 간의 동기화는 지원하지 않기 때문에, 분산 환경에서는 적합하지 않고 단일 서버 환경에 적합합니다.

트랜잭션 종료 시, 락 해제나 세션 관리를 잘 해주어야 하기 때문에 구현 부분에 까다롭습니다.

특정 작업에서 락을 장기간 소유할 시, 커넥션 풀이 부족해지는 현상 때문에 데이터 소스를 잘 분리해서 사용해야 합니다.

별도의 커넥션 풀을 관리하지 못한다면 락에 관련된 부하를 RDS에 주기 때문에 데이터베이스 성능 저하의 원인이 됩니다.


분산 락(Distributed Lock)

분산 락은 여러 서버나 노드가 존재하는 분산 시스템에서 자원에 대한 동기화를 보장하는 방법입니다.

비관적 락과 낙관적 락의 단점을 개선하고 다중 서버/데이터베이스를 운영하는 과정에서 동시성 이슈를 효과적으로 해결할 수 있습니다.

현재 진행 중인 프로젝트는 다중 DB로 확장될 수 있기 때문에 분산 락을 통해 해결하겠습니다.

다중 DB 환경에서 비관적 락과 낙관적 락을 적용할 수 있지만, 분산된 데이터베이스에서 락을 관리하는 것은 쉽지 않고 여러 데이터베이스 간의 일관성을 유지하는데 한계가 있습니다.

Redis

분산 락Redis 를 이용해 구현할 수 있습니다.

Redis는 현재 프로젝트에서 캐싱과 저장소로 활용되고 있기 때문에 추가적인 인프라 구축이 필요없고 Redis의 높은 성능과 간편한 구현이 가능하기 때문에 Redis를 저장소로 선택하겠습니다.

Redis Lettuce와 Redis Redission

Redis Lettuce는 분산 락 구현 시 setnx, setex과 같은 명령어를 이용해 지속적으로 Redis에게 락이 해제됐는지 요청을 보내는 스핀락 방식과 유사한 동작을 합니다.

결국 요청이 많을수록 Redis에 부하가 커지게 됩니다.

반면 Redis RedissionPub/Sub 방식을 사용합니다.
락이 해제되면 락을 subscribe 하는 클라이언트는 락이 해제되었다는 신호를 받고 락 획득을 시도하게 됩니다.

Redis Redission 적용 시 주의해야 할 점

분산 락 서버가 다운되면 분산 락을 사용하는 모든 서비스에 영향을 줄 수 있기 때문에 서버 복구 체제를 마련해야 합니다.

분산 락 서버는 모든 서버가 공통으로 사용하기 때문에 하나의 트랜잭션이 락을 오랫동안 소유하는 것을 현상을 막아야 합니다. 그래서 락에 대한 적절한 타임 아웃을 설정하여 다른 서버들의 요청이 무한정 대기하는 현상인 데드락을 방지해야 합니다.

RedissonClient의 getLock() 메서드


Redis Redission 라이브러리 적용

build.gradle

dependencies {
    // redisson
    implementation group: 'org.redisson', name: 'redisson', version: '3.23.3'
}

Redisson 라이브러리를 사용하기 위해 의존성을 추가합니다.

RedisConfig.java

@Configuration
public class RedisConfig {

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + host + ":" + port);
        return Redisson.create(config);
    }
    
    ...
    
}

redissonClient 를 사용하기 위해 Config 설정을 빈으로 등록합니다.

RedissonLockFacade.java

@Component
@RequiredArgsConstructor
@Slf4j
public class RedissonLockFacade {

    public static final int WAIT_TIME = 5;
    public static final int LEASE_TIME = 3;

    private final PostLikeService postLikeService;
    private final RedissonClient redissonClient;

    public void postLike(final Long memberId, final Long boardId) {
        RLock lock = redissonClient.getLock(boardId.toString());

        try {
            boolean available = lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS);

            if (!available) {
                log.info("lock 획득 실패");
                return;
            }

            postLikeService.postLike(memberId, boardId);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
        }
    }
}

redissonClient.getLock() : Redisson 클라이언트를 통해 분산 락을 가져옵니다.
lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS) : WAIT_TIME 시간 동안 락을 얻으려 시도하며, LEASE_TIME 시간 동안 락을 보유합니다.

분산 락을 적용한 후, 동시성 이슈 테스트

class RedissonLockFacadeTest extends IntegrationTestSupport {

    public static final int N_THREADS = 4;

    @Autowired
    private RedissonLockFacade redissonLockFacade;

    @Autowired
    private PostLikeRepository postLikeRepository;

    @Autowired
    private MemberRepository memberRepository;

    @Autowired
    private BoardRepository boardRepository;

    @AfterEach
    public void clear() {
        postLikeRepository.deleteAllInBatch();
        boardRepository.deleteAllInBatch();
        memberRepository.deleteAllInBatch();
    }

    @DisplayName("Redisson 분산 락을 이용하여 100명의 회원이 동시에 좋아요를 눌렀을 때, 총 좋아요 개수가 전부 반영된다.")
    @Test
    void redissonLockTest() throws InterruptedException {
        // given
        List<Member> members = Stream.generate(this::getMember).limit(100)
                .collect(Collectors.toList());

        Board createdBoard = getBoard(members.get(0));

        memberRepository.saveAll(members);
        boardRepository.save(createdBoard);

        int threadCount = members.size();
        CountDownLatch latch = new CountDownLatch(threadCount);

        ExecutorService executorService = Executors.newFixedThreadPool(N_THREADS);

        // 회원 100명이 동시에 좋아요를 누르는 상황
        for (Member member : members) {
            executorService.execute(() -> {
                try {
                    redissonLockFacade.postLike(member.getId(), createdBoard.getId());
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();

        // when
        Board board = boardRepository.findById(createdBoard.getId())
                .orElseThrow(() -> new BadRequestException(NOT_FOUND_BOARD_EXCEPTION));

        // then
        assertThat(board.getTotalLikeCount()).isEqualTo(100);
    }
    
    private Member getMember() {
        return Member.builder()
                .loginAccountId("1234")
                .name("이름")
                .profileImageUrl("URL")
                .roleType(USER)
                .build();
    }

    private Board getBoard(final Member createdMember) {
        return Board.builder()
                .member(createdMember)
                .title("제목")
                .content("내용")
                .build();
    }
}

100명의 회원이 좋아요를 동시에 누르고 총 좋아요 개수가 100개가 된 것을 확인하는 테스트입니다.

Redission 라이브러리를 활용해 분산 락을 적용했을 때, 정상적으로 총 좋아요 수가 반영되는 것을 확인할 수 있었습니다.

profile
📚개발 기록

0개의 댓글