티스토리 뷰

본격적인 이슈를 소개하기에 앞서 기능하나를 소개하려고 한다.

사용자 스토리 : 사용자는 관심있는 주식을 관심항목으로 등록할 수 있다.

 

간단한 기능이다. 이를 구현하기 위해 서버에서는 interest_asset 테이블을 설계했다.

 

account_id 가 설정한 관심자산 목록은 콤마(,) 로 이어져서 데이터베이스에 저장한다.

 

예를들면 A 사용자가 52번, 418번, 3번, 17번 자산을 관심자산으로 등록하면 "52,418,3,17" 이런식으로 저장된다고 생각하면 된다. (각각의 데이터를 ROW 로 빼지 않고 이렇게 문자열 형태로 저장한 이유에 대해서는 해당 포스팅에서 소개하진 않는다! 이 포스팅에서 중요한건 그게 아니기 때문에 우선 넘어가자!)

 

관심자산 추가 API 의 로직은 이렇다.

 

account_id 의 관심자산이 존재하는지 interest_asset 테이블에 먼저 조회하고, 

 

  1. 이미 등록된 관심자산이 존재한다면 기존 interest_asset 에 추가하고 UPDATE 한다.
  2. 처음 관심자산을 등록하는 사용자라면, 새로운 interest_asset 에 관심자산을 추가하고 INSERT 한다.

 

따닥(연속) 요청으로 인한 이슈 발생


따닥(연속) 요청이란?

동일한 API 가 거의 동시에 연속으로 호출되는 상황을 말한다.
이런 현상이 발생하는 이유는 휴대폰에서 사용자가 터치했을 때 사용자도 모르게 2번 눌리거나 사용자가 의도적으로 빠르게 버튼을 누르는 등의 이유가 있을 수 있다. 이런일이 있을까 싶지만 서비스를 운영하면서 많은 부분에서 이런일이 발생했다... ㅜ_ㅜ

 

query did not return a unique result: 2

서비스를 오픈하고 어느날 위와 같은 에러가 올라왔다!!

발생한 에러에 대해서 서칭을 해보니 Spring Data Repository 에 작성한 메소드의 반환값을 단건 Entity 로 설정했는데 실제 DB 에서 조회된 데이터의 수가 여러 개일 경우 발생하는 에러라는 것을 알 수 있었다.

 

로직에 따르면 account_id 에 대한 interest_asset 는 1건만 존재해야 하는데 그 이상이 존재해 발생하는 이슈였다.

 

그럼 왜 이런 상황이 발생했는지 이유를 알아보자.

 

위에서 잠깐 소개했지만 관심자산을 추가할 때 이미 해당 사용자의 interest_asset 이 존재하면 새로 만들지 않고 기존 Entity 에 추가해 UPDATE 한다. 처음 관심자산을 등록하는 상황이라면 새로운 interest_asset 을 생성해 INSERT 하게 된다.

 

즉, 한 명의 사용자가 2번 관심자산을 등록하면 첫 번째 요청에서는 INSERT, 두 번째 요청에서는 UPDATE 가 발생해야 한다. 하지만 한 명의 사용자가 2번 관심자산을 등록했을 때 두번 모두 INSERT 가 발생했다.

 

그 이유가 바로 따닥(연속) 요청이다.

 

첫 번째 요청이 새로운 interest_asset Entity 를 생성해 추가하고 save 메소드를 호출하기 전에 두 번째 요청이 interestAssetRepository.findByAccountId() 메소드를 호출한다면 당연히 아직 DB 에는 해당 사용자의 interest_asset 이 존재하지 않고 결과적으로 두 번째 요청도 새로운 interest_asset Entity 를 생성하게 되는 것이다.

 

그 결과 두 번의 요청이 끝나고 DB 에는 동일한 사용자의 interest_asset 이 2개 존재하게 된다.

 

Redis Lock 을 이용한 이슈 해결


나는 위에서 발생한 이슈를 Redis Lock 을 이용해 해결했다.

해당 포스팅에서는 구체적인 해결방법에 대해서는 논하지 않을 것이다. Redis Lock 에 대한 내용은 아래 포스팅에서 확인할 수 있다.

https://wiz-banmincho.tistory.com/7

 

