집요하게 파고드는 것은 새로운 기술을 알게 되는 통로가 되기도 하고 기존 기술에 대한 이해도를 높이는 원동력이 되기도 한다. 집요하게 파다 보면 논리력을 키우며 해결책을 찾아내는 데 매우 큰 도움을 준다. 끈질김, 고집, 열정이라고도 표현할 수 있겠다. 앞 시간에 다룬 호기심이 집요함의 출발점이다.
집요함을 발휘할 만한 포인트들을 살펴보면 (숫자는 편의를 위한 것일 뿐, 우선순위와 상관없음)
1. 새로 접한 기술이 어렴풋이 이해는 되는데 명확하진 않을 때
2. 구조적으로 개선하고 싶은데 아이디어가 잘 떠오르지 않을 때
3. 한 끗만 더 개선하면 더 나은 품질의 제품(product)을 만들 수 있을 것 같을 때
4. 되긴 되는데 이게 최선인 건가 싶을 때
5. 안되는데 왜 안되는지 모르겠을 때
6. 서비스에서 이따금 이상 현상이 발생하지만 간헐적으로 발생할 때
등등이 있다.
위 6가지 경우는 어느 정도 타협해 볼 만한 여지가 있다 보니 유혹에 빠지기 쉽다. 1~4번은 이미 어느 정도 구색이 갖춰진 상황이다. 개념도 대충은 이해가 된 것 같고, 기능 자체는 동작하고 있기 때문이다. 5번은 왜 안되는지 모르겠으니 기존에 알고 있던 방식을 쓰거나 꼼수를 써서 해결하려고 시도할 여지가 있다. 6번은 이따금 발생하니까 걍 아몰랑 시전 하고픈 유혹이 들 수 있다.
이 순간 우리는 3초 안에 좀비 모드로 전환해야 한다.
이전 시간에 살핀 것처럼, 귀찮거나 하기 싫은 본성을 3초 컷 룰에 따라 쳐내고, 해결해야 할 문제를 씹어 먹을 때까지 집요하게 따라붙어야 한다.
집요하게 파고들 때 어떻게 성장이 일어나는지를 몇 가지 가상의 사례를 통해 살펴보겠다. 모르는 개념들이 나오면 찾아가며 어느 정도 이해해 보길 추천한다. 실무에서 개발하면 자주 접할 개념들이기 때문이다.
나는 요구된 스펙대로 동작하는 백엔드 API를 개발했다. 현재 잘 동작하고 있으며, 아무도 문제 제기를 하지 않았다. 그런데 나 자신이 조금 아쉽다. 서비스의 이용자가 늘어나면서 API 응답 시간도 조금씩 느려지기 시작했기 때문이다. 어쨌든 동작하고 있는데 그냥 둘까? 아니면 보다 나은 퍼포먼스를 위해 개선해 볼까?
더 파보기로 결심했다. 그렇다면 API의 어디서 응답 시간을 가장 많이 잡아먹는지를 찾아야 한다. 로그를 찍어가며 조사를 해보니 DB에서 데이터를 읽어올 때 시간이 꽤 소요된다는 것을 파악했다.
이 부분을 어떻게 해결할 수 있을까? 응답 속도를 개선하기 위한 구글링과 주위 동료의 자문을 구했다. 그리고 cache라는 개념을 알게 됐다. cache를 DB 레이어 앞단에 두면 응답 속도를 꽤 개선할 수 있을 것 같았다. 찾아보니 cache를 구현하는 방식들이 여럿 있으며, 자주 cache로 사용되는 Redis라는 존재도 덤으로 알게 됐다. 로컬 환경에서 간단히 cache를 붙여서 빠르게 테스트를 해보니 실제로 API 응답 속도가 많이 개선되는 것을 확인했다! 됐구나!!!
....
정말 된 것일까?
여기까지 파악한 것도 잘한 것은 맞다. 그러나 정말 이게 끝일까? 아니다! 우리는 더 집요할 수 있다. DB에서 데이터를 읽어오는 게 왜 오래 걸렸던 것인지를 우린 파보지 않았다.
DB 쿼리 속도가 느릴 때 어떻게 해결할 수 있는지 구글링을 하고 주위 동료들에게도 물어보며 조사하기 시작했다. 종합해 보니 공통적으로 인덱스에 관한 얘기들이 있었다. 인덱스? 데이터베이스 수업 때 몇 번 들어본 적은 있는 것 같은데.. 인덱스가 무엇이고 왜 필요한지 찾아봤다.
'인덱스... 검색 속도를 높이는데 매우 중요한 존재였구나..' 인덱스의 필요성을 이해하고 난 뒤, 데이터를 읽어오는 시간이 오래 걸렸던 쿼리(query)를 찬찬히 살펴봤다. 헉스! 적절한 인덱스가 걸려있지 않아서 DB 전체를 full scan 하고 있다는 것을 알아챘다! 인덱스를 어떻게 걸어줄 수 있는지 추가로 공부한 후에 적절한 인덱스를 걸어준 후 다시 테스트를 해보니, 와우!! 응답 속도가 매우 빨라짐을 확인할 수 있었다. (cache를 쓰지 않아도 말이다!!)
덤으로 알게 된 사실은 인덱스는 주로 B tree나 B+ tree라는 자료 구조를 사용한다는 점이다. 또 덤으로 알게 된 사실은 DB 쿼리 속도가 느릴 때 인덱스를 확인하는 것 외에도 DB connection pool의 connection 개수는 충분한지, 데이터를 읽어오는 쿼리에 튜닝할 것은 없는지, DB 테이블 자체가 잘못 쪼개져 있는 것은 아닌지, DB 서버의 하드웨어 자원 자체가 충분한지, DB 튜닝이 안 되어 있어서 하드웨어 자원이 놀고 있는 것은 아닌지 등등 쿼리 속도가 느릴 때 확인해야 할 여러 요인들이 있다는 것도 알게 됐다.
만약 cache를 붙이는 것만으로 만족했다면 cache에 대한 지식은 늘었겠지만 근본적인 원인은 놓쳤을 것이다. 더 집요하게 팠기 때문에 인덱스가 걸려 있지 않았다는 원인을 찾았고, 그로 인해 인덱스와 DB에 대한 이해가 더 깊어지는 성장의 시간을 가질 수 있었다.
사실 API 응답 속도가 느려지기 시작한 것을 알게 된 계기는, 내가 개발한 API를 사용하는 페이지의 로딩 속도가 점점 느려지는 것 같았기 때문이다. 크롬에서 검사 항목을 켠 뒤 페이지를 로딩해 보면 관련 HTTP 요청들의 응답 시간을 네트워크 탭에서 확인할 수 있는데, 확인해 보니 내가 개발한 API의 응답속도가 몇 백 밀리초에서 많게는 수 초의 시간이 걸리는 것이었다. 다행히 인덱스를 추가한 뒤로는 20 ~ 200 밀리초 내로 응답 시간이 떨어지는 것을 확인했다.
그러다 갑자기 호기심이 생겼다. 웹 페이지의 로딩 속도를 올리는 방법은 또 뭐가 있을까? 어차피 문제도 해결했겠다, 잘 동작하고 있으니 그냥 지나칠까 하다가 그래도 호기심을 꺾지 않기로 결심했으니 구글링을 해 보았다. '웹페이지 로딩 속도 개선', 'http 응답 속도' 등등 여러 키워드들을 한글로도 영어로도 검색을 해보니 몇 가지 주요 개념들이 나왔다. keep-alive 얘기도 나오고, pipeline 얘기도 나오고, 그래서 이런 개념들의 의미가 무엇인지 찾아보니 HTTP 1.1에 대해 공부하게 되고 그다음 버전인 HTTP2에 대해서도 공부하게 됐다. 우리 서비스의 백엔드는 HTTP1.1을 지원하는데 multiplexing 기술을 기반으로 하는 HTTP2.0을 지원하도록 전환한다면 페이지 로딩 속도를 더 개선할 수 있겠다는 생각이 들었다. '좀 더 제대로 공부한 뒤에 팀에 제안해 봐야지,,!' 뭔가 성장한 것 같아서 뿌듯한 기분이 들었다.
어느 날, 백엔드 애플리케이션이 정상적으로 동작하지 않는 현상이 감지됐다. 전체적으로 응답 시간이 매우 느려지거나, 심지어 아예 응답 불능의 상태에 빠지기도 했다. 서비스 장애가 발생한 것이다!! 어떻게든 빨리 해결해야 한다는 마음에, 어릴 때부터 익숙한 방법인 껐다 켜기를 시전했다. 자바 애플리케이션을 급히 kill 하고 다시 재기동 했더니 더 이상 그런 현상이 발생하지 않았다.
원인이 뭐였을까? 다행히 모니터링 툴을 통해 장애 시점에 메모리 사용량이 80%를 넘어섰다는 것을 확인했다. '왜 메모리를 이렇게 많이 사용했지??' 코드에서 메모리를 많이 사용할 만한 부분을 차근차근 살펴봐도 딱히 원인이 보이지 않았다. 곰곰이 생각해 보니 최근에 여러 이벤트를 하면서 서비스 이용자가 늘어나긴 했었다. 어쩌면 트래픽이 늘었기 때문에 발생한 자연스러운 현상이었을지도 모른다. 음, 좀 더 생각해 보니 맞는 것 같다. 트래픽이 늘어난 것 때문임이 틀림없다. 서비스가 잘 성장하고 있는 것 같아 흡족해하며 서버 메모리를 늘려주기로 결심했다. 어차피 AWS의 EC2 서버를 쓰고 있으니, 버튼 몇 번 눌러서 더 좋은 서버로 업그레이드만 해주면 그만이었다. 두 배 많은 메모리를 가진 타입으로 서버를 업그레이드했다. 이제 됐다. 간단하게 해결한 것이다.
...
반전은 3일 뒤에 일어났다.
비슷한 현상이 또 발생한 것이다!! 메모리를 두 배로 늘려줬는데 왜 또 같은 현상이 일어나는 것인가?! 메모리를 두 배로 늘려줘도 안될 만큼 우리 서비스가 급격히 성장하고 있는 것인가? 설마 내가 정말 로켓에 올라탄 것인가?! 혼자 상상의 나래를 펼치며 메모리를 더 늘려줘야 하는지 고민하던 중에,, 지난번 해결책도 엄밀히 따지면 원인 분석이 나의 뇌피셜로 마무리된 것은 아니었는지 스스로 뼈를 때려 봤다. 사실 그랬다. 그 당시 원인 분석이 잘되지 않으니 답답하기도 했고, 시간이 부족하다고 합리화하며 느꼈기 때문에 문제의 원인을 제대로 찾지 않고 쉬운 방법을 택했던 것이다.
다시 초심으로 돌아가서 찐 원인을 찾아 나섰다. 찬찬히 논리적으로 생각해 보며 모니터링 툴을 확인해 보니, 시간이 지남에 따라 메모리 사용률이 조금씩 증가하고 있었다는 것을 발견했다. 그러다가 백엔드 애플리케이션을 재시작하게 되면 메모리 사용률이 뚝 떨어졌다가 다시 서서히 증가하는 양상을 보이고 있었다.
뭔가 살짝 촉이 왔다. 어딘가 메모리 릭(leak)이 발생하고 있는 것이다. 만약 그렇다면, 메모리 누수가 일어나는 위치를 어떻게 찾을 수 있을지 고민해 봤다. 번뜩이는 아이디어가 하나 떠올랐다! '그래, 처음부터 이런 현상이 생기진 않았을 거야. 아마도 새로운 기능이 배포되면서 버그가 같이 배포됐겠지. 그렇다면.. 메모리가 꾸준히 증가하기 시작했던 그 시작점을 찾아서 그때 즘 배포된 코드를 확인해 보면 되겠다!!' 꽤 괜찮은 접근이지 않는가?
하지만 난관에 부딪혔다. 안타깝게도 모니터링 툴의 스탯 보관 기간은 2주였어서 최근 2주 분량의 메모리 사용률만 확인할 수 있었는데, 메모리가 증가하는 양상은 2주 전보다 더 이전부터 시작했기 때문이다. '흑,,, 좌절스럽다. 걍 메모리를 다시 더 올릴까?'
포기하지 말자. 내가 만난 대부분의 문제는 이미 여러 사람들이 만난 문제였을 것이다. 그리고 이미 이런 상황을 위한 솔루션들이 있을 것이다. 다시 집요하게 검색하고 또 검색했다. 그리고 새롭게 알게 된 기술이 바로 heap dump였다. heap dump는 현재 구동 중인 자바 웹 애플리케이션에서 사용되는 다양한 객체들이 각각 메모리를 얼마나 점유하고 있는지 확인할 수 있도록 도와주는 툴이다.
바로 heap dump를 써보기로 했다. 마침 현재 메모리 사용률이 65%에서 70%로 증가하고 있던 중이었다. heap dump를 통해 메모리 구조를 분석해 보니 특정 객체가 메모리의 전체 사용률 중 90% 가까이를 점유하고 있었다. 곧바로 해당 객체가 생성 혹은 사용되는 코드를 분석했고 최종적으로 메모리 누수(leak)이 발생하는 포인트를 찾아 해결할 수 있었다.
heap dump를 알게 된 것은 큰 소득이었다. 덤으로 heap dump 외에도, 여러 스레드(thread)가 같은 자원을 점유하려다가 무한 대기 상태에 빠지는 등의 문제가 발생했을 때 thread들의 현재 상태를 확인할 수 있는 thread dump도 알게 됐다.
어느덧 이곳에서 개발한지도 6개월이 지나간다. 그동안 참 열심히 개발했던 것 같다. 지금까지 구현했던 많은 기능들은 여전히 잘 동작하고 있다. 그런데 내 실력이 아직 부족하다 보니 그간 개발했던 코드들의 구조를 살펴보면 아쉬움이 남는다. 내부 구현을 구조적으로 좀 더 깔끔하면서도 유연하며 확장 가능하게 만들 수 있을 텐데.. 이 상태를 그대로 두면 이후로 기능 개발이 추가될 때마다 조금씩 더 복잡해질 것 같고 손도 많이 갈 것 같았다. 그렇다. 리팩토링과 관련된 부분이다. 그런데 어디서부터 어떻게 시작해야 할지 감이 잘 오지 않는다. '어쨌든 잘 동작하고 있으니 미래의 누군가가 하겠지..' 유혹의 스멜이 스멀스멀 올라온다.
하지만 좋은 구조의 중요성은 수많은 대가들이 책을(클린 코드, 리팩토링 등등) 써가며 강조하고 있다. 좋은 구조란 단지 예쁜 예술품 정도를 의미하는 것이 아니라, 계속해서 신규 기능이 추가될 때도 유연하고 신속하게 기능들을 수용할 수 있는, 읽기 쉽고 이해하기에 난해하지 않는 구조를 의미하기 때문이다.
귀찮긴 하지만 시간을 더 써서라도 리팩토링을 하기로 결심했다. 팀장님께 이슈 레이징을 하여 2~3주의 시간을 확보한 뒤 리팩토링에 들어갔다. 어렴풋이 알고 있던 객체지향 설계 패턴도 다시 한번 복습하고, 현재 구조의 문제점이나 코드의 가독성 등에 대한 피드백을 동료로부터 받은 뒤, 여러 문제를 개선하기 위한 리팩토링 작업에 착수했다.
내가 짠 코드를 다시 리팩토링하면서 좋은 구조란 무엇인지, 이해하기 쉽고 읽기 쉬운 코드란 무엇인지, 확장 가능한 구조란 무엇인지 고민하고, 레퍼런스가 될만한 글들이나 동료들의 조언을 참고하면서 끈기 있게 개선해 나갔다. 그 결과 리팩토링을 수행한 이후의 구조에서는 동료 개발자들이 새로운 기능을 추가하는 것이 더 쉬워졌고, 신규 입사자분들도 구현자의 설계 의도를 파악하는 것이 그렇게 어렵지 만은 않은 구조와 코드 퀄리티가 됐다. 이후의 개발 속도가 소폭 빨라진 점도 좋아진 점이었다.
새로운 프로젝트에 착수하게 됐다. 서비스 규모가 점점 커지면서 MSA(micro service architecture) 구조로 확장하다 보니, 신규 컴포넌트를 만들 일이 생긴 것이다. 비용 절감을 위해 같은 서버 스펙으로 보다 나은 처리량(throughput)을 만들 방법을 찾다가 Spring boot의 webflux라는 기술을 알게 됐다.
더 공부를 해보니 비동기를 통해 처리량(throughput)을 늘릴 수 있는 게 장점인 것 같다. 그런데 공부를 하면 할수록 여러 개념들이 등장한다. reactive stream, nonblock IO, publisher, subscriber, subscription, backpressure, netty, channel, event loop, worker thread.. 머리가 아파진다. 찝찝하긴 하지만 대충 개념이 파악됐으니 webflux를 써서 개발을 시작해 볼까? 아니면 좀 찝찝하니까 그냥 tomcat 기반으로 개발할까?
예전에 나라면 둘 중에 하나를 선택했겠지만.. 보여줄게 완전히 달라진 나!! 좀비 모드로 전환하여 집요하게 파기 시작했다. webflux와 그 생태계가 찝찝하지 않을 정도로 이해될 때까지, 그래서 webflux를 어떻게 써야 제대로 그 기술의 효과를 발휘할 수 있고, 반면에 어떻게 쓰면 오히려 역효과를 내는지를 분별할 수 있을 때까지 말이다.
어느 정도 만족할 만큼 이해가 된 후에 본격적으로 개발에 착수했다. 그러다 보니 webflux를 쓰면서도 blocking으로 개발하는 실수를 하지 않을 수 있게 됐다. reactive를 지원하는 DB 드라이버나 http client를 찾아 사용함으로 nonblock IO를 십분 활용하도록 했다. 각종 스트림의 처리를 미려하게 사용할 수 있었다.
여러 예제를 통해 살펴본 것처럼, 집요함에 바탕한 추적과 탐구는 우리를 성장시킨다.
그러니 집요한 싸움에서 늘 살아있자.
긴 글을 끈질기게 여기까지 읽어온 것처럼!