일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- Spring cloud gateway
- AOP
- 톰캣
- Kotlin
- HikariCP
- Transactio
- 살아남았다.
- resilience4j
- MDC
- 동시성문제
- JWT
- 테스트코드
- 최종 합격
- 우테코
- 트랜잭션
- Thread
- 우아한 테크 코스
- 오어스
- Elk
- oauth
- 우아한테크코스
- redis
- 커넥션 풀
- circuitbreaker
- Spring Batch
- 우테코 5기
- tomcat
- spirng
- DispatcherServlet
- Gateway
- Today
- Total
코딩은 내일부터
분산락(Named Lock)으로 동시성 관리하기 본문
문제의 상황
동글 서비스는 글, 카테고리를 저장할 때 동시성을 AOP를 사용해서 제어해주고있었다.
AOP를 구현할 당시에는 동글 서비스가 서버 2대 이상 띄우는 시기가 먼 미랜줄 알고
서버가 2대 이상인 환경을 별로 신경 안 쓰고 있었다.
하지만.... 이번 6차 데모데이의 요구사항 중에 무중단 배포가 있어 무중단 배포를 학습하고
구현하는 도중 다음과 같이 서버가 동시에 2대 실행되고 있는 순간이 있다는 거를 확인하였다.
AOP로 동시성을 제어하는 로직이 사용할 수 없어 다른 제어방법을 생각해야 했다.
구글에 분산락을 검색해 보면 Redis를 사용하는 방법 등등 여러 방법이 나오는데 이러한 기술을 사용하면
인프라 구축하는 비용 + 기술 학습 비용 이 들어가기 때문에 프로젝트 초기부터 사용해오고 있는
MySQL을 이용하여 분산락을 구현하기로 결정했다.
이름하여 Named Lock을 이용해 분산락 구현하기!!
이번 포스팅은 Named Lock을 사용해서 분산락을 구현해 보겠다.
분산락과 Named Lock이란?
먼저 분산락은 멀티 스레드 환경에서 공유 자원에 접근할 때,
데이터의 정합성을 지키기 위해 사용하는 기술이다.
단일 서버일 때는 자바에서 제공하는 synchronized라는 키워드를 이용해서 정합성을 지킬 수 있지만,
다중 서버에서는 synchronized키워드 만으로 정합성을 지킬 수 없다.
여기서 다중 서버에서 정합성을 지켜줄 수 있는 기법이 분산락이다.
다음으로 Named Lock은 이름 그대로 이름을 기반으로 하는 락(Lock)을 말한다.
장점
Named Lock은 쿼리를 이용해 잠금을 획득하고 해제할 수 있는 등 Named Lock 관리가 쉽다는 장점이 있다.
단점
MySQL에서만 사용가능하다는 단점이 있다.
Named Lock의 동작 방식
위의 사진은 동글 1, 동글 2, 동글 3 3개의 named으로 Lock을 확득하고자 하면 획득할 수 있지만
동일한 이름으로 락을 획득하고자 하면 처음 접근한 스레드만 Lock을 획득할 수 있다.
즉, 이때 획득한 쓰레드가 Lock을 반환하지 않으면 똑같은 이름의 락은 절~~~대 획득할 수 없다는 얘기다.
구현
MySQL에서 제공하는 함수 중에서 GET_LOCK()과RELEASE_LOCK()을 이용하여 분산락을 구현하였다.
executeWithLock() 한번의 호출로 Lock을 얻고, Lock 을 푸는 작업을 하기 위해 비즈니스 로직은 Supplier 인터페이스를 이용한 콜백으로 수행되도록 구현하였다.
@Repository
public class LockRepository {
private static final String GET_LOCK = "SELECT GET_LOCK(:memberId , 1)";
private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(:memberId)";
private static final String EXCEPTION_MESSAGE = "LOCK 을 수행하는 중에 오류가 발생하였습니다.";
private final NamedParameterJdbcTemplate jdbcTemplate;
public LockRepository(final NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public <T> T executeWithLock(final String memberId, final Supplier<T> supplier) {
try {
final int lockNumber = getLock(memberId);
if (lockNumber == 1) {
return supplier.get();
}
throw new ConcurrentAccessException();
} finally {
releaseLock(memberId);
}
}
private int getLock(final String memberId) {
final Map<String, Object> params = new HashMap<>();
params.put("memberId", memberId);
return jdbcTemplate.queryForObject(GET_LOCK, params, Integer.class);
}
private void releaseLock(final String memberId) {
final Map<String, Object> params = new HashMap<>();
params.put("memberId", memberId);
final Integer result = jdbcTemplate.queryForObject(RELEASE_LOCK, params, Integer.class);
checkResult(result);
}
private void checkResult(final Integer result) {
if (result != 1) {
throw new RuntimeException(EXCEPTION_MESSAGE);
}
}
}
Named Lock을 구현하고 해당 저장 로직에 적용해보았다.
하지만 여기서 여러 블로그에서는 락과 비즈니스 로직을 분리를 해야 동시성을 제어할 수 있다고 말을 하는데
머릿속으로 이해가 안돼서 다음과 같은 하나의 트랜잭션으로 묶어서 테스트를 진행해 봤다.
테스트를 진행하면서 Named Lock이 하나도 동작을 안 하는 거를 볼 수 있는데
그 이유는 위와 같이
Synchronized키워드를 사용해도 Commit 되기 직전에 다른 트랜잭션으로 메서드가 실행될 수 있다.
이러한 문제가 Named Lock에서 트랜잭션을 하나로 묶으면 발생하는데
위사진을 다시 확인해 보면 CategoryFacadeService에서 메서드가 끝나는 순간
Commit이 되는데 그 끝나고 Commit이 되는 그 사이 순간에 다른 트랜잭션이 실행된다.
정리해 보면 처음 addCategory() 메서드가 끝나도 Commit이 되지 않아
변경된 내용이 두 번째 addCategory() 메서드가끝나면 실행된어 트랜잭션이 끝나고
Commit되는 순간에 다른 트랜잭션이 실행되 하나의 트랜잭션을 묶어놓으면
동시성 제어를 할 수 없는 이유가 이러한 동작 방식 때문에였다.
그래서 @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용하여
별도의 트랜잭션으로 관리를 해야 동시성을 제어할 수 있는 거였다.
즉, 처음 addCategory() 메서드를 실행되고 두 번째 addCategory() 메서드가 실행되는데
두 번째 addCategory() 메서드가 종료되고 변경된 부분을 저장(Commit)을 해서 동시성문제가 발생하지 않는다.
REQUIRES_NEW의 위험성
하지만 REQUIRES_NEW를 사용했을 때 문제가 생긴다.
위 사진을 보면 상황을 설명하겠다.
우선 Connection을 3개까지만 연결할 수 있다고 가정하고 사진을 보면
처음 addCategory() 메서드를 시작할 때 커낵션이 3개 할당된다.
근데두 번째 addCategory() 메서드의 트랜잭션 전파속성을 보면 REQUIRES_NEW다.
현재 커낵션이 3개 할당된 상태에서 3개의 동글이(요청)는 어느 하나 커낵션이 반환되기를 기대하고 있지만,
서로 메서드가 끝나야지만 커낵션이 반환된다. 즉, 데드락이 발생한다는 말이다!
어? 그러면 위 사진과 같이 처음 addCategory는 트랜잭션 에노테이션을 안 붙이고
다음으로 호출되는 addCategory에 트랜잭션 에노테이션을 붙이면 되는 거 아니야?라고 생각할 수 있다.(내가 그랬다..)
왜냐하면 executeWithLock() 메서드 내부에 GET_LOCK()과RELEASE_LOCK() 메서드가
jdbcTemplate로 쿼리가 실행되고 트랜잭션이 종료되게 되면 pool에서 얻어온 Connection을 반환하게 되기 때문에
락을 가져오고 반납이 잘 진행되는 줄 알았다...
하지만 Named Lock을 위에서 알아봤을 때는 그냥 이름의 해당하는 Lock을 가져오고 반납하는 줄 알았다.
다시 알아보니 Connection1, Connection2,... Connection N개의 Connection이 있으면
Named Lock은 GET_LOCK을 할 때 가져온 Connection에 해당이름의 락을 걸고,
RELEASE_LOCK을 하면 해당 GET_LOCK을 할때 가져온 Connection에 락을 반환하는 것이다.
다시 봐보면
RELEASE_LOCK을 하면 해당 GET_LOCK을 할 때 가져온 Connection에 락을 반환하는 것이다.
그렇다.. GET_LOCK을 할때 가져온 Connection을 그대로 RELEASE_LOCK을 할때 사용해야 한다는 것이다...
그래서 위에 사진처럼 분산락을 구현하게 되면 트랜잭션이 종료되고 나면
Connection을 반환하게 되면서 획득한 Lock을 반환하지 못하는 경우가 발생할 수 있다!!
그래서 GET_LOCK과 RELEASE_LOCK 할 때 동일한 Connection을 사용해야 한다.
최종 코드
public interface LockRepository {
<T> T executeWithLock(final String memberId, final Supplier<T> supplier);
}
@Repository
public class LockRepositoryImpl implements LockRepository {
private static final String GET_LOCK = "SELECT GET_LOCK(:memberId , 1)";
private static final String RELEASE_LOCK = "SELECT RELEASE_LOCK(:memberId)";
private static final String EXCEPTION_MESSAGE = "LOCK 을 수행하는 중에 오류가 발생하였습니다.";
private final NamedParameterJdbcTemplate jdbcTemplate;
public LockRepositoryImpl(final NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
@Transactional
public <T> T executeWithLock(final String memberId, final Supplier<T> supplier) {
try {
final int lockNumber = getLock(memberId);
if (lockNumber == 1) {
return supplier.get();
}
throw new ConcurrentAccessException();
} finally {
releaseLock(memberId);
}
}
private int getLock(final String memberId) {
final Map<String, Object> params = new HashMap<>();
params.put("memberId", memberId);
return jdbcTemplate.queryForObject(GET_LOCK, params, Integer.class);
}
private void releaseLock(final String memberId) {
final Map<String, Object> params = new HashMap<>();
params.put("memberId", memberId);
final Integer result = jdbcTemplate.queryForObject(RELEASE_LOCK, params, Integer.class);
checkResult(result);
}
private void checkResult(final Integer result) {
if (result != 1) {
throw new RuntimeException(EXCEPTION_MESSAGE);
}
}
}
최종적인 Named Lock의 구현 모습이다.
executeWithLock메소드에 @Transaction어노테이션을 붙여 동일한 커넥션으로 동작하게한 후 동시성을 제어해야하는 내부로직을REQUIRES_NEW와 timeout을 설정해 데드락과 동시성을 제어할 수 있도록 설정해주었다.
이렇게 다중 서버환경에서 동시성을 제어할 수 있다!!
마치며
pessmistic lock을 사용하면 되지 Named Lock을 사용한 이유는
pessmistic lock은 하나의 레코드에 대한 lock을 건다.
그래서 pessmistic lock은 해당 레코드의 정보를 사용하는 로직은 동시 처리 능력이 떨어지만,
named lock은 문자열에 대해 lock을 획득하고 반납하기 때문에 때문에
그 해당 name으로 lock을 사용해서 처리하는 로직에만 영향을 준다.
결론적으로 전체적인 성능에 영향을 끼지는 pessmistic lock을 쓰는것이아닌 named lock으로 동시성을 제어하는게 더 적합하다고 판단했다.
이상!!
적용 코드
https://github.com/woowacourse-teams/2023-dong-gle/pull/582
[BE] Feat: 글, 카테고리 저장 로직에 named lock 적용 by ingpyo · Pull Request #582 · woowacourse-teams/2023-dong-gl
🛠️ Issue close #581 ✅ Tasks named lock 구현 writing, category의 named lock 적용 ⏰ Time Difference 6
github.com
'우아한 테크 코스(우테코) > 우테코 공부' 카테고리의 다른 글
Resilience4j를 사용해서 장애 내성 기르기 (0) | 2023.11.08 |
---|---|
HikariCP 커넥션 풀을 조정해서 TPS개선하기 (3) | 2023.11.08 |
동글 프로젝트 테스트 코드 개선 및 최적화 (7) | 2023.10.01 |
동글 프로젝트 쿼리 개선기 (0) | 2023.09.24 |
LinkedList로 글과 카테고리의 순서 구현했을 때 동시성 제어 (2) | 2023.09.16 |