대메뉴 바로가기 본문 바로가기

데이터 기술 자료

데이터 기술 자료 상세보기
제목 성능 장인의 항해 : 일반화 VS 성능
등록일 조회수 4487
첨부파일  

성능 장인의 항해

일반화 VS 성능



3회에 걸쳐 진행될 본 컬럼에서는 소프트웨어(이하 SW) 성능을 바라보는 세 가지 관점에서의 성능 향상을 위한 필자의 경험과 노력을 소개한다. 그 첫 번째 시간에서는 서로 반대되는 일반성과 성능이라는 두 관점에서 일반성을 조금 양보하고 특수성을 활용함으로써 얻게 되는 성능 개선 사례를 소개한다.


어느덧 SW 업계는 페타급 빅데이터를 저장하고 테라급 인메모리 연산을 요구하는 시대와 마주하고 있다. 시스템의 성능은 날로 좋아지지만, 그보다 빠른 속도로 데이터양은 많아지고 분석의 복잡도가 높아지고 있다. 하드웨어의 발전에도 불구하고 최적의 성능을 향한 SW 개선 요구는 끊임없이 증가하는 추세다. 작은 데이터셋에서 별로 중요시되지 않았던 SW의 성능이 테라, 페타급이 되면 시간, 일 단위의 차이를 보였기 때문이다. 성능 지향적인 SW를 위한 자세로는 새로운 기술 습득보다는 장인 정신이 더 필요하다. 한 제품을 수 년, 수십 년 동안 개선하고 보강하지 않고서는 요구하는 성능을 만족시킬 수 없어서다. 성능 향상은 다양한 각도로 테스트하는 실험 정신과 원인을 추적하는 논리력, 사고력, 그리고 문제 해결을 위한 지식과 적용 능력 등 개발의 총체적인 능력을 요하는 작업이다. 기발하고 혁신적인 생각도 필요하지만, 그보다 더욱 중요한 것은 정성과 노력의 자세일 것이다.


SW 개발자들은 간결함을 미덕으로 삼고, 확장성을 중시하기 때문에 로직의 일반화를 선호한다. 일반화된 함수는 이해하기 쉽고 재사용성이 높기 때문에 개발자들은 생각을 정제하고 정제해 간결하고 일반화된 함수를 만들려고 노력한다. 여러 타입의 데이터를 인자로 받을 수 있는 정렬 함수를 util 패키지에 넣고 시스템의 여러 영역에서 util의 정렬 함수를 참조하면 각 영역에서 개별적으로 정렬하는 것보다 코드는 간략해지고 구조적으로 더 효율적이게 된다. 이런 이유로 일반화 지향의 반대편은 흔히 나쁜 것으로 치부된다. 데이터 타입별로 정렬하는 함수를 여러 벌 둔다는 것은 분명 개발자를 경악케 하는 일이다. 그러나 이 경악스러운 발상에도 이점은 있다. 바로 성능 향상이 그것이다.


일반성을 해치지 않는 범위에서 추구할 수 있는 성능은 일단 논외로 한다. 일반성을 희생하고 얻을 수 있는 성능 향상에 대해 이야기하고자 한다. 이 양극단 사이에서 주어진 시스템의 성격과 환경을 잘 고려해 일반성과 성능 향상을 조율하는 데에는 고민이 뒤따른다. 일반화를 선호하는 입장에서 성능은 때론 필요악이다. 필요악을 어떻게 우아하게 풀어낼 것인가는 개발의 흥분시키는 주제이기도 하다. 특수성을 활용한 성능 개선은 크게 세 가지 범주로 나눌 수 있다.



지름길 개척

잘 정비된 도시의 도로에서 어느 특정 지점과 다른 지점을 잇는 길에 병목현상이 자주 생긴다고 가정하자. 두 곳을 바로 잇는 지름길을 건설하는 것이 지름길 개척의 한 방법이 될 수 있다. 물론 지름길이 난무하다보면 도시 계획은 엉망이 되고 도로의 복잡도도 감당할 수 없는 지경에 이를 수 있다. 그렇기에 언제나 ‘정도’가 중요하다. 감당 가능한 수준의 복잡도를 유지만 한다면 지름길은 드라미틱한 성능 개선을 가능케 할 것이다.


