. 레코드 관리 원칙

취소 레코드는 편집코드와 재실행 코드가 통신을 하는 수단이다. 편집 함수는 취소 레코드에 편집 내용을 기록해놓으며 취소/재실행 함수는 이 기록을 참조하여 문서를 재 편집한다. 이 레코드에 기록되는 내용과 기록 방식은 일종의 엄격한 약속이기 때문에 양쪽이 정확하게 준수해야 한다. 만약 편집 함수가 기록을 잘못해놓거나 취소/재실행 함수가 기록된 내용을 엉뚱하게 해석한다면 제대로 취소도 안될뿐더러 문서의 포맷이 깨져 심각한 버그의 원인이 될 수 있다.

약속에 맞게 기록해야 함은 물론이고 또한 메모리 사용량을 최소화해야 한다. 입력이 들어오는 족족 레코드에 기록하기만 한다면 취소 레코드의 개수는 감당하기 힘들 정도로 많아지게 될 것이다. 꼭 필요한 만큼만 메모리를 할당하고 비슷한 레코드는 병합 처리하여 레코드 개수를 가급적이면 작게 만들어야 한다.

이런 제약 사항들이 있기 때문에 취소 레코드를 관리하는 코드는 무척 복잡하다. 복잡한 코드는 작성하기도 힘들지만 작성한 후 유지하는 것은 더 힘들다. 그래서 복잡한 동작을 조금이라도 간단하게 하기 위해 몇 가지 원칙을 마련하고 이 원칙을 철저히 준수할 필요가 있다. 원칙이란 계약의 쌍방이 약속을 지킬 수 있는 수단임과 동시에 질서를 잡아 주는 역할을 하며 원칙 내에서는 자유로운 기교를 허용하는 중요한 잣대가 된다. 처음 원칙을 만들 때는 머리가 고생을 좀 하겠지만 일단 확고한 원칙이 마련되면 이후 손발이 편해질 것이다.

 

편집 대상 문자열인 data 멤버는 동적으로 관리한다. 입력되는 문자열의 길이는 도저히 예측할 수 없기 때문에 고정 길이의 배열을 쓸 수 없다. 최초 data 멤버는 NULL로 초기화되며 다음 함수에 의해 할당 및 재할당된다.

 

void CApiEdit::AllocURData(int idx,int need,int extra)

{

     if (pUR[idx].data == NULL) {

          pUR[idx].data=(TCHAR *)malloc(need);

     } else if (need > (int)_msize(pUR[idx].data)) {

          pUR[idx].data=(TCHAR *)realloc(pUR[idx].data,need+extra);

     }

}

 

3개의 인수를 받아들이는데 idx는 취소 레코드의 인덱스이다. need는 필요한 메모리량을 바이트 단위로 전달하며 extra는 재할당할 때의 여유분이다. 재할당할 때 약간 여유분을 주어 미리 할당해놓으면 재할당 횟수를 줄일 수 있다.

data 멤버가 NULL이면 need만큼 할당하고 할당된 메모리는 모두 0으로 초기화한다. 이미 할당되어 있고 need가 할당된 크기보다 더 크면, 즉 더 많은 메모리가 필요하면 need+extra만큼 재할당하여 크기를 늘려준다. 이미 need만큼 할당되어 있다면 재할당하지 않는다. 기록 함수들은 data 멤버에 문자열을 기록하기 전에 이 함수를 호출하여 충분한 메모리를 확보해야 한다.

이 함수의 할당 대상은 취소 레코드의 data 멤버이다. data와 같은 공간을 공유하고 있는 dest멤버는 data가 할당되면 자연스럽게 같이 할당된다. 결국 편집 동작에 대한 정보는 data에만 기록된다고 볼 수 있으며 dest data를 좀 더 읽기 쉬운 형태로 캐스팅하기 위해 선언된 멤버라고 생각할 수 있다.

레코드 배열도 동적으로 관리된다. InitDoc에서 최초 100의 크기로 할당되는데 편집 내용이 많아지면 필요한 레코드 수도 많아지므로 재할당하여 늘려야 한다. 레코드 배열을 재할당하는 시점은 다음 레코드로 이동할 때이다. 다음 함수는 다음 레코드로 이동하면서 레코드 배열의 크기를 점검한다.

 

void CApiEdit::NextRecord()

{

     nowur++;

 

     if (nowur==URSize-1) {

          URSize+=64;

          pUR=(UndoRecord *)realloc(pUR,sizeof(UndoRecord)*URSize);

          memset(pUR+URSize-64,0,sizeof(UndoRecord)*64);

     }

}

 

