. 문자열 검색

검색의 대상은 열린 문서이며 문서에 대한 모든 정보는 ApiEdit가 가지고 있다. 따라서 문자열 검색의 주체는 호스트가 아닌 CApiEdit 객체여야 한다. ApiEdit.cpp에 문자열을 검색하는 다음 함수를 추가하고 ApiEdit.h에 이 함수의 원형을 기록한다.

 

TCHAR *FindString(TCHAR *buf, int nPos, TCHAR* what,BOOL bDown, BOOL bCase)

{

     TCHAR *pWhat;

     int nOff;

     TCHAR *pResult;

     BOOL bFind;

     int Inc;

     int len;

     TCHAR ch;

 

     pWhat=(TCHAR *)malloc(lstrlen(what)+1);

     lstrcpy(pWhat,what);

     if (bCase==FALSE) {

          CharUpper(pWhat);

     }

     len=lstrlen(pWhat);

 

     if (bDown) {

          Inc=1;

     } else {

          Inc=-1;

     }

 

     for (;;nPos+=Inc) {

          if (buf[nPos]==0 || nPos<0) {

              pResult=NULL;

              break;

          }

 

          bFind=TRUE;

          for (nOff=0;nOff<len;nOff++) {

              if (bCase) {

                   ch=buf[nPos+nOff];

              } else {

                   ch=toupper(buf[nPos+nOff]);

              }

 

              if (ch!=pWhat[nOff]) {

                   bFind=FALSE;

                   break;

               }

          }

 

          if (bFind==TRUE) {

              pResult=buf+nPos;

              break;

          }

     }

 

     free(pWhat);

     return pResult;

}

 

이 함수는 buf nPos에서 what 문자열이 있는 위치를 찾아 리턴하되 문자열이 발견되지 않으면 NULL을 리턴한다. bDown은 아래/위 검색 방향을 지정하며 bCase는 대소문자 구분 여부를 지정한다. 표준 C 함수 중에 문자열 버퍼에서 원하는 문자열의 위치를 정확하게 찾아 주는 strstr이라는 함수가 있지만 이 함수는 대소문자 구분 무시, 역방향 검색은 지원하지 않는다. strrstr 함수와 strstri, strrstri 함수가 지원된다면 굳이 이 함수가 필요없겠지만 그런 함수가 없으니 직접 만들어 쓸 수밖에 없다.

보다시피 이 함수는 CApiEdit의 멤버함수가 아닌 일반함수로 정의되어 있다. 이 함수가 멤버가 될 수 없는 이유는 호스트도 파일검색을 위해 문자열 검색 함수를 필요로 하기 때문이다. 복수 개의 파일을 검색할 때 ApiEdit 객체에 일일이 텍스트를 넣고 정렬한 후 검색하자면 너무 느려지기 때문에 파일만 읽은 상태에서 버퍼를 바로 뒤져야 한다. 그러기 위해서 이 함수는 ApiEdit와 논리적인 상관이 없는 일반함수가 되어야 한다.

이런 일반적인 목적의 함수라면 Util.cpp에 작성할 수도 있지만 그렇게 되면 ApiEdit가 독립성을 잃어버리게 된다. Util 모듈은 당근의 것이지 ApiEdit의 것이 아니다. 반대로 ApiEdit 모듈에 이 함수를 일반함수로 작성해놓고 원형만 공개하면 Dangeun도 이 함수를 자유롭게 쓸 수 있고 ApiEdit도 독립성을 잃지 않는다. Dangeun은 원래부터 ApiEdit에 종속적이므로 문제될 것이 없다. 좀 더 형식성을 따지자면 이 함수는 CApiEdit 클래스의 정적 멤버가 되는 것도 나쁘지 않지만 CApiEdit와 직접적인 연관성이 없어 그렇게 하지 않았다.

함수 선두에서는 검색 루프를 간단하게 만들기 위한 두 가지 예비 동작을 한다. 첫 번째 예비 동작은 대소문자 구분 무시 옵션이 주어졌을 때(bCase==FALSE) 검색 대상 문자열을 모두 대문자로 바꾸어 놓는 것이다. 대소구분없이 두 개의 문자가 같은지 비교하려면 대대, 대소, 소대, 소소 4가지 케이스를 비교한 후 논리합을 취해야 하는데 문자 수가 많아지면 이 조합이 기하 급수적으로 늘어나 버린다. 그래서 비교를 단순하게 하기 위해 모두 대문자로 바꾼 후 비교하도록 했다.