발상의 전환이 필요한 경우

지름길 수준을 벗어나 아예 발상의 전환을 필요로 하는 경우도 더러 있다. 문제별로 틀에 박힌 풀이 방법에서 벗어나 특수성을 최대한 고려해 아예 다른 풀이법을 찾아야 하는 경우다.


다양성의 수용

일반성의 극단적인 양보에 해당되는 다양성의 수용도 특수성을 활용한 성능 개선 방법 중 하나다. 쉽게 말해 case-by-case로 각각의 경우에 최적을 구현하는 것이다. DBMS와 같은 견고한 시스템에서는 이러한 case-by-case 식의 최적화 수법이 의외로 많이 쓰인다. 정공법이 아닌 편법처럼 보이는 전략이지만 좋게 포장하면 한 땀 한 땀이 깃든 정성으로도 볼 수 있다.



1. 지름길 개척

특수성을 활용한 가장 간단한 성능 개선 방법은 상황이 특정 조건일 때 더 빠르고 효율적인 로직을 수행하게 끔 조건부를 다듬는 것이다. 예컨대 퀵 정렬은 in-place(추가적인 메모리 없이)로 n×log n의 성능을 내는 뛰어난 정렬 알고리즘이다. 주로 쓰이는 3-way 퀵 정렬(2-way 보다 선호되는 이유는 차후 설명한다)은 n이 클 경우 좋은 성능을 내지만 n이 작으면 n×n인 Insertion 정렬보다 느리다. 이는 퀵 정렬을 위해 스택을 이용한 이터레이션(iteration 혹은 재귀호출) 연산 비용이 추가되기 때문이다. 그러므로 리스트 크기가 일정 개수 이하인 경우 퀵 정렬 대신 insertion 정렬로 대체해 처리하는 것이 성능상 이점이 있다(<리스트 1> 참조).


