PostCSS, IE 그리고 함수형 프로그래밍

권세규
네이버 플레이스 개발 블로그
13 min readAug 1, 2022

Glace CIC에서 담당하는 서비스인 스마트플레이스는 글로벌 진출을 고려한 개발을 진행하고 있습니다. 몇 개월 전, 이에 관련된 IE & CSS 이슈를 담당한 적이 있는데요. 꽤 재미있는 방식으로 문제를 풀었고, 사내 발표를 거쳐 기술 블로그에도 기고하게 되었습니다.

사실 이 글을 쓰는 시점에선 이미 IE 지원이 종료되었으며, 서비스 지원 범위에서도 제외되었습니다. 하지만 크로스브라우징 문제가 IE에만 국한된 것은 아니기에, 문제 해결 과정 및 사고 도식을 공유하고자 합니다.

문제 파악

문제가 발생했을 당시 상황을 알아볼까요. 스마트플레이스는 Next.js와 거기에 내장된 PostCSS를 적극적으로 활용하고 있었습니다. 또한 기술부채를 최소화하기 위해, 로직에 국가 분기가 들어가더라도 최대한 동일 소스를 유지하는 정책을 유지하였습니다.

기존 서비스가 녹색 위주인 것에 비해, 일본 서비스의 경우 붉은색조로 기획

이때 국가별로 동일한 기능이지만, 요소의 색상만 달라지는 요구사항이 있었는데요. 기존의 방대한 CSS파일을 일일이 바꾸지 않고 문제를 풀기 위해, 마크업 팀에서는 CSS 커스텀 속성을 활용하여, 아래와 같이 variables.css라는 파일을 만들어서 이를 해결하였습니다.

// variables.css
:root {
.ko {
--main-color1: #08c17c;
--main-color1-2: #08c17c;
}

.ja {
--main-color1: #ed605a;
--main-color1-2: #fff;
}
}

