본문 바로가기
우아한테크코스/원정대 | 스터디

[디버깅 원정대] 2. ms 파헤치기

by jetproc 2026. 4. 22.
728x90
🔍 우테코 디버깅 원정대 시리즈

[디버깅 원정대] 1. 디버깅 원정대 첫걸음
[디버깅 원정대] 2. ms 파헤치기
[디버깅 원정대] 3. is.js를 파악한 두 가지 방법
[디버깅 원정대] 4. lodash.js 디버깅톤과 결론

# ms란?

1편에서 원정대의 방향성을 잡는 데에도 꽤 시간이 걸렸다는 걸 이야기했다.
우여곡절 끝에 방향을 정하고 나서, 우리는 첫 번째 라이브러리로 ms를 선택했다.
ms가 대체 어떤 라이브러리일까? 요약하면 다음과 같다.

ms는 Vercel에서 TS로 개발한 시간 변환 유틸 라이브러리다.
메인 코드는 한 파일 안에 작성되어 있고, 전체 코드는 200줄 정도로 상당히 작은 규모이다.
ms

# Vercel 직원들 앞에서 ms 설명하기

안톨리니는 명확한 목표를 위해 상황을 부여해 줬다.

Vercel팀에 합류했다. Vercel팀에서 ms 라이브러리의 메인테이너로 날 임명했다. 내일 Vercel 직원들 앞에서 ms 라이브러리의 소스코드를 설명해야 한다.

 
그리고 이해 과정에서 우리가 해야 할 과제는 다음과 같았다.

ms 라이브러리 코드 베이스를 이해하는 사고의 흐름 과정을 최대한 상세히 기록하기

 
사고의 흐름을 기록한다는 게 포인트였다.
단순히 코드를 분석해 오는 것이 아니라, 어떤 순서로 어떤 생각을 하며 코드를 읽어나갔는지까지 남겨오는 것이었다.
우리 원정대는 각자의 방식대로 ms를 파헤쳐보기로 했다.


# ms 끄적이기

나의 방식은 '끄적이기'였다.
끄적이기는 내가 무언가를 공부하거나 분석할 때 쓰는 방식이다.
노션에 코드를 보면서 드는 생각, 의문, 가설을 날것 그대로 적어나가는 것이다.
정제하지 않고, 틀려도 괜찮다. 나중에 검증하면 되니까. 중요한 건 그 순간의 사고 흐름을 끊지 않는 것이다.
끄적이기는 크게 다음과 같은 순서로 진행했다.

  1. ms.js가 뭘까? — 이름과 리드미 보고 유추하기 / 가설 세우기
  2. 직접 사용을 해봐야지 — 직접 사용해 보고 궁금증 작성해 보기
  3. 내부를 탐험해 보자— 파일 구조 살펴보기, 각 함수 살펴보기

그리고 각 단계에서 여러 궁금증을 작성하며 기록했다.

ms 끄적이기

## 1. ms가 뭘까?

### 1) 이름 보고 내용 추론하기

코드를 열어보기 전에, 이름만 보고 이게 뭘 하는 라이브러리일지 먼저 유추해 봤다.

  • ms.js? 마이크로소프트는 아닐 거고 밀리세컨드? 시간 단위를 바꿔주는 유틸들이 모여 있는 라이브러리인가?
  • js이고 npm이 있으니까 node 환경에서 돌아가겠네. 혹시 다른 언어도 지원하려나?
  • 레포를 보니까 버셀에서 만들었네! 그럼 node 환경에서만 돌아갈 확률이 크겠다.

이 가설들은 나중에 AI에게 물어보며 검증해 봤다.
대체로 맞았지만, 'Node 환경에서만 돌아간다'는 추측은 틀렸다.
ms는 범용 JS 유틸에 가까워서 브라우저/Deno/Bun 같은 다른 JS 런타임에서도 사용할 수 있다고 한다.

실제 끄적이기 현장

 

### 2) 깃헙 리드미 파헤치기

리드미가 그렇게 길지 않길래 일단 쭉 읽어보기로 했다.
리드미의 가장 첫 줄엔 다음과 같이 작성되어 있었다.

Use this package to easily convert various time formats to milliseconds.

앞에서 생각한 대로 시간 관련 라이브러리가 맞는구나. 바로 아래에 예시 코드가 있었다.

ms('2 days')  // 172800000
ms('1d')      // 86400000
ms('10h')     // 36000000
ms('2.5 hrs') // 9000000
ms('-3 days') // -259200000
ms('100')     // 100