다중 인스턴스 환경에서 동시성 문제와 분산락 (Feat. Redis)

분산락을 고려해야하는 상황은 무엇일까? Scale-out 을 통해 서버를 여러대로 확장한 상황에서 하나의 데이터에 대한 동시성 문제를 해결하기 위해 사용한다. 내가 동시성 문제를 겪은 상황은 API

wiz-banmincho.tistory.com

 

간략하게 설명하면 Redis 는 기본적으로 싱글 스레드로 동작하기 때문에 동시에 여러 개의 요청이 발생하더라도 순서대로 동작하게끔 만들 수 있다. 첫 번째 요청이 save 까지 끝낸 후에야 두 번째 요청이 find 할 수 있도록 만드는 것이다. 이렇게되면 위에서 말한 이슈는 더 이상 발생하지 않는다.

 

우선 Redis Lock 을 적용함에 따라 상황은 일단락됐다.

 

다시 발생한 에러


"Redis Lock 을 적용했으니 이제 이 코드는 안전해!!" 라고 방심할때쯤 동일한 이슈가 발생했다.

 

처음에는 이해가 되지 않았다. Redis Lock 이 내가 의도한대로 동작하지 않나..? 사실 Redis 는 싱글 스레드가 아니었던걸까..? 이런 고민들이 이어졌다. 게다가 해당 에러는 굉장히 간헐적으로 발생했다.

 

이내 마음을 추스리고 다시 코드를 보니 원인을 찾을 수 있었다.

그 사이에 여러 요구사항에 의해 관심자산을 추가하는 로직에 여러 코드들이 추가됐다. 변화한 addInterestAsset 메소드를 한번 보자.

 

이것저것 추가된 addInterestAsset

여러가지 기능이 추가되었지만 대표적으로 "관심자산을 등록하면 해당 자산에 대한 알림을 기본적으로 받도록 설정" 하라는 요구사항이 적용된 코드이다.

 

기존에 Redis Lock 을 거는 로직은 DomainService 에게 위임됐다.

그러면 왜 다시 따닥(연속) 요청으로 인한 에러가 발생한 걸까? (사실 너무 기본적인 이유라 조금 창피하다 🥺)

 

 

범인은 바로 트랜잭션이었다.

 

거의 동시에 두 개의 관심자산 추가 요청이 들어왔고, 이를 각각 A 요청, B 요청이라고 해보자.

 

A 요청이 Lock 을 획득하고 find-save (addIfAbsent) 를 수행한다. 당연히 처음 관심자산을 추가하는 단계이기 때문에 find 의 결과는 없고, 새로운 interest_asset Entity 를 생성해 INSERT 하게된다. 그리고나서 A 요청이 Lock 을 돌려놓는다.

 

그러면 뒤이어 들어온 B 요청이 Lock 을 획득하고 find-save 를 수행한다.

이때 find 의 결과가 존재한다면 해당 결과에 관심자산을 추가해 UPDATE 할 것이고, 존재하지 않는다면 새로만들어 INSERT 할것이다.

 

A 요청이 앞서 INSERT 를 했기때문에 B 요청이 호출한 find 메소드의 결과가 존재할 것이라 기대한다.

하지만 과연 B 요청이 호출한 find 메소드의 결과가 존재한다고 확신할 수 있을까?

 