이 함수가 하는 가장 중요한 일은 nowur 1 증가시켜 다음 레코드로 이동시키는 것이다. 이동 후 레코드 배열이 충분한 크기를 가지고 있는지 점검한 후 만약 크기가 부족하다면 재할당한다. 레코드 배열의 할당 크기는 URSize에 저장되어 있으므로 이 크기보다 1 작은 레코드를 사용중이라면 배열을 재할당해야 한다. URSize와 직접 비교하는 것이 아니라 URSize-1과 비교하는 이유는 제일 끝의 레코드는 항상 action 0(UR_NONE)으로 남겨 두어야 하기 때문이다. action 0인 레코드는 레코드의 끝이라는 의미를 가지도록 약속이 되어 있다.

한 번 재할당할 때마다 레코드 배열을 64개씩 늘리는데 재할당을 좀 덜 하고 싶으면 이 값을 좀 더 크게 만들어도 무방하다. 재할당한 후 새로 할당된 레코드의 모든 멤버는 0으로 초기화되는데 action status 0이어야 빈 레코드라는 것을 알 수 있으며 data NULL이어야 새로 할당을 하게 된다.

이 함수는 다음 레코드로의 이동 외에도 레코드 배열을 관리하는 중요한 일을 하기 때문에 레코드 기록함수들은 nowur++로 직접 레코드를 이동해서는 안되며 반드시 NextRecord 함수를 호출하여 다음 레코드로 이동하면서 메모리를 점검할 기회를 제공해야 한다.

편집 내용이 많아진다고 해서 레코드 배열을 무한정 늘릴 수는 없다. 만 번 편집을 했다고 해서 레코드 배열 크기가 반드시 만이 되어야 할 필요는 없다. 현실적으로 만 번 편집한 내용을 모두 취소해서 문서를 원래대로 되돌릴 경우란 거의 없으므로 일정한 개수만큼의 레코드만 가지고 있으면 된다. 그래서 레코드 배열이 적당한 크기가 되면 앞쪽에 작성한 레코드는 삭제하여 메모리를 절약할 수 있다.

취소 레코드의 용량 상한값을 미리 정해놓고 이 이상의 취소 레코드는 삭제하는 처리가 있어야 한다. 이 코드는 NextRecord 함수에 작성되는데 모든 코드를 다 작성한 후에 따로 작성해보도록 하자.

재실행 가능한 상태에서 편집되었으면 이후의 모든 취소 레코드는 삭제되어야 한다. 재실행 가능하다는 것은 현재 레코드가 취소된 레코드라는 뜻인데 취소된 레코드를 편집하면 이후의 모든 취소 레코드는 무효해진다. 구체적인 예를 들어 보자. 가는 말이 고와야 오는 말이 곱다라는 문장을 입력했으면 왼쪽 그림처럼 여섯 개의 취소 레코드가 기록된다. 작성 완료된 레코드들이므로 status 값은 모두 UR_MAKING이 되며 nowur 5를 가리키고 있을 것이다.

이 상태에서 4번 취소를 하면 오른쪽 그림처럼 가는 말이 까지만 남게 되며 4개의 레코드는 취소되어 status가 모두 1(UR_CANCELED)이 된다. nowur 2번 레코드를 가리키고 있으며 2~5번까지는 모두 취소되었다. 이 상태에서 재실행을 4번 반복하면 취소한 입력 동작을 다시 재생할 수도 있다. 하지만 2번 레코드를 가리키고 있는 상태에서 천리간다라는 문장을 새로 입력하면 어떻게 될까?

위쪽의 취소된 레코드는 이제 의미를 상실했기 때문에 모두 삭제되어야 한다. 취소할 때 이 레코드를 삭제하지 않고 status만 변경시키는 이유는 다시 재실행할 수 있도록 하기 위해서인데 앞쪽의 레코드가 다른 내용으로 변경되면 재실행할 수 없게 된다. 오프셋이 맞지 않아 재실행해 봐야 엉뚱한 위치를 가리키고 있기 때문에 제대로 복구되지 않는다.

