약 한 달여 간 프로젝트를 진행하면서, 다양한 경험을 쌓고 많은 고민거리를 가져볼 수 있었다.
프로젝트의 목적이었던 '문제 해결 능력 키우기'에 대해서도 정말 많은 것을 느꼈다.
이번 글에서는 사용자 경험 개선의 최종 결과와, 나의 행동에 대한 피드백에 관해 이야기해보고자 한다.
가장 먼저 첫 실행 결과는 평균 응답 시간 4239ms로, 심각한 수준의 성능을 보여주었다.
이를 완화하기 위해, 첫 번째로 HotSpot JVM의 Warm-up을 진행했다. 필요한 코드들을 충분히 달궈 실행 속도를 높이는 것이다.
사실 JVM에 대해 이론적으로 지연 로딩을 한다는 것은 알고 있었지만, Warm-up 여부가 큰 차이를 보인다는 것은 이번에 처음 알게 되었다.
도입 전에는 혹시나 싶어 10번 정도 연속적으로 실행해봤는데, 평균 응답 속도가 계속 나아지다가 5번째 이후에는 큰 변화가 없는 것을 확인했다.
이 현상이 HotSpot JVM의 문제라고 직감하고, 가장 효율적으로 Warm-up을 진행하는 찾아봤는데, 그 중 특정한 JVM 인자와 함께 미리 한 번 실행해보는 전략을 선택하기로 했다.
그 결과, 평균 응답 시간 4239ms → 3253ms으로, 약 1000ms 정도 개선되었다. 무려 1초씩이나 빨라졌다!
다음으로는 프로파일링을 통해 성능 문제에 접근했다.
프로파일링을 통한 문제 해결이 이번 프로젝트의 목표이기도 했다. 항상 친절한 예외 메시지와 로그만을 보며 문제를 해결해왔는데, 원인을 직접 찾아야하는 도전적인 기술적 문제를 해결하는 경험을 쌓고 싶었다.
그 과정 속에서 나의 문제 해결 전략을 단단히 키워나가고 싶었던 것이 이 프로젝트의 시작이기도 했다.
아무튼 수집한 Metrics를 시각화하여 의심되는 부분을 하나씩 살펴봤는데, 클라이언트 측과 서버 측의 응답 시간 차이가 너무 컸다.
서버는 0.3초만에 요청 하나를 끝내는데, 클라이언트는 3초씩이나 걸렸다고 주장하는 것이다.
이게 뭔가 싶었는데, 병렬화에 관한 문제였다. 가령 은행에 12000명의 사람이 몰렸는데 은행원이 8명뿐이면, 처음 업무를 본 사람은 빨리 끝나겠지만 뒤로 갈 수록 엄청 오래 기다리게 된다. 즉, 평균 응답 시간이 떨어진다.
병렬화를 위해 선택한 것은 Tomcat 스레드 풀의 크기를 조정하는 일인데, 별 효과가 없었다. 애초에 은행원이 8명인데, 한 번에 많은 업무를 받아놓고 이거 했다가 저거 했다가 하면 빨라지겠는가? 문맥 교환에 의한 비용만 들 뿐이다.
그래서 어쩔 수 없이 vCPU 수를 늘렸다. 기존 성능이 너무 처참해서 하드웨어 확장밖에 방법이 없었다.
결과가 조금 나아지긴 했지만, 여전히 부족함이 많았다. vCPU를 더 늘릴 수도 있겠지만, 그게 정말 비싸다. 해볼 수 있는건 다 해보고 그래도 안 되면 늘리기로 했다.
더 이상의 병렬화는 무리라고 판단하고, 다음으로 주목한 것은 DB Connection에 대한 경쟁이다.
서버가 요청 하나를 처리하는 데에 0.3초가 든다고 했는데, 그중 90% 이상이 DB Connection 획득을 위해 기다린 시간이다. 분명한 병목이다.
왜 그럴까 생각해봤는데 HikariCP의 기본 풀 크기가 최대 10이다. Tomcat 스레드는 몇 백개 정도 되고, x-lock도 걸기 때문에 Connection이 심심치않게 Blocking될 것이다.
그래서 풀 크기를 최대 15로 늘려봤는데 0.4초가 빨라진다. 계속 늘려봤더니 처음에 비해 1초씩이나 더 빨라졌다. 병목의 원인을 찾은 것이다.
그런데 진짜 Tomcat 스레드 수와 HikariCP Connection 수의 차이로 인한 경쟁이 심할까?하는 궁금증도 생겼다. 그래서 두 변수를 바꿔가면서 측정해봤는데, 큰 차이는 없는 것 같았다. 그냥 Connection Blocking에 의한 병목인 것 같다.
결과적으로 평균 응답 시간은 3253ms → 1468ms로, 약 1800ms 정도 개선되었다.
풀 크기에 의한 병목 해결 이후에도, 여전히 DB Connection 획득 시간이 요청 처리 시간의 50%정도 차지했다.
이게 맞나 싶었는데, 더 이상 다른 것을 건드릴 수 없으니 쿼리를 개선해보기로 했다.
그런데 문제는 '쿼리 성능을 어떻게 측정할 것인가'였다. Metrics가 일반적으로 성능 추이를 보기 위함이여서 그런지, 쿼리마다 수행 시간같은 정보를 얻기가 어려웠다.
MySQL에서는 Perfomance Schema의 테이블로 쿼리 성능 정보를 제공했는데, 이게 너무 low-level이여서 다루기가 난감했다. 이거 공부하느라 며칠은 날릴 것 같았다.
조금 더 빠른 해결책은 없을까 했는데 Slow Query Log라는 기능을 찾았다. 사실 이건 실제 서비스 중에 정말 오래 걸린 쿼리가 있으면 그걸 기록해두는 목적으로 사용하는데, 나는 이걸 살짝 다른 목적으로 활용해보기로 했다.
Slow Query의 Threshold를 0.01초 정도로 엄청 낮게 설정하면, 웬만한 쿼리는 다 기록하게 된다. 이때 mysqlslowdump
라는 도구로 간단한 통계를 확인할 수 있고, 비효율적인 쿼리가 무엇인지 확인할 수 있는 것이다.
의심되는 쿼리를 개선해보는데, 큰 진전은 없었다. 아무리 커도 0.1초가 안 되는 정도만 개선됐다.
그러다가 35만개의 레코드를 읽는 쿼리를 발견했다. 찾아봤는데 나의 안일한 판단으로 인한 문제였다.
한 학생의 모든 수강 이력을 조회하는 쿼리인데, 어차피 많아봤자 40개가 안되니 문제 없다고 판단했었다.
그런데 학생이 총 2000명이다. 게다가 캐시 기능도 없어서 6번씩 조회를 반복한다. 이 모든 것을 6초 정도의 짧은 시간 안에 수행해야 한다. 확실히 성능에 무리를 주는 것 같았다.
조회 범위를 축소시켰더니 평균 응답 시간이 1395ms → 441ms로, 약 950ms 정도 개선되었다. 생각보다 크게 개선되어서 놀랐다.
수강 신청이라는 도메인 자체가 쓰기 기능에 집중하다 보니, 캐싱을 활용할만한 상황이 많이 없었다.
하나 생각한게 만석 강의 조회다. 12000개의 요청 중 약 2000개는 만석 강의를 조회하는데, 이때 s-lock이 아닌 x-lock을 걸어버리므로 다른 요청을 기다리게 만든다.
그래서 만석 강의를 캐싱하면 조금 개선되지 않을까 해서 방법을 생각해봤다.
구현을 위해 다른 캐싱 프레임워크는 사용하지 않았다. 객체를 캐싱하기 보다는, 만석이라는 정보를 캐싱하는 것이기 때문이었다. 어렵지 않게 캐싱 로직을 구현할 수 있었다.
다만 멀티스레딩 환경을 고려하여 ConcurrentHashMap
을 사용했다. Hashtable
처럼 thread-safe한 자료구조인데, 읽기 연산에 lock을 수행하지 않아 동시성이 좋다는 장점이 있다.
결과적으로 평균 응답 시간은 441ms → 383ms, 약 60ms 정도 개선됐다. 역시 만석 강의 조회가 빈번하지 않은 만큼, 성능 개선도 크게 이루어지지는 않았다.
나만의 문제 해결 전략에 대해 다시 한 번 생각해볼 수 있었다.
이 프로젝트를 진행하기 전, 나는 다음과 같은 전략으로 문제 해결에 임했다:
문제 정의 → 원인 파악 → 해결을 위한 의사결정
이것만으로도 모든 기술적 문제를 해결할 수 있을 것이다.
그러나, 너무 추상적이었다. '문제 정의'와 '원인 파악'을 어떻게 할 것인지 깊게 생각해보지 못 했다.
그 이유는, 비교적 쉬운 문제만을 해결해왔기 때문이었다. 항상 예외 메시지나 로그, 디버깅과 같은 힌트가 주어져서, 문제 정의에 큰 어려움을 겪지 않았다.
가령 평균 응답 시간이 너무 큰 문제가 있다고 하면, 이 문제를 어떻게 정의할 것인가?
문제를 명확히 정의하기 위해서는 많은 힌트가 필요하다. 이번 프로젝트에서 유용했던 힌트는 프로파일링이었다.
응답 시간에 영향을 주는 수치들을 살펴보면서, 하나씩 의심해보고 가설을 검증하는 일을 반복했다.
쉽지만은 않았다. 수치를 의심한다는 것 자체가 직관에 의존하는 것이고, 경험을 통해 쌓을 수 밖에 없다. 때문에 초짜인 나를 방황하게 만들었다.
게다가, 용의자가 한 둘이 아니다. 가설이 꼬리를 물고 나아가면서 여러 갈래가 생긴다. 마치 트리 구조같다.
'A' 가설이 맞다고 생각하면, 그 이유를 설명해줄 'A-1' 가설과 'A-2' 가설이 있고, 각 가설들을 모두 검증해봤는데 그 결과로 'A'가설이 틀렸다는걸 알면, 또 'B' 가설을 세우고...
이에 대해 내가 찾은 방안은 '깊게 생각하는 것'과 '논리적으로 파고드는 것'이었다.
나는 깊은 생각을 좋아한다. 산책 중 한 가지 주제로 여러 생각을 하는 것을 즐긴다. 이 점은 많은 가설을 생각해야 할 때 도움을 주었다.
그래서 효율적인 문제 해결을 위해 여유를 갖도록 했다. 책상에 앉아 있지만 말고, 자주 나가서 깊은 생각을 하고 오는 것이다. 나만의 노하우가 생긴 셈이다.
논리적으로 파고드는 것은 반대로 앉아서 해야 하는 일이다. 나는 잘 까먹는 편이라, 메모 해 가면서 생각을 이어나가는게 좋은 것 같다.
가설이 복잡한 트리 구조를 만든다고 해도, 그림으로 그려보면 그렇게 복잡하지 않다. 이런 식으로 접근하면 논리적 사고는 큰 어려움이 없는 것 같다.
결과적으로, 지금의 문제 해결 전략은 이렇게 바뀌었다:
힌트 수집 수단 구축 → 문제 인식 → { 힌트를 통해 가설 제시 → 가설 검증 } 반복 → 해결을 위한 의사결정
이번 프로젝트는 낯선 기술을 정말 많이 사용해야 했다. 뿐만 아니라, 이미 알고 있던 기술에 대해서도 깊게 이해해야 했다.
즉, 많은 양의 공식 문서를 읽고 빠르게 이해해야 했다. 그러나 내가 천재도 아니고, 처음 보는 기술을 찰떡같이 이해할 리가 없다. 실습이 병행되어야 했다.
따라서 기술의 핵심 개념을 먼저 학습하고, 부차적인 부분은 필요할 때마다 공부하는 전략을 사용했다. 마치 지연 로딩을 하는 것처럼 말이다.
또한, 간혹 공식 문서가 미흡한 기술도 있다. 혹은 자세한 설명을 더 필요로 하는 부분이 있기도 했다. 이 때에는 다른 레퍼런스를 참고하면서 공식 문서를 이해했다. 문서만 보는 것보다 훨씬 빠르게 이해할 수 있었고, 타 래퍼런스의 단점인 신빙성 문제도 보완할 수 있었다.
근본적으로 프로젝트의 목적이 장기적인 서비스가 아니라, 나 개인의 성장에 집중되어 있었다.
때문에 기술 선택에 있어서도 이 부분을 크게 고려했다. 미래를 생각하기 보다는 현재에 집중했다.
그러나 '이 선택이 나에게 정말 도움이 될까'하는 생각이 들었다.
내가 앞으로 새로운 기술을 도입해야 하는 순간에는, 그 기술을 잠깐 쓰고 말아야 하는 상황일까? 보통은 그 반대일 것이다.
따라서 후회가 많이 남는다. 속도에 집중하느라 놓친 것이 꽤 있었다.
단순히 기술적 문제를 해결해보는 경험보다, 유지할 수 있는 프로젝트 운영 경험이 더 값지다는 것을 뒤늦게 깨달았다.
가령 쿼리 성능 측적을 위해 MySQL의 Slow Query Log를 사용했는데, 이걸 앞으로도 쭉 사용해도 괜찮을까?
mysqlslowdump
통계의 값은 소수점 두 자리까지만 표시된다. 빠른 쿼리가 많이 실행되는 나의 상황에는 조금 맞지 않다.
애초에 Slow Query Log는 지속하는 서비스에서의 모니터링을 위한 기능이며, 내 상황과 방향성이 달랐다. 그저 빨리 도입할 수 있어서 활용했을 뿐이다.
나중에 상황이 더 복잡해진다면 이 기능만으로도 프로파일링이 가능할까? 어려움이 클 것 같다.
물론 모든 선택이 잘못되지는 않았다. 프로젝트 진행에 있어 생산성은 결코 무시할 수 없는 요소이다.
프로젝트라는게 한 번에 뚝딱 만들어지는게 아니니까, 지금의 상황에서 점차 시스템을 구축해나갈 수도 있다.
이번 프로젝트에서는 지금까지 쌓은 지식을 기반으로 기술적인 문제를 해결해보는 경험을 쌓을 수 있었고, 이는 내게 큰 성과라고 생각한다.
앞으로는 더 나아가, 실제로 지속하는 서비스 속에서, 문제 해결의 실마리를 얻을 수 있도록 시스템을 구축해두는 경험을 통해, 실무에 근접한 경험을 쌓아보고 싶다.
새로운 지식을 습득하는 데에 필요한 것은, 지속적인 학습 동기와 습득 전략, 그리고 기반 지식이라고 생각한다.
이번 프로젝트에서는 그 중 기반 지식의 중요성에 대해 느끼게 되었다.
기반 지식이라는게 꼭 기초 CS만을 말하기보다는, Git이나 Linux, Shell, Python, Docker 등 다양한 분야에서 활용할 수 있는 기반 기술에 대한 이해이다.
많은 상황에서 활용할 수 있는 기술들을 미리 알고있으면 정말 큰 도움이 될 것 같다. 그때 그때 배워야 할 기술이 산더미일텐데, 매도 먼저 맞는게 낫다고, 미리 습득해두면 미래의 짐을 덜 수 있을 것 같다.
그래서 당장은 기반 지식을 공부하는 데에 시간을 쓰고 싶다.
기반 지식을 잘 쌓아두면, 다음 프로젝트를 진행할 때 든든한 버팀목이 되어줄 것이라 믿는다.