지금 동시성 제어를 무려 나는 5가지를 통해 시도해봤다. 그리고 오늘은 그 결론을 내보고자 한다.
나는 현재 조회 수를 Redis에서 관리하여서 원자적 연산을 통해 동시성 제어를 하는 데에 성공했다. 그리고 해당 조회 수를 팀장님이 만드신 분산 락이 적용된 작업 스케줄러를 이용하여 반영하기로 했다.
분산 락과 레디스를 공부하기 위해 원래는 조회 수 관리를 Redis에서 진행하면서, 조회를 누를 때마다 레디스의 값이 집계 테이블에 업데이트 되는 정말 불필요한 연산을 했었다.
/**
* 채용공고 리다이렉트 동시성 제어를 위한 레디스 조회수 카운팅 메서드 입니다. viewCount 정보를 관리하는 레디스에서 조회수가 카운팅됩니다.
* 레디스와 분산 락이 적용됩니다.
*/
@Transactional
public String increaseViewCount(Long jobOpeningId) {
redisViewCountManager.increaseViewCount(jobOpeningId);
String result = updateViewCount(jobOpeningId);
return result;
}
/**
* 레디스에 있는 채용공고 조회수를 집계테이블로 업데이트 하는데 사용하는 메서드
* @param jobOpeningId
*/
public String updateViewCount(Long jobOpeningId) {
String lockKey = "JOB_OPENING_VIEW_COUNT_" + jobOpeningId;
int retryCount = 3; // 최대 3번 재시도
boolean success = false;
while (retryCount-- > 0) {
success = redisDistributedLockManager.tryLockAndRun(
lockKey,
10,
5,
TimeUnit.SECONDS,
() -> {
// 실제 처리할 비즈니스 로직
Long redisviewCount = redisViewCountManager.getViewCount(jobOpeningId);
JobOpeningViewCount jobOpeningViewCount = findByJobOpeningViewCountInJobOpeningId(jobOpeningId);
jobOpeningViewCount.increaseViewCount(redisviewCount);
jobOpeningViewCountRepository.save(jobOpeningViewCount);
});
if (success) {
// 락 획득에 성공한 케이스
log.info("JobOpeningId: {} 반영조회수: {} 락 획득 로직 실행 완료", jobOpeningId , redisViewCountManager.getViewCount(jobOpeningId));
return "success";
}
log.warn("JobOpeningId: {} 락 획득 실패", jobOpeningId);
try {
Thread.sleep(100);// 1초 대기
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
log.error("JobOpeningId: {} 조회수 동기화 최종 실패 (모든 재시도 실패)", jobOpeningId);
return "fail";
}
private JobOpeningViewCount findByJobOpeningViewCountInJobOpeningId(Long jobOpeningId) {
JobOpeningViewCount jobOpeningViewCount = jobOpeningViewCountRepository.findByJobOpeningId(
jobOpeningId)
.orElseGet(() -> {
JobOpening jobOpening = jobOpeningFindByService.findById(jobOpeningId);
return jobOpeningViewCountRepository.save(
JobOpeningViewCount.create(jobOpening));
});
return jobOpeningViewCount;
}
길고 길었던 코드, 이제는 싹 지워버리는거다. 그래서 조금 더 단촐하게 만든 후 그 동안 진행했던 것들과 비교하는 작업을 해 볼 예정이다. 그리고 동시성 문제에서 어째서 엘라스틱 서치를 안 쓰게 되었는지도 후술할 예정이다.
그리하여 쓸데없는 파일들은 그냥 따로 두고, 써야 할 파일들만 메인 메서드로 땡겨온 후에, redis의 id와 count 값을 아래와 같은 방법으로 추출해왔다.
/**
* 채용공고의 조회수를 전체 가져오는 메서드입니다.
* 동기화를 위한 메서드
* @return 현재 Redis에 저장된 조회수 전부
*/
public List<ViewCountResponseDto> findAllViewCount() {
Set<String> keys = redisTemplate.keys(VIEW_COUNT_KEY_PREFIX + "*"); // 모든 조회수 키 찾기
List<ViewCountResponseDto> result = new ArrayList<>();
for(String key : keys) {
Long jobOpeningId = Long.parseLong(key.replace(VIEW_COUNT_KEY_PREFIX, ""));
Long count = Long.valueOf(Objects.requireNonNull(redisTemplate.opsForValue().get(key)));
result.add(new ViewCountResponseDto(jobOpeningId, count));
}
return result;
}
이제 스케줄러를 고쳐서 viewCountReposiotory의 메서드가 아니라 위의 메서드를 가져와서 레디스 값을 반영해보기로 했다.
솔직히 스케줄러는 이미 쓰고 있던 것을 경로만 고치는 거여서 List 타입으로 리팩토링 한 거 말고는 고칠 게 별로 없었다.
@Override
@Transactional
public void handle(Map<String, Object> payload) {
List<ViewCountResponseDto> redisJobOpeningViewCountList = redisViewCountManager.findAllViewCount();
for (ViewCountResponseDto jobOpening : redisJobOpeningViewCountList) {
Long jobOpeningId = jobOpening.key();
Long viewCount = jobOpening.Value();
log.info("🔍 JobOpening ID: {}, ViewCount: {}", jobOpening, viewCount);
if (viewCount != null && viewCount > 0) {
jobOpeningRepository.updateViewCount(jobOpeningId, viewCount);
log.info("✅ JobOpening ID {}: ViewCount {} 적용 완료", jobOpeningId, viewCount);
}
}
log.info("조회수 동기화 완료");
}
이런 식으로 handle을 Override 해서 스케줄러로 쓰는 거다.
그리고 나는 이거 쓰려면 혹시 도커 써야해요? 라는 잘못된 질문을 하므로써 한 세월을 날려야 했다.
아래는 나의 뻘짓기록이다.
환경변수도 도커 타입으로 바꿔서 실행을 돌려보았다.
그런데,
롸? 에러가 왜 뜨지?
생각해보니 도커에 MySQL이니 레디스니 안 깔아서 그런 듯 하여, yml 파일 중에 적절한 친구 docker-compose.yml
이 있길래 docker-compose up -d
명령어를 써서 깔아봤다.
오, 이런 식으로 잘 되고 있다. 만약 yml 파일이 없었으면 하나하나 깔아야 해서 약간 귀찮았을텐데 뭔가 준비가 되어있는 우리 팀에게 감사를 느낀다.
음? MySQL은 왜 Starting이지?
에러메시지를 보면 Port를 이미 사용중이라고 뜨는데... 아, 맞다... 인텔리제이에서 MySQL을 연결시켜놨으니 당연히 사용 중이라고 뜨는 거였다. ㅋㅋㅋㅋㅋ
역시나 반짝이고 있었구나 ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ
음, 근데 여전히 같은 에러가 떠서 아무래도 강제로 해당 포트를 운행종료 시켜줘야 할 듯 하다...
윈도우 Power Shall에서는 port가 이미 사용되고 있다고 할 때는 아래의 명령으로 확인이 가능하다.
netstat -ano | findstr :확인할 포트번호
이렇게 되어있다면 이제 종료를 시켜주자.
taskkill /PID 종료시킬 ID번호 /F
이 명령을 쓰면 종료가 가능하다.
오케이, 이제 다시 진행해봐야겠다.
되었다! 미션 컴플리트~~
또 다시 안되는 게 확인되었다. 도커 쪽에 mySql이 실행되다 말던데 그게 이유일까?
도대체 이유를 알 수 없어서 찾아보니 에러에 대한 로그를 찾을 수 있는 법을 알게 되었다.
아, 이거 ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ
MySQL을 설정할 때 쓰는 아이디가 root라서 문제였던 거였다.
그리하여, root가 아닌 다른 이름을 작성해주니 제대로 실행이 되었다.
이럴 줄 알았으면 로컬에서부터 이름을 제대로 설정해둘걸, 하는 후회와 함께 ㅋㅋㅋ 제대로 실행해보았다.
현재 에러가 계속 나서 일단 팀장님에게 문의를 넣었다.
다른 설정들을 모두 잘 했으나, 프로그램이 바뀌고 build를 해주었냐는 말에 어머... 안했다는 걸 깨달았다.
빌드 시도 -> 도커로 켜기 위해 mySQL이 꺼져 있어서 안되는지라 net start MySQL80
명령으로 다시 켜줌
연결을 성공하고, 다시 실행 도전
이번에는 도커로 8080 포트를 쓰고 있어서 문제였다. 그것도 일단 꺼줘야겠다. ㅋㅋㅋㅋㅋ
이제 다시 이렇게 꺼줬고, 재실행!
test 제외하고 빌드하는 걸로 해서 드디어 빌드에 성공했다.
빌드를 성공한 후에도 뻘짓은 계속되었다.
빌드를 한 후에는 일부 연결이 끊기는 바람에 아예 다 끄고 재시작을 했다.
근데 재시작 하기까지가 괜히 도커 desktop을 안 꺼서 문제가 발생하거나 ㅋㅋㅋㅋㅋ 도커 이름 목록 우측 하단 점 3개 눌러서 전원버튼처럼 생긴걸 눌러서 꺼줘야하는데 그것도 놓치고... 이것저것 놓쳤는데 결국 우여곡절 끝에 제대로 다시 빌드파일 올리기를 시작한 거 같다.
도커가 익숙치 않아서 더 어려운 거 같다.
근데 알고보니 도커로 돌릴 게 아니라 그냥 dev로 돌리면 되는 거였다. 괜히 도커 물어봐서 팀장님은 "네? 다중 인스턴스일 때 스케줄러 돌리는 걸 물어보는건가?" 하고 이해한거고, 나는 "스케줄러 돌리는 거를 도커로 해야한다고!?" 라고 이해한 것이다.
결론, 좋은 의미로 도커를 공부했고, 나쁜 의미로 의사소통 능력을 더 키우자.
그리하여 테스트를 돌려본 결과, 만족스러운 결과물을 얻을 수 있었다.
드디어... ㅠㅠㅠ
또한 위와 같이 스케줄러를 볼 수 있게 redis insight를 사용하게 되었다.
조회 수 업데이트 -> 작업 스케줄러(RDB에 Redis의 조회 수 더하기, 더한 후 Redis 키 지우기) -> 채용공고에 반영
채용공고에 반영되면 위와 같이 viewCount를 Redis 쪽에서 조회를 해도 키가 없어져서 키가 없습니다라고 뜬다.
private final JobOpeningRepository jobOpeningRepository;
private final RedisViewCountManager redisViewCountManager;
@Override
public String getTaskType() {
return "syncJobOpeningViewCount";
}
@Override
@Transactional
public void handle(Map<String, Object> payload) {
List<ViewCountResponseDto> redisJobOpeningViewCountList = redisViewCountManager.findAllViewCount();
for (ViewCountResponseDto jobOpening : redisJobOpeningViewCountList) {
Long jobOpeningId = jobOpening.key();
Long viewCount = jobOpening.Value();
if (viewCount != null && viewCount > 0) {
jobOpeningRepository.updateViewCount(jobOpeningId, viewCount);
redisViewCountManager.resetViewCount(jobOpeningId);
}
}
log.info("조회수 동기화 완료");
}
@Override
public long getScheduleIntervalMillis() {
return 3600000L; // 1시간
// return 300000L; //5분
}
최종적으로 돌아가는 코드는 위와 같다.
주석도 달고, 테스트 코드도 만들어야하지만 일단은 이렇게 완성하는 걸로 개발은 마무리짓기로 했다.
드디어 끝났다 동시성제어!!
나는 처음에 낙관적 락을 하려고 했었다. 왜냐하면 비관적 락의 경우 정합성 유지는 되지만 비용이 너무 높아질 가능성이 높기 때문이었다.
업데이트 할 때마다 락이 걸려서 부하가 걸리고, 레이스 컨디션 상태가 될 가능성이 높은 것 등의 문제점이 많았기 때문에 조회 수 하나 가지고 비관적 락을 하는 게 맞는가? 의문으로 인해 낙관적 락을 적용하기로 해 본 것이다.
그러나, 낙관적 락은 의도적으로 충돌을 야기시켰을 때는 100개를 테스트 하면 10개가 나오는 심각하게 낮은 정합성을 보여주었다.
낙관적 락 관련 게시글은 여기를 눌러보세요.
그로 인해 나는 이대로 끝낼 수는 없다고 생각하여 비관적 락도 공부해보기로 했다.
다음은 채용공고 테이블에 직접적으로 비관적 락을 해봤었다. 비관적 락에서는 속도가 많이 느려졌고, 제일 큰 문제로 데드락 문제가 불거졌다.
만약에 조회를 할 때마다 채용공고 테이블이 업데이트 되어야 해서 비관적 락이 걸린다면, 관리자가 조회 수 하나 때문에 다른 채용공고 테이블의 정보를 업데이트하지 못하는 문제가 발생할 수 있었고, 락으로 인한 조회 속도 저하도 발생하고 있었다.
낙관적 락과 비관적 락에 대한 비교 관련 게시글은 여기를 눌러보세요.
그 다음은 집계테이블을 별도로 생성하고, 집계 테이블만 비관적 락을 걸어버리는 방법을 사용해봤다. 그리고 정기적으로 스케줄러를 돌게 하여 집계된 조회 수를 채용공고 테이블에 업데이트 되도록 한 것이다.
이 방법으로 비관적 락의 정합성은 가져가면서 채용공고 테이블은 락으로부터 자유로워졌다. 그래서 조회와 무관하게 관리자의 채용공고 정보 변경 등 타 트랜잭션의 접근이 가능하며, 조회속도 또한 락이 빠져서 빨라진 걸 알 수 있었다.
테이블 분리를 한 비관적 락에 대한 게시글은 여기를 눌러보세요
다음으로 나는 엘라스틱 서치로 동시성 제어를 해봤다. 이거 정말 흔하지 않은 방법이고, 신기한 방법이었다.
먼저 키바나로 이벤트에 대한 탬플릿을 만들어준다. -> 인텔리제이에서 Post를 날려서 매번 조회할 때마다 새로운 Document를 생성해준다 -> 집계를 사용하여 Document의 갯수를 집계하여 인텔리제이에 반환해준다.
근데 이 방법을 사용했을 때는 주의할 점이 두 가지 있었다.
첫번째는 엘라스틱 서치를 사용하여 Document를 매 번 생성하는 것이 과연 레디스보다 빠른가? 였다. 애초에 나는 처음엔 레디스를 하려고 했으나, 다른 팀원들과 이야기한 결과 조회 수 동기화 문제 등을 이유로 엘라스틱 서치를 사용하라고 권유를 받았었다. 그래서 일단 엘라스틱 서치를 사용해보긴 한건데 구현을 하고 보니 엘라스틱 서치가 레디스 적용을 했을 때랑 비교했을 때 더 빠른거 같진 않았기 때문이다. 사실 5번 레디스를 먼저 적용해봤기 때문에 나는 그런 판단을 내릴 수 있었다.
두번째는 이미 인덱싱이 적용된 엘라스틱 서치에 조회 수를 어떻게 반영하는가였다. 내 머리로는 그냥 기존 코드를 변경해서 조회 수를 내가 구현한 메서드 쪽에서 가져오는 방법 정도 밖에는 떠오르지 않았는데, 키바나 쪽에서 인덱싱 정보를 업데이트 해 줄 수 있지 않을까 하는 이야기를 다른 팀원에게 들었다.
그래서 그 부분을 열심히 알아봤지만 결국 엘라스틱 서치는 Post와 Put이나 Petch 모두 Delete 후에 다시 Post하는 환경이라는 이야기만 알 수 있었다. 나는 이쯤 되어서 굳이 왜 동시성 제어를 엘라스틱 서치로 해야하는지 납득할 수 없었다. 엘라스틱 서치에서 기존 인덱싱에 반영하려면 결국 다른 분의 코드를 많이 바꾸어야 하거나, 또는 비효율적으로 Put이나 Petch를 써서 Delete-Post 해야한다는 것만 알게 된 것이다.
이게 왜 엘라스틱 서치가 되어야 하느냐 하면 조회 수 뿐만 아니라 채용공고 정보 등을 엘라스틱 서치에 빠르게 동기화할 수 있는 방법이었기 때문이다.
하지만 나의 경우 동시성 하나만을 관리하기 위해서 엘라스틱 서치까지 끌어들여서 이것저것 손보기에는 엘라스틱 서치로 Document 하나하나를 Post로 날려서 조회 수를 관리하는 시스템 자체가 과연 맞는가? 조회 수를 가져올 때마다 집계를 해야하는데 이게 효율적인 게 맞는가??? 하는 내면의 고민을 계속하게 된 것이다. 이러한 방식으로 관리하는 경우는 불운하게도 구글링에서도 찾아보지 못했고, 튜터님에게 제시 받은 방식이었지만 나는 타당성을 동기화 빼고는 모르겠어서 결국 이 방식을 배제하기로 했다.
그리고 팀원들과 의견을 나눈 끝에 협의하여 조회 수는 엘라스틱 서치가 아닌 레디스로 관리하는 쪽으로 이야기를 할 수 있었다.
개발을 하면서 팀원 간 의견이 다를 때 어떻게 해야할 지, 설득을 어떻게 더 잘해야하는지 체감할 수 있는 사건(?) 이라면 사건이다
엘라스틱 서치로 동시성 제어를 하는 것에 대한 게시글을 보려면 여기를 눌러주세요.
마지막 나의 선택은 레디스와 분산 락이었다.
레디스는 원자성을 보장해주는 연산을 하기 때문에 레디스에서 조회 수를 집계하면 부하 테스트를 돌린다고 해도 정합성이 보장이 된다.
그리고 조회 수를 반영하기 위해서 원래는 분산 락을 작성했었는데 이 부분도 그냥 작업 스케줄러를 돌리는 것으로 해결했다.
처음에는 레디스에서 집계테이블, 집계테이블에서 다시 채용공고 테이블까지 2중으로 조회 수를 반영시켰고, 그 과정에서 집계테이블과 채용공고는 스케줄러가 돌아갔었다. 하지만 스케줄러가 돌아가는 게 다중 인스턴스 환경에서는 겹칠 수 있기 때문에 어지간해서는 스케줄러를 없애야 한다고 생각했다. (물론 나중에 팀장님과 이야기해보니 없애란 소리는 아니었다고 했다.)
아무튼 그리하여 처음에는 레디스 -> 집계테이블 반영을 실시간으로 하면서 분산 락 공부를 할 겸 분산 락을 걸었는데, 팀장님이 너무 예쁜 작업 스케줄러를 만들어오는 바람에 굳이 내가 매번 집계테이블에서 분산 락을 걸 필요성을 못 느끼게 되었다.
솔직히 나도 분산 락을 공부할 목적으로 1차 저렇게 구현한 뒤에 스프링 배치나 스케줄러 등을 사용해서 정기적으로 반영되게끔 리팩토링 하려고 했으나, 리팩토링 할 무렵이 되어보니 이미 쓸만한 스케줄러를 남이 만들어놓은 상태라서 "감사합니다." 하고 반영하게 되었다.
코드를 쓸 때는 내가 스스로 개발하는 것도 중요하지만, 남이 만든 걸 잘 활용하는 것도 중요하다는 생각이 들었다. 그리고 나도 남들이 사용하기에 쓸모있는 코드를 짤 수 있도록 노력해야겠다는 생각을 할 수 있었다.
우리의 코드는 이제 개발하는 부분은 완료가 되었다고 볼 수 있고, 이제는 최종 발표까지 불과 1주를 남기고 있다. 이제 일주일간은 리팩토링과 발표준비, 테스트 코드 등을 추가할 시간이다.