다들 이미 익숙해져버린 에러 메시지들이 있을것이다. 특정 타이밍에 온다던가, 같은 API에서 이따금씩 한번만 발생한다던가 등등의 사유로 무감각해지는 에러들이 있다. (그러면 안되지만 🥲)
요청 빈도가 높은 조회 API중 하나가 일주일에 한두번 정도 500 에러를 내뱉는 경우가 있었다.
처음엔 부랴부랴 확인했지만, 이후 들어오는 동일 요청에 대해 (파라미터까지 일치하는) 정상 응답을 보내고 있어, 순단 현상인가..? 생각하며 그 유심히 살펴보지 않았다.
어느날 빈도도 높지 않은 조회 API에서 갑자기 500에러 얼럿이 온 후, 또 이후 요청에서 정상 응답을 보내는 같은 양상을 보이기에, 한번 유심히 들여다보기로 했다.
아래와 같은 순서로 알아봤다.
1. 에러 메시지
2. 의문점
3. 상세 구현체 훑어보기
4. 결론
System.ArgumentException: Destination array was not long enough. Check destIndex and length, and the array's lower bounds.
at System.Array.Copy(Array sourceArray, Int32 sourceIndex, Array destinationArray, Int32 destinationIndex, Int32 length, Boolean reliable)
at System.Collections.Generic.HashSet1.SetCapacity(Int32 newSize, Boolean forceNewHashCodes)
at System.Collections.Generic.HashSet1.AddIfNotPresent(T value)
at SGS.API.Domain.Shop.Implementations.ShopV2IndieService.<>c__DisplayClass49_3.<<_IndieCartList>b__8>d.MoveNext()
--- End of stack trace from previous location where exception was thrown ---
at ... ...
일단 가장 먼저 눈에 들어온 부분은 "array was not long enough" 이 부분이었다.
하지만, 에러 메시지에도 있다싶이 조금 더 살펴보니 "HashSet에서 발생한 이슈인데 왜... Array..?" 라는 생각이 들었다.
평소 발생하던 조회 빈도가 높은 API 500에러도 같은 에러 메시지를 갖되, Dictionary 사용시 발생했던 이슈다.
정리하면, HashSet, Dictionary 둘 다 Hash값을 기준으로 값을 관리하는 Collection에서 Array가 충분하지 않다는 이유로 발생한 500에러였다.
해서 더 자주 발생했던 이슈인 Dictionary 기준으로 C#에서 어떻게 구현되어있는지 살펴봤다.
System.ArgumentException: Destination array was not long enough. Check destIndex and length, and the array's lower bounds.
at System.Array.Copy(Array sourceArray, Int32 sourceIndex, Array destinationArray, Int32 destinationIndex, Int32 length, Boolean reliable)
at System.Collections.Generic.Dictionary2.Resize(Int32 newSize, Boolean forceNewHashCodes)
at System.Collections.Generic.Dictionary2.Insert(TKey key, TValue value, Boolean add)
에러 메시지를 살펴보면, Dict.Insert > Dict.Resize > Array.Copy 를 진행하던 중 정상 동작하지 못했으므로
Dictionary에 값이 추가될 때, 내부 구현체에서는, Entry[]? entries = _entries
로 Key와 Value를 관리하고 있으며, Entry[] 배열 내 비어있는 공간이 없으면 Resize()를 수행하게 된다.
아래는 Resize의 구현체다.
Entry[] 배열을 새로운 사이즈로 갱신하며, Arrays.copy 부분을 통해 배열을 복제한다.
하지만! 바로 위 int count = _count
부분에서, _count는 같은 Dictionary에서 공유되는 자원으로서, 여러개의 스레드에서 동시에 _count를 증가시키는 경우가 발생한다.
따라서, Array.Copy에서는 복사할 대상과 및 옮겨질 대상의 길이와, 넘겨주는 길이의 값이 달라지면서 정상적인 복사가 이루어지지 않게 되고,
위 Array.Copy 코드 내부에서 정상적으로 조건문을 만족시키지 못하여
CopyImpl 메서드를 수행하게 되면서 내부 로직에 의해 Lower bound 관련 에러메시지가 노출되게 되었다.
해당 이슈는, 비동기 반복문을 순회하는 과정에서, 여러 스레드가 하나의 Collection에 접근하면서 발생한 이슈였다.
이를 ConcurrenyDictionary로 해결하였으며, 해결 이후 간헐적으로 나타나던 500에러 이슈는 해소할수 있었다.
간단히 ConcurrentDictionay는 어떻게 구성되어있는지 확인해보면,
ConcurrenyDictionary는 내부적으로 Tables라는 sealed class를 통해 Lock과 Node 등을 관리하게 된다. (Node 하위에 Key, Value값 존재)
해서, Lock을 통해서 여러 스레드의 접근을 방지하고, Resize 대신 growTable 함수를 통해 수행시 또한, Lock을 활용하여 Thread Safe하게 동작할 수 있도록 구현되어 있었다.
추가적으로 현재 IIS 설정에서 주기적으로 Applicaiton Pool 재시작을 수행하는데, 이때 서버에서 사용하는 Memory Cache(Dictionary로 관리)가 전부 초기화 되며, 조회 시 다량의 내용이 Memory Cache에 Update되면서 발생한 이슈였다.
직접 IIS풀 초기화 누르면서 테스트해보다가 이거다..! 싶었을 때 조금 대견했다.
명확하게 이해하고 싶다는 마음에서 출발한 여정이 그래도 확실한 해답을 나름 깔끔하게 얻어서 경장히 기분 좋았다.