
해당 포스트는 멀티패러다임 프로그래밍 (유인동 저)을 읽고 개인적으로 정리한 글입니다.
현대의 프로그래밍 언어는 대부분 멀티패러다임 언어가 되었다. 함수형, 객체지향, 명령형 등 다양한 패러다임을 상황에 따라 선택하고 결합하는 접근이 가능해졌고, 이러한 접근 법은 코드 스타일을 다양화하는데 그치지 않고 다양한 실무 상황을 더 높은 가독성, 유지보수성, 확장성을 갖춘 코드로 해결하는 전략을 제시한다.
이 장에서는 언어가 제공하는 다양한 기능들을 전략적으로 조합하여 까다로운 문제들을 우아하고 직관적인 코드로 해결하는 방법을 알아보자.
HTML 템플릿 엔진 만들기
Tagged Templates
Tagged Templates는 템플릿 리터럴을 보다 유연하게 활용할 수 있게 해주는 도구다. 사용자 정의 함수를 통해 템플릿 문자열과 삽입된 값을 처리할 수 있게 해준다.
이를 통해 문자열을 유연하게 처리할 수 있다. 문자열 조작, 다국어, 보안 검사 (SQL 인젝션 방지, XSS 방지 등)등과 같은 다양한 작업에 활용할 수 있다.
function upper(strs: TemplateStringsArray, ...vals: string[]) {
console.log(strs); // ["a: ", ", b: ", "."]
console.log(vals); // ["a", "b"]
return strs[0]
+ vals[0].toUpperCase()
+ strs[1]
+ vals[1].toUpperCase()
+ strs[2];
}
const a = 'a';
const b = 'b';
const result = upper`a: ${a}, b: ${b}.`;
console.log(result); // a: A, b: B.
함수에 템플릿 리터럴을 넘기면 첫번째 매개변수로 템플릿 리터럴의 문자열부분들이 배열로 전달된다. 그리고 두 번째 인자부터 ${}
표현식 안의 값들이 전달된다.
리스트 프로세싱으로 구현하기
Tagged Templates를 활용하여 HTML 템플릿 엔진을 만들어보자. 우선 템플릿 리터럴에서 전달된 str
(고정 문자열 배열)과 vals
(표현식 안의 값들)의 길이를 맞추고 zip
함수로 두 배열을 결합해 튜플을 반환한다.
function html(strs: TemplateStringsArray, ...vals: string[]) {
vals.push('');
return pipe(
zip(strs, vals),
toArray
);
}
const a = 'A',
b = 'B',
c = 'C';
const result = html`<b>${a}</b><i>${b}</i><em>${c}</em>`;
console.log(result);
// [["<b>", "A"], ["</b><i>", "B"], ["</i><em>", "C"], ["</em>", ""]]
그 다음 flat
을 추가하여 이터레이터를 평탄화한 뒤 reduce
를 사용해 하나의 문자열로 만들어보자.
function html(strs: TemplateStringsArray, ...vals: string[]) {
vals.push('');
return pipe(
vals,
zip(strs),
flat,
reduce((a, b) => a + b),
);
}
const a = 'A',
b = 'B',
c = 'C';
const result = html`<b>${a}</b><i>${b}</i><em>${c}</em>`;
console.log(result);
// <b>A</b><i>B</i><em>C</em>
push를 concat으로
템플릿의 문자열 부분인 strs
가 표현식 값 부분인 vals
보다 무조건 1만큼 크기 때문에 서로 길이를 맞춰주기 위해 vals
에 빈 값을 push
하여 맞춰 주었었다. 하지만 이를 concat
으로 바꿀 수도 있다.
push
는 기존 배열을 변경하지만concat
은 기존 배열을 변경하지 않고 지연 평가되는 이터레이터를 반환하므로 부수 효과 없이 동일한 결과를 얻을 수 있다.- 시간 복잡도 면에서도 전체 배열을 새로 만들거나 모든 값을 재할당하지 않으므로 사실상 차이가 없다.
const html = (strs: TemplateStringsArray, ...vals: string[]) =>
pipe(
concat(vals, ['']),
zip(strs),
flat,
reduce((a, b) => a + b)
);
이 변경에서 주목해야할 점은 부수 효과의 감소보다는 표현식만으로 코드를 조합할 수 있다는 점이다. 표현식만으로 코드를 구성하면 이후 문장에 의한 값 변형이나 참조 가능성이 사라져 코드의 예측 가능성이 높아진다.
XSS 공격 방지
XSS는 웹페이지에 악성 스크립트를 삽입하여 해당 페이지를 보는 다른 사용자에게 피해를 주는 공격 기법이다. 모든 사용자 입력을 신뢰할 수 없다는 원칙 하에, 입력된 값 중 HTML 문법으로 해석될 수 있는 문자를 안전한 형태로 변환하는 작업이 필요하다.
const escapeMap = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
'`': '`',
};
const source = '(?:' + Object.keys(escapeMap).join('|') + ')';
const testRegexp = RegExp(source);
const replaceRegexp = RegExp(source, 'g');
function escapeHtml(val: unknown): string {
const string = `${val}`;
return testRegexp.test(string)
? string.replace(replaceRegexp, (match) => escapeMap[match])
: string;
}
미리 지정해 놓은 특별한 의미의 문자를 대응되는 HTML 엔티티로 치환하는 정규식을 만들고, 문자열을 검사하여 필요한 경우에만 변환을 수행한다.
이 함수를 map
함수와 함께 사용하여 vals
의 각 값에 적용해보자.
const html = (strs: TemplateStringsArray, ...vals: unknown[]) =>
pipe(
vals,
map(escapeHtml),
append(''),
zip(strs),
flat,
reduce((a, b) => a + b)
);
const a = '<script>alert("XSS")</script>';
const b = 'Hello & Welcome!';
console.log(html`
<ul>
<li>${a}</li>
<li>${b}</li>
</ul>
`);
// <ul>
// <li><script>alert("XSS")</script></li>
// <li>
// </ul>
중첩 데이터 처리로 컴포넌트 방식 개발 지원하기
현대 웹 개발에서는 재사용 가능한 컴포넌트 단위로 UI를 구성하는 것이 일반적이다. 이를 지원하려면 템플릿 엔진도 중첩된 구조를 자연스럽게 처리할 수 있어야 한다.
Tagged Templates은 그 함수 내에서도 템플릿 리터럴을 반환할 수 있기에 중첩해서 사용이 가능하다. 하지만 아래 코드에서는 menuHtml
함수가 반환하는 값도 html
함수에 의해 일반 문자열로 인식되어 escapeHtml
함수에 의해 이스케이프 처리되고 있다.
type Menu = {
name: string;
price: number;
};
const menuHtml = ({ name, price }: Menu) => html`<li>${name} (${price})</li>`;
const menu: Menu = { name: 'Choco Latte & Cookie', price: 8000 };
const a = '<script>alert("XSS")</script>';
const b = 'Hello & Welcome!';
const result = html`
<ul>
<li>${a}</li>
<li>${b}</li>
${menuHtml(menu)}
${html`<li>${html`<b>3-step nesting</b>`}</li>`}
</ul>
`;
console.log(result);
// <ul>
// <li><script>alert("XSS")</script></li>
// <li>Hello & Welcome!</li>
// <li>Choco Latte & Cookie (8000)</li>
// <li><b>3-step nesting</b></li>
// </ul>
이는 html
함수가 입력받은 모든 값을 기본적으로 이스케이프 처리하기 때문이다. 현재 구조에서는 '이 값이 안전한 HTML이므로 이스케이프하지 않아도 된다.'라는 정보를 전달할 방법이 없다.
구조의 문제는 객체지향으로, 로직의 문제는 함수형으로 해결하기
- 함수형의 강점: 데이터 변환, 불변성 유지, 조합 가능성
- 객체지향의 강점: 복잡한 상태 관리, 계층적 구조, 다형성
지금까지 해결해야하는 문제는 다음과 같다.
- 계층적 구조 문제: HTML 구문을 중첩된 컴포넌트 형태로 표현하려 할 때 단순한 문자열 결합만으로는 의도한 출력 결과를 얻기 힘들다.
- 선택적 이스케이프 문제: 모든 값을 이스케이프 처리해야 하는 것이 아니기 때문에 특정 값은 이스케이프 처리하지 않고 그대로 사용해야 한다.
이처럼 중첩된 구조와 선택적 변환 로직이 결합된 문제는 단순하지 않다. 중첩 깊이를 알기 위해서 데이터의 최심부까지 모두 순회하는 재귀적 접근이 필요한데, 이를 즉흥적으로 if문이나 while
문을 추가하며 해결하면 코드가 금세 복잡해지고 유지보수하기 어려워진다.
그렇다면 어떻게 접근하는 것이 가장 좋을까? 객체지향의 다형성과 캡슐화를 활용해보자!
class Html {
constructor(
private strs: TemplateStringsArray,
private vals: unknown[]
) {}
private escape(val: unknown) {
return val instanceof Html
? val.toHtml()
: escapeHtml(val);
}
toHtml() {
return pipe(
this.vals,
map(val => this.escape(val)),
append(''),
zip(this.strs),
flat,
reduce((a, b) => a + b)
);
}
}
const html = (strs: TemplateStringsArray, ...vals: unknown[]) =>
new Html(strs, vals);
클래스 자체는 데이터를 들고 있고, 로직이 데이터를 어떻게 이스케이프하고 결합할지 결정한다. 또한 escape
메서드를 통해 Html
인스턴스일 경우 재귀적으로 toHtml()
을 호출하면서 여러 단계 중첩된 HTML 구조도 문제 없이 풀어낼 수 있다.
const menuHtml = ({ name, price }: Menu) => html`<li>${name} (${price})</li>`;
const a = '<script>alert("XSS")</script>';
const b = 'Hello & Welcome!';
const menu: Menu = { name: 'Choco Latte & Cookie', price: 8000 };
const result = html`
<ul>
<li>${a}</li>
<li>${b}</li>
${menuHtml(menu)}
${html`<li>${html`<b>3-step nesting</b>`}</li>`}
</ul>
`;
console.log(result.toHtml());
// <ul>
// <li><script>alert("XSS")</script></li>
// <li>Hello & Welcome!</li>
// <li>Choco Latte & Cookie (8000)</li>
// <li><b>3-step nesting</b></li>
// </ul>
이번 문제를 해결하면서 '구조 문제는 객체지향으로 로직 문제는 함수형으로 해결하라'는 방법론을 익혔다. 이는 각 패러다임의 강점을 최대한 활용하면서도 단점을 상호 보완하는 전략이다.
배열로부터 html 문자열 만들기
지금까지는 키-값 구조의 중첩 데이터나 단일 값 중심으로 html 템플릿 엔진을 다뤘다. 하지만 실제 상황에서는 배열 형태의 데이터도 자주 등장한다. 사용자 목록, 상품 목록, 메뉴 목록 등 대부분의 동적 콘텐츠가 배열 기반이다.
다음 예제는 메뉴 배열을 받아 <ul>
안에 <li>
로 렌더링을 시도한다.
const menuHtml = ({ name, price }: Menu) => html`<li>${name} (${price})</li>`;
const menuBoardHtml = (menus: Menu[]) => html`
<div>
<h1>Menu list</h1>
<ul>
${menus.map(menuHtml).reduce((acc, a) => acc + a.toHtml(), '')}
</ul>
</div>
`;
console.log(menuBoardHtml(menus).toHtml());
// <div>
// <h1>Menu list</h1>
// <ul>
// <li>Americano (4500)</li>
// <li>Cappuccino (5000)</li>
// <li>Latte & cookie set (8000)</li>
// </ul>
// </div>
이 코드에서 문제점은 다음과 같다
- toHtml() 결과는 단순 문자열: 순수 문자열이 상위
html
템플릿 함수에 전달되며 이 값을 일반 문자열로 인식하고 이스케이프 처리를 한다. - 안전한 데이터와 일반 문자열의 구분 부재: '안전하게 처리된 HTML'이라는 정보를 상위
html
함수에 전달할 방법이 없다.
객체를 함수형으로 더하기
단순히 문자열을 합치는 대신 다수의 Html 인스턴스를 하나의 Html 인스턴스로 누적한다면 어떨까? 이렇게 하면 '안전한 HTML'이라는 상태 정보를 잃지 않고 유지할 수 있다.
const menuBoardHtml2 = (menus: Menu[]) => html`
<div>
<h1>Menu list</h1>
<ul>
${menus.map(menuHtml).reduce((a, b) => html`${a}${b}`)}
</ul>
</div>
`;
문자열이 아닌 Html 인스턴스끼리 결합하게 함으로써 '이미 안전한 HTML'이라는 상태를 유지한 채 중첩 데이터 처리와 이스케이프 로직까지 해결할 수 있다.
배열 처리를 클래스 내부로 이동하기
개발자 편의를 위해 Html
클래스 내부에 combine
메서드를 추가해보자. 이렇게 하면 배열을 직접 템플릿에 넣는 것만으로도 자동으로 HTML이 생성된다.
class Html {
constructor(
private strs: TemplateStringsArray,
private vals: unknown[]
) {}
// 추가!
private combine(vals: unknown) {
return Array.isArray(vals)
? vals.reduce((a, b) => html`${a}${b}`, html``)
: vals;
}
private escape(val: unknown) {
return val instanceof Html
? val.toHtml()
: escapeHtml(val);
}
toHtml() {
return pipe(
this.vals,
map(val => this.escape(this.combine(val))), // 추가!
append(''),
zip(this.strs),
flat,
reduce((a, b) => a + b)
);
}
}
이렇게 변경하면 단순히 배열을 넘겨주는 것만으로도 HTML 문자열을 얻을 수 있게 된다.
const menuBoardHtml = (menus: Menu[]) => html`
<div>
<h1>Menu list</h1>
<ul>
${menus.map(menuHtml)}
</ul>
</div>
`;
console.log(menuBoardHtml(menus).toHtml());
// <div>
// <h1>Menu list</h1>
// <ul>
// <li>Americano (4500)</li>
// <li>Cappuccino (5000)</li>
// <li>Latte & cookie set (8000)</li>
// </ul>
// </div>
멀티패러다임 언어가 제시하는 기회
만약 이 절에서 다른 문제들을 오직 하나의 패러다임으로 구현하려 했다면 해결하기가 훨씬 어려웠을 것이다. 반면 멀티패러다임 언어의 장점을 적극 활용하면 구조적 복잡성(객체지향)과 변환 로직(함수형)이라는 서로 다른 문제를 조화롭게 해결할 수 있다.
이러한 접근은 유연한 전략을 구사할 수 있는 토대가 되며, 다양한 문제에 대응할 수 있는 안정적이고 확장 가능한 해법을 제시하며 앞으로의 프로그래밍 업무에 든든한 기반이 될 것이다.
마무리
HTML 템플릿 엔진을 만들어가는 과정을 통해 구조적 관점과 로직적 관점을 서로 다른 패러다임으로 분리하여 생각하는 관점을 얻게되었다. 원래라면 반복문과 조건문이 뒤섞인 복잡한 코드로 처리했을 문제들을 각 패러다임의 강점을 활용해 훨씬 읽기 쉽고 유지보수하기 좋은 코드로 해결할 수 있게 되었다.
또한 단순히 여러 패러다임을 섞어 쓰는 것이 아니라, 각 패러다임의 본질적 강점을 이해하고 문제의 성격에 따라 전략적으로 선택해야 한다는 생각도 들었다.
앞으로는 "이 문제에 어떤 패러다임이 가장 적합한가?"를 먼저 생각하고, 필요에 따라 여러 패러다임을 조합하는 전략적 사고를 한번 더 거치게될 것 같다.