프론트엔드 개발을 하다 보면 TypeScript나 SCSS처럼 브라우저에서 직접 동작하지 않는 파일들도 디버깅해야 하는 경우가 자주 있습니다. 그런데 생각해보면 이상한 일입니다. 브라우저는 HTML, CSS, JavaScript 만 읽을 수 있으니까요.
어떻게 브라우저에서 TypeScript 파일의 코드 라인 위치를 정확히 알려주는 걸까?
하지만 실제로 디버깅을 해보면 브라우저에서 인식할 수 없는 파일이라고 해도 코드 라인의 위치를 정확히 짚어주는 것을 확인할 수 있습니다. 이것이 가능한 이유는, 우리가 브라우저에게 원본 소스 코드와 변환된 소스 코드의 관계를 맺어주는 정보인 소스 맵(Source map) 을 제공하기 때문입니다. 그렇다면 소스 맵은 어떤 방식으로 그러한 정보를 저장하고 있는 걸까요?
그래서 오늘은 웹 개발에서 디버깅을 훨씬 쉽게 만들어주는 필수적인 파일인 소스 맵에 대해 알아보겠습니다. 이번 포스트를 통해 소스 맵의 동작 원리, 생성 방법 및 디버깅 경험을 개선하는 방법이 궁금하신 분 들께 도움이 되길 바랍니다.
TL;DR
- 소스 맵의 정보에는
mappings
라는 속성이 있다.- 해당 속성은
A->B:C
형태의 문자열이 반복되는 구조로 되어있다.- A는 변환된 코드의 글자 위치, B는 원본 코드의 라인 위치, C는 원본 코드의 글자 위치를 나타내는 방식으로 원본 코드와 변환 코드를 매핑한다.
소스 맵이 필요한 이유
예전에는 순수한 HTML, CSS 및 JavaScript로 웹 애플리케이션을 구축했고 작성한 파일을 그대로 웹에 배포했습니다. 즉, 개발자가 작성한 코드가 웹에 배포되는 코드와 동일했죠.
그러나 현대 웹 애플리케이션은 매우 복잡한 구조로 발달했습니다. 더 빠르고, 더 안정적이며, 더 많은 개발 편의를 위해서는 다양한 도구를 써야만 합니다. 일반적으로 사용되는 도구들은 다음과 같습니다.
복잡한 웹 애플리케이션을 구축하기 위해 사용되는 도구들
- 템플릿 언어 및 HTML 전처리기: Pug, Nunjucks, Markdown
- CSS 전처리기: SCSS, LESS, PostCSS
- JavaScript 프레임워크: Angular, React, Vue, Svelte
- JavaScript 메타 프레임워크: Next.js, Nuxt, Astro
- 고수준의 프로그래밍 언어: TypeScript, Dart, CoffeeScript
- 번들러: Webpack, Rollup, Parcel
- 등등…
이러한 도구를 사용하면 개발자의 생산성이 향상되고, 코드의 재사용성이 높아지며, 유지보수가 쉬워집니다. Terser 같은 라이브러리는 코드를 압축하고 난독화하는 기능도 제공하기 때문에, 이런 도구를 쓴다면 애플리케이션의 성능 향상도 기대할 수 있죠.
가령 아래와 같은 프로젝트를 만드는 경우를 생각해 봅시다.
버튼을 클릭하면 랜덤 숫자가 그려지는 간단한 앱
여기에 사용된 TypeScript 코드를 빌드하면서, 동시에 번들러로 코드 압축 및 난독화를 진행한다고 가정해 봅시다.
/* TypeScript 데모: script.ts */
document.querySelector('button')?.addEventListener('click', () => {
const num: number = Math.floor(Math.random() * 101);
const greet: string = 'Hello';
(document.querySelector('p') as HTMLParagraphElement).innerText = `${greet}, you are no. ${num}!`;
console.log(num);
});
위 코드를 빌드하면 아래와 같은 JavaScript 코드가 생성됩니다. 정확히 어떤 점이 달라졌을까요?
/* TypeScript 데모의 빌드 결과물: script.min.js */
document.querySelector("button")?.addEventListener("click",(()=>{const e=Math.floor(101*Math.random());document.querySelector("p").innerText=`Hello, you are no. ${e}!`,console.log(e)}));
우선 전체 코드가 한 줄짜리 JavaScript로 압축되었다는 점이 눈에 띄네요. 또한 num
의 변수명이 e
로 바뀌었고, 변수 greet
의 값 "Hello"
는 문자열에 바로 삽입이 되었습니다. 코드의 양을 줄이기 위해 여러 최적화 기법이 적용된 것을 볼 수 있죠.
코드의 양이 줄어들었다는 것은 곧 브라우저에서 해당 코드가 담긴 파일을 다운로드하고 해석하는 데에 걸리는 시간도 짧아진다는 것을 의미합니다. 이것은 곧 사용자가 체감할 수 있는 성능 향상으로 이어지겠죠.
하지만 이 때문에 디버깅 과정에서 문제가 생깁니다. 코드가 한 줄로 압축되었고 변수 이름도 변경되다 보니 문제가 발생했을 때 원인을 정확히 찾아내가 어려워집니다. 어떤 코드가 어떤 역할을 하는지를 이해하기도 까다롭고, 원본 소스 코드와 변환된 소스 코드 사이의 관계를 파악하기도 어렵습니다.
이런 문제를 해결하기 위해 소스 맵이 등장했습니다.
소스 맵이란?
소스 맵(source map) 은 원본(original) 소스 코드와 변환된(transpiled) 소스 코드 사이의 매핑 정보가 선언된 파일입니다. 즉 웹 개발자가 변환된 코드를 디버깅할 때 원본 소스의 몇 번째 라인, 몇 번째 글자의 코드를 참조해야 하는지를 알 수 있도록 도와주죠.
소스 맵은 *.map
확장자를 가진 파일로, 이 파일을 웹 서버에서 제공한다면 브라우저는 소스 맵을 적용해 변환된 코드를 디버깅할 수 있습니다. 일반적으로 Vite, webpack, Rollup, Parcel, esbuild 같은 번들러에서 소스 맵을 생성하는 옵션을 제공하고 있기 때문에, 이를 활성화하면 소스 맵을 쉽게 생성할 수 있습니다.
/* Vite에서의 소스 맵 활성화 예시: vite.config.js */
/* https://vitejs.dev/config/ */
export default defineConfig({
build: {
sourcemap: true, // 프로덕션 환경에서 소스 맵을 활성화
},
css: {
devSourcemap: true, // 개발 과정 중에서 CSS 소스 맵을 활성화
},
});
소스 맵 파일의 구조
소스 맵 파일이 어떻게 정의되어야 하는지에 대한 사양도 별도로 있다
이러한 소스 맵 파일에는 컴파일된 코드가 원래 코드에 어떻게 매핑되어야 하는지에 대한 정보가 포함되어 있어, 브라우저가 이를 해석할 수 있습니다. 소스 맵 파일은 JSON 형식으로 작성되어 있으며, 아래와 같은 프로퍼티를 가지고 있습니다.
// script.min.js.map
{
"version": 3,
"file": "script.min.js.map",
"sources": ["src/script.ts"],
"sourcesContent": ["document.querySelector('button')..."],
"names": ["document","querySelector", ...],
"mappings": "AAAAA,SAASC,cAAc,WAAWC, ...",
}
각 속성은 아래와 같은 의미를 가지고 있습니다.
version
: 소스 맵의 기반이 되는 버전 번호file
: 변환된 코드의 파일명sources
: 원본 소스 코드의 경로sourcesContent
: 원본 소스 코드의 내용names
: 변환된 코드에서 사용된 식별자의 이름mappings
: 변환된 코드와 원본 코드 사이의 매핑 정보
소스 맵의 동작 원리
AAAA,SAAS,cAAc,WAAW,iBAAiB,SAAS;IAC1D,...
우리가 주목해야 할 부분은 mappings
입니다. 콤마로 나누어진 알 수 없는 문자열을 볼 수 있는데, 이는 VLQ Base64 라는 형태로 인코딩 된 문자열입니다. 해당 압축 방식에 대한 설명은 현재 포스트의 범위를 벗어나므로 생략합니다.
이 문자열은 인코딩 되어서 복잡해 보이지만, 저 4~5자의 문자열에는 꽤 많은 정보가 담겨 있습니다.
- 변환된 코드의 몇 번째 글자인지
- 원본 코드가 등장하는 파일이 무엇인지
- 원본 코드의 몇 번째 라인인지
- 원본 코드의 몇 번째 글자인지
- 만약 변수, 메서드, 파라미터처럼 이름이 식별되어 있다면 원래 이름은 무엇인지
이를 쉽게 시각화 해주는 도구인 source-map-visualization과 Source Map Visualization를 이용하면 그 의미를 쉽게 파악할 수 있습니다.
위 사진에서 오른쪽에 있는 코드가 원본 TypeScript 코드, 왼쪽에 있는 코드가 변환된 JavaScript 코드, 아래쪽에 나열된 숫자들은 mappings
에 해당하는 값입니다. 코드 라인 별로 색상이 다르게 표시되어 있고, 식별자를 기준으로 블록이 쪼개져 있는 것을 볼 수 있습니다.
여기서 mappings
에 해당하는 값이 A->B:C
의 형태로 이루어져 있는데 이것이 핵심입니다. 여기서 A는 변환된 코드의 라인, B는 원본 코드의 라인, C는 해당 라인의 몇 번째 글자인지를 나타냅니다. 즉 173->5:10
은 변환된 코드의 173번째 글자부터 등장하는 코드는 원본 코드의 5번째 라인의 10번째 글자에 해당한다는 의미죠.
실제로 그런지 한 번 살펴볼까요? 64->2:2
를 살펴보면, 실제로 64번째 글자부터 등장하는 코드가 원본 코드의 2번째 라인의 2번째 글자에 해당하는 것을 확인할 수 있습니다. 생각보다 원리가 간단하죠?
최적화 과정에서 사라진 코드는 소스 맵에 나타나지 않음
한편 원본 코드에서 색상이 입혀지지 않은 라인도 보이는데, 이는 빌드 과정에서 해당 라인이 제거되었음을 의미합니다.
브라우저의 개발자 도구가 소스 맵을 적용했음을 나타내는 부분
이러한 방식으로 개발자는 축소된 코드와 원래 코드 간의 관계를 빠르게 식별할 수 있으므로 디버깅 프로세스가 더 원활해집니다. 브라우저 개발자 도구는 이러한 소스 맵을 적용하여 브라우저에서 바로 디버깅 문제를 더 빨리 찾아낼 수 있도록 도와줍니다.
한계점
변수 num
은 커서를 올리면 값이 뜨지만, greet
은 빌드 과정에서 제거되었기 때문에 참조할 수 없다
이러한 소스 맵에도 한계가 있습니다. 우리가 번들러 최적화를 적용하는 과정에서 greet
변수는 빌드 과정에서 문자열에 직접 입력되는 방식으로 제거되었기 때문에, 소스 맵에는 해당 매핑 정보가 들어있지 않습니다. 따라서 greet
변수는 원본 코드에는 있지만 디버깅이 불가능한 값이 됩니다. 이런 경우에는 개발자가 변수의 실제 값을 유추할 수 없기 때문에 종종 불편함을 느낄 수 있습니다.
또한 매우 드문 경우이긴 한데, 소스 맵이 잘 못 생성되거나 참조되어서 오히려 디버깅에 불편함을 겪는 경우가 생기기도 합니다. 물론 올바른 소스 맵을 새로 생성하는 것이 제일 좋지만, 그럴 상황이 되지 않는다면 개발자 도구에서 소스 맵 적용을 끄는 방식으로 원본 코드를 직접 디버깅할 수 있습니다.
마무리
이처럼 소스 맵은 개발자가 디버깅을 할 때 매우 유용하지만, 소스 맵 파일을 생성하고 관리하는 것은 빌드 시간을 늘리고 빌드 결과물의 크기를 늘리는 원인이 되기도 합니다. 또한 소스 맵이 노출되면 악의적인 사용자가 원본 코드를 쉽게 열람할 수 있으므로 보안상 취약해지기도 하죠.
그렇기 때문에 소스 맵은 개발 환경에서만 사용하고, 프로덕션 환경에서는 소스 맵을 생성하지 않도록 해야 합니다. 대부분의 번들러들은 이러한 기능을 이미 제공하고 있기 때문에, 이런 부분을 조금만 신경을 쓴다면 문제가 되지는 않을 것 같네요.