숫자 문자열만 넣으면 그냥 밀리초로 인식하는구나. 음수도 되네. 아래에는 반대 방향 변환 예시도 있었다.

ms(60000)           // "1m"
ms(2 * 60000)       // "2m"
ms(ms('10 hours'))  // "10h"

왜 곱연산자(*) 예시만 있지? 덧셈/뺄셈은 왜 없을까?
2 days + 2 days 같은 것도 충분히 하고 싶을 수 있을 것 같은데.
아마 시간 계산에서 60*24*… 같은 방식이 관용적이라서 그런 걸까.
그 아래에는 포매팅 옵션도 있었다.

ms(60000, { long: true })           // "1 minute"
ms(2 * 60000, { long: true })       // "2 minutes"
ms(ms('10 hours'), { long: true })  // "10 hours"

두 번째 인자로 { long: true }를 넘기면 풀네임으로 출력된다.
예시에는 이 옵션밖에 없는데, 다른 옵션은 없는 걸까?
아니면 확장성을 열어두면 예측 가능성이 떨어진다고 판단해서 단순하게만 만들어놓은 걸 수도 있겠다.
리드미 아래에는 지원하는 단위 목록도 타입으로 명시되어 있었다.

type Years   = 'years' | 'year' | 'yrs' | 'yr' | 'y'
type Months  = 'months' | 'month' | 'mo'
type Weeks   = 'weeks' | 'week' | 'w'
type Days    = 'days' | 'day' | 'd'
type Hours   = 'hours' | 'hour' | 'hrs' | 'hr' | 'h'
type Minutes = 'minutes' | 'minute' | 'mins' | 'min' | 'm'
type Seconds = 'seconds' | 'second' | 'secs' | 'sec' | 's'
type Milliseconds = 'milliseconds' | 'millisecond' | 'msecs' | 'msec' | 'ms'

친절하게 다 정리해 줬구나. 근데 여기서 걸리는 게 하나 있었다.

m 단위가 Minutes에 속해있다.

날짜를 표기할 때 yy/mm/dd 형식을 자주 쓰다 보니 m을 month로 쓰려다가 버그가 생기는 사람도 있지 않을까?
그리고 바로 밑에 이런 설명이 있었다.

These formats can be lowercase (minutes), uppercase (MINUTES), or capitalized (Minutes). There can be a space (2 minutes) or not (2minutes).

띄어쓰기가 없는 건 어떻게 구분했을까?
정규표현식을 썼을까? 아니면 다른 방식일까? 그리고 모든 케이스에서 에러 없이 완벽하게 분리가 될까?
 
스크롤을 더 내리니 Advanced Usage 섹션이 나왔다.

import { parse, format } from 'ms';

parse('1h');  // 3600000
format(2000); // "2s"
import { parseStrict } from 'ms';

parseStrict('1h'); // 3600000

export 함수가 ms() 하나만이 아니라 parse, format, parseStrict도 있었다. 그럼 내부에 함수가 생각보다 더 있겠는데?


## 2. 직접 사용을 해봐야지

기능이 많지 않아 간단하게 몇 번 사용해서 눈으로 확인해 봐도 좋을 것 같다고 판단했다.
폴더를 만들고 node로 노드 환경을 구축했다.

> const ms = require('ms')
> ms('2 days')   // 172800000
> ms(2days)      // Uncaught SyntaxError: Invalid or unexpected token
> ms(12000000)   // '3h'

사용하면서 궁금증이 두 가지 생겼다.
1.ms(12000000)처럼 3시간이 넘을 때 분 없이 시간만 3h로만 나오는 것이 정말 UX적으로 맞는 것인가?
- 데이터 손실이 꽤 있을 것 같은데 왜 이렇게 설계했을까?

2.숫자를 점점 키우면 ms/s/m/h/d까지는 자동 변환되는데 왜 d 이후부터는 w/mo/y로 변환되지 않을까?
- 아마 d까지는 24시간이 절댓값인데, 한 달은 30일/31일이 공존해서일 것 같다. 이해는 가지만 뭔가 UX적으로 부자연스럽다고 느낄 수도 있을 것 같다.


## 3. 내부를 탐험해 보자

### 1) 어느 파일부터 봐야 할까?