물론 대소문자를 구분한다면(bCase==TRUE) 대문자로 바꾸어 놓을 필요가 없다. 이 옵션 여부에 따라 pWhat 버퍼는 what의 사본을 가지든지 아니면 what을 대문자화한 문자열을 가지게 될 것이다. 이후 검색 루프에서는 인수로 전달된 what을 찾는 것이 아니라 대소문자 구분 옵션을 이미 적용한 pWhat을 찾게 된다. 검색 문자열의 길이도 루프에서 종종 참고되므로 len 변수에 미리 길이를 조사해두었다.

두 번째 예비 동작은 Inc 변수에 검색의 방향을 미리 계산해놓는 것이다. 아래로 검색이면 이 값에 1을 대입하여 nPos를 증가시켜 가며 검색을 진행하고 위로 검색이면 이 값에 -1을 대입하여 nPos를 감소하면서 검색하면 된다. Inc는 검색 루프에서 nPos를 증감시키는 값으로 사용되는데 미리 이 값을 계산해 둠으로써 검색 루프에서는 bDown 인수에 대한 고려는 하지 않아도 된다. Inc 변수는 bDown옵션을 루프에서 바로 쓸 수 있는 형태로 가공한 것이라고 생각하면 된다.

for 루프는 nPos에서부터 시작하여 Inc 방향으로 무한루프를 돌며 pWhat 문자열을 검색한다. 이 루프를 빠져 나오는 탈출 조건은 둘 중 하나이다. 첫 번째는 버퍼의 끝이나 처음에 이를 때까지 원하는 문자열을 찾지 못했을 때이며 두 번째는 성공적으로 찾은 경우이다. 검색에 실패했을 때는 pResult NULL을 대입하고 성공했을 때는 찾은 번지를 대입하였다.

현재 nPos 위치에 pWhat 문자열이 있는지는 안쪽 for (nOff) 루프에서 검사한다. 일단 for (nOff)루프에 들어가기 전에 bFind TRUE로 초기화하고 buf[nPos]를 베이스로 한 문자열과 pWhat 문자열을 일대일로 모두 비교해 본다. 이 중 하나라도 틀린 것이 발견되면 nPos 위치에는 pWhat이 없으며 bFind FALSE가 된다. 바깥쪽 for 루프는 다음 nPos 위치로 진행하는 Inc 값에 따라 앞이나 뒤로 nPos를 이동시킨 후 계속 비교하기를 반복한다.

비교 루틴에는 bCase 조건이 포함되어 있는데 대소문자 구분 상태이면 buf에서 읽은 값과 pWhat의 대응되는 값을 바로 비교하고 대소문자 무시 상태이면 buf에서 읽은 값을 대문자로 만든 후 비교하였다. 만약 dog를 대소문자 구분없이 검색한다면 pWhatDOG가 되며 본문 중에 Dog 문자열은 비교할 때 D, O, G로 변환된 후 비교될 것이다.

이런 식으로 바깥쪽 for 루프는 검색에 성공하거나 완전히 실패할 때까지 반복을 계속하며 그 결과를 pResult에 대입한다. 검색 대상 문자열의 사본인 pWhat을 해제한 후 pResult를 리턴하면 이 함수는 종료된다. 이 함수의 논리는 아주 직선적이고 단순하기 때문에 이해하기 쉬울 것이다. 만약 루프 구조가 조금 복잡해서 얼른 이해가 되지 않는다면 디버거로 동작을 살펴보기 바란다.

구조가 간단한 대신 속도는 좀 느린 편인데 좀 더 효율을 높이고자 한다면 공개된 검색 알고리즘을 사용할 수 있다. 문자열 검색 알고리즘은 해시, 점화식 사용 등 몇 가지 알고리즘들이 이미 개발되어 있어 이 알고리즘만 수정해도 눈에 띄게 속도가 증가할 것이다. 하지만 여기서는 성능보다 단순함을 위해 일단 이 수준에서 만족하기로 한다. 어차피 다음에 정규식(Regular Expression)까지 지원하려면 대대적인 구조 조정을 해야 하기 때문이다.

