Object.prototype을 건드리면 어떻게 되나

들어가며

진행중인 프로젝트의 서버 단을 잠깐 수정하는 중이었다.

다섯 커밋 정도 불태운 후였을까, 유닛 테스트만 돌리다가 npm run을 하려니 실행이 안 되는 문제가 발생하였다.

no-info-error-message.png

TypeError: this.$_terms[key].slice is not a function 란다. 이렇게 도움 안되는 에러 메시지가 또 있을까?

이런 부류의 에러는 보통 무언가 꼬였을 때에 발생한다. 이렇게 꼬인 경우는 매우 드물기 때문에 사례를 찾아보기 쉽지 않아 구글링도 소용없었다.

어디서부터 잘못된 건지 하나씩 되짚어 보았다.

시작부터 추적하기

서버 앱은 node -r esm index.mjs [인자...] 명령으로 실행된다. 가장 먼저 index.mjs의 모든 함수를 지워보았다. 증상이 계속되었다. 혹시나 싶어 import문도 지워보았다. 문제가 사라졌다.

에러가 뜬 시점이나 import문에 의해 발생하는 것으로 보아, 모듈이 로드될 때 문제가 터지는 것으로 추정했다.

index.mjs에서 문제가 된 import문은 Hapi 서버를 생성하는 createServer라는 함수를 가리켰다. 해당 파일은 Hapi와 관련된 의존성들을 잔뜩 포함하고 있었다.

import Hapi from '@hapi/hapi';
import Blipp from 'blipp';
import Vision from '@hapi/vision';
import Inert from '@hapi/inert';
import HapiAuthJwt2 from 'hapi-auth-jwt2';
import HapiSwagger from 'hapi-swagger';
import HapiGood from '@hapi/good';

이들을 지워 보니 문제가 발생하지 않았다.

그런데 Hapi에 갑자기 문제가 생긴 것은 아닐테고, 진짜 원인은 다른 곳에 있는 듯 했다.

master 브랜치와 비교

브랜치를 push한 다음 GitHub으로 가서 master와 비교해 보았다.

dependencies-updated.png

package.json 변경 사항

의존성 버전을 업데이트한 것이 눈에 띄었다. 혹시나 싶어 예전 버전의 package.json을 가져와 cheerio(새로 설치)만 추가하고 npm install && npm start 해보았다. 또 안됐다.

커밋 단위로 비교하기

master 브랜치의 최신 버전은 아주 잘 돌아간다. 현재 개발중인 브랜치는 master에서 왔고, 지금은 문제가 있어도 예전 어느 시점에는 잘 실행되었다.

back-to-the-past-git.png

Git 커밋 목록

브랜칭 직후 커밋으로 가 보았다. 이 때까지는 잘 돌아갔다. 그 다음 커밋도 마찬가지였다.

동일한 작업을 반복해 문제를 발생시킨 커밋을 특정해 내었다.

지난 커밋 기준으로 하나씩 되돌려보기

보조 모니터에 브라우저로 GitHub을 띄워 놓고 변경된 파일을 하나씩 점검하였다. 새로 추가된 부분을 지우고, 지워진 부분을 복구하며 계속 실행을 시도하였다.

변경 사항 목록 중간 즈음에 다다를 무렵, 새로 추가한 DirectMenuConverterimport하는 문이 문제라는 것을 발견했다.

원인

DirectMenuConverter는 원시 텍스트(교내 식당의 메뉴 정보)를 가공하는 역할을 담당한다. 중간에 이런 코드가 있다.

const results = regexStrings
  .map((exprString) => new RegExp(exprString))
  .map((expr) => expr.exec(text))
  .filter((result) => !!result)
  .takeIf((collection) => collection.length > 0);

takeIf를 사용하였다. 코틀린에서 차용한 것인데, 스타일을 유지하면서 작성하기 위해 만들었다.

takeIfObejct의 프로토타입에 추가된 메소드이다. 즉, 이런 코드를 사용하였다.

Object.prototype.takeIf = function(predicate, nullishValue=undefined) {
  if (predicate(this)) {
    return this;
  } else {
    return nullishValue;
  }
};

export default Object;

해당 코드가 DirectMenuConverter에 import되어 실행 초반에 evaluate되면 문제가 발생하는 것이었다.

즉, Object에 임의의 프로토타입 메소드를 추가한 것이 원인이었던 것이다.

또 다른 원인

그런데 아까 에러 로그를 보면 Hapi가 계속 언급되었다. Hapi의 import를 지우면 문제가 사라지기도 하였다.

이를 통해 미루어 볼 때, Obejct의 프로토타입을 건드린 것 자체가 문제라기보다는, 해당 행위를 Hapi가 싫어한다고 추측하였다.

그 근거로 아래 예시를 참고하였다.

Hapi와 Object

HapitakeIf라는 심볼을 점유해서 충돌이 난 것이 아닐까 생각해 보았다.

Object.prototype.noOneWillUseThisNameTrulySure = function() {};

허나 다른 이름을 사용해도 같은 에러가 발생하였다.

함수를 넣어준 것이 문제인가 싶어 숫자를 넣어 보아도 마찬가지였다.

Object.prototype.aNumber = 123;

null은 어떻까 싶어 넣어 보았다.

Object.prototype.nullish = null;

뭔가 다른 메시지가 출력됐다.

Error: Invalid term override for any nullish

스트링을 대입해 보아도 같은 메시지가 출력되었다.

Object.prototype.aString = 'Why this happen to me?';
Error: Invalid term override for any aString

Stack tract를 따라가보니 @hapi/joi/lib/extend.js 41행으로 연결되었다.

const terms = Object.assign({}, parent.terms);
if (def.terms) {
    for (const name in def.terms) {                                     // Only apply own terms
        const term = def.terms[name];
        Assert(schema.$_terms[name] === undefined, 'Invalid term override for', def.type, name);
        schema.$_terms[name] = term.init;
        terms[name] = term;
    }
}

schema.$_terms[name] === undefinedfalse일 때, 즉 schema.$_terms[name]에 무언가가 있을 때에 에러가 throw되도록 짜여 있었다.

추측하건데, schema.$_termsObject를 나타내고, name은 위에서 정한 프로토타입의 이름 nullishaString인 듯 하다.

결국 아무 프로토타입도 넣지 말라는 뜻으로 풀이된다.

null과 비슷한 undefined는 어떨까?

Error: Invalid template variable "#label" fails due to: Formula constant aString contains invalid undefined value type

또 다른 에러다.

for (const constant in options.constants) {
    const value = options.constants[constant];
    if (value !== null &&
        !['boolean', 'number', 'string'].includes(typeof value)) {

        throw new Error(`Formula constant ${constant} contains invalid ${typeof value} value type`);
    }
}

이 에러는 우리가 정한 상수(aString = undefined)가 null은 아닌데 boolean도 아니고 number도 아니고 string도 아닐 때에 발생한다.

결론

도대체 왜 이렇게 만들었는지 모르겠다. 상당히 불친절하다. 찾아도 안 나온다.

eslint에는 네이티브 객체(Object 등)에 속성을 추가하는 것을 방지하는 규칙이 있다. 이것만 잘 따랐어도 이렇게 오래 삽질할 일은 없었을 터인지라 조금 허탈하긴 하다.

댓글