오픈 소스 코드를 까보는 경험은 완전히 처음이었기 때문에 아예 감이 안 잡혔다. 그래서 습관적으로 package.json부터 보게 되었다.
모르는 키워드가 너무 많았다. 내가 아는 거라곤 tsc, jest, pnpm 정도였고 sideEffects: false, biome, husky, tsdown 같은 건 처음 보는 것들이었다.
지금 단계에서 이것들까지 파고들면 메인 목표에서 너무 멀어질 것 같아 우선 pass 하고 파일 구조를 보기 시작했다.
 

### 2) 디렉터리 탐색

ms/
├── .github/workflows/
├── .husky/
├── assets/
├── src/
│   ├── format.test.ts
│   ├── index.test.ts
│   ├── index.ts
│   ├── parse-strict.test.ts
│   └── parse.test.ts
├── package.json
├── tsconfig.json
└── tsdown.config.ts

프로젝트 규모가 굉장히 작기 때문에 진입점을 한 번에 알 수 있었다.
따라서 src/index.ts부터 보기로 했다.
 

### 3) index.ts 톺아보기

파일이 250줄밖에 안 돼서 놀랐다. 짧기 때문에 한 호흡으로 쭉 보기로 했다.
상단엔 전역 변수로 상수 선언과 type 선언이 되어있었다. 왜 이 부분을 별도 파일로 분리하지 않았을까 싶었다.
전체 함수 리스트를 한 번에 보기 위해 함수 단위로 접어서 살펴봤다.

export function ms(value: StringValue, options?: Options): number;
export function ms(value: number, options?: Options): string;
export function ms(value: StringValue | number, options?: Options): number | string

export function parse(str: string): number
export function parseStrict(value: StringValue): number

function fmtShort(ms: number): StringValue
function fmtLong(ms: number): StringValue

export function format(ms: number, options?: Options): string
function plural(ms: number, msAbs: number, n: number, name: string): StringValue
  • 총 7개 함수, 그중 export 함수는 4개였다.
  • 리드미에서 봤던 ms, parse, parseStrict, format 네 개가 공개 API라는 걸 확신할 수 있었다.
  • ms는 오버로딩된 함수였다.

이제 각 함수를 하나씩 파헤쳐보자.
 

### 4) 각 함수 파헤치기

[1] function ms()

export function ms(value: StringValue | number, options?: Options): number | string {
  if (typeof value === 'string') {
    return parse(value);
  } else if (typeof value === 'number') {
    return format(value, options);
  }
  throw new Error(`Value provided to ms() must be a string or number. value=${JSON.stringify(value)}`);
}

당연히 가장 메인 함수인 ms부터 보기 시작했다.
ms 함수는 TS의 오버로딩 선언을 이용하여 "문자열이 들어오면 숫자 반환, 숫자가 들어오면 문자열 반환"하도록 제한을 두었다.
그런데 여기서 이상한 점이 있었다. 왜 value: string이 아니라 value: StringValue를 썼을까?
아마 원하는 문자열 폼으로 한 번 더 제한을 걸기 위해서겠지. 아무 String이나 다 받으면 예외처리가 힘들어질 테니까.
 
그래서 StringValue의 선언부를 찾아가 보았다.

export type StringValue = `${number}` | `${number}${UnitAnyCase}` | `${number} ${UnitAnyCase}`;

정규식을 썼을 것이라는 예상과 달리, 단순히 3개의 케이스로 나눠서 직접 지정을 해줬다.
근데 이게 가장 좋은 방법일까? 예를 들어 ms('2  days')와 같이 사용자가 실수로 숫자와 단위 사이에 띄어쓰기를 2개 넣는다면? 
직접 돌려보니 잘 돌아간다. 이유가 뭐지..
 
AI에 물어봐서 알게 된 건 TS 컴파일 타임과 JS 런타임의 차이 때문이었다.

  • TS 환경: ms('2  days')는 StringValue의 3가지 케이스에 맞지 않으므로 컴파일 에러가 난다.
  • JS 환경(런타임): TS는 실행 전 순수 JS로 변환되면서 타입 정보는 전부 지워진다. 그래서 런타임에서는 parse 내부 정규식이 처리해 준다.

타입은 TS 사용자에게 힌트를 주고, 정규식은 런타임에서 실제로 방어한다는 이중 구조였다. 이 라이브러리가 TS 사용자만 쓰는 게 아니기 때문에 생긴 설계였다.
즉 내부에서 정규식을 쓸 것이라는 가설은 사실이 맞았고, 타입 지정은 단순히 TS 환경에서 일차적으로 컴파일에서 거르기 위한 안전장치임을 알게 되었다.
 
