빌드노트
검색어를 보여주는 코드에 innerHTML을 쓰면 생기는 일
검색어를 innerHTML로 출력하면 태그가 화면 요소가 된다. 실제 브라우저에서 요소 수와 원문 보존을 비교하고, 평문은 textContent로 표시하되 기존 아이콘을 지킬 출력 위치와 HTML 정제가 필요한 별도 경로를 구분한다.
검색어를 그대로 표시하려는데 <strong>봄</strong>이 굵은 ‘봄’으로 보인다면, 값을 화면에 넣는 코드를 확인한다. innerHTML에 문자열을 대입하면 브라우저가 HTML로 해석한다. 검색어·파일명·평문 메모를 보여주는 일반 요소에는 textContent로 원문 문자열을 넣는다. 저장소에서 태그를 지우거나 입력값을 바꾸기 전에, 출력 위치가 HTML을 필요로 하는지부터 판단할 수 있다.
OWASP의 DOM XSS 예방 지침 (새 탭에서 열림)도 신뢰할 수 없는 값을 표시할 때 textContent를 사용하도록 안내한다. 아래는 실제 서비스 장애 기록이 아니라, 무해한 태그를 넣어 두 출력 방법을 비교한 로컬 실험이다.
평범한 검색어로는 드러나지 않는다
봄 검색을 넣었을 때는 두 방법 모두 같은 글자만 보였고 자식 요소도 없었다. 입력을 <strong>봄</strong> & "메모" <span>검색</span>으로 바꾸자 차이가 생겼다. innerHTML 쪽에는 strong과 span 요소가 만들어졌다. textContent 쪽은 꺾쇠와 따옴표를 포함한 전체 문자열이 그대로 보였다.
직접 비교하려면 Python 3와 자바스크립트를 실행할 브라우저를 준비한다. 새 빈 폴더에 아래 내용을 UTF-8로 저장하고 파일 이름을 reader-demo.html로 정한다. 실제 서비스의 입력창이나 운영 페이지에서 실험할 필요는 없다.
<!doctype html><meta charset="utf-8">
<p id="as-html"></p>
<p id="as-text"></p>
<script>
const query = '<strong>봄</strong> & "메모" <span>검색</span>';
const htmlBox = document.querySelector('#as-html');
const textBox = document.querySelector('#as-text');
htmlBox.innerHTML = query;
textBox.textContent = query;
console.log(htmlBox.childElementCount, htmlBox.textContent);
console.log(textBox.childElementCount, textBox.textContent);
console.log(textBox.firstChild.nodeType, textBox.textContent === query);
</script>
그 폴더에서 다음 명령을 실행하고 브라우저로 http://127.0.0.1:18765/reader-demo.html을 연다. 개발자 도구의 Console에서 출력 세 줄을 확인한다. Python 실행 명령이 python3인 환경은 명령 이름을 바꾼다. 포트를 이미 사용 중이라는 오류가 나면 기존 서버를 종료하지 말고 비어 있는 포트로 명령과 주소를 함께 바꾼다. 확인을 마치면 터미널에서 Ctrl+C로 실습 서버를 종료한다.
python -m http.server 18765 --bind 127.0.0.1
Chromium 147.0.7727.15에서 실제 나온 출력은 다음과 같다.
2 봄 & "메모" 검색
0 <strong>봄</strong> & "메모" <span>검색</span>
3 true
첫 줄의 2는 HTML로 해석한 쪽의 자식 요소 수다. 둘째 줄의 0은 텍스트로 넣은 쪽에 자식 요소가 없다는 뜻이다. 마지막 줄의 3은 첫 자식의 노드 종류가 텍스트라는 값이며, true는 읽어 낸 텍스트가 원문 문자열과 정확히 같다는 결과다. 겉모습만 비교하지 않고 요소 생성과 원문 보존을 함께 확인했다.
이는 두 API가 맡은 일의 차이다. HTML 명세의 innerHTML 정의 (새 탭에서 열림)는 문자열을 파싱한 노드로 내용을 바꾸며, 기본적으로 위험한 요소나 속성을 정제하지 않는다고 설명한다. DOM 명세의 textContent 설정 절차 (새 탭에서 열림)는 일반 요소의 기존 자식을 주어진 문자열의 텍스트 노드로 바꾼다. 이 실험에서는 스크립트 실행을 시도하지 않았다. 태그가 요소가 됐다는 관찰과, 어떤 입력이 실제 코드를 실행할 수 있는지는 구분해야 한다.
로컬 예제에서 위 출력이 나오지 않으면 먼저 파일 이름·UTF-8 저장·Console의 구문 오류를 확인한다. 자신의 앱에서 textContent로 바꿨는데도 요소가 생긴다면 같은 값을 나중에 innerHTML 등으로 다시 넣는 코드가 있는지 살핀다. 이번 예제처럼 대입 직후의 자식 요소와 텍스트를 확인하면, 최초 출력 문제인지 이후 다시 그리는 과정의 문제인지 좁힐 수 있다.
바꿀 곳은 검색어를 담는 작은 요소다
화면에 검색 아이콘과 검색어가 함께 있다면 부모 전체를 바꾸지 않는다. 검색어 전용 span을 두고 document.querySelector('#query-text').textContent = query처럼 그 요소만 갱신한다. 예를 들어 고정 HTML이 <p><span class="icon">검색: </span><span id="query-text"></span></p>이면 두 번째 span이 출력 위치다. 요소가 아직 없다면 선택 결과는 null이므로, HTML 생성 시점과 선택자를 먼저 맞춘다.
이 범위도 별도로 비교했다. 아이콘과 텍스트 영역이 있는 부모에 textContent를 대입하면 기존 아이콘 자식까지 사라졌다. 검색어 영역에만 대입하면 아이콘은 남고, 그 영역에 원문이 텍스트로 들어갔다. textContent는 기존 자식 안에 글자를 조금씩 끼워 넣는 API가 아니라 대상 요소의 자식을 교체하는 API다.
원문을 미리 HTML 문자 참조로 바꿀 필요도 없다. 추가 실험에서 <strong>봄</strong> & 메모를 textContent에 넣자 <와 &라는 글자까지 그대로 표시됐다. 같은 값을 innerHTML에 넣었을 때는 문자 참조가 해석돼 <strong>봄</strong> & 메모라는 텍스트가 됐다. 이때 strong 요소가 다시 만들어진 것은 아니다. 평문 출력에 이미 변환한 문자열을 섞으면 독자가 입력한 원문과 표시가 달라질 수 있다.
또 가격 < 100 & "메모"는 두 방법 모두 같은 텍스트로 보였다. <가 있다는 이유만으로 매번 태그가 생기는 것은 아니다. 이런 입력 한 번이 정상이라고 innerHTML을 평문 출력에 계속 써도 된다고 판단할 수는 없다. 따옴표나 꺾쇠를 직접 치환하는 함수를 늘리는 대신, 평문을 받는 출력 API를 선택한다.
HTML 서식이 정말 필요한 값은 별도로 처리한다
사용자가 굵게 표시한 편집기 본문처럼 HTML 서식 자체를 보존해야 한다면 textContent로 모두 바꾸는 것만으로는 요구를 만족하지 못한다. 검색어·파일명처럼 원문을 표시하는 경로와, 허용한 HTML을 렌더링하는 경로를 나눈다.
HTML 경로에서는 검증된 정제기를 적용한다. OWASP의 HTML 정제 안내 (새 탭에서 열림)는 DOMPurify를 권장하며, 정제 후 문자열을 다시 조합하거나 다른 라이브러리가 바꾸면 보호가 깨질 수 있다고 설명한다. 허용할 태그·속성을 정하고 정제기 보안 업데이트를 유지해야 한다. 이 글에서는 DOMPurify를 설치하거나 특정 설정의 정제 결과를 실행 검증하지 않았다.
textContent를 권하는 범위는 p·span·div 같은 일반 요소의 평문 내용이다. URL 속성이나 자바스크립트·스타일을 담는 요소까지 같은 규칙으로 안전해지는 것은 아니다. CSP는 추가 방어 수단이며, 평문을 HTML로 파싱하는 출력 위치의 수정을 대신하지 않는다. OWASP의 추가 방어 설명 (새 탭에서 열림)
직접 실행한 환경은 Windows 11, Python 3.12.10, Playwright 1.59.0과 Chromium 147.0.7727.15다. loopback의 합성 HTML에서 일반 문자열·무해한 태그·문자 참조·자식 교체만 비교했다. 쿠키 접근이나 외부 요청을 만드는 입력은 사용하지 않았다. 다른 브라우저 엔진, 실제 서비스의 프레임워크 재렌더링, HTML 정제기의 방어 범위는 이번 관찰에 포함되지 않는다.