아직도 수행중인 이 배치는 처음부터 느리진 않았다

햄도·2021년 8월 2일
1

사실 원래 느린 배치이긴 했다.

그래도 하루 단위 배치이고 처리해야 하는 데이터 양이 작을 때에는 괜찮았다. 데이터 양이 급속도로 늘어났다고는 하지만 고작해야 몇백만건을 처리하는 배치의 수행시간이 하루를 넘어가기 시작했다.

처음에는 기술적으로 해결해야 하는 문제라고 생각했다. 우리 로직에는 실수가 없고, 뭔가 멋들어진 이름의 기술들을 사용하거나 코드레벨에서 최적화를 해 금방 고칠 수 있다고 생각했다. 여기에서부터 완전히 잘못 생각했다.

성능은 금방 좋아지지 않았고, 배치의 수행시간은 이틀도 넘겼다. (놀랍게도 몇십시간동안 수행된 후 끝나긴 끝났다. 느리지만 그래도 끝까지 맡은 바를 다 하는 모습을 보며 감동마저 느꼈다🥺)

해결을 위해 아래와 같은 방법들을 시도했다.

프로파일링

원인 파악을 위해, cProfile을 이용해 QA 환경에서 배치를 수행하며 프로파일링을 해보았다.

하지만 눈에 띄게 느린 부분은 없었다.

이 때 왜 운영에서는 느린데 QA에서는 느리지 않은건지 더 파고들었다면 더 빨리 문제를 해결할 수 있었을텐데..

코드 레벨 개선

일단 프로파일링은 했으니 프로파일링 누적수행시간 상위에 있는 메소드들부터 코드레벨에서 개선을 해봤다.

검색해서 찾은 자료도 참고했는데.. 그닥 빨라지지 않았다🐢

쿼리 검수

프로파일링 결과 상위에는 쿼리 수행도 있었다.

검수를 빠뜨린 쿼리들도 검수를 받았고, 몇 쿼리들이 빨라지긴 했지만 이것도 배치를 느리게 만드는 원인은 아니었다.

쿼리 수행 모니터링

lock이 잡히는 등의 이유로 오래 걸린 쿼리가 있는지 DBA에게 모니터링 로그를 요청해 확인했다.

딱히 오래 걸린 쿼리도 없었고 lock이 잡혔다고 해도 금방 끝났다.

DB의 isolation level에 대해서도 이 부분을 확인하며 배우게 되었는데, 우리가 사용하는 DB는 postgres의 기본 설정인 read commited 를 사용하고 있어 엄격하게 lock을 잡을만한 조건은 아니었다.

여러 프로세스가 수행되며 DB에 접근해 데이터를 바꾸는 구조이기 때문에 로그에는 잡히지 않은 채 lock이 걸렸을 수도 있다고 생각했지만, 한 프로세스만 수행될 때에도 여전히 느렸기때문에 lock의 문제는 아닌것같았다.

select문 줄이기

배치 수행 중 수백만번 반복되는 for loop 안에서 한 건씩 select를 하는 로직이 있었는데, 이것을 모두 한 번에 조회하도록 수정했다.

수행시간이 줄긴 했지만.. 여전히 오래 걸리는 로직은 하루가 넘게 수행되었다

bulk upsert

배치 수행 중 for loop 안에서 한 건씩 upsert하는 로직도 있었다. 이 부분도 배치의 수행시간에서 꽤 많은 부분을 차지하고 있었기 때문에, bulk로 처리하는 방법이 없는지 찾아보았다.

찾아본 결과 다음과 같이 postgresql의 unnest 함수를 이용해 처리하는 예시들이 있었다.

나름 신중하게 DBA와 같이 QA에서 테스트 및 DB 부하 모니터링을 한 후, 운영에 반영했는데 문제가 생겼다.

운영 → QA 데이터 동기화가 번거로워 자주 하지 않았고, 때문에 운영 환경에서 처리하는 데이터의 양과 QA에서 처리하는 데이터 양이 20배정도 차이가 났다.

물론 이 점도 고려해서 테스트 및 모니터링을 진행했고 괜찮다고 판단해 운영에 올렸는데...

운영 환경에서 한 트랜잭션에 10만건씩 반복적으로 upsert를 하니 dead tuple이 너무 많이 생겨 테이블 사이즈가 기하급수적으로 커졌다. 덕분에 새벽부터 운영 DB가 터질뻔했다.

조금 긴 토막 지식 🧐

  • postgres는 데이터를 삭제하거나 업데이트할 때 해당 row의 내용을 실제로 바꾸는 게 아니라, 새로운 row을 추가해 이전 버전 row를 필요로 하는 트랜잭션에서 기존 row를 참조할 수 있도록 한다.
  • 이 때 발생하는 이전 버전의 row를 dead tuple💀이라 하고, 이게 많아지면 테이블의 크기가 더 커지기 때문에 조회가 더 느려질 수 있다.
  • dead tuple은 autovacuum이나 수동 vacuum을 통해 삭제할 수 있는데, autovacuum은 해당 테이블에 대해 일정횟수 이상의 트랜잭션이 발생하면 수행된다고 한다. 각종 설정값도 바꿀 수 있는 모양인데 여기에 대해서는 깊이 알아보지 않았다.
  • 수동 vacuum에도 vacuum과 vacuum full이 있다. vacuum full은 새로운 테이블을 만들어 기존 테이블을 통째로 옮기는 방식이기 때문에 느리고 테이블 전체에 lock을 건다.
  • vacuum 또한 vacuum full만큼은 아니지만 디스크 입출력 부하를 만들어 다른 세션을 느리게 만들 수 있기 때문에 둘 다 웬만하면 DBA에게 요청하는 것이 좋을 것 같다.
  • 참고: 정기적인 vacuum 작업

