. 최적화 기법의 충돌

여러 가지 최적화 기법들을 적용함으로써 프로그램은 점점 더 빨라지고 있는데 이런 성능 향상을 확인해 가며 코딩을 하는 개발자는 참 신바람이 날 것이다. 조금 빨라졌다고 해서 여기서 만족할 수는 없으므로 최대한 힘 닿는 데로 욕심을 부려 볼만한 작업이다. 그래서 속도 측정을 정밀하게 해보고 각종 프로파일링 툴을 사용하기도 하고 코드를 꼼꼼히 점검해보기도 하는데 ApiEdit도 아직 더 최적화할 곳이 많이 남아 있다.

어떤 부분이 그런지 좀 더 점검해보면 금방 눈에 띄는 부분이 있는데 바로 입력중의 덮어쓰기 처리코드이다. 덮어쓰기 관련 코드는 OnChar, OnImeChar, OnImeCompostion 세 군데가 있는데 대표적으로 한글을 조립할 때의 덮어쓰기 처리코드를 보자.

 

LRESULT OnImeComposition(HWND hWnd, WPARAM wParam, LPARAM lParam)

{

     ....

              if (bOvr && bNewIns && bPrevSel==FALSE) {

                   if (IsDBCS(off)) {

                        if (buf[off] != ‘\r’) {

                            Delete(off,2);

                        }

                   } else {

                        Delete(off,1);

                   }

              }

 

              Insert(off,szComp);

              off+=len;

          }

 

덮어쓰기 모드이면 현재 위치의 문자를 지우고 새로 입력받은 문자로 대체하는데 이 과정에서 Delete Insert가 각각 한 번씩 호출된다. Delete Insert에서 각각 재정렬을 하므로 덮어쓰기 모드에서는 한 글자가 입력될 때 두 번 정렬을 하는 셈이다. Delete 호출과 Insert 호출 사이에는 화면출력도 없고 캐럿이 이동할 리도 없기 때문에 Delete에서 굳이 재정렬을 하지 않아도 Insert에서 한 번만 정렬을 하면 된다. 버퍼의 임시적인 변화에 대해서는 정렬을 생략할 수 있다는 것이다.

이런 예는 문자열 드래그를 처리하는 CopyString에도 있다. 드롭된 곳에 먼저 삽입한 후 선택영역을 삭제하는데 삽입할 때 정렬하고 삭제할 때 또 정렬을 할 필요가 없다. 삽입할 때는 잠시 정렬하지 않고 두었다가 삭제할 때 한꺼번에 정렬하면 된다. Delete Insert 함수는 항상 정렬을 할 필요없이 필요할 때만 정렬을 하도록 처리할 수 있는데 이 함수들을 다음과 같이 수정한다고 해보자. 문제가 있는 코드이므로 직접 실습하지 말고 보기만 하자.

 

void Insert(int nPos, TCHAR *str, BOOL bTemp/*=FALSE*/)