대소문자 구분과 검색 방향은 FindString 유틸리티 함수가 처리하므로 ApiEdit는 되돌리기, 단어 단위로 등의 나머지 옵션들을 처리하는 검색 함수를 작성한다. 이 함수는 전역 버퍼를 대상으로 검색하므로 CApiEdit의 멤버로 포함된다. 다음과 같이 함수를 작성하도록 하자.

 

BOOL CApiEdit::FindText(int nPos, TCHAR *what, DWORD dwFlag)

{

     int nStart;

     TCHAR *pFound;

     BOOL bWrap;

 

     if (nPos == -1) {

          if (SelStart==SelEnd) {

              nStart=off;

          } else {

              if (dwFlag & AE_FIND_UP) {

                   nStart=min(SelStart,SelEnd);

              } else {

                   nStart=max(SelStart,SelEnd);

              }

          }

     } else {

          nStart=nPos;

     }

 

     bWrap=((dwFlag & AE_FIND_WRAP)!=0);

     if (dwFlag & AE_FIND_UP) {

          nStart=nStart-lstrlen(what);

          if (nStart<0) {

              if (bWrap) {

                   bWrap=FALSE;

                   nStart=max(0,doclen-1);

              } else {

                   return FALSE;

              }

          }

     }

 

     for (;;) {

          pFound=FindString(buf,nStart,what,(dwFlag & AE_FIND_UP)==0,

              (dwFlag & AE_FIND_MATCHCASE)!=0);

          if (pFound) {

              if (dwFlag & AE_FIND_WHOLEWORD) {

                   if ((pFound==buf || IsDelimiter(pFound-buf-1)) &&

                        IsDelimiter(pFound-buf+lstrlen(what))) {

                        break;

                   } else {

                        if (dwFlag & AE_FIND_UP) {

                            nStart=pFound-buf-1;

                        } else {

                            nStart=pFound-buf+lstrlen(what);

                        }

                        continue;

                   }

              }

              break;

          }

 

          if (bWrap) {

              bWrap=FALSE;

              if ((dwFlag & AE_FIND_UP)==0) {

                   nStart=0;

              } else {

                   nStart=max(0,doclen-1);

              }

              continue;

          }

          break;

     }

 

     if (pFound) {

          SetSelect(pFound-buf,pFound-buf+lstrlen(what));

          return TRUE;

     }

     return FALSE;

}

 

세 개의 인수를 받아들이는데 nPos는 검색을 시작할 오프셋이고 what은 검색 문자열, dwFlag는 검색 옵션이다. nPos -1일 경우는 현재 위치에서 검색을 시작하는데 여기서 현재 위치는 선택영역 유무와 검색 방향에 따라 달라진다. 선택영역이 없다면 당연히 off가 현재 위치가 되지만 선택영역이 있다면 방향에 따라 선택의 시작점이나 끝점이 검색 시작점이 되어야 한다.

검색 방향이 위쪽일 때는 이외에 한 가지 조건이 더 필요한데 발견된 문자열은 적어도 현재 위치보다는 더 앞쪽에 있어야 한다. 다음 그림을 보자.

이 문장에는 두 개의 Korea 문자열이 있는데 두 번째 K위치에서 앞쪽으로 Korea를 찾도록 했다. 이때 검색 시작위치에서 바로 Korea가 발견되었으므로 두 번째 Korea가 선택될 것이다. 맞는 것 같지만 이렇게 하면 안된다. 이 상태에서 다시 앞쪽으로 찾기를 실행하면 계속 그 자리에 있는 것이 맞는지 아니면 다른 Korea를 찾아 앞쪽으로 가는 것이 맞는지 생각해보라. 앞쪽으로 가는 것이 옳다.

그렇다면 이번에는 조금 다른 경우를 생각해보자. 두 번째 Korea r위치에서 앞쪽으로 찾기를 실행했을 때는 어떤 Korea를 검색하는 것이 맞을까? 적어도 현재 위치보다는 앞쪽으로 이동했으므로 두 번째 Korea를 선택하는 것이 맞는 것 같기도 하고 검색결과 문자열의 길이가 현재 위치보다 뒤쪽에 걸치기 때문에 첫 번째 Korea를 선택하는 것이 좋아 보이기도 한다. 정답은 이 경우도 첫 번째 Korea를 찾는 것이 맞다. 왜냐하면 사용자는 Korea 단어의 중간에서 위로 찾기 명령을 내렸는데 이는 지금 빤히 보고 있는 단어가 Korea가 맞는지를 묻는 것이 아니고 이 단어와 같은 앞쪽의 다른 단어를 원한 것이기 때문이다.

