洪民憙 (홍민희) 블로그

Semantics and optimizations

Twitter에서 pjax의 시멘틱에 대한 얘기가 나왔다. pjax의 기본적인 아이디어는 이렇다.

The idea is you can’t tell the difference between pjax page loads and normal page loads. On complicated sites, browsing just feels faster.

그래서 나는 지금까지 pjax의 용도가 단순히 페이지 로딩의 체감 속도를 투명하게 향상시키는 것이라고 생각했다. 실제로 pjax는 XHR을 이용해 비동기적으로 요청했다가 대기 시간이 길어지면 그냥 일반적인 하이퍼링크 클릭처럼 페이지 이동을 시켜버린다. pjax의 덕을 제대로 본 경우에는 페이지의 다른 부분은 변하지 않고, 실제로 내용이 바뀐 부분만 갱신된다.

내부 구현은 HTML 5에서 추가된 history.pushState() 메서드를 이용해 브라우저 히스토리를 조작하는 식이다(이때 주소창의 내용도 갱신한다). 브라우저가 history.pushState() API를 지원하지 않는 경우에는 작동하지 않는다. 그럼 그럴 때는 어떻게 할까?

When pjax is not supported, $('a').pjax() calls will do nothing (aka links work normally) and $.pjax({url:url}) calls will redirect to the given URL.

그냥 일반 링크처럼 작동한다. 그럼 pjax의 특징을 정리해보자.

  1. 사용자가 보기에는 일반 링크처럼 작동하지만 체감 속도가 조금 더 빠르다.
  2. 여의치 않으면 좀 느려도 원래 했던대로 폴백한다.

이걸 좀더 일반화하면 다음과 같이 정리할 수 있다.

  1. 시멘틱(semantic)을 유지하며 성능을 개선한다.
  2. 가정(assumptions)에 부합하지 않을 경우의 general counterpart도 마련하다.

이건 요약하자면, 최적화(optimization)의 정의나 다름없다. 그래서 내가 보기에 pjax는 일반 HTML 링크 이상의 기능이 아닌, 링크의 단순 최적화다. history.pushState() API는 투명하게(즉, 사용자가 보기에는 일반 링크와 똑같은 시멘틱으로 여겨지게) 작동하도록 만들기 위한 구현 디테일이라고 생각한다. 만약 다른 방식으로도 HTML 링크의 시멘틱을 유지하면서 체감 속도를 개선할 수 있다면 pjax는 그 방식을 써서 구현됐을지도 모른다.

그러나 Twitter에서 이야기해보니 pjax는 history.pushState()를 통해 구현되었기 때문에 상태를 언제나 유지하는 것이 바람직한데, 여의치 않으면 카운터파트로 폴백하는 동작 때문에 문제가 될 때가 있다고 생각하시는 분들도 계신다. 이를테면 음악 재생을 하는 페이지에서 pjax를 쓴다는 것은 페이지 상태를 유지하기 위해서인데 의도치 않게 카운터파트가 작동해서 음악이 중단되면 어떡하냐는 이야기다. 그렇게 생각할 수도 있겠구나 싶다. 확실히 pjax는 이름에서부터 구현 디테일에 대한 인상을 심어준다.

어떤 기술이 최적화냐 부가적인 기능이냐를 판단하는 여부는, 그 기술의 동기, 즉 용도를 어떻게 보느냐에 따라 달라진다. 설명을 위해 또 다른 예를 들어보겠다.

예전에 LangDev에서 강성훈 씨가 이런 주장을 한 적이 있다: TCO(tail call optimization)는 TCO가 적용되지 않았을 때의 카운터파트 시멘틱과 직교적(orthogonal)이지 않기 때문에 컴파일러가 암시적으로 적용하면 안된다. 워낙 파격적이고 생각도 해본 적 없는 주장이라 채널의 다른 사람과 말이 많았다. 강성훈 씨의 생각은 이렇게 요약할 수 있다. TCO는 TCO가 적용되지 않았을 때 일어나는 스택 오버플로우(stack overflow)에 대한 시멘틱을 훼손하기 때문에 암시적으로 일어날 경우 디버깅이나 유지 보수에 혼란을 만든다. 이를테면 TCO의 존재를 모르는 언어 사용자가 TCO가 우연히 잘 적용되고 있는 재귀 함수를 작성하여 사용하고 있었는데, 이 함수는 원래대로라면 스택 오버플로우를 발생시킬 수 있는 코드였다. 만약 이 함수를 같은 언어의 다른 구현체(컴파일러)에서 컴파일을 하게 되거나, 함수의 내용을 약간 바꿔서 TCO의 암시적 적용이 무효화되고 카운터파트로 폴백되어 스택 오버플로우가 나기 시작했다고 해보자. 사용자는 이 버그를 어떻게 생각해야 하는가? 어째서 이러한 문제가 갑자기 일어나기 시작했는지 추리할 방법이 있을까?

내 생각에는 이 주장 역시 프로그래밍 언어가 명세에서 함수 호출의 시멘틱을 어떻게 정의하느냐에 따라 맞는 얘기가 되기도 하고, 틀린 얘기가 되기가 될 수도 있다고 본다. 만약 해당 언어에서 함수 호출이 스택과 같은 것을 언급하지 않고 컴파일러의 구현 디테일로 치부한다거나 (이렇게 되면 일반 함수 호출에서 C 스택을 사용하는 것은 컴파일러의 구현 문제가 된다) 함수 호출을 컨티뉴에이션(continuation)을 이용해 정의하면 암시적인 TCO는 말 그대로 최적화일 뿐 시멘틱을 훼손한다고 볼 수는 없게 된다. 하지만 스택과 스택이 다 찼을 때의 런타임 오류 핸들링을 언어 기능으로 본다면 TCO는 특정 케이스에 언어 명세에 의거해서는 틀린 예측을 낳으므로 시멘틱을 훼손하는 버그로까지 여길 수 있다.

다시 pjax 얘기로 돌아가자면, pjax는 처음 만들어졌을 때 그 동기와 용도에 대해 딱히 천명한 적은 없다. 즉, pjax가 애초에 일반 링크의 체감 속도를 최적화한 것인지, 아니면 페이지의 상태를 유지하면서 일반 링크의 기능을 가져오려고 한건지 알 수는 없다. pjax의 용도를 전자로 본다면, pjax가 자동으로 카운터파트로 폴백을 해서 상태가 초기화되는 것은 의도적으로 무시한 부수 효과라고 볼 수 있을 뿐만 아니라, 오히려 상태가 유지되는 것이 history.pushState() API를 써서 생긴 부수 효과라고 봐야 한다. 하지만 pjax의 용도가 후자라고 생각한다면 pjax가 여의치 않다고 상태를 때때로 잃어버리는 현상은 부수 효과도 뭣도 아니고 그냥 버그가 된다.

하지만 나는 pjax가 때때로 상태를 잃어버리는 것이나 TCO가 때때로 스택 오버플로우를 내지 않는 것이 버그라고 여겨지지는 않는다.