{

     ....

     if (bTemp == FALSE) {

          UpdateLineInfo(nPos,lstrlen(str));

          UpdateScrollInfo();

     }

     ....

 

void Delete(int nPos, int nCount, BOOL bTemp/*=FALSE*/)

{

     ....

     if (bTemp == FALSE) {

          UpdateLineInfo(nPos,-nCount);

          UpdateScrollInfo();

     }

}

 

bTemp라는 디폴트 인수가 하나 추가되었으며 이 인수가 TRUE값을 가지면 임시적인 변화이므로 재정렬을 하지 않는 것이다. bTemp가 디폴트값인 FALSE이면 별다른 지정이 없으므로 정렬을 한다. 덮어쓰기 모드에서 삭제할 때나 문자열 드래그에서 삽입할 때는 이 인수를 TRUE로 주어 정렬하지 않아도 곧바로 다시 정렬함수가 호출되므로 별 문제가 없다. 이렇게 되면 덮어쓰기 처리코드나 문자열 드래그 코드를 다음과 같이 수정하여 한 번의 불필요한 정렬을 생략할 수 있다.

 

LRESULT OnImeComposition(HWND hWnd, WPARAM wParam, LPARAM lParam)

{

     ....

              if (bOvr && bNewIns && bPrevSel==FALSE) {

                   if (IsDBCS(off)) {

                        if (buf[off] != ‘\r’) {

                       Delete(off,2,TRUE);

                        }

                   } else {

                   Delete(off,1,TRUE);

                   }

              }

 

              Insert(off,szComp);

              off+=len;

          }

 

덮어쓰기를 위해 삭제할 때는 정렬하지 않도록 했으며 잠시 후 삽입될 때 재정렬을 하도록 했다. 다른 부분도 이렇게 고칠 수 있는데 일단 이 함수만 수정한 후 제대로 동작하는지 테스트해보자. 예제 실행 후 문장을 입력해보고 덮어쓰기 모드에서도 입력해보자. 이 최적화 전략은 사실 문제가 있는데 다음 그림처럼 테스트를 해보자.

  

첫 번째 줄에 네 글자를 입력하고 4줄 띄우고 다섯 번째 줄에 세 글자를 입력해놓았다. 덮어쓰기 모드에서 정렬루틴정열함수로 수정하기 위해 자에 캐럿을 두고 입력해보면 화면이 깨지는 것을 목격할 수 있다. 다섯 번째 줄이 점점 밑으로 내려갈 뿐만 아니라 문서 끝에 엉뚱한 문자가 보이기도 한다. 문서가 파괴되는 것이 아니라 정렬에 문제가 있는 것이다.

이 문제의 원인은 디버깅을 해보면 알 수 있는데 문자가 삽입될 때의 정렬정보에 문자의 삭제 사실이 반영되어 있지 않기 때문이다. Delete에서 재정렬을 하지 않았으므로 Insert에서 참조하는 pLine 배열은 삭제되기 전의 상태 그대로이며 이 상태에서 2바이트가 새로 삽입된 것으로 인식하므로 유사 패턴을 잘못 찾는 것이다. 위 예의 경우 두 번째 줄에서 유사 패턴을 찾지 못하며 세 번째 줄이 이전 정렬의 두 번째 줄과 유사하다는 오판을 하게 된다. 자세한 원인을 설명하기는 어렵고 굳이 몰라도 상관없는데 궁금하면 직접 디버깅해보기 바란다.

그렇다면 임시적인 버퍼 변화에 대한 재정렬 생략이라는 최적화 전략이 논리적으로 문제가 있는가 하는 의문이 제기되는데 이 전략 자체는 근본적으로 문제가 없다. 삭제 후 곧바로 다시 삽입을 하므로 처음 삭제할 때는 분명히 정렬을 생략할 수 있다. 하지만 실제로는 문제가 생겼는데 그 이유는 이 전략(A전략)과 후방 정렬 간소화 전략(B전략)이 충돌을 했기 때문이다.

만약 B전략을 쓰지 않았다면 UpdateLineInfo 함수는 항상 GetLine 함수를 호출하여 실제 메모리의 상태를 보고 정렬을 할 것이고 이 상태라면 A전략은 논리적으로 아무 문제가 없다. B전략은 정렬 간소화를 위해 이전 정렬정보인 pLine을 재활용하므로 pLine은 항상 메모리의 실제 상황과 같아야 한다. 그러나 A전략은 불필요한 정렬 생략을 위해 pLine이 잠시 동안이라도 메모리와 불일치한 상황을 만들어 내며 이 상태에서 B전략이 먹혀 들어가기 위한 전제 조건이 파괴된 것이다.

상황이 무척 복잡한데 여기서 말하고자 하는 것은 최적화를 과하게 하다 보면 최적화 방법끼리 충돌이 나는 경우가 반드시 있다는 것이다. 본래 최적화라는 것이 불필요한 동작을 안하도록 하는 것인데 그 불필요한 동작이 다른 코드에서는 반드시 필요할 수도 있기 때문이다. 두 전략이 충돌을 했을 때는 둘 중 하나를 선택하거나 아니면 꼭 두 전략을 다 쓰고 싶다면 충돌을 하지 않도록 구조를 바꾸어야 한다.

이때 선택의 기준은 물론 어떤 전략이 속도 향상에 더 많은 이바지를 하는가이다. 이 예제의 경우 A전략에 의한 정렬 생략보다는 B전략에 의한 정렬 간소화가 훨씬 더 효과가 크다. 수치적으로 비교를 하기는 어렵지는 적어도 몇 십 배는 더 클 것이다. 그러므로 개발자는 당연히 A전략을 포기하고 B전략을 택했다. 어차피 정렬이 빨라지면 불필요한 정렬을 한 번 더 한다고 해서 많이 손해를 보지도 않는다.

보다시피 최적화를 하게 되면 코드가 복잡해질 뿐만 아니라 섣불리 고급 기법을 쓸 수 없어 코드를 유지하기가 어려워진다. 논리적으로 아무리 확신이 있다 하더라도 최적화 때문에 다른 코드가 영향을 받지 않는다는 보장을 하기는 어렵다. 이래 저래 최적화는 결코 쉬운 기술이 아닌 것이다. 왜 최적화를 프로젝트 개발 단계의 제일 마지막에 해야 하는지 이해가 될 것이다.

이 예제에서 최적화에 의해 코드 유지가 어려워지는 예를 또 찾아 볼 수 있는데 바로 GetRCFromOff 함수이다. 이 함수를 진작 처음부터 이분 검색을 하도록 하지 왜 지금에서야 이렇게 최적화를 하는지 의아해할 수도 있을 것 같다. 이분 검색이란 그렇게 어려운 알고리즘도 아닌데 말이다. 최초 이 함수를 순차 검색으로 작성했던 이유는 pLine 배열이 없었기 때문이다. 이분 검색이란 정렬된 배열을 기반으로 하기 때문에 실시간으로 줄 정보를 구하는 구조에서는 사용할 수 없다.

그리고 순차 검색이 이분 검색보다 반드시 열등한 알고리즘이 아니라는 것도 알아 두어야 한다. 어떤 경우는 오히려 순차 검색이 더 빠를 수도 있고 특별한 조건이 없어도 검색이 가능한 것도 큰 장점이다. 이분 검색이 가능하려면 항상 모든 자료의 정렬 상태를 유지해야 하는데 이것도 보통 어려운 일이 아니다. 두 알고리즘은 검색 방법의 다른 종류일 뿐이지 모든 경우에 있어서 이분 검색이 더 우월하다는 법은 없다. 이렇게 GetRCFromOff 함수를 이분 검색법으로 개선하면 모든 것이 다 좋아질 것 같지만 그렇지도 않다.

이 함수 자체만으로 볼 때는 이분 검색이 탁월한 선택이 될 수 있지만 다른 최적화 방법과 결합되면 차라리 순차 검색이 더 나을 수도 있다. 이분 검색이란 정렬된 완전한 정보가 있어야 가능한 방법이다. 그래서 이 함수가 이분 검색을 사용하는 순간부터 pLine은 항상 완전해야 한다는 제약이 생기게 된다. 백그라운드 정렬이라든가 스레드를 돌려 가며 다른 작업을 할 때는 항상 이 점을 조심해야 하는 부담이 생긴 것이다.