그래서 검색 시작위치를 일단 계산한 후 위로 검색 옵션이 선택되어 있을 때는 검색 시작위치에서 검색 문자열의 길이만큼 앞쪽으로 이동하였다. 아주 특수한 경우로 이 조정에 의해 버퍼 언더런이 발생했으면 되돌아가기(bWrap) 옵션에 따라 문서 끝으로 가든지 아니면 검색에 실패한다. 검색 시작위치 nStart가 결정되면 다음 함수 호출로 검색을 한다.

 

          pFound=FindString(buf,nStart,what,(dwFlag & AE_FIND_UP)==0,

              (dwFlag & AE_FIND_MATCHCASE)!=0);

 

앞에서 작성해놓은 FindString 유틸리티 함수를 호출하였으며 검색 옵션 중 방향과 대소문자 구분 옵션을 인수로 전달하여 이미 적용하였다. 이 함수가 리턴하는 pFound의 결과가 곧 일차적인 검색결과가 된다. 어디까지나 일차적인 검색결과일 뿐이므로 제대로 찾았는지 또는 확실히 실패한 것인지를 더 점검해 봐야 하며 따라서 FindString 호출이 for 무한루프로 싸여져 있다. 이 루프는 확실히 성공했거나 아니면 완전히 실패할 때까지 반복된다.

우선 검색에 성공한 경우를 보자. 일단 성공했더라도 단어단위로 옵션이 선택되어 있으면 검색된 위치의 문자열이 과연 단어가 맞는지 확인한다. 검색 문자열의 앞뒤로 구분자가 있으면 단어를 제대로 찾은 것이고 그렇지 않다면 아직 찾지 못한 것이므로 nStart를 다음 위치로 옮긴 후 계속 검색해야 한다. 예를 들어 api를 찾으라고 했는데 winapi rapid 등의 부분 문자열을 찾았다면 잘못 찾은 것이다. 단어 단위로 옵션이 선택되어 있지 않으면 일차 검색결과가 그대로 성공으로 인정된다.

검색된 위치가 단어가 아닐 경우 이동할 다음 위치는 검색 방향에 따라 달라진다. 위로 검색이었으면 nStart=pFound-buf-1 위치 즉, 검색된 위치보다 1바이트 앞으로 이동한다. 이때 1바이트 앞쪽이 한글의 경계인지 아닌지는 신경쓸 필요가 없다. 왜냐하면 문자열 검색이란 바이트 단위로 비교를 하는 것이지 글자 단위로 비교를 하는 것이 아니기 때문이다. pFound가 문서의 선두일 때 nStart -1이 되는데 FindString 함수에서 오프셋의 한계 점검을 하고 있기 때문에 음수 오프셋을 가리키더라도 문제가 되지 않는다. 아래로 검색이었으면 검색된 위치에서 검색 문자열 길이만큼 뒤로 이동하면 된다.

다음은 검색에 실패한 경우를 보자. 비록 실패했더라도 되돌아가기 옵션이 선택되어 있다면 문서 처음으로(또는 끝으로) 돌아가 검색을 계속해야 한다. 오프셋 1234에서 아래로 korea를 찾기 시작했다면 오프셋 768에 있는 korea는 검색하지 못했을 것이다. bWrap 변수는 for 루프에 들어오기 전에 dwFlag에서 되돌아가기 옵션의 상태를 대입받았다. 검색에 실패했더라도 이 값이 TRUE이면 방향에 따라 문서 처음이나 끝으로 돌아가 한 번 더 검색해보도록 한다.

, 되돌아가기 옵션은 두 번 적용될 수 없으므로 한 번 적용하자마자 bWrap FALSE로 바꾸어 놓아 다음 실패시에는 정말로 실패할 수 있도록 해야 한다. 그렇지 않으면 for 루프는 정말로 무한루프가 되어 버릴 것이다. FindString 유틸리티 함수가 검색의 주요 기능을 제공하는데도 불구하고 이 함수가 그리 간단하지가 않다. 순서도로 이 함수의 흐름을 정리해보도록 하자.