확신할 수 없다!! 이 부분을 간과한게 화근이었다... :(

(핑계를 대보자면 기존에 Redis Lock 에 의해 잘 동작하던 코드였고 단지 리팩토링이었기 때문에 가볍게 생각했다.. 다시한번 테스트 코드의 중요성을 깨닫게되는 경험이었다..)

 

A 요청에 대한 트랜잭션은 아직 끝나지 않았다.

interestAssetDomainService.add() 를 한 후에도 알람을 추가하고 이것저것 요구사항을 위한 비즈니스 로직을 수행해야한다.

 

트랜잭션이 끝나지 않았는데 INSERT 쿼리가 실제 DB 에 반영되었을리 없다.

그럼 당연히 B 요청이 find 해도 결과는 나오지 않는다 -.-

그 결과 해당 account_id 에 대한 interest_asset 2개의 ROW 가 INSERT 되는 것이다.

 

해결방법


이에 대한 해결방법은 크게 두 가지 가닥일 것 같다.

 

  1. Lock 의 범위를 넓혀 트랜잭션이 Commit 되는 시점까지 A 요청이 소유한 Lock 을 놓지 않도록 한다.
  2. 트랜잭션 범위를 조절한다.

핵심은 따닥(연속) 요청의 첫 번째 요청이 Lock 을 반납하기 전에 트랜잭션 커밋이 발생하도록 해야한다는 것이다.

 

1번 방법은 쉽고 단순하지만 Lock 의 범위가 넓어짐에 따라서 사용자에게 불편함을 제공할 수 있다는 문제가 있다. Lock 을 획득하지 못한 B 요청이 실패한다면 사용자에게 에러가 내려갈 것이고, Lock 을 획득할 때까지 기다려야한다면 서비스가 느리다고 인식될 수 있다. 1번 방법과 같이 Lock 의 범위를 넓힐거라면 그에 대한 영향도 파악을 반드시 해야한다.

 

2번 방법은 여러 가지 방향으로 나눠질 수 있을 것 같다.

 

우선 정말 트랜잭션이 필요한 상황인지를 먼저 고민해봐야한다.

"알람 등록하다가 실패했다고 잘 등록된 관심자산까지 롤백되어야할까?" 와 같은 고민이다.

나는 이러한 고민에서 NO 가 나와서 관심자산을 등록하는 트랜잭션을 격리했다.

어떻게 격리했는지는 아래 포스팅을 통해 @Transactional Propagation 을 공부해보자!

https://wiz-banmincho.tistory.com/8

 

[Spring] @Transactional 파보기 (Feat. Transaction Propagation 활용 사례소개)

사실 @Transactional 은 Spring Boot 로 진행한 첫 프로젝트부터 지금까지 정말 많이 써온 어노테이션이다. 지금까지 사용하면서 이해하고 있었던 바를 먼저 정리해보면 아래와 같다. 1. DB 에 접근하는

wiz-banmincho.tistory.com

 

결과적으로 이런 코드가 되었다!!

트랜잭션이 격리됐기 때문에 Lock 을 반납함과 동시에 해당 트랜잭션이 Commit 된다.

 

반드시 하나의 트랜잭션으로 묶여야하는 상황에는 어떻게 해야할까..?

 

1번 방법을 이용해 Lock 의 범위를 늘리거나 Lock 을 반납하는데 약간의 딜레이를 주는 방법 등이 떠오르지만 아직 명확한 방법을 찾진 못했다! (좋은 아이디어가 있다면 댓글 부탁드립니다 🙏🏼🙏🏼🙏🏼)

 

간헐적으로 발생하는 이 이슈는 Lock 을 반납하고 처리되는 로직의 시간이 길어지면 길어질수록 발생할 확률이 높아진다.

즉, 하나의 트랜잭션에서 너무 많은 일을 처리하고 있다는 것인데 해당 작업들이 반드시 하나의 트랜잭션에서 처리해야하는 일들인지 다시 한번 돌아보는게 좋다고 생각한다.

 

결론 및 회고


  1. 따닥(연속) 요청에 의해서 find-save (addIfAbsent) 구조에서 2개의 데이터가 INSERT 되는 이슈가 발생.
  2. 이를 해결하기 위해 find-save 로직에 Redis Lock 적용.
  3. 첫 번째 요청이 Lock 을 반납하고도 트랜잭션을 종료하지 않는다면, 두 번째 요청이 Lock 을 잡고 find 하는 시점에 DB 에 데이터가 없을 수 있다. → 그로인해 2개의 데이터가 INSERT 이슈가 발생.
  4. 트랜잭션 범위 혹은 Lock 의 범위를 조절해 첫 번째 요청이 Lock 을 반납하기 전에 트랜잭션 커밋이 일어나도록 해 해결.

 

최근 겪은 이슈중에 제일 기본적인 이유를 간과해 생긴 이슈였던것 같다.

늘 기본에 충실하자 충실하자 하지만.. 아직 갈 길이 먼것 같다 ㅠ_ㅠ

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/09   »
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
29 30
글 보관함