카테고리 없음

해시 값을 이용해 문자열 변경 사항 확인하기

klm hyeon woo 2024. 12. 7. 19:22

목차

· 글을 시작하며

· 처음에는 어떻게 시도했을까?

· 어떻게 변경을 했을까?

· 개선은 되었을까?


글을 시작하며

백오피스에서 위협 룰에 대한 코드 문법 기능을 위해 에디터를 탑재해야하던 순간이 있었어요.

코드 문법 기능을 통해 에디터를 찾던 도중 우아콘 컨퍼런스에서 들었던 에디터를 사용해서 코드에 대한 변경사항에 따른 예외 처리를 진행해줘야했어요. 변경에 따른 예외처리 상황은 아래와 같이 설명할 수 있을 것 같아요.

  • 코드를 작성하고, 올바른 코드일 경우 문법 검사가 통과되어야하는 조건
  • 문법 검사를 통과 후 코드를 수정할 경우, 다시 문법 검사를 하게 해야하는 조건
  • 코드를 수정했지만, 이전 통과한 코드들에 대해서 문법 검사를 다시 실행하지 않고 내부 값으로 저장하고 있다가 해당 값을 재활용하는 조건

처음에는 어떻게 시도했을까?

예전 프로젝트를 진행하면서 문자가 변경되었는지에 대한 유무를 판별하는 경험을 하긴 했지만, 이 방법이 그다지 효율적인 방법은 아니라고 생각을 했어요. 이를테면 아래와 같은 코드와 같이 비교를 진행했었습니다.

const firstArray = [1, 2, 3];
const secondArray = [1, 2, 3];
 
const isEqualArray = (firstArray, secondArray) => {
    if (firstArray.length !== secondArray.length) return false;
    return firstArray.every((val, idx) => val === secondArray[idx]);
}
 