// ModalLayout.module.css
.modal_footer {
button[class*='btn'] {
color: var(--main-color1);

CSS에서 :root 선택자 밑에 클래스를 두어 전역 속성처럼 동작하게 만든 뒤, Next.js의 Custom Document에서 클래스 명을 주입해주는 방식을 사용하는데요.

// _document.tsx
export default class MyDocument extends Document {
...
render() {
return (
<Html>
<Head />
<body className={process.env.NEXT_PUBLIC_DEFAULT_LOCALE || Language.ko}>
<Main />
<NextScript />
</body>
</Html>
)
}

이 방식은 한 가지 문제가 있었습니다. 바로 IE에서 커스텀 속성을 사용할 수 없다는 점이었죠.

IE가 CSS 커스텀 속성을 해석하지 못하여, 백색으로 처리된 버튼

시행 착오

사실, 커스텀 속성이 표준에 들어오기 전부터 PostCSS 생태계에 이를 처리하는 플러그인이 존재했습니다. (비표준 스타일 $name의 플러그인은 제외)

둘의 차이는 사용 제약의 차이인데요. 전자는 아무 곳에나 쓸 수 있지만 후자는 :root 선택자의 직속에 선언된 커스텀 속성만 처리할 수 있습니다. 이는 잠재적인 모순 을 허용하느냐 배제하느냐의 차이로, :root의 하위에 국가별 클래스를 추가한 스마트플레이스에서는 전자를 쓸 수 밖에 없었습니다.

이걸 가져다 쓰기만 하면 문제가 해결 될 것만 같았죠. 그러나 플러그인을 적용했더니, 최신 브라우저 환경에서조차 깨지기 시작하였습니다.

왜 undefined가 나왔을까?

PostCSS는 파일 단위로만 처리한다

이름에서부터 알 수 있듯, PostCSS는 원래 ‘후처리기’ 용도로 개발되었습니다. 전처리기나 후처리기나 트랜스파일러라는 점은 똑같긴 해서, 중첩(nesting) 같은 기능을 겸사겸사 지원을 하는 것 뿐이지요.

때문에 PostCSS 플러그인의 대부분은 ‘여러 파일을 넘나드는 분석’을 하지 않습니다. 그저 파일 하나를 파싱해서, AST를 규칙에 맞게 변환할 뿐입니다. 다른 파일에서 무슨 클래스를 선언했는지는 모릅니다.

실제로 해당 플러그인의 소스를 분석해보면, 어디에도 파일을 넘나드는 분석을 하지 않는다는 것을 확인할 수 있습니다. 따라서 변수 선언이 다른 파일(variables.css)에 정의돼 있으므로, 트랜스파일 된 css 파일에선 그 값이 undefined 으로 채워지게 됩니다.

참고로 IE에서도 가능한 CSS import를 사용을 해봤으나, 문제가 해결되지 않았습니다. 근본적으로 서드파티 플러그인에서 한 파일 내의 AST만 사용했기 때문입니다.

아직 한 발 남았다

그런데 postcss-css-variables 플러그인에 한 줄기 빛이 내렸습니다. 전역 :root 에서 쓸 변수를 postcss.config.js에서 주입이 가능하다 는 것이었습니다.

그렇다면, js 스크립트로 variables.css를 읽고 config에 주입해줄 수 있다면? 문제가 해결될 수 있으리라 생각했습니다. Next.js에서 PostCSS 관련 설정은 postcss.config.js에서 수행할 수 있는데, 이게 근본적으로는 node 런타임에서 돌아가는 JavaScript라는 것에서 아이디어를 얻었습니다.

이론적으로 가능은 합니다. 실제로 몇 개만 하드코딩해서 테스트해봤더니 잘 되더군요.

의사 결정

하지만 여전히 커스텀 스크립트를 짠다는 것은 마음이 편치 않았습니다. 몸을 비틀어서 억지로 문제를 해결하는 것은, 많은 경우 바람직하지 못합니다. 오히려 커스텀 스크립트가 기술 부채가 될 수도 있었죠.

이쯤되면 그냥 문법이 거의 비슷한 SASS를 쓰면 되지 않을까?? 제가 이것을 혼자 판단하기엔 경험이 부족했고, 같은 팀 리더님께 자문을 구했습니다. 그 결과 아래와 같은 결론에 이르렀죠.

다른 사람의 의견을 듣는 것은 매우 중요합니다. 특히 자신의 경험이 부족하다면요.

SASS로 이전하는 것의 비용이 크다고 판단한 이유는 아래와 같았습니다.

  • 수 백 개의 파일에서 참조하는 모든 .css.scss를 변경해야 한다
  • next.config.js에서 커스텀 웹팩 설정을 밀어넣어, .css를 보면 .scss로 해석하게 만드는 식으로 몸을 비틀 순 있지만… 바람직하지 않으며 추가적인 리스크 발생
  • 모든 CSS파일의 --custom-name$custom-name으로 바꿔야 한다.
  • Sprite 이미지를 PostCSS Mixin으로 자동으로 생성하는 유틸리티 스크립트가 있는데, 이걸 SASS Mixin으로 바꾸도록 개조해야 한다.
  • SASS는 성능 이슈가 있다.
  • 어차피 IE 버리고 나면 쓸모없어지는데, 들어가는 비용이 너무 크다

이렇게 논의 끝에, 커스텀 스크립트를 작성하여 문제를 해결하기로 결정하였습니다.

문제 해결

여기서부터는 CSS파일을 파싱하고 AST를 탐색하면서 변수를 추출하기만 하면 됩니다.

CSS 파일 파싱은 매우 쉬운데요, PostCSS에서 이미 JavaScript API를 제공하고 있기 때문입니다. (비슷한 원리로, Babel이나 SWC, Terser 같은 도구들도 번들링 체인에서만 쓸 수 있는 건 아닙니다)

const postcss = require('postcss')
const fs = require('fs')
const path = require('path')
const cssString = fs.readFileSync(path.join(__dirname, '../styles/base/variables.css'), {
encoding: 'utf-8',
})
const root = postcss.parse(cssString)

하지만 AST 탐색이 문제입니다. 학생 시절 때, 초보적인 크롤러를 만들기 위해 beautiful-soup를 사용해서 AST 탐색을 한 적이 있는데요. 그 경험은 정말 지독했습니다.

해 본 사람만 안다… 이 고통을…

이때 저는 사내에서 함수형 프로그래밍(Functional Programming, FP) 스터디를 수강하고 있었습니다. 그래서 혹시 FP가 이 문제를 깔끔하게 해결해주지 않을까? 라는 희망을 가져보았습니다.

출처: 글쓴이의 사내 발표 자료

실제로 FP는 이 문제를 푸는 것에 최적화 돼 있습니다.

FP는 프로그램을 “함수의 합성”으로 바라봅니다. 이때 함수를 편리하고 안전하게 합성하기 위해서, 두 가지 개념을 알아둬야 합니다. 이 글에서 정확히 설명하기엔 너무나 깊은 내용이라, 간단하게만 짚고 넘어가볼게요.

모나드(Monad)

모나드는 에러나 값 없음 같이 기성적인 패러다임에서는 예외적인 상황조차, 일관된 값으로 정의하는 것입니다. 모나드를 수학적으로 엄밀하게 이해하려면, 범주론(Category Theory)이라는 매우 난해한 추상대수를 배워야 합니다. (26분짜리 맛보기 영상)

FP에서 모나드는 범주론의 그것과 100% 일치하지는 않아서, 실용적인 관점에서만 이해하고 넘어가도 좋습니다. 보통 상자로 비유를 많이 하는데요, 예시를 보면 좀 더 빠르게 이해할 수 있습니다.

JavaScript의 Promise는 3가지 상태를 갖는데, 값이 계산되기를 대기(pending), 계산 완료되어 충족(fulfilled), 오류 등으로 거부(reject) 상태를 가질 수 있습니다.

출처: https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Promise

Promise를 다루는 함수 입장에서는, 저 3가지 상황을 ‘값’으로서 다룰 수 있지요. 이렇게 하면, JavaScript 문법의 도움으로 훨씬 간결한 코드를 작성할 수 있습니다.

배열 또한 모나드로 쓸 수 있습니다. 이 경우, 값이 비어있는 상황도 여전히 배열이라는 특성을 갖습니다.

클레이슬리 합성(Kleisli Composition)

클레이슬리 합성은 모나드를 반환하는 함수들을 합리적으로 합성하는 방법입니다. 이것 역시 범주론의 Kleisli Category에서 유래했는데요. 예시를 보면 바로 이해할 수 있습니다.

Promise를 반환하는 두 함수 f, g를 생각해볼까요. 이것을 합성하는 방법은 수도 없이 많습니다만, 아래와 같이 간단하게 합성하면 좋은 성질을 갖습니다.

function exampleF(url) {
return fetch(url)
}
async function exampleG(promise) {
return (await promise).foo + ' 완료'
}
function compose(f, g) {
return url => Promise.resolve(url).then(f).then(g)
}
const composedGF = compose(exampleF, exampleG)

만약 f나 g에서 이상이 생겨서 거부(reject)가 되면, g º f 또한 reject된 Promise를 반환합니다. 이때 f가 문제가 생기면 g는 실행되지 않죠.

배열도 비슷합니다. 배열에 적용할 수 있는 연산(map, filter)을 사용하여 함수를 구성하면(=합성하면), 배열의 길이가 0일 때엔 조용히 추가적인 과정을 생략하고, 그 배열을 그대로 반환합니다.

function exampleF(list) {
return list.map(x => x ** 2)
}
function exampleG(list) {
return list.filter(x => x % 2 > 0)
}
function compose(f, g) {
return list => g(f(list))
}
const composedGF = compose(exampleF, exampleG)

이름은 거창하지만, 실제로 실현하는 것은 허탈할 정도로 쉽습니다. 핵심은 중간에 예외 상황이 생겼을 때, 예외 상황을 담은 값을 반환하고 나머지 과정은 실행하지 않아도 동일한 결과를 갖도록 한다는 점입니다.

AST 탐색 코드가 고통스러운 이유는, 각 요소를 찾는 단계 간에 관심사가 분리돼 있음에도, 절차지향적 코드로는 그것을 분리하기가 어렵기 때문입니다. 그렇기에 각 단계를 함수로 빼게 되고, 그것을 좀 더 예쁘게 추상화 한 것이 FP의 접근이 됩니다.

보안 관계상 전체 소스코드를 공개할 수는 없으나, FP를 적용하여 대략 아래처럼 구현을 했더니 핵심 로직은 단 20줄만으로 표현이 가능했습니다. 끔찍한 인덴트도, 번거로운 if-else도, 한 번만 쓰는데 스코프 끝까지 잔류하는 1회용 변수도 사라졌죠.

이렇게 쉽다고?

무엇보다도 추후에 CSS 파일의 구조가 바뀌었을 때, 함수 한 두 줄 정도 추가/수정하기만 하면 된다는 점이 FP의 강력한 장점입니다.

저는 얼마 지나지 않아 PR을 올린 뒤 머지했고, 이슈를 종결할 수 있었습니다.

갈무리

이 문제는 얼핏 보면 어려워 보입니다. 하지만 제가 이 이슈를 받고 해결하기까지 소요된 시간은 단 이틀입니다. 의사소통 지연이나 다른 업무를 병행하고 있던 것을 고려하면, 실제 작업시간은 반나절도 채 되지 않습니다.

그 비결을 꼽아보자면 아래와 같았던 것 같습니다.

  • 문제 쪼개기 — 원인을 규명하고 해결을 위해 어디를 건드리면 되는지 파악하여, 커보이는 문제를 작게 바꿉니다.
  • 충분한 조사 — 섣불리 해결하려고 하지 말고, 선택지를 늘릴 수 있도록 다양하게 조사합니다.
  • 합리적인 의사결정 — 만약 혼자서 성급하게 SASS 이관 선택을 했다면 막대한 시간 낭비를 했을 것입니다.
  • 평소에 무기를 모아두기 — Node 런타임의 이해 및 AST 탐색 경험이 없었다면 변수 주입은 생각조차 못했을 것이고, FP에 대한 지식이 없었다면, 깔끔한 해결은 하기가 어려웠을 것입니다.

이것들을 하나로 간추려보면, 자신에게 되묻는 ‘사고의 힘’이었던 것 같습니다. 이게 정말 최적이야? 더 나은 방법이 있을까? 정말 이게 맞다고 생각해?

또한 이런 문제는 생각보다 자주 경험하기 어렵습니다. 그렇기에 이 문제를 풀 수 있었던 상황에 감사하고, 또 문제를 푸는데 도움을 주신 분들께 감사의 인사를 전하며 글을 마무리하려 합니다.

--

--