전반적으로 ms 내부 코드를 보며 이 정도로 예측 가능성이 높게 설계할 수 있는 거였나..라는 생각이 들었다.
함수 분리가 잘 되어있었고, 인자 이름도 군더더기 없이 명확했다.
에러문에서도 에러 원인과 함께 value 값을 한 번 더 명시해서 개발자가 원인 파악을 하기 쉽도록 해주었다.
 

[2] function parse()

export function parse(str: string): number {
  if (typeof str !== 'string' || str.length === 0 || str.length > 100) {
    throw new Error(...);
  }
  const match =
    /^(?<value>-?\d*\.?\d+) *(?<unit>milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|months?|mo|years?|yrs?|y)?$/i.exec(str);

  if (!match?.groups) return NaN;

  const {value, unit = 'ms'} = match.groups as {...};
  const n = parseFloat(value);
  const matchUnit = unit.toLowerCase() as Lowercase<Unit>;

  switch (matchUnit) {
    case 'years': case 'year': case 'yrs': case 'yr': case 'y':
      return n * y;
    // ...이하 생략
    default:
      matchUnit satisfies never;
      throw new Error(...);
  }
}

처음 보자마자 눈에 들어온 건 match 상수에 있는 정규식 하드 코딩이었다.
아무리 여러 가지를 고려해도 저 하드 코딩은 정말 납득이 안 되었다.
단위가 추가될 가능성을 아예 0으로 판단한 것일까? 저게 과연 최선이었을까..
 
또 걸렸던 부분.
const n = parseFloat(value); < 왜 변수명을 n으로 했을까?
한 파일에서 모든 코드가 짜여 있고 규모가 크지 않아서 문제가 없다고 판단한 걸까.
같은 n이 plural() 함수에서도 등장하는데, 거기서도 마찬가지로 무슨 값인지 바로 유추가 안 됐다.
switch case도 하드코딩이었는데, 타입을 잘 활용하면 직관성을 유지하면서 유지보수성을 훨씬 높일 수 있을 것 같았다.
이 부분은 원정대가 끝나고도 계속 걸렸던 지점이었다.
 
그리고 처음 보는 문법이 하나 나왔다. matchUnit satisfies never;
이건 "여기까지 왔으면 matchUnit은 남은 경우가 없어야 한다"를 타입 시스템에게 확인시키는 exhaustive check 용도로 쓰는 문법이라고 한다.
switch case의 default에서 단순히 에러를 던지는 게 아니라, 모든 케이스를 빠짐없이 처리했는지 타입으로 한 번 더 크로스 체킹하는 것이다.
처음 접해보는 방식이었고, 한 수 배워가는 지점이었다.
 

[3] function parseStrict()

처음엔 parse랑 뭐가 다르지? 싶었다. 정리해 보면 이렇다.

  • parse는 파라미터 타입이 str: string이라 TS 환경에서도 parse('hello')를 넣을 수 있고, 런타임에 가서야 NaN을 반환한다.
  • parseStrict는 파라미터 타입이 value: StringValue컴파일 단계에서 잘못된 입력을 잡아낸다.
  • 런타임에서는 내부적으로 똑같이 parse(value)를 호출하므로 동작은 완전히 같다.

그런데 정작 라이브러리 내부에서는 parseStrict테스트 파일에서만 호출되고 있었다.
외부 TS 사용자에게 더 엄격한 타입 안전성을 제공하기 위한 export 함수인 셈이었다.
 

[4] function format()

export function format(ms: number, options?: Options): string {
  if (typeof ms !== 'number' || !Number.isFinite(ms)) {
    throw new Error('Value provided to ms.format() must be of type number.');
  }

  return options?.long ? fmtLong(ms) : fmtShort(ms);
}

Number.isFinite를 이용해서 무한수에 대한 예외처리를 하고 있었다.
근데 이 함수엔 한 가지 이상한 점이 있었다.
parse와 다르게 함수에 주요 로직이 없다는 것이었다.
사실상 format 함수 자체는 fmtLong과 fmtShort를 옵셔널 체이닝으로 호출할 뿐이었다.
주요 로직이 fmtLong과 fmtShort에 있다는 건데, 그럼 둘 사이에 중복 코드가 분명히 있을 것 같다는 가설을 세웠다.
 

[5] function fmtShort() & fmtLong()