console.log(isEqualArray(firstArray, secondArray);

위와 같이 every 메서드를 통해 비교를 하게 되면 하나라도 조건에 부합하지 않는다면 연산을 멈추고, 도중에 false를 반환하고, 그렇지 않으면 true를 반환해요. every 메서드로 구현을 했을 때는 구현에 보다 직관적이고, 배열의 각 요소를 순회하며 비교를 하기 때문에 코드가 명확하다는 장점을 가지고 있습니다.

 

다만 문자열이 길어질수록 비교 시간이 증가한다는 단점을 가지고 있는데, 긴 문자열 일수록 각 문자를 하나씩 비교해야하므로 성능이 저하될 수 있어요. 단순 비교만을 하는 것이 아닌, 이전 통과한 코드들에 대해서도 로그를 가지고 있어야 하기 때문에, 이 방법으로는 어느정도 해결에 제약이 있다고 생각을 했어요.

 

어떻게 변경을 했을까?

긴 문자열을 배열로 담고 있으면, 내부적으로 계속 포함 관계를 판별할 때 비교하기가 힘들다는 단점을 가지고 있기 때문에, 이를 해시 값으로 가지고 해시 값을 통해 비교하는 것이 어떨까라는 생각을 했어요. 그래서 간단한 FNV-1a 알고리즘을 이용해 문자열을 8자의 해시 값으로 변환해주는 함수를 만들었어요.

/**
 * @description FNV-1a 알고리즘을 통한 해시 변환 유틸 함수입니다.
 * @example
 * const hash = simpleHash('Hello BackOffice');
 * console.log(hash); // c88adc38
 * @param {string} value
 * */
const getSimpleHash = (value) => {
  const FNV_OFFSET_BASIS = 0x811c9dc5;
  const FNV_PRIME = 0x01000193;
 
  let hash = FNV_OFFSET_BASIS;
 
  for (let i = 0; i < value.length; i++) {
    hash ^= value.charCodeAt(i);
    hash *= FNV_PRIME;
    hash >>>= 0;
  }
 
  return hash.toString(16).padStart(8, '0');
};

해시 계산 자체는 문자열을 순회하는 방법인 every()와 동일한 0(n) 시간이 걸릴 수 있어요. 하지만 변환된 해시 값은 원본 문자열보다 짧기 때문에 배열에 긴 문자열을 계속해서 가지고 연산을 하는 것보다 훨씬 효과적으로 메모리 사용량을 줄일 수 있습니다.

 

위의 해시 변환 함수는 백오피스에서 문법 검사 버튼을 누르고, 서버를 통해 검증을 통과했을 때 프론트엔드 코드를 통해 변환이 되어 로컬 배열 변수에 저장이 되어 차곡차곡 쌓여요.

const hashSet = ['c88adc38', 'x18qcc48', 'yc2qvc41']; // 문법 검사를 통해 쌓인 해쉬 셋들
const rule = 'import "pe" 
rule DisguiseWithNotepad001 {
    meta:
        description = "disguise with notepad.exe"
        author = "Auto Rule Maker"
        date = "2024-08-15"
  
    condition:
       pe.version_info["OriginalFilename"] iequals "notepad.exe" and
       filesize > 40KB and filsize < 800KB and
       pe.imports("kernel32.dll", "CreateFileA")
}'; // 사용자가 입력한 코드 (예시)
const scaledToHash = getSImpleHash(rule); // 긴 코드 문자열을 해시 값으로 변경
 
console.log(hashSet.includes(scaledToHash)); // 현재 코드 문자열이 이미 저장된 해시 셋에 포함되어있는지 확인

이런 해결 방법을 통해 아래와 같이 결과를 확인할 수 있었어요, 아래의 예시는 수정화면에 대한 예시예요. 수정의 경우 이미 통과된 문법이 들어가있기 때문에 초기 마운트 시에 통과했다는 플래그 전달을 통해 배열에 해시 값을 저장하고, 그 다음 문법 수정 후 사용자가 저장 버튼을 누르면 배열에 순차적으로 해시 값이 저장되어 만약 사용자가 수정을 하다가 이전 통과된 문법 코드와 똑같이 작성할 경우 이전 해시 값 비교를 통해 문법 검사는 자동적으로 통과함으로서 효율적으로 검사 플로우를 진행할 수 있다는 장점을 얻을 수 있답니다.

 

화면 기록 2024-12-07 오후 7.16.48.mov
7.73MB

 

개선이 잘 되었을까?

사실 개선이 잘 되었을까에 대한 섹션은 원래 없었어요. 나름 깃허브의 해시 전략을 차용해서 적용을 했다고 쳐도 정량적인 수치로서 판단을 했던 순간이 없었고, 이를 어떻게 증명할 수 있을까에 대해 생각을 해보았던 것 같아요. 그래서 두 가지 방법을 통해 개선에 대한 측정을 진행했어요. 첫 번째는 console.time() 을 통한 자바스크립트의 코드 실행 시간이었고, 두 번째 방법으로는 메모리 측정에 대한 부분이었어요.

 

먼저 시간 측면에서 이야기를 해볼게요, 시간은 오히려 해시 변환 알고리즘에 대한 시간이 추가되어 해시 변환부터 문법 검사까지의 시간이 더 오래 걸렸어요. 이 때 테스트 케이스를 두 가지로 나누어서 실험을 해보았는데, 지금와서보면 큰 문자열을 해시로 변환하기 때문에 당연한 결과였던 것 같아요.

 

[TC] 해시 변환부터 문법 검사까지의 시간 측정

1. 초기 해시 변환 진행

2. 문자열 배열에 변환된 해시 문자열 추가

3. 문법 검사 시에, 기존 문자열 배열에 검사한 기록이 있는지 확인

최고 시간 1.30ms

[TC] 검사한 문자열 그대로 문법 검사까지의 시간 측정

1. 문자열 배열에 검사하고자 하는 문자열 추가

2. 문법 검사 시에, 기존 문자열 배열에 검사한 기록이 있는지 확인

최고 시간 0.96ms

 

다음으로는 메모리 측면에서 이야기를 해볼게요, 메모리는 오히려 해시 변환부터 문법 검사까지의 로직이 메모리를 덜 차지하는 것을 확인할 수 있었어요. 실제로 위협 룰에 대한 부분이 이 정도는 아니겠지만, 만 줄 정도의 코드를 여러 번 로그 기능을 하는 문자열 배열 안에 넣으며 테스트를 진행해보았어요. 이때도 시간 측정과 동일하게 두 가지의 테스트 케이스를 나누어 진행을 했어요.

 

[TC] 해시 변환부터 문법 검사까지의 메모리 측정

1. 초기 해시 변환 진행

2. 문자열 배열에 변환된 해시 문자열 추가

3. 문법 검사 시에, 기존 문자열 배열에 검사한 기록이 있는지 확인

메모리 사용량 99.3MB

[TC] 검사한 문자열 그대로 문법 검사까지의 시간 측정

1. 문자열 배열에 검사하고자 하는 문자열 추가

2. 문법 검사 시에, 기존 문자열 배열에 검사한 기록이 있는지 확인

메모리 사용량 103MB

위 테스트 케이스들을 진행하면서, 분명 큰 문자열들을 반복적으로 배열에 담고 이를 비교하는 것이 처음에는 굉장히 비효율적인 일이라고 생각을 했어요. 그래서 이를 테스트하고자 시간과 메모리 측면에서 테스트를 진행을 했었는데 해시 문자열로 변환 후 짧은 문자열을 통해 문법 검사를 진행하는 방식은 해시 변환으로 인해 시간은 오래걸리지만 메모리 측면에서 효율을 얻을 수 있었고, 반면에 통 문자열을 배열에 넣고 매번 검사를 진행하며 문법 검사를 진행하는 방식은 해시 변환 방식보다는 시간은 덜 걸리지만, 메모리 측면에서 조금 더 많은 리소스를 잡아먹는다는 것을 알게되었어요.

 

코드 실행 시간을 지킬 것인가, 메모리의 효율성을 지킬 것인가에 대해 팀원과 가볍게나마 토론을 할 시간이 있었는데 이에 대한 답은 개발자마다 다를 수 있다고 생각을 했어요. 제가 초기 설계하고자 했던 개선의 방식은 큰 문자열을 매번 비교하는 것에 대한 불만족이었기 때문에 메모리 측면에서 효용성을 얻는 것이 우선이라고 생각을 할 수 있겠지만, 어떤 방식이 맞는지에 대해서는 조금 더 고민을 해보아야했던 순간이었던 것 같아요.

 

사실 이번 성능 개선을 하고자 했던 부분은 처음에 어떤 방식을 차용해서 적용을 했기에 성능이 개선되었다는 뇌피셜로 시작을 했던 것 같아요. 직접 "이게 그래도 조금의 차이는 있을까?"와 같은 실험 정신을 통해 매번 "이게 최선일까?"에 대한 질문을 품으며 개발 해야겠다는 마음가짐도 생겼던 것 같습니다 :D