재실행이란 취소 후 문서를 직접 변경시키지 않고 원래대로 되돌릴 때만 의미가 있으며 그래서 취소된 레코드가 편집될 때는 위쪽의 취소 레코드는 모두 삭제해야 한다. 복잡한 것 같지만 조금만 생각해보면 지극히 상식적인 내용이다. 비주얼 스튜디오의 편집기 동작을 살펴보면 이 원칙이 적용되는 것을 확인할 수 있다. 위쪽 취소 레코드 삭제는 다음 함수가 담당한다.

 

void CApiEdit::ClearRedo()

{

     int i;

 

     for (i=nowur;;i++) {

          if (pUR[i].action==UR_NONE) {

              break;

          }

          if (pUR[i].data != NULL)

              free(pUR[i].data);

          memset(&pUR[i],0,sizeof(UndoRecord));

     }

}

 

이 함수는 현재 레코드(nowur) 이후 action UR_NONE인 빈 레코드(=끝 레코드)를 만날 때까지 모든 레코드를 삭제한다. data에 할당된 메모리는 해제하고 모든 멤버는 0으로 만들어 초기화한다. 현재 레코드도 다시 작성되므로 삭제대상이다.

모든 편집 동작이 하나의 레코드에 기록되는 것이 아니라 같은 종류의 레코드끼리는 병합하도록 하여 레코드 개수를 줄인다. 예를 들어 123 오프셋에 대한민국이라는 단어를 입력했다고 하자. 이때 다음 두 레코드 집합은 동일한 편집 동작을 기록한다.

각각의 음절에 대해 하나씩 레코드를 작성하는 것과 한 단어에 대해 하나의 레코드를 작성하는 것은 결국 같은 삽입 동작에 대한 기록이다. 하지만 음절 하나 하나에 대해 레코드를 기록하는 것은 메모리 낭비일 뿐만 아니라 취소할 때도 한 음절씩 취소되므로 오히려 불편한다. 단어 단위로 레코드를 병합하면 메모리도 절약할 수 있고 취소와 재실행도 단어 단위로 할 수 있다.

문자열이 입력될 때는 단어가 한꺼번에 입력되는 것이 아니라 자소가 모여 음절이 되고 음절이 모여 단어가 된다. 그래서 매 음절이 입력될 때마다 현재 레코드에 병합 가능한 동작인지 살펴 보고 병합할 수 있다면 현재 레코드에 덧붙여 기록하고 그렇지 않다면 새 레코드를 작성하면 된다. 레코드를 병합할 수 있는 병합 조건은 다음과 같다.

 

우선 액션이 같아야 한다. 삽입 동작은 삽입 레코드와 병합되며 삭제 동작은 삭제 레코드와 병합될 수 있다. 삽입중에 삭제를 했다면 이 동작은 병합할 수 없으며 새 레코드를 작성해야 한다.

편집 위치가 연속적이어야 한다. 123에서 를 입력하고 125에서 을 입력했다면 이 두 동작은 123에서 대한을 입력한 것과 같으므로 병합할 수 있다. 하지만 160으로 캐럿을 옮긴 후 을 입력했다면 병합할 수 없다. 액션이 같더라도 위치가 불연속적이면 같은 단어를 구성하는 음절이 아니다.

액션이 같고 위치가 연속적이더라도 공백과 엔터가 입력되었으면 강제로 레코드를 분할한다. 긴 문장을 계속 입력하면 위치는 계속 연속적이다. 그렇다고 이 문장 전체를 한 레코드에 다 기록하면 취소할 때 전체 문장이 삭제되므로 오히려 불편할 것이다. 취소/재실행의 단위는 단어로 설정하는 것이 가장 합리적이며 그래서 공백과 엔터는 레코드 구분자의 역할을 하게 된다.

, 클립보드에서 대량의 데이터를 붙여넣거나 블록을 선택한 후 삭제할 때는 공백과 엔터코드의 포함여부에 상관없이 무조건 한 레코드에 기록해도 무관하다. 이런 동작은 사용자가 선택한 문장 전체를 하나의 단위로 하여 편집한 것이기 때문이다.

이동동작은 무조건 한 레코드를 차지하며 병합하지 않는다. 이동은 자주 있는 편집도 아니고 병합 조건을 찾기도 어렵기 때문에 병합하지 않는 것이 더 간단하다.

 

여기서 마련한 원칙들이 실제 코드에서 어떻게 적용되고 구현되는지는 잠시 후에 보게 될 것이다. 원칙에 따라 코드를 작성하고 작성된 코드를 통해 다시 한 번 원칙을 확인해보기 바란다. 이 원칙들 중 일부는 프로그램의 정책에 따라 조금씩 변경할 수도 있다.