25장: 코드 튜닝 전략
25장: 코드 튜닝 전략
25.1 성능이란?
품질 특성과 성능
- 사용자는 코드의 순수 품질보다 업무 효율성을 높여주는 프로그램 특성에 더 관심을 둠.
- 예: 파일 전송 코드가 약간 빠른 소프트웨어보다 조작 절차를 대폭 줄여주는 카드리더기가 더 뛰어난 성능 경험을 제공함.
- 오히려 실행 속도 향상 코드에 지나치게 집중하면 유지보수성이나 가독성 등 다른 중요한 품질 특성을 해칠 수 있음.
성능 개선 관점과 코드 튜닝
-
프로그램 요구사항: 과도한 성능 요구사항은 비현실적인 설계 복잡도와 프로젝트 비용을 유발하므로 사용자 요구를 재분석해 정밀 조정해야 함.
- 프로그램 설계: 아키텍처 수준에서 자원 목표를 설정하고 높은 모듈화 구조를 갖춰야 비효율적 컴포넌트의 손쉬운 교체가 가능함.
- 클래스와 루틴 설계: 적절한 데이터 구조와 알고리즘을 선택하는 것이 핵심임.
- 예: 버블 정렬 대신 퀵 정렬, 선형 검색 대신 이진 검색
- 운영체제 상호작용: 파일 및 메모리 I/O 과정에서 예상치 못한 운영체제 루틴 호출이나 라이브러리 과부하가 발생할 수 있음.
- 코드 컴파일: 고성능 컴파일러의 최적화 기능을 활용하면 수동 튜닝 없이도 최적화된 기계어 코드를 얻을 수 있음.
- 하드웨어: 소수 고객을 위한 주문형 소프트웨어의 경우 하드웨어 업그레이드가 인건비와 유지보수 비용을 줄이는 가장 경제적인 최적화 대안임.
- 코드 튜닝: 소규모 코드(단일 루틴이나 몇 줄의 문장)를 정확히 수정하여 성능을 높이는 전술적 접근 방법임.
25.2 코드 튜닝 소개
코드 튜닝의 매력과 문제점
-
코드 튜닝은 성능을 향상시키는 가장 효과적이거나 쉽고 저렴한 방법이 아님.
- 코드 몇 줄을 수정해 실행 시간을 대폭 줄이는 만족감과 프로그래밍 문화의 명성 때문에 매력적으로 느껴짐.
- 하지만 미세하게 효율적인 코드가 항상 더 나은 코드는 아니며 품질을 저해할 수 있음.
파레토 법칙 (80/20 법칙)
- 전체 실행 시간의 80%는 코드 전체의 20% 이하(커누스의 연구에 따르면 4% 이하)에서 소비됨.
-
줄 계수 프로파일러 등으로 측정해 과열지점을 찾은 후 해당 4% 영역 최적화에 집중해야 함.
- 무작정 코드를 최적화하면 영향력이 없는 유휴 루프만 재작성하는 실수를 범할 수 있음.
- 시스템을 먼저 완성하여 작동하게 만든 후, 핵심적인 극소수 영역에 최적화를 집중해야 함.
코드 튜닝에 대한 오해 (노부인들의 이야기)
-
고급 언어의 코드를 줄이면 기계어 코드의 속도나 크기가 향상됨: 거짓임. 10번 반복하는 for 루프보다 10줄로 늘려 쓴 직선형 코드가 비주얼 베이직과 자바에서 60% 이상 빠른 성능을 보임. 고급 언어의 코드 줄 수와 최종 성능 간에는 아무 연관성이 없음.
- 어떤 연산이 아마 다른 것보다 빠르거나 작을 것임: 거짓임. 프로그래밍 언어, 컴파일러 버전, 라이브러리, 프로세서에 따라 성능 특성이 달라지므로 항상 직접 측정해서 확인해야 함.
- 코드를 작성하면서 최적화해야 함: 거짓임. 완성 전에는 전체 시간의 50%를 차지하는 4%의 병목을 파악할 수 없어 불필요한 96%의 코드를 최적화하느라 시간을 낭비하게 됨. 또한 가독성, 정보 은닉, 정확성 등 더 중요한 품질 목표를 해침.
- 빠른 프로그램은 정확한 프로그램만큼 중요함: 거짓임. 올바르게 작동하지 않는 프로그램은 아무리 실행 속도가 빨라도 무의미함.
올바른 튜닝 시점
- 고수준 설계를 적용해 모듈화와 변경 용이성을 확보하며 프로그램을 정확하게 작성함.
- 프로그램이 완성되고 정상 동작할 때 성능을 측정하고, 실제 튜닝이 필요하다고 판단될 때만 최적화를 진행함.
컴파일러 최적화
- 최신 컴파일러의 최적화 기능은 복잡하고 교묘한 코드보다 직관적이고 분명한 코드를 더 잘 최적화함.
- 수동 코드 튜닝 전에 최적화 컴파일러 옵션을 설정하는 것만으로도 40% 이상의 성능 향상을 기대할 수 있음.
-
컴파일러나 자바 가상 머신(JVM)의 성능 특성을 측정하여 적합한 환경을 선택하는 것이 우선임.
언어 / 환경 컴파일러의 최적화를 사용하지 않은 시간 컴파일러의 최적화를 사용한 시간 시간 절약 성능 비율 C++ 컴파일러 1 2.21 1.05 52% 2:1 C++ 컴파일러 2 2.78 1.15 59% 2.5:1 C++ 컴파일러 3 2.43 1.25 49% 2:1 C# 컴파일러 1.55 1.55 0% 1:1 비주얼 베이직 1.78 1.78 0% 1:1 자바 VM 1 2.77 2.77 0% 1:1 자바 VM 2 1.39 1.38 <1% 1:1 자바 VM 3 2.63 2.63 0% 1:1
25.3 느리고 비대한 부분
- 코드를 튜닝할 때는 프로그램에서 느리고 비대한 부분을 찾아 집중적으로 최적화해야 함.
- 어떤 부분이 느리고 비대한지 확실히 파악하기 위해 항상 프로파일링을 우선 수행해야 함.
비효율성의 공통적인 원인
- 불필요한 I/O 연산 방지: 입력/출력(I/O) 연산은 성능 저하의 주원인이므로, 공간이 허용한다면 인메모리 데이터 구조를 활용함.
- 메모리 페이징: OS 메모리 페이지 교체 연산은 단일 페이지 연산보다 훨씬 느리므로, 배열 접근 순서를 조정해 페이지 오류를 최소화해야 함.
// 1. 페이지 오류를 많이 유발하는 초기화 루프 (열 중심 접근) for ( column = 0; column < MAX_COLUMNS; column++ ) { for ( row = 0; row < MAX_ROWS; row++ ) { table[ row ][ column ] = BlankTableElement(); } } // 2. 페이지 오류를 줄인 초기화 루프 (행 중심 접근) for ( row = 0; row < MAX_ROWS; row++ ) { for ( column = 0; column < MAX_COLUMNS; column++ ) { table[ row ][ column ] = BlankTableElement(); } } - 시스템 호출: 커널 Context Switching이 수반되는 시스템 루틴 호출은 비용이 크므로, 호출을 줄이거나 직접 작성한 저수준 서비스로 대체함.
-
인터프리트 언어: 인터프리트 언어는 기계어 코드 생성 및 실행 전 각 명령을 처리해야 하므로 컴파일 언어 대비 현저한 성능 손실이 발생함.
언어 언어의 타입 C++에 비례한 실행 시간 C++ 컴파일 1:1 비주얼 베이직 컴파일 1:1 C# 컴파일 1:1 자바 바이트코드 1.5:1 PHP 인터프리트 >100:1 파이썬 인터프리트 >100:1 - 오류: 방치된 디버깅 코드, 메모리 누수, DB 테이블 색인 누락 등 단순 오류가 주요 성능 병목의 원인이 됨.
공통적인 연산의 상대적인 성능 비용
- 변수 할당, 루틴 호출, 정수 및 부동 소수점 연산 등 대다수 공통 연산의 비용은 대략 비슷함.
- 난해한 수학 함수나 다형성 루틴 호출은 일반 연산보다 다소 비싼 비용을 소모함.
- 코드 튜닝을 통한 속도 향상은 기본적으로 비싼 연산 루틴을 상대적으로 저렴한 루틴으로 대체하여 얻어짐.
25.4 측정
- 프로그램의 일부 구간이 실행 시간을 많이 차지하므로 코드를 측정해 과열지점을 찾고, 최적화 후에는 재측정하여 향상도를 평가해야 함.
- 성능의 많은 특성은 직관에 반하며, 장비·언어·컴파일러의 변화로 과거 경험이 효용을 잃으므로 실제 측정 전까지는 최적화 효과를 장담할 수 없음.
// 1. 행렬 요소를 더하기 위한 직관적인 C++ 예제 sum = 0; for ( row = 0; row < rowCount; row++ ) { for ( column = 0; column < columnCount; column++ ) { sum = sum + matrix[ row ][ column ]; } } // 2. 포인터 연산으로 코드 튜닝을 시도한 C++ 예제 sum = 0; elementPointer = matrix; lastElementPointer = matrix[ rowCount - 1 ][ columnCount - 1 ] + 1; while ( elementPointer < lastElementPointer ) { sum = sum + *elementPointer++; } //저자의 컴파일러에서는 동일한 어셈블리어가 나왔다.
측정은 정확해야 한다
- 프로파일링 도구를 활용하거나 연산에 소모된 시스템 시간을 기록하는 정밀 루틴을 사용해야 함.
- 날짜 시각이 아닌 프로그램에 할당된 CPU 클럭 틱 수만 집계하여 타 프로그램 영향과 시스템 구동/측정 오버헤드를 배제해야 함.
25.5 반복
- 한 가지 기법만으로는 큰 성능 향상을 얻기 어렵지만, 여러 최적화 기법을 결합하고 지속적으로 반복 시도하면 누적되어 비약적인 성능 향상을 달성함.
- 극단적인 코드 튜닝은 실행 속도를 획기적으로 개선하는 대신, 가독성과 유지보수성을 극도로 떨어뜨릴 수 있으므로 주의가 필요함.
- 예: DES(데이터 암호화 표준) 알고리즘 구현 시 초기 실행 시간이 21분 40초였으나, 수십 차례의 반복적인 튜닝 과정을 거쳐 목표치인 37초까지 단축시킴 (단, 최종 어셈블리 코드는 유지보수가 매우 어려워짐).
25.6 코드 튜닝 단계 요약
- 이해하고 변경하기 쉬운 잘 설계된 코드를 사용하여 소프트웨어를 개발한다.
- 성능이 좋지 않다면
- a. 나중에 “마지막으로 좋았던 상태”로 돌아올 수 있도록 작동하는 버전의 코드를 저장한다.
- b. 과열지점을 찾기 위해서 시스템을 측정한다.
- c. 성능이 취약한 것이 부적절한 설계 때문인지, 데이터형이나 알고리즘 때문인지를 판단하고 코드 튜닝이 적절한지 판단한다. 코드 튜닝이 적절하지 않다면 1단계로 돌아간다.
- d. c 단계에서 규명된 병목을 튜닝한다.
- e. 한 번에 하나씩 성능을 측정한다.
- f. 코드의 성능이 향상되지 않았다면 a 단계에서 저장했던 코드로 되돌아간다
- 2단계를 반복한다
체크리스트: 코드 튜닝 전략
전체적인 프로그램 성능
- 프로그램의 요구사항을 변경함으로써 성능을 개선할 것을 고려했는가?
- 프로그램의 설계를 수정함으로써 성능을 개선할 것을 고려했는가?
- 클래스의 설계를 수정함으로써 성능을 개선할 것을 고려했는가?
- 운영체제와의 상호작용을 피함으로써 성능을 개선할 것을 고려했는가?
- I/O를 피함으로써 성능을 개선할 것을 고려했는가?
- 인터프리트 언어 대신 컴파일 언어를 사용하여 성능을 개선할 것을 고려했는가?
- 컴파일러 최적화 사용을 통한 성능 향상을 고려했는가?
- 다른 하드웨어로 교체함으로써 성능을 개선할 것을 고려했는가?
- 최후의 수단으로 코드 튜닝을 고려했는가?
코드 튜닝 접근 방법
- 코드 튜닝을 시작하기 전 프로그램이 완전히 정확한가?
- 코드 튜닝을 시작하기 전에 성능 병목을 측정했는가?
- 각 코드 튜닝 변경의 효과를 측정했는가?
- 의도한 성능 향상이 없을 때 코드 튜닝 변경을 복구시켰는가?
- 각 병목의 성능을 향상시키기 위해 한 가지 이상의 변경을 반복적으로 시도했는가?
참고 자료
- 코니 스미스 외, 『Performance Solutions』(Addison-Wesley, 2002): 개발 전 단계에서의 성능 구현, 웹 응용 프로그램 및 확장성 전략을 다룸.
- 요셉 뉴커머, 「Optimization: Your Worst Enemy」(2000): 비효과적인 최적화 전략의 여러 가지 함정을 상세히 다룸.
- 도널드 커누스, 『컴퓨터 프로그래밍의 예술 1~3』(한빛미디어, 2006~2008): 기초 알고리즘, 준수치적 알고리즘, 정렬 및 검색을 수식과 가상 언어로 깊이 있게 다룬 고전 참고서임.
- 로버트 세지윅, 『Algorithms in Java/C++/C』: 정렬, 검색, ADT, 그래프 알고리즘 등 광범위한 문제 해결 방법을 체계적으로 조사 정리함.
요점 정리
- 성능은 소프트웨어 품질 전체의 한 측면일 뿐이며 일반적으로 가장 중요한 항목도 아니다. 정밀하게 튜닝된 코드는 전체적인 성능의 한 측면일 뿐이며 일반적으로 그 효과가 눈에 보이지도 않는다. 프로그램 아키텍처, 상세 설계, 자료 구조와 알고리즘 선택이 일반적으로 코드의 효율성보다 프로그램의 수행 속도와 크기에 더큰 영향을 미친다.
- 양적인 측정은 성능 최대화의 핵심이다. 그것은 성능 향상이 정말 중요한 영역을 찾는 데 필요하고 최적화가 소프트웨어의 성능을 향상시켰는지를 검증하는 데도 필요하다.
- 대부분의 프로그램은 작은 코드 영역에서 대부분의 시간을 보낸다. 코드를 측정하기 전까지는 그것이 어느 코드인지 알 수 없다.
- 코드 튜닝을 통해 원하는 만큼의 성능을 개선하기 위해서는 일반적으로 여러 번 반복해야 한다.
- 초기 코드 작성 시 성능 작업을 준비하는 가장 좋은 방법은 이해하고 수정하기 쉬운 분명한 코드를 작성하는 것이다.