function fmtLong(ms: number): StringValue {
  const msAbs = Math.abs(ms);
  if (msAbs >= y) {
    return plural(ms, msAbs, y, 'year');
  }
  if (msAbs >= mo) {
    return plural(ms, msAbs, mo, 'month');
  }
  if (msAbs >= w) {
    return plural(ms, msAbs, w, 'week');
  }
  if (msAbs >= d) {
    return plural(ms, msAbs, d, 'day');
  }
  if (msAbs >= h) {
    return plural(ms, msAbs, h, 'hour');
  }
  if (msAbs >= m) {
    return plural(ms, msAbs, m, 'minute');
  }
  if (msAbs >= s) {
    return plural(ms, msAbs, s, 'second');
  }
  return `${ms} ms`;
}

function fmtShort(ms: number): StringValue {
  const msAbs = Math.abs(ms);
  if (msAbs >= y) {
    return `${Math.round(ms / y)}y`;
  }
  if (msAbs >= mo) {
    return `${Math.round(ms / mo)}mo`;
  }
  if (msAbs >= w) {
    return `${Math.round(ms / w)}w`;
  }
  if (msAbs >= d) {
    return `${Math.round(ms / d)}d`;
  }
  if (msAbs >= h) {
    return `${Math.round(ms / h)}h`;
  }
  if (msAbs >= m) {
    return `${Math.round(ms / m)}m`;
  }
  if (msAbs >= s) {
    return `${Math.round(ms / s)}s`;
  }
  return `${ms}ms`;
}

놀라울 정도로 두 함수의 코드 구조가 완전히 똑같았다. 앞에서 세운 중복 코드 가설이 참이었다.
 
이때 나는 한 함수로 합쳐서 타입을 미리 정의해 놓고 중복을 제거하면 어떨까 싶어서 직접 GPT를 통해 두 함수를 합쳐봤다.
근데 단위를 변수가 아닌 타입으로 정의하는 순간 코드가 엄청나게 복잡해졌다.
어쩌면 일부러 중복 코드를 유지해서 직관성에 초점을 맞춘 걸 수도 있겠다는 생각이 들었다.
코드 설계에 있어서 중복 제거가 늘 최선은 아니라는 걸 체감한 지점이었다.
 

[6] function plural()

function plural(
  ms: number,
  msAbs: number,
  n: number,
  name: string,
): StringValue {
  const isPlural = msAbs >= n * 1.5;
  return `${Math.round(ms / n)} ${name}${isPlural ? 's' : ''}` as StringValue;
}

함수 내용을 봤을 때 몇 가지가 걸렸다.

  • 인자 변수명이 의미가 없다. plural 함수만 봤을 때 n이 뭔지 아예 유추가 안 됐다.
  • const isPlural = msAbs >= n * 1.5; 이 코드가 뭘까 한참 생각했다. 왜 하필 1.5배일까?
  • 리턴문에서 boolean에 따라 s를 붙이고 안 붙이는데, 만약 s가 아니라 es를 붙여야 하는 단위였다면? 지금은 영어권 시간 단위만 다루니까 s만 붙이면 되지만, 확장성을 생각하면 아쉬운 구현이었다.

 

# 서로의 방식을 공유하며

각자 과정을 정리해 온 뒤 원정대 회의에서 서로의 방식을 공유했다.
같은 라이브러리를 읽었는데도 접근 순서, 멈추는 지점, 도구 활용 방식이 모두 달랐다.
 

안톨리니

안톨리니의 방식에서 가장 인상적이었던 건 라이브러리 규모를 먼저 파악한다는 것이었다.
별도의 문서 사이트가 있는지, 없다면 리드미 길이를 보고 기능이 얼마나 있는지 파악하는 것이다.
이유가 명확했다. 규모에 따라 코드베이스를 해석하는 방식이 아예 달라지기 때문이다.
작은 라이브러리는 인덱스 파일 하나를 통째로 읽어도 되지만, 큰 라이브러리는 처음부터 필요한 부분만 좁혀서 파야 한다.
 
그다음엔 사용자가 되어보는 것이었다.
리드미 예시를 직접 막 써보고, 동작하는 결과를 보면서 '왜 이렇게 동작하지?'라는 호기심을 리스트업 하는 것이다.
이후에는 그 리스트를 하나씩 드릴링하며 파고들었다. 그리고 마지막 단계가 인상적이었다.
내 언어로 다시 라이브러리를 정의해 보는 것. 분석을 마치고 나서 "이 라이브러리는 이런 것이다"라고 자기 언어로 정리하는 단계가 있었다.
나는 이 마지막 단계가 없었다. 분석이 끝나면 그게 끝이었는데, 내 언어로 다시 정리하는 과정이 이해를 훨씬 견고하게 만들 것 같았다.
 