이 과정에서 postgres의 vacuum 매커니즘에 대해서도 배울 수 있었다..

이 부분은 급한 개선포인트가 아니라서 추후 조금씩 upsert를 하거나, text 파일에 저장하여 COPY 하는 방식으로 개선하기로 하고 보류했다.

dead tuple 때문인가?

테스트 중 QA에서 100만건정도를 일괄 업데이트하고 select를 수행하니 멀쩡하던 쿼리가 매우 느려지는 현상을 발견했다.

해당 테이블의 dead tuple을 확인하니 테이블의 전체 live tuple 크기보다 컸고, 양해를 구하고 해당 테이블을 사용하는 세션을 모두 죽인 다음 vacuum을 돌리니 다시 조회가 빨라졌다. (vacuum이 부하를 발생시킨다는 것은 이후에 알았다. DBA님 죄송합니다🙇‍♀️)

특히 느린 부분이 select-update/insert를 몇백만건에 대해 계속 수행하는 로직이라서 dead tuple이 쌓여 느려질 수 있겠다는 생각이 들었다.

하지만 아니었다.

이때까지 신나게 삽질을 하고 있었는데.. 보다못한 팀장님이 운영 환경에서 로그를 찍으며 구간별로 수행시간을 체크하라는 조언을 해주셨다.

(이걸 왜 안해봤는지 모르겠다.. 뭔가 운영환경 로그는 깨끗하게 유지해야 한다는.. 디버깅용 로그는 찍으면 안된다는 강박이 있었던것같다)

그리고 전혀 예상치 못한 병목이 발견됐다.

hnsw index

문자열을 임베딩하고, 유사 벡터를 검색하기 위해 hnsw index를 사용하는 부분이 있었다.

로그를 찍어보니 이 인덱스에서 문자열을 임베딩하고, 벡터를 검색하는 부분이 다른 sql 실행같은 부분보다 10~100배쯤 오래 걸렸다. (건당 0.2초 수준)

어차피 인덱싱을 하는 로직이 분리되어있고, 임베딩은 인덱싱할때 같이 하니 문제가 되지 않았다. 일단 필요한 문자열을 미리 임베딩하고, 인덱싱에도 포함해 빠르게 검색되도록 수정했다.

이렇게만 해도 임베딩하는 시간이 줄고, 이미 인덱싱된 벡터 검색은 더 빠르니 훨씬 빨라질거라 기대했는데 그렇게 빨라지지 않았다.

다시 확인해보니 벡터 검색이 너무 오래 걸렸다. 같은 로직을 사용하는 다른 곳(A라 하자)에서는 2만건을 5초 안에 처리하는 반면, 문제가 되는 배치에서는 10000건 처리에 30초나 걸렸다.

한 가지 다른 점은, A에서는 hnswlib에서 제공하는 knn_search() 메소드를 그대로 사용하고, 문제의 배치에서는 knn_search() 를 한번 래핑하여 몇 가지 로직을 더해 사용하고 있었다.

결국 timeit으로 이 메소드의 의심되는 로직을 하나씩 주석처리해가며 테스트하고 나서야 문제를 발견할 수 있었다🤦‍♀️

len(hnsw_index.get_ids_list())

위와 같이 인덱스에 등록된 전체 element의 id를 가져오는 get_ids_list()를 호출한 후 len()을 구하는게 문제였다.

QA에서 테스트할땐 인덱스의 크기가 작아서 당연히 프로파일링에서 걸리지 않았을거다.

인덱스의 크기가 커지니 get_ids_list() 호출 자체도 오래 걸렸고, 가뜩이나 오래 걸리는 부분이 수백만 번 호출되니 느려지는 게 당연한 거였다.

다시 hnswlib 문서를 확인하니 get_current_count()라는 좋은 메소드가 있었다. 이걸로 바꾸니 만 건 수행에 30초 걸리던 로직이 1초 이내에 수행됐다.

문제의 원인을 찾긴 했지만.. 너무 허무하다.

결론/느낀점

🔮 DB는 오랜 시간동안 최적화되어왔기 때문에 생각보다 성능이 좋다. 하지만 DB마다 동작 매커니즘이 꽤 다르고, 내부 매커니즘을 잘 모르고 사용하면 엄청나게 성능이 나빠질수도 있다.

🦢 프로파일링같은 우아한 방법으로 성능개선 포인트를 찾으면 좋겠지만.. 그게 안될땐 그냥 운영에서 수행시 로그를 찍어보는것도 나쁘지 않다. QA의 데이터 규모를 운영과 비슷하게 유지하는 방법도 괜찮지 않을까 싶다.

🙅‍♀️ 매우 큰 리스트를 불러오는 메소드를 호출하고 len()을 구하지 말자. 반복적으로 구하는 것은.. 더더욱 금물이다.

✨ 오픈소스도 생각보다 성능이 좋다. 스타가 많이 붙은 오픈소스는 내가 직접 개발한 것보다 좋을 가능성이 더 크다. 문서를 꼼꼼히 읽고 최대한 해당 오픈소스에서 제공하는 메소드를 사용하자.

🚿 성능 프로파일링을 할 때에도 클린 코드가 중요한 것 같다. 메소드가 잘 분리되어 있고 의존성이 적어야 cProfile이나 timeit을 이용해 느린 부분을 메소드 단위로 확인하고, 가설을 세운 후 수정해 결과를 보는 프로세스를 빠르게 돌려볼 수 있다.

🌆 물론 이런 툴들을 사용하지 않고도 성능을 개선할 수 있지만, 그러면 배치 전체를 통으로 돌려가면서 테스트를 해봐야 할것 같은데.. 이 짓은 끔찍하게 오래 걸린다. 해봐서 안다.

profile
developer hamdoe

0개의 댓글