서비스의 규모가 커진다면, 하나의 자원에 다수의 요청이 한 번에 들어와동시성 이슈
가 발생할 수 있습니다.
데이터의 일관성을 보장하지 못한다면 금융 거래가 발생했을 때, 고객과 회사에 막대한 피해를 줄 수 있습니다.
현재 프로젝트에서 총 좋아요 개수
에 동시성 이슈
가 발생하여 해결해 보겠습니다.
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)
을 비교해보고 적절한 해결 방법을 적용해 보겠습니다.
낙관적 락이란??
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이 설정됩니다.
낙관적 락에서 데드락이 발생하는 경우
데드락
발생해당 내용을 쉽게 설명하면 외래키가 존재하는 상황에서 자식 테이블에서 삽입, 수정, 삭제 작업이 발생했을 때 부모 테이블에 공유 락(S-Lock)
이 걸린다.
다음으로 부모 테이블에서 수정이 발생하면 베타적 락(X-Lock)
이 부모 테이블에 걸리는 것을 시도하는데, 공유 락(S-Lock)
과 베타적 락(X-Lock)
은 동시에 소유할 수 없어 데드락 현상
이 발생하는 것이다.
비관적 락이란??
일관성은 보장할 수 있지만, 비관적 락은 모든 트랜잭션에 대해 Lock을 사용하여 필요하지 않은 상황에서도 Lock을 사용하기 때문에 성능이 저하되는 문제가 발생합니다.
비관적 락을 적용해도 데드락
현상이 발생합니다.
트랜잭션 A가 X테이블의 1번 데이터 row에 Lock을 건다.
트랜잭션 B가 Y테이블의 1번 데이터 row에 Lock을 건다.
트랜잭션 A가 Y테이블의 1번 테이터 row에 접근하지만, 이미 트랜잭션 B가 Lock을 걸어놔서 대기한다.
트랜잭션 B가 X테이블의 1번 데이터 row에 접근하지만, 이미 트랜잭션 A가 Lock을 걸어놔서 대기한다.
서로 다른 트랜잭션이 각자의 자원을 점유하고 서로 상대방의 자원을 얻기 위해 데드락
이 발생한다.
네임드 락
은 특정 이름을 통해 락을 관리하는 방식입니다. 락 이름은 문자열 형태로, 해당 락을 요청하거나 해제할 때 이름을 사용합니다.
MySQL 에서GET_LOCK()
과RELEASE_LOCK()
를 사용하여 락을 획득하고 해제할 수 있습니다.
네임드 락
은 단순한 구현과 효과적인 동시성 제어로 동시성 이슈를 해결할 수 있습니다.
하지만 여러 서버 간의 동기화는 지원하지 않기 때문에, 분산 환경에서는 적합하지 않고 단일 서버 환경에 적합합니다.
트랜잭션 종료 시, 락 해제나 세션 관리를 잘 해주어야 하기 때문에 구현 부분에 까다롭습니다.
특정 작업에서 락을 장기간 소유할 시, 커넥션 풀이 부족해지는 현상 때문에 데이터 소스를 잘 분리해서 사용해야 합니다.
별도의 커넥션 풀을 관리하지 못한다면 락에 관련된 부하를 RDS에 주기 때문에 데이터베이스 성능 저하의 원인이 됩니다.
분산 락
은 여러 서버나 노드가 존재하는 분산 시스템에서 자원에 대한 동기화를 보장하는 방법입니다.
비관적 락과 낙관적 락의 단점을 개선하고 다중 서버/데이터베이스를 운영하는 과정에서 동시성 이슈를 효과적으로 해결할 수 있습니다.
현재 진행 중인 프로젝트는 다중 DB로 확장될 수 있기 때문에 분산 락을 통해 해결하겠습니다.
다중 DB 환경에서 비관적 락과 낙관적 락을 적용할 수 있지만, 분산된 데이터베이스에서 락을 관리하는 것은 쉽지 않고 여러 데이터베이스 간의 일관성을 유지하는데 한계가 있습니다.
분산 락
은 Redis
를 이용해 구현할 수 있습니다.
Redis
는 현재 프로젝트에서 캐싱과 저장소로 활용되고 있기 때문에 추가적인 인프라 구축이 필요없고 Redis
의 높은 성능과 간편한 구현이 가능하기 때문에 Redis
를 저장소로 선택하겠습니다.
Redis Lettuce
는 분산 락 구현 시 setnx
, setex
과 같은 명령어를 이용해 지속적으로 Redis
에게 락이 해제됐는지 요청을 보내는 스핀락
방식과 유사한 동작을 합니다.
결국 요청이 많을수록 Redis
에 부하가 커지게 됩니다.
반면 Redis Redission
은 Pub/Sub
방식을 사용합니다.
락이 해제되면 락을 subscribe
하는 클라이언트는 락이 해제되었다는 신호를 받고 락 획득을 시도하게 됩니다.
분산 락 서버가 다운되면 분산 락
을 사용하는 모든 서비스에 영향을 줄 수 있기 때문에 서버 복구 체제를 마련해야 합니다.
분산 락 서버는 모든 서버가 공통으로 사용하기 때문에 하나의 트랜잭션이 락을 오랫동안 소유하는 것을 현상을 막아야 합니다. 그래서 락에 대한 적절한 타임 아웃
을 설정하여 다른 서버들의 요청이 무한정 대기하는 현상인 데드락
을 방지해야 합니다.
RedissonClient의 getLock() 메서드
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
라이브러리를 활용해 분산 락을 적용했을 때, 정상적으로 총 좋아요 수
가 반영되는 것을 확인할 수 있었습니다.