FindText 함수는 검색에 성공했을 경우 검색된 문자열을 블록으로 선택하고 TRUE를 리턴한다. SetSelect 함수는 SetCaret을 호출하며 SetCaret은 새로 이동한 위치가 화면에 보이도록 스크롤할 것이다. 실패하면 아무 동작도 하지 않으며 FALSE가 리턴된다.

찾기 대화상자에서 찾기 버튼을 클릭하면 이 함수를 호출하여 문자열 검색을 하도록 한다. 이때 메인 윈도우로 WM_USER+2 메시지가 보내지며 wParam 1의 값을 가진다.

 

LRESULT CALLBACK DGWndProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)

{

     switch(iMessage) {

          ....

          case WM_USER+1:OnUser1(hWnd,wParam,lParam);return 0;

        case WM_USER+2:OnUser2(hWnd,wParam,lParam);return 0;

     }

     return(DefFrameProc(hWnd,g_hMDIClient,iMessage,wParam,lParam));

}

 

void OnUser2(HWND hWnd,WPARAM wParam,LPARAM lParam)

{

     HWND hActive;

     SInfo *pSi;

 

     if (IsWindowEnabled(hWnd)==FALSE)

          return;

 

     hActive=(HWND)SendMessage(g_hMDIClient,WM_MDIGETACTIVE,0,NULL);

     if (hActive == NULL) {

          return;

     }

     pSi=(SInfo *)GetWindowLong(hActive,0);

     switch (wParam) {

     case 1:

          if (pSi->Ae.FindText(-1,arFind[0].Get(0), FindFlag) == FALSE) {

              MessageBox(hWnd,"찾는 문자열이 없습니다.","알림",MB_OK);

          }

          break;

     }

}

 

찾기 대화상자에서 검색 문자열은 arFind[0]의 선두에, 검색 옵션은 FindFlag에 대입해두므로 이 두 값을 FindText 함수로 넘겨 주기만 하면 된다. 만약 검색에 실패하면 메시지박스로 실패 사실을 알려 주도록 했다. 이제 찾기 대화상자에서 다양한 검색 옵션으로 찾기를 할 수 있을 것이다.

이 즈음에서 약간 잔소리를 좀 해야겠다. 여기까지 만들어진 검색 핵심 함수는 FindString, FindText 두 개이며 이후의 바꾸기, 파일검색의 기본이 된다. 이 함수들의 코드는 사실 아주 쉽게 파악될 수 있을 정도로 단순한 편이라 금방 이해할 수 있을 것이다. 하지만 지금 책을 건성으로 읽으면서 책장만 넘기는 사람들에게는 도무지 왜 저렇게 만들었는지 이해가 안 갈 정도로 복잡해보인다. 현재 위치의 정의가 저렇게 복잡한 이유는 무엇이고 왜 함수를 둘로 나누었으며 FindString 호출이 왜 무한루프에 싸여 있는지 마치 안개 속을 걷는 것 같은 느낌일 것이다.

이 코드를 쉽게 이해한 사람과 아직 이해하지 못한 사람의 근본적인 차이점은 코드를 보고 설명을 읽는가 아니면 설명을 읽고 코드를 보는가의 차이이다. 코드 자체가 가장 함축적이고 정확한 설명을 하기 때문에 항상 코드를 중심으로 논리를 이해하려고 노력해야 한다. 말로 된 설명은 코드를 풀어서 설명하기는 하지만 코드만큼 정확하게 모든 것을 보여줄 수가 없다. 남이 만들어 놓은 함수를 분석할 때는 먼저 코드를 스스로 분석하려고 시도해 본 후 자신의 분석 내용을 설명으로 확인하고 이해되지 않는 부분에 대해서는 설명을 참조하는 것이 좋다.

수동적으로 타의적인 설명에 의존하지 말고 스스로 코드를 먼저 분석해보려는 적극적이고 능동적인 태도를 가지라는 말을 하고 싶은 것이다. 그렇지 않으면 아무 생각없이 책장만 넘어갈 것이고 결국 아무에게도 도움이 되지 못한다. 이런 잔소리를 자꾸 하는 것이 반갑지 않겠지만 앞으로 다룰 내용도 계속 이런 식으로 코드를 보지 않으면 이해하기 어려운 내용이라 나로서는 꼭 한 번 주의를 환기시킬 필요가 있다고 판단했다.