라바

라바의 방식은 내 방식과 가장 달랐다.
나는 리드미부터 읽었는데, 라바는 인터페이스를 먼저 보고 내부 동작을 예측하는 것에서 시작했다.
코드를 처음엔 바로 읽으려 했다가 어려움이 생겨서 리드미로 넘어갔다고 했는데, 그 순서 자체가 흥미로웠다.
가장 눈에 띈 건 디버깅 중단점 기준을 사전에 정한다는 것이었다.
함수 진입점, 조건문 분기 직전, 반환 직전. 그냥 코드를 읽어 내려가는 게 아니라 "어디서 멈출지"를 먼저 정하고 들어갔다.
중단점을 찍은 상태에서 변수값을 직접 쳐보며 값을 눈으로 확인하는 방식이었다.
아쉬운 점으로 라바 본인이 언급한 게 있었다.
가설 검증 후 틀렸을 때, 개발자의 의도를 이해하는 시간을 충분히 갖지 못했다는 것.
이건 나도 마찬가지였다. 틀린 가설이 왜 틀렸는지보다 맞는 답을 확인하는 데서 끝낸 경우가 많았다.
 

비비빙

비비빙은 AI에게 자문을 구해 직접 playground를 만들어서 실험적으로 이해하는 방식을 채택했다.
직접 import 해보고 → 입력 시나리오를 쪼개서 분기점을 찾아보고 → 디버거 중단점으로 코드를 분석하고 → 동작 흐름이 파악되면 내부 함수 안으로 진입하고 → 마지막으로 예외 처리와 엣지 케이스를 찾아내는 순서로 진행했다고 했다.
그리고 궁금한 질문들을 AI에 다 넣고 어떻게 검증할 수 있는지 방법까지 물어봤다.
나는 AI를 "검색 대체제" 수준으로 썼는데, 비비빙은 AI를 "분석 설계 도구"로 썼다. 같은 도구를 완전히 다른 방식으로 쓰고 있었다.


# ms를 파헤치고 나서

ms는 200줄짜리 작은 라이브러리였지만, 코드를 읽는 과정에서 생각보다 많은 것들이 튀어나왔다.
라이브러리 설계 측면에서 잘 된 점도 있었고 아쉬운 점도 있었다. TS 타입과 런타임 정규식의 이중 방어 구조, 함수 오버로딩을 통한 명확한 API 설계는 잘 된 부분이었다. 반면 정규식과 switch case의 하드코딩, 의미 없는 변수명 n, fmtShort와 fmtLong의 중복 코드는 아쉬웠다.
내 분석 방식에 대해서는 나중에 돌이켜봤을 때 한 가지가 걸렸다.
나는 끄적이기 과정에서 궁금증과 가설을 명확히 구분하지 않았다는 것이다.
예를 들어 "왜 곱연산자 예시만 있지?"는 궁금증이고, "아마 시간 계산에서 *를 많이 써서 그럴 거야"는 가설이다. 근데 끄적이기를 돌아보면 이 두 가지가 뒤섞여 있었다. 궁금증이 생기는 대로 적고, 중간중간 가설이 있기도 하고 없기도 했다.

본문을 다시 보면 알겠지만, 끄적이기 과정에서 생긴 궁금증은 '파란색' 배경으로, 가설은 '보라색' 배경으로 표시해 놓았다.

이렇게 보니 더더욱 뒤섞여 있음을 체감할 수 있었다.
만약 "궁금증: ~" / "가설: ~ (이유: ~)"을 명확히 구분해서 작성했다면, 나중에 코드를 읽으며 검증할 때 훨씬 체계적으로 접근할 수 있었을 것이다.
지금은 어떤 게 검증됐고 어떤 게 안 됐는지도 불분명하다.
 
ms 분석 과정은 어떤 순서로 어떤 생각을 하며 접근해야 하는지 감을 잡는 과정이기도 했고, 내 방식의 허점을 발견하는 과정이기도 했다.
다음은 is.js이다. 이번엔 가설과 궁금증을 명확히 구분하고, 가설을 먼저 세운 뒤 검증하는 방식으로 접근해 보기로 했다.
그리고 원정대원들의 의견을 종합해 디버깅 도구를 본격적으로 사용해보기로 했다.

728x90