<리스트 1> 적은 개수의 배열은 insertion 정렬로 처리하는 퀵 정렬 예 void quick_sort(int list[], int count, int insertion_threshold) { if (count < insertion_threshold) { // insertion sort ... } else { // quick_sort // ... if (left_count > 0) quick_sort(&list[0], left_count, insertion_threshold); if (right_count > 0) quick_sort(&list[count-right_count], right_count, insertion_threshold); }}


실 환경에서는 재귀호출보다 스택을 이용한 이터레이션이 더 많이 사용되지만, 편의상 재귀호출이라고 가정했다.

insertion_threshold 값에 따른 성능 변화는 <그림 1>과 같다. 이 테스트는 1600만 개(정확하게는 16×1024×1024개)의 정수(interger) 정렬을 수행한 시간을 표시한 결과다. <그림 1>에서 볼 수 있듯 리스트의 원소 개수가 16개 이하일 경우 insertion 정렬을 적용했을 때 최적의 성능을 발휘했다. 이처럼 조건을 달아 예외적인 경우 더 효과적인 로직 수행이 성능 개선의 첫 걸음이다. 때론 고정 크기의 블록 단위로만 메모리를 할당할 수 있는 상황이 있다. 정렬 수행을 위해서는 개별 블록들을 퀵 정렬로 처리한 후 나중에 전체 정렬을 위해 합병(Merge)해야만 하는 경우다. 개별 데이터의 크기는 수십, 수백 바이트가 넘을 수 있어 이동 비용이 컸다. 이에 필자는 out-place 방식으로 새로운 블록에 합병 결과를 옮기는 것보다는 개별적으로 정렬된 블록들을 정렬 순서대로 한 데이터씩 바로(just in time) 가져가는 방식을 택했다(<그림 2> 참조).


<그림 1> insertion sort를 위한 리스트 길이별 정렬 성능


<그림 2>개별 정렬된 블록들로부터 바로바로 합병 정렬하는 과정


<그림 2>에서 leaf들은 개별적으로 정렬된 데이터가 있는 블록이다.
그 위의 노드들은 just-in-time 합병을 위한 바이너리 트리(binary tree) 노드들이다.


root 노드의 less가 가리키는 블록의 첫 번째 데이터를 반환하고 그 블록의 parent path를 따라 올라가면서 좌, 우 블록과 대소를 비교해 less 포인터를 갱신하는 식이다.



<리스트 2> just-in-time 머지 정렬과 그를 위한 자료구조 typedef struct node { int *data; int pos; node* parent; node* less; node* left; node* right; } node; int fetch_next(node *anode, int max) { node *target = anode->less; node *lless; node *rless; int result = target->data[target->pos]; target->pos++; target = target->parent; while (target != NULL) { lless = target->left->less; rless = target->right->less; if (lless->pos >= max) { target->less = rless; } else if (rless->pos >= max) { target->less = lless; } else if (lless->data[lless->pos] <= rless->data[rless->pos]) { target->less = lless; } else { target->less = rless; } target = target->parent; } return result; }


leaf 블록들이 수천, 수만 개가 되면 트리의 깊이가 깊어지기 때문에 parent path를 따라 올라가면서 less를 갱신하는 비용이 꽤나 비싸다. 이를 해결할 방법은 없는 것일까? 만약 해당 블록의 값을 반환한 후 그 반환한 데이터의 다음 데이터가 반환한 데이터와 같다면 less 포인터는 갱신될 필요가 없을 것이다.


<그림 3>반환한 데이터와 다음 데이터의 값이 동일할 경우엔 less 포인터를 갱신할 필요가 없다


예컨대 <그림 3>과 같은 상황에서 480을 반환한 후 다음 값이 480이면 굳이 Parent Path로 올라가 less 포인터를 갱신할 필요가 없다. 이 지름길을 구현한 코드는 <리스트 3>이다.


<리스트 3> 지름길 코드가 적용된 just-in-imte 합병 정렬 int fetch_next(node *anode, int max) { node *target = anode->less; node *lless; node *rless; int result = target->data[target->pos]; target->pos++; if ((target->pos < max) && (result == target->data[target->pos])) { return result; // propagation이 필요없이 바로 함수를 빠져나갈 수 있다. } target = target->parent; while (target != NULL) ... }


if 문이 추가된 만큼 연산 부담이 조금 늘었다. 그러나 같은 값들이 많은 경우 비용을 상당히 절감할 수 있다. 값의 분포는 상황에 의존적인 경우가 많다. 그러므로 확률이 더 높은 특수한 상황을 가정해 약간의 비용 상승을 감수하고 효율을 개선하는 것도 한 방법이다. 때로는 비용을 희생하지 않고 지름길을 통해 성능 향상이 가능한 경우도 있다. JDBC 드라이버를 DBMS에 연결해 테이블을 조회하는 절차에서도 그 가능성을 발견할 수 있다.


<그림 4> 클라이언트 앱(APP)과 JDBC 드라이버, DB 서버간의 시퀀스 다이어그램


사용자가 executeQuery를 호출하면 드라이버에서 DB 서버로 해당 프로토콜을 전달하고 결과를 받아온다. ResultSet의 getXXX()와 같은 메소드를 통해 데이터를 얻은 후 ResultSet을 닫으면(close) 드라이버는 서버로 하여금 커서(DB Server 내에 있는 ResultSet과 비슷한 구조체)를 닫게 만든다. 클라이언트 앱과 JDBC 드라이버는 같은 프로세스 내에서 이루어지는 함수 호출 관계이지만, 드라이버와 DB 서버의 경우 TCP/IP 통신을 한다는 데 주목하자. 즉 간단한 테이블 조회를 위해 2번의 통신이 일어나는 것이다. 이것이 이 실행 사이클의 성능 향상의 포인트라고 할 수 있다.


<리스트 4> 작은 테이블 조회를 수차례 반복하는 예 for (int i=0; i< LOOP_COUNT; i++) { ResultSet rs = stmt.executeQuery("SELECT * FROM EMP WHERE ID=10"); if (rs.next()) { rs.getString(1); } rs.close(); }


<리스트 4>처럼 테이블 조회가 계속 반복되는 경우를 생각해 보자. 조금 비현실적인 코드지만 이처럼 한 건 또는 아주 적은 row 조회를 빈번히 수행하는 경우 말이다. 한 사이클에 두 번의 통신이 왕복으로 이루어지는데, 여기서도 지름길을 찾을 수 있다. 서버에서 커서가 열리고 모든 row를 패치한 상황이면 닫는 것 외에 할 수 있는 게 없다(forward only 커서인 경우). 따라서 서버에서 커서의 모든 row가 패치되면 닫고 이를 드라이버에게 알려주는 지름길을 생각해볼 수 있다. 추후 사용자가 ResultSet.close를 호출한다고 하더라도 서버와 통신은 이루어지지 않는다. 드라이버 자체적으로 관련된 ResultSet의 close를 수행하면 된다.


짧은 데이터가 반복적으로 질의되는 상황에서 이 방식은 큰 성능 향상 효과를 발휘한다. 일반적인 상황만 고려했다면 프로토콜의 원칙대로 ResultSet.close 시에는 서버의 커서를 닫는 게 정상적일 것이다. 이 경우 특수 상황을 고려한 끝에 효율을 선택한 것이다. 이 성능 향상 방법에 댓가가 전혀 없는 것은 아니다. 분명 이는 프로토콜의 원칙을 깬 예외처리다. 커서를 서버에서 스스로 닫는 규칙도 추가로 구현해야 하며, 드라이버는 서버에서 스스로 닫을 수 있다는 점도 고려해야 한다. 이러한 예외적인 상황이 많을수록 시스템은 복잡해지고, 그 복잡도를 감당하는 유지보수 비용은 개발자, 나아가 기업의 몫이 된다.



2. 특수성을 이용한 발상의 전환

이번에는 특수성을 활용해 지금길이 아닌 완전히 다른 알고 알려진 알고리즘인 퀵 정렬부터 살펴보자. 전통적인 퀵 정렬는 맨 왼쪽이나 오른쪽 원소를 pivot으로 한 후 pivot보다 큰 수를 오른쪽에, 작은 수를 왼쪽에 두고 pivot 좌우 서브 리스트를 다시 재호출해 정렬한다. 그러나 이 경우 같은 수가 많거나 이미 정렬된 리스트를 처리할 때에는 성능이 급격히 떨어지는 단점이 있다. 그래서 현업에서는 주로 이를 보완한 3-way 퀵 정렬을 주로 이용한다. 3-way 퀵 정렬은 데이터 분포라는 특수 상황을 이용한 개선이라기보다는 2-way 퀵 정렬이 특수 상황일 때 최악의 성능을 보이는 결점을 보완한 방법이다.


<그림 5> 랜덤 값의 분포도에 따른 퀵 정렬 성능


<그림 5>는 1600만 개의 데이터(정확하게는 16×1024×1024)에 대해 이터레이션으로 수행하는 3-way 퀵 정렬과 2-way 퀵 정렬의 수행시간을 비교한 그래프다. x축은 random 값을 % 연산에 사용한 값이다. x가 10000이면 정렬에 사용된 데이터는 random()%10000이다. 이렇게 함으로써 중복 데이터의 개수를 늘린 효과가 난다. 3-way 퀵 정렬은 pivot과 같은 값들을 다음 정렬할 서브 리스트에 포함시키지 않기 때문에 같은 값들이 많을수록 수행 시간은 짧아진다.


반면 2-way 퀵 정렬은 pivot으로 정한 데이터 이외의 모든 요소들이 다음으로 정렬할 서브 리스트에 포함된다. 또한 동일 값이 많으면 서브 리스트가 극단적으로(길이가 각각 1과 n-1인 서브 리스트로) 나눠지는 경향이 강해 성능이 n×n에 가깝게 된다. 3-way 퀵 정렬은 더 다양한 환경에 맞춰 개선한 알고리즘인 것이다. DBMS에서 특수성을 이용한 성능 추구 사례를 살펴보자. 많은 사람들이 오라클과 같은 DBMS에서 sort merge join이나 order by을 퀵 정렬과 같은 알고리즘으로 수행할 것이라고 생각한다. 그러나 실제로는 그렇지 않다. DBMS에서는 다음과 같은 이유로 퀵 정렬 사용을 지양한다.



공간의 연속성 제약

DBMS에서 정렬을 위해 필요한 메모리는 연속적인 공간이 아니다. DBMS는 많은 양의 데이터를 다루기 때문에 그 많은 데이터를 연속된 메모리 공간에서 정렬할 만한 메모리를 할당할 수 없다. 대부분 블록이라는 단위로 쪼개진 메모리 조각들을 할당할 뿐이다. 그렇기 때문에 병합 과정이 불가피하다. 이 과정에서 데이터들은 새로운 블록으로 옮겨다니기 때문에 순수한 퀵 정렬만큼 빠르지 않다.


비싼 비교 연산 비용

DBMS는 어떤 데이터 타입을 정렬해야 할지 가정할 수 없기 때문에 정렬 함수 내부에서 직접 비교 연산을 할 수 없다. 그러므로 비교 함수를 인자로 받아 함수를 통해 비교해야 한다. 더욱이 order by 절에 복수개의 컬럼이 올 경우 비교 연산은 여러 컬럼에 대해 수행되므로 비교 연산을 하기 위해 여러 코드 영역을 넘나들어야 한다. 이로 인해 캐시 미스가 높아지고 함수 콜 비용도 추가된다. 이 때문에 DBMS에서의 비교 연산 사용을 정수 비교 연산처럼 생각해서는 안 된다.


만약 데이터에 특수한 가정을 적용한다면 퀵 정렬보다 더 빨리 정렬할 수 있을 것이다. 가령 데이터 하나의 크기가 특정 크기로 제한되고, 크기 비교에 있어 상위 바이트가 클수록 더 크다는 사실을 보장할 수 있는 데이터라면 Radix 정렬 적용을 고려할 수 있다. 데이터 d1, d2가 있고, length(d1) == length(d2)이며, 각 데이터의 i번째 바이트를 d1_b[i], d2_b[i]라고 했을 때 1 <= i <= length(d1)의 i에 대해 d1_b[i] != d2_b[i]를 만족하는 최소 i가 있다고 가정하자. 그리고 d1_b[i] > d2_b[i]이면 d1 > d2이고 d1_b[i] < d2_b[i]이면 d1 > d2임을 만족하는 경우 Radix 정렬을 적용 할 수 있다.


Radix 정렬의 수행비용은 n×l이다(n은 데이터 개수, l은 데이터 길이). Radix 정렬에는 LSD(least significant digit) 방식과 MSD(most significant digit) 두 가지 방식이 있는데, 데이터 길이가 짧다면 LSD 방식이 더 유리하다. 오라클 데이터베이스는 Radix 정렬을 사용하는데 이를 위해 모든 타입의 데이터를 Radix sort가 가능한 형태로 저장한다. 즉 오라클 데이터베이스는 정수 데이터 타입을 하드웨어에서 제공하는 integer(native interger) 타입을 그대로 사용하는 게 아니라 NUMBER 타입 형태로 저장한다. 이 때문에 정수 타입에 대해서도 Raidx 정렬을 수행할 수 있다. native interger는 최상위 비트가 음수 표시에 사용하기 때문에 Radix로 비교할 수 없다. 만약 모든 데이터 타입이 위 조건식을 만족하면(radix comparable) 타입에 관계없이 동일한 연산 가능하다. 이는 성능 관점에서 굉장한 강점이라고 할 수 있다. 또한 Radix 정렬은 연속된 메모리 공간도 필요없다. 블록 단위로 쪼개진 메모리를 사용해도 아무런 문제가 없다는 뜻이다.


오라클이 Radix 정렬을 적용한 사례가 특수성을 이용한 발상의 전환이라고 해야 할지, 더 나은 알고리즘을 위해 일반성을 제거하고 데이터를 특수화한 것인지는 알 수 없다. 어쨌든 특수성을 활용한 좋은 예라고 할 수 있다. 필자가 몸담고 있는 선재소프트의 인메모리 DBMS인 선DB는 native 숫자 타입을 지원하지만 내부적으로는 Radix 정렬을 이용한다. 더블(Double)이나 몇몇 적용이 불가능한 경우에는 퀵 정렬과 합병 정렬도 사용하기도 하지만, native integer 같은 경우에는 특수한 처리를 한 후 Radix 정렬을 수행한다. 즉 특수 상황을 인위적으로 만들기도 하고 피하기도 하며 더 나은 알고리즘을 적용할 수 있는 환경을 구현했다.


참고로 정렬에서 극단적인 특수성을 이용할 수도 있다. 정렬되는 데이터가 short(2바이트 정수)라면 65536개의 엘리먼트를 갖는 integer array를 이용해 O(n)으로 수행되는 counting 정렬이 그 예가 될 것이다. limit 절 같이 상위 몇 개의 정렬을 하는 상황에서는 전체 정렬보다 상위 k개를 유지해 nⅹlog k 비용으로 처리할 수도 있을 것이다. 정리하면. 주어진 환경을 최대한 활용해 거기에 국한된 최상의 알고리즘을 찾아보는 것은 성능 개선의 좋은 방안이 될 수 있다.


3. 다양성 수용

앞서 데이터 타입 별로 정렬 함수를 두는 것은 개발자를 경악시킬 만하다고 말한 바 있다. DBMS처럼 성능을 극단적으로 추구하는 시스템 SW는 얼마든지 그런 상황도 수용할 수 있다.


<리스트 5> 타입별로 최적화된 정렬을 수행하는 예 void sort(void *data, metaInfo *meta) { sortFunc doSort; if (radixComparable(meta) == TRUE) { do_sort = radixSort; } else if (meta->keyColumnCount == 1) { if (meta->keyColumn[0].dataType == SHORT) { do_sort = countingSort; } else if (meta->keyColumn[0].dataType == INTEGER) { doSort = quickSortInteger; } else ... } else { doSort = sortGeneral; } doSort(data, meta); } void quickSortGeneral(void *data, metaInfo *meta) { ... int compareResult = 0; // 두 컬럼의 비교 연산 for (int i=0; i< meta->keyColumnCount; i++) { compareResult = meta->keyColumn[i].compare(data1, data2); if (compareResult != 0) { break; } } if (compareResult > 0) { ... } else ... } void quickSortInteger(void *data, metaInfo *meta) { ... if (data1 > data2) { // 직접 비교가 가능하다. ... } else ... }


sort 함수 내에서 비교 연산이 일어나는 횟수를 생각하면 quickSortGeneral과 quickSortInteger의 성능 차이가 어느 정도일지 충분히 상상이 될 것이다. 이처럼 여러 상황별로 다양한 정렬 방법을 사용하는 것은 코드의 redundancy를 굉장히 높인다. 시스템 SW의 덩치는 이런 데서 비롯된다고 해도 과언이 아니다. 덩치가 클수록 SW 동작이 더 굼뜨다고 생각하기 쉽지만 이외로 처리를 위한 준비 과정이 복잡하고 더뎌도 막상 크리티컬한 수행(execution 과정)은 굉장히 빨라 놀랄 때가 많다. 모든 처리 과정이 execution의 최소 수행 시간에만 맞춰져 있기 때문이다. 다른 사례도 살펴보자. DBMS의 옵티마이징은 상황별로 최적화를 얼마나 다양하게 구현하는가에 따라 품질이 좌우된다고 할 수 있다.


<리스트 6> in-subquery의 예 SELECT ... FROM table1 WHERE col1 IN (SELECT col2 FROM table2)


<리스트 6>는 in subquery 최적화 기법이라는 것인데, 이 질의문은 다음 각각의 상황을 모두 고려해야 한다.



● col1이 key인가 아닌가(index가 있는가 없는가와 같은 의미)

● col2가 key인가 아닌가(index가 있는가 없는가와 같은 의미)

● table2가 큰가 작은가



예컨대 col1이 key가 아니고 col2가 key인 경우 col1을 outer로 하고 col2를 inner로 한 nested loop join(NL) 방식으로 모든 col1과 col2에 대해 index search를 수행할 수 있다. 반면 col1이 key이고 col2가 key가 아닌 경우 col2의 중복값을 제거한 후 각각에 대해 역으로 col1의 index search가 이루어진다. 전자의 index search에서는 1개의 값만 발견되면 in 조건을 만족하게 되고, 후자의 index search는 만족하는 모든 값들이 결과에 반영된다. table2가 큰가 작은가는 해시나 임시 소트 테이블을 만들 때 비용 계산을 위해 사용된다.


SQL 옵티마이저는 모든 질의문 유형에 대해 이런 상황별 최적화된 거대한 엔진이다. 얼마나 많은 상황들이 고려됐는가가 옵티마이저의 품질을 결정한다. SQL 옵티마이저야 말로 특수성에 대한 다양성을 최대한 활용한 가장 좋은 예라고 할 수 있다.


자바로 프로그램을 구현하다보면 데이터 일반을 나타내기 위해 Obejct 클래스를 많이 사용하게 된다. 모든 데이터를 포용할 수 있는 가장 일반적인 타입이기 때문에 자주 사용되지만, 자바의 primitive 타입에는 사용할 수 없다. 이를 위해 자바는 이들 타입의 wrapper 클래스인 integer, short, long 등의 타입을 지원한다. 이 덕분에 프로그램의 명료성과 간결성이 확보됐다. 반면 많은 양의 데이터를 다루는 경우 객체 생성 비용이나 메모리 사용량 측면에 있어서는 약점을 드러난다.


눈치챘겠지만 int, short, long, double, float 등 primitive 타입별로 데이터를 처리하는 방안도 고려할 수 있다. ArrayList로 Object 수만 개를 유지하는 것과 수만 크기의 primitive 타입 array를 사용하는 것은 성능이나 자원 효율성 측면에서 의미 있는 차이를 보인다. JDBC 드라이버의 ResultSet은 때로는 굉장히 많은 row를 메모리에 캐싱할 때가 있다. 컬럼의 데이터를 Object array에 담으면 수십만, 수백만 개 이상으로 할당되는 경우도 있다. 실제로 Primitive 타입의 컬럼들은 Object array 대신 primitive 타입의 배열에 담았을 때 메모리 사용량이 현저히 줄어들고, 가비지 콜렉션으로 인한 성능상의 불안전성이 개선된 경험이 있다. int [1000000]을 위해서는 4MB 크기의 힙을 사용해야 하지만 Integer[1000000]은 19MB 이상의 힙 용량을 요구한다.


성능 개선에도 중용이 필요해

지금까지 일반성을 양보해 특수성을 활용한 성능 개선 접근법들을 살펴봤다. 특수성을 활용한다는 것은 그 만큼 다양한 환경에 대처하는 능력이 떨어진다는 의미이기도 하다. 일반성과 특수성을 활용한 성능 관계가 이처럼 trade-off 특성을 가졌기 때문에 SW의 특성에 맞는 절충점을 고민할 필요가 있다. 이 절충점과 같은 말로 옛 현자들은 중용(中庸)이라는 우아한 표현을 즐겨썼다. 중용사상은 상황 판단에 앞서 양 극단을 잘 살피고 시의적절함을 잘해야 한다는 가르침을 준다. 공자의 손자인 자사는 공자의 가르침 중에서도 이 진리를 극히 존숭해 오늘날 고대 중국의 4서중의 하나로 일컫어지는 <중용>을 후세에 남겼다. 그 만큼 선조들은 매사에 중용이 필요치 않은 순간이 없고, 선택이 모여 인생을 이루고 인류사를 만든다는 진리를 깨달았기 때문에 중용이 인류의 지혜로 남을 수 있었다. 중용사상을 성능 개선에 적용하면 그 적절함을 판단함에 있어 개발자의 개인 성향에 의존하기보다 SW를 지향할 수 있다. 이는 회사의 개발 여력과 일정, 개발자의 열의의 정도, 팀워크, 시장 요구 등 다양한 상황을 총체적으로 판단함을 의미한다.



출처 : 마이크로소프트웨어 12월호

제공 : 데이터전문가 지식포털 DBguide.net