오래되고 기능이 많은 레거시 소프트웨어를 관리하는 조직에서, 신규 기능을 추가하기보다는 여기저기서 터져나오는 문제를 해결하는데 시간을 훨씬 많이 쓰게 되는 경우가 있다. 특히 프로젝트 초기에 좋은 시스템 구조가 설계되지 않았거나, 프로젝트 도중 적절한 리팩토링을 거치지 않았거나, 또는 테스트 코드가 마련되지 않은 상태에서 개발이 진행되다 보면 그리 오랜 시간이 지나지 않아 이런 상황에 빠지기 쉽다. 이런 조직이 다시 개발 생산성을 높이기 위해서는 문제가 생겼을 때 단순히 그 문제를 빠르게 해결하기보다는 좀더 체계적인 방법으로 디버깅을 해야 한다. 그러면 어떻게 디버깅을 체계적으로 할 수 있을까. 그 고민의 결과물을 공유한다.

디버깅은 단순히 버그를 없애는 것이 아니다

위키피디아에 따르면 ‘버그’는 1870년대부터 기계적 결함을 나타내는 용어로 쓰여왔다. 컴퓨터 영역에서는, 1940년대에 초창기의 컴퓨터 하버드 마크 II가 패널에 나방이 끼는 바람에 오작동한 사건을 기록한 것에서부터 ‘버그Bug’와 ‘디버깅Debugging’이라는 용어가 널리 쓰이기 시작했다고 한다.

The very first recorded computerxbug

최초로 기록된 컴퓨터 버그.

나는 이 디버깅이라는 단어 자체가 일종의 함정을 지니고 있다고 느낀다. 디버그라는 말을 들었을 때 가장 쉽게 연상되는 뜻은 de + bug, 즉 버그를 없앤다는 것이다(실제로 최초의 버그는 패널에 낀 나방을 제거함으로써 해결하긴 했을 것이다). 그래서인지 개발 경험이 적을수록 디버깅할 때 버그를 없애는 것에만 집중하는 경향을 보이는 듯하다. 존경하는 개발자인 김정훈 님으로부터 몇년 전 이런 얘기를 듣기 전까지 나도 그런 개발자였다.

훌륭한 개발자는 버그를 대하는 태도가 다릅니다. 버그를 만났을 때 일반적인 개발자는 짜증내며 빠르게 고치려고만 합니다. 훌륭한 개발자는 버그를 만나면 버그가 생길 수밖에 없었던 근본적 원인을 생각해보고 리팩토링하는 기회로 삼습니다.

당시 수많은 함수들이 들어있는 general.js 에서 이해할 수 없는 문제가 생겨서, console.log를 무작정 찍어가며 디버깅하던 나를 보며 말씀하셨던 걸로 기억한다. 결국 찾아낸 문제의 원인은 변수명의 오타였다. 정훈님은 오타를 고치는 게 아니라 파일명, 변수명을 비롯한 코드 구조 자체를 바꿔야 한다고 말씀하셨다. 이런 근본적인 문제가 해결되어야 나중에 비슷한 문제가 생길 가능성이 줄어든다면서.

‘버그가 생길 수밖에 없었던 근본적 원인을 찾아 개선한다’는 말은 내 머릿속에 깊이 새겨졌고, 이 말을 내 안에서 오랫동안 숙성시켜 나만의 디버깅 템플릿을 만들어봤다. 이 템플릿은 기본적으로는 유저가 리포트한 버그를 고치는 것을 가정하여 만들었지만, ‘버그’는 결국 ‘구현한 기능이 의도대로 동작하지 않는다’는 것이고, 따라서 일반적으로 개발할 때 겪는 문제에 대해서도 적용할 수 있다. 디버깅 과정을 현상, 문제 원인, 해결책, 환경 개선의 네 단계로 나누는 것에서부터 시작한다.


1. 현상

유저 레벨에서 버그를 바라보며 명확한 재현 조건을 찾는다. 문제에 대해 실패하는 테스트 케이스를 만들어낸다고 생각하면 된다. “이런 조건에서(Given) 이런 액션을 했을 때(When) 이런 예상 결과가 나와야 하는데 실제 결과는 이랬다(Then).”와 같은 문장을 도출해낸다.

1-1. 누가, 어디서, 어떤 문제 현상을 겪었는가?

에러 리포트를 기반으로 최초의 테스트 케이스를 만들어내기 위한 질문이다. 웹 프론트엔드 영역에서는 아래와 같이 바꿀 수 있다. 실제로 문제를 겪은 사람의 리포트 링크, 문제 상황 스크린샷, 에러 로그 등의 데이터는 따로 기록해둔다.

  • 누가: 유저의 권한이 무엇인가?
  • 어디서: OS 버전은? 브라우저 버전은? 배포된 앱 버전은? 네트워크 환경은? 어느 페이지에서?
  • 문제 현상: 어떤 액션을 수행했는가? 이 때 예상 결과는 무엇이었는가? 실제 결과는?

1-2. 현상이 발생하는 조건은 무엇인가?

테스트 케이스의 완성도를 높이는 질문이다. 문제 액션을 고정한 채 다른 조건들을 바꿔보면서, 문제가 어떨 때 생기고 어떨 때 생기지 않는지를 관찰하며 재현 조건을 찾는다. 명확한 재현 조건 없이는 해결책을 적용한 뒤에도 문제가 해결됐는지를 확신할 수 없다.

  • ‘누가’를 바꿔본다. 다른 권한의 유저에게도 발생하는가?
  • ‘어디서’를 바꿔본다. 다른 OS, 다른 브라우저, 다른 앱 버전에서도 발생하는가? 네트워크 환경이 바뀌면 어떤가?
  • 문제 액션을 실행하기까지의 플로우를 바꿔본다. 문제 액션 이전에 어떤 액션을 했느냐에 따라 달라지는 앱의 상태도 조건의 일부다.
  • 비슷한 액션인데 문제가 발생하지 않는 상황, 그리고 비슷한 문제가 다른 조건에서 발생하는 상황은 없는지 탐색한다.

2. 문제 원인

실패하는 테스트 케이스를 만들어냈으므로, 내 코드의 어디가 어떻게 문제라서 실패하는지를 찾아낸다.

2-1. 코드상에서 문제를 일으키는 부분이 어디인가?

문제를 일으키는 코드를 포착하려면 내 코드에서 어떤 부분이 문제에 영향을 주고 어떤 부분은 영향을 주지 않는지를 알아내야 한다. 문제 액션에 관련된 코드의 범위를 줄여나가면서 정확히 어디가 문제인지 찾는다.

재현 조건이 명확한데도 문제를 일으키는 코드의 위치를 쉽사리 알기 어렵다면 그 자체가 나쁜 코드의 징후다. 대개 적절한 테스트 코드가 없었거나, 에러 로깅이 잘 되어있지 않았거나, git 버전 관리가 잘 안 되어있을 때 그렇다(환경 개선 단계에서 해결한다). 이럴 때 의외로 효과적인 방법이 코드 일부분을 주석처리하고 다시 실행해서 문제가 재현되는지 확인하는 것이다. 이런 분할 정복법으로 문제에 영향을 끼치는 부분을 절반 정도씩 줄여나가면 상당히 빠르게 문제 코드를 찾아낼 수 있다.

2-2. 그 코드가, 그 조건에서, 왜 의도대로 동작하지 않았는가?

테스트 케이스가 왜 실패했는가? 코드가 의도대로 동작하지 않은 이유를 이해해야 문제를 근본적으로 해결할 수 있다. 코드의 의도가 명확하지 않다면 이 질문에 답하기 어려울 수 있는데 이 또한 나쁜 코드의 징후다.

2-3. 파악한 원인으로 인한 다른 문제는 없는가?

유저가 리포트한 문제는 실제 문제의 한 사례에 불과하다. 문제의 원인에서부터 거꾸로 다른 문제를 탐색해나간다(다른 테스트 케이스를 만든다). 의도대로 동작하지 않은 코드가 사용된 다른 부분을 살펴보는 것은 물론이고, 정확히 그 코드는 아니더라도 비슷한 스타일로 작성된 코드가 다른 조건에서 문제를 일으키고 있지는 않은지 확인한다. 이 과정에서 현상 - 문제 해결 단계를 반복하게 될 수도 있다.

3. 해결책

현상을 유저의 눈으로 살펴 실패하는 테스트 케이스를 만들어냈고, 그 다음 개발자의 눈으로 테스트가 특정 조건에서 어떤 코드 조각 때문에 왜 실패하는지 알아냈다. 그러면 이제 테스트 케이스를 성공시켜야 하는데, 여기에는 기획자의 눈이 필요하다. ‘해결책 찾기’는 코딩이라기보단 의사결정의 과정이고, 기능의 구현 의도와 앱 사용자의 행태를 이해해야 좋은 결정을 내릴 수 있기 때문이다.

3-1. 앱을 의도대로 동작시키기 위한 방법은 무엇이 있는가?

언제나 해결책은 단수가 아니다. 해결책을 구현하는 방법도 여러가지일뿐 아니라, 그 층위도 여러 수준이 있다. 예를 들어 앱의 특정 버전에서, 함수의 파라미터로 null이 들어와서 null reference error 가 생기는 문제가 있다고 해보자.

개발자 A: 파라미터가 null 값이 아닐 때만 함수를 실행하도록 예외처리합시다.

개발자 B: 함수 실행부의 구조를 바꿔서 파라미터에 null 이 들어오지 못하도록 강제합시다.

개발자 C: 현재 버전에는 해결된 문제니까 새로고침하면 해결됩니다. 앞으로 이런 문제를 방지할 수 있도록, 앱 실행시 버전 체크해서 오래된 버전이면 캐시를 비우고 새로고침하게 하는 모달을 띄웁시다.

이 모두가 앱을 의도대로 동작시키는 방법이며, 분류하자면 A는 현상 수준, B는 원인 수준, C는 환경 수준의 해결책이다. 물론 세 가지 방법을 섞어서 쓸 수도 있겠지만, 뒤로 갈수록 더 근본적이고 더 바람직한 해결책이라고 할 수 있다. 그러나 항상 C와 같은 방법이 최선인 것은 아니다.

3-2. 각 해결방법의 장단점은 무엇인가?

더 근본적인 해결책이 언제나 더 좋은 해결책이라는 보장은 없다. 근본적인 해결책은 생각하고 구현하는 데 시간이 더 오래 걸리기 마련인데, 프로젝트의 진행 상황이나 가용 개발 리소스 등 앱을 둘러싼 환경은 가변적이기 때문이다. 물론 근본적인 해결책이라고 해서 꼭 오래 걸리는 것 또한 아니다. 어쩌면 D가 나타나서 “이거 기획자에게 확인해보니 이제 쓰이지 않는 기능이라는데, 그냥 함수를 삭제하죠?” 라고 해줄 수도 있는 것 아닌가.

어쨌든 확실한 것은, 프레드 브룩스로부터 몇십 년이 지났지만 여전히 소프트웨어 세계에 만병통치약은 없다는 사실이다. 현재 팀의 상황에 비추어 장단점을 비교해보고 가장 적절한 의사결정을 해야 한다. 다만 시간과 비용이 허락하는 한에서는 언제나 더 근본적인 해결책을 적용하도록 노력하자.

장단점을 분석할 때 간과하기 쉬운 부분을 하나 이야기하고 싶다. 어떤 해결방법은 다른 방법과 다르게 프로그래밍 모델, 즉 “팀이 디자인하고 개발하는 방식”에 영향을 줄 수 있으며 그로 인해 새로운 문제들이 생겨날 수 있다는 점이다. 예를 들어 페이지네이션을 적용한 게시판 앱에 문제가 생겨서 페이징 대신 무한스크롤을 적용하여, 스크롤을 페이지 끝으로 내릴 때마다 추가 컨텐츠가 로딩되도록 했다고 해보자.

debugging-on-debug_infinite-scroll-01

푸터가 있는 게시판 앱. 페이지네이션에서 무한스크롤로 바뀌었다.

무한스크롤을 적용할 때 가장 처음 문제가 되는 부분은 푸터를 비롯해 페이지의 아래쪽에 위치한 요소들이다. 원래 고정되어있던 컨테이너의 크기가 가변적으로 변했기 때문이다. 페이지 하단에 푸터 영역도 잘 존재하고 각 링크도 작동하니까, 푸터 링크 클릭을 시뮬레이트하는 테스트는 여전히 잘 작동한다. 하지만 실제로는 사용자가 푸터 링크를 클릭하기 위해 스크롤을 내릴 때마다 새 컨텐츠가 로드되면서 푸터가 계속 멀어질 것이다. 따라서 푸터 영역을 고정하거나, 페이지 최하단의 요소들을 다른 곳으로 옮기거나.. 하는 추가 작업이 필요해질 뿐더러, 좀 더 일반적으로는 이전과 달리 페이지 로드 후에 요소가 동적으로 생성될 수 있으므로, 더이상 고정된 페이지가 아님을 고려하여 페이지를 디자인해야 한다.

이러한 종류의 결정들(정적 -> 동적, 클라이언트 -> 서버, 코드 분할, 라이브러리 교체 등)은 기존에 팀이 일하던 방식에 큰 변화를 일으킬 수 있으므로, 결정을 내릴 때 팀원들과 충분한 의사소통이 필요하다.

3-3. 선택한 방법이 문제를 잘 해결하는지, 부작용은 없는지 어떻게 확인할 수 있는가?

해결 방법 하나를 선택해서 구현했다면 이제 테스트할 시간이다. 발견된 문제를 커버하는 테스트 코드가 없었다면 이 때가 추가할 좋은 타이밍이다. 테스트 코드가 있었다면 왜 그 테스트 코드로 문제를 발견하지 못했었는지 확인해야 하고, 또 구현이 변경됐으므로 테스트 코드 또한 변경해야 할 것이다.

테스트를 진행하면서, 앱에 생긴 변화가 기존에 작동하던 무언가를 깨뜨리지는 않는지 또한 확인한다. 문제를 해결하기 위한 코드가 새로운 문제를 일으키는 사례는 생각보다 흔하다. 통합 테스트가 잘 갖춰져있는 프로젝트라면 기능 수준에서는 확인이 되겠지만, 이런 프로젝트에서도 다른 문제가 얼마든지 생길 수 있다. 위에서 얘기한 프로그래밍 모델의 변화가 좋은 예다. 적절한 논의와 테스트를 거치지 않는다면 어떤 비극이 생길지 모른다. 멀리 갈 것도 없이, 우리 팀에서도 앱 패키징 용량을 줄이려고 동적 import를 도입했다가 끊임없이 생기는 버그 때문에 고생한 바 있다. 큰 조직에서 만든 서비스도 상황이 크게 다르지는 않은 것 같다.

debugging-on-debug-footer

로그아웃과 고객센터 문의를 줄이는 대신 유저의 스트레스를 늘린 디자인.

4. 환경 개선

기획자의 시선에서 테스트 케이스를 통과시켰으니, 이제는 팀 차원에서 리팩토링을 할 시간이다. 환경 개선은 다음에 비슷한 문제가 발생했을 때 더 쉽게 해결하기 위한 것이다. 이는 더 좋은 소프트웨어 개발 프로세스를 만드는 것과도 비슷하다.

4-1. 현상의 재현조건을 더 쉽게 찾아내려면?

현상 재현에 가장 많은 도움을 주는 도구는 로깅이다. 요즘에는 에러 로그를 수집하는 좋은 서비스가 많다. 우리 회사에서는 Sentry를 쓰고 있는데, 우리가 catch하지 못한 자바스크립트 에러가 발생했을 때 알려줄 뿐 아니라 에러 직전에 일어난 일들(어떤 링크가 눌렸나, 어떤 API가 호출됐나 등)을 breadcrumb이라는 기능으로 제공하고 있어 유용하다. 이렇게 자동으로 기록되는 정보들이 있으면 유저의 명시적인 버그 리포팅 없이도 에러의 재현 조건을 찾아내기 쉽다.

An illustration of a fake user interface, showing an xhr POST request, a click on a password field, an input event on the password field, and an exception being thrown.

Sentry의 breadcrumb 예시.

좀 더 어려운 문제는 에러가 아닌 버그다. 위에서 버그를 ‘구현한 기능이 의도대로 동작하지 않는다’로 정의했고, 실제로 우리가 유지보수하는 문제는 정말로 에러가 나서라기보다는 의도대로 작동하지 않아서가 원인일 때가 더 많은 것 같다. 유저가 (그들 입장에서) 비정상적인 동작을 찾아내서 쉽게 피드백할 수 있게 해주는 좋은 서비스도 많다. 현재 우리 회사에서 쓰고 있는 서비스는 Intercom으로, 유저가 메시지를 보내면 그 유저의 OS/브라우저 등 환경 정보나 우리가 세팅한 UserId 등의 정보도 볼 수 있어서 재현에 도움이 된다. 추가로, 우리는 Intercom에서 제공하는 이벤트 로깅 API를 이용해서 Sentry breadcrumb 처럼 이 사람이 메시지를 보내기 전에 우리 앱에서 어떤 행동을 했는지 확인하고 있다.

4-2. 문제 원인이 되는 코드를 더 쉽게 찾아내려면?

이것도 우선은 로깅이다. 팀만의 로깅 시스템을 사용할 수도 있고, 단순한 console.error 도 상관없다. 특정 에러에 대해 적절한 에러 메시지를 쌓고 있다면 문제의 원인이 되는 코드를 찾아내기 훨씬 쉽다.

그 다음은 ‘의도 드러내기’다. 너무 기본적인 것이지만 변수명, 클래스명, 파일명 등이 너무 일반적이지는 않은지, 함수가 수행될 때 사이드 이펙트가 생기진 않는지 등을 확인하고 리팩토링한다. 만약 팀의 사정상 근본 원인 해결이 아닌 땜질식 해결책을 적용하기로 했다면, 해당 부분에는 이렇게 구현한 이유에 대한 적절한 주석을 작성해둬야 할 것이다. 이런 해결책은 다음번에 생긴 비슷한 문제를 가려버리는 효과가 있기 때문에 적용할 때 더욱 주의하고, 빠른 시일 내에 다시 제대로 구현해야 한다.

4-3. 해결책을 더 쉽게 찾아내려면?

안전하게 이것저것 실험해볼 수 있는 환경이 있다면 문제의 해결책을 찾아내기 쉽다. 안전한 실험 환경의 핵심은 크게 테스트 코드, 모듈화, 버전 관리 세 가지다. 테스트 코드는 적용된 해결책으로 인해 기존에 작동하던 기능들이 깨지지 않는지 확인해준다. 모듈화로 변경을 적용하고자 하는 기능이 다른 부분에 끼치는 영향이 적게 만들면 실험하기가 더 수월하다. 버전 관리가 잘 되어있다면 실험하다가 다시 잘 작동하던 버전으로 언제든지 돌아갈 수 있다. 물론 이 세 가지는 문제 원인을 찾는 것에도 큰 도움이 된다.

그리고 마지막으로, 어쩌면 가장 중요한 것이 팀 내의 의사소통 구조다. 버그가 대체 왜 생길 수밖에 없었는가를 계속 파고들다 보면 궁극적으로는 의사소통 문제로 수렴한다. 테스트 코드가 없어서 버그가 있었는지 몰랐는데, 개발자는 그 케이스를 테스트해야 하는지 몰랐고, 기획자는 그 케이스가 존재하는지 몰랐고.. 와 같이 말이다. 이상적으로는 기능을 설계하는 시점에서부터 기획자와 개발자가 활발하게 대화하면서, 요구사항을 테스트 가능한 수준으로 만들어내는 것이 좋다. 특히 앞서 얘기했듯 ‘프로그래밍 모델’에 영향을 끼치는 해결책은 팀 내 의사소통이 원활하게 이루어지지 않는다면 더 큰 문제를 일으킬 수 있다.

0. 아직 재현 조건을 / 원인을 / 해결책을 찾지 못한 채 x시간이 지났다면…

사실 모든 디버깅 과정이 이렇게 스무-스한 건 아니다. 오히려 교착상태에 빠질 때가 더 많다. 그럴 땐 잠시 디버깅을 멈추고, 한 발자국 물러서서 생각해보는 것도 좋다. 현재 나에게 이 문제가 얼마나 중요한지 다시 생각해보자. 이 문제에 영향을 받는 유저 또는 동료가 많은가? 정말 이 문제를 해결해야만 다음 문제를 해결할 수 있는가?

정확한 재현 조건을 파악할 때까지 (또는 새로운 버그 리포트가 올라올 때까지) 해결을 의도적으로 미루고 다음 일로 넘어갈 수도 있다. 또는, 지금까지 파악한 내용을 바탕으로 stackoverflow 등에 질문을 할 수도 있다. 다른 일을 하는 도중에, 또는 질문을 위해 내가 한 일을 정리하다가 불현듯 답이 떠오르는 경우도 많다.

내가 “ABC 문제”에 빠진 건 아닌지도 한번 점검해보자. ABC 문제는 임의로 붙인 이름인데, 이런 걸 말한다. “A 문제의 해결책이라고 알려진 B를 적용하는데 생기는 문제인 C를 해결하는 D를 적용하는데 에러가 난다.” 원인 분석 없이 구글링을 통해 복사 & 붙여넣기만 하다 보면 어느새 원래 해결하고자 했던 문제에서 벗어나버리기 쉽다. D는 C하기 위한 것이고 C는 B하기 위한 것인데, A의 해결책이 정말 B밖에 없는가? 해결책은 언제나 단수가 아니라는 사실을 기억하면서, 내가 원래 풀고자 했던 문제는 무엇이었는지 다시 돌아가서 생각해보자. 의외로 다른 단순한 해결책이 나올 수도 있고, 문제 자체를 바꿀 수도 있다.


예시: 리사이즈가 안돼요

실제로 직장에서 내가 겪었던 문제에 이 템플릿을 간단하게 적용해보자. 아래와 같이 왼쪽에는 좌우 폭을 조절할 수 있는 컴포넌트, 오른쪽은 여러 종류의 컨텐츠가 들어갈 수 있는 컴포넌트가 있는 페이지가 있었다. 평소에는 괜찮은데, 우측에 영상 플레이어가 있을 때는 좌측 컴포넌트 폭을 조절할 때 가끔씩 이상하게 마우스가 튀면서 리사이즈가 잘 안 된다는 리포트를 받았다.

debugging-on-debug_resize-01

왼쪽은 좌우 폭을 조절할 수 있는 컴포넌트. 여러 종류의 컨텐츠가 들어갈 수 있는 오른쪽에는 Youtube 영상을 재생하는 플레이어가 렌더링된 상황.

현상

  • 윈도우즈 OS 크롬 브라우저에서, 좌우 분할해서 컴포넌트를 보는 페이지에서 리사이즈할 때 가끔씩 이상하게 마우스가 튀면서 리사이즈가 잘 안 된다.
  • 유저의 권한과는 상관없었고, 앱 버전과도 상관없었다. 최근에 이쪽과 관련된 변경을 하지도 않았다.
  • 관찰 결과, 1) 리사이즈를 왼쪽으로 할 때는 아무 문제 없이 빠르게 리사이즈가 됐다. 2) 오른쪽으로 할 때는 리사이즈를 천천히 하면 괜찮고 빨리 하면 마우스가 튀었다. 3) 유투브가 아닌 다른 컨텐츠가 우측에 있을 때는 리사이즈가 잘 됐다. 그런데… 4) PDF 파일이 우측에 있을 때는 유투브와 비슷하게 리사이즈 문제가 생겼다.

문제 원인

  • ‘마우스가 튄다’는 게 어떤 의미인가? 좌측의 Resizable 컴포넌트는, 리사이즈 핸들을 클릭한 채 드래그할 때마다 handleMouseMove 함수를 호출하게 구현되어 있다. 이 함수는 마우스의 X 위치가 바뀔 때마다 그 위치에 리사이즈 핸들이 따라가기 위한 것이다. 따라서 함수가 호출되지 않으면 마우스와 리사이즈 핸들 위치가 달라질 수 있다. 즉 ‘마우스가 튄다’.
  • 언제 함수가 호출되지 않을 수 있는가? handleMouseMovewindow 오브젝트의 mousemove 이벤트에 바인드되어있다. 그러므로 windowmousemove 이벤트가 전달되지 않으면 함수가 호출되지 않는다.
  • 언제 전달되지 않을 수 있는가? 재현 조건을 찾으면서 어떤 컨텐츠가 우측에 있느냐에 영향을 받는다는 사실을 알았다. 유투브와 PDF를 렌더하는 코드에서 대신 빈 컨텐츠를 로드했더니 정상적으로 리사이즈가 된다는 것을 확인했다. 이 두 타입의 공통점은 다른 컨텐츠와 다르게 iframe 을 활용한다는 것이었다. 그런데 iframeResizable을 호출한 window와 다른 window이므로 이벤트가 전달되지 않는다! 마우스를 빠르게 움직이면 포인터가 리사이즈 핸들을 벗어나 iframe 위로 올라가게 되고, 이 때 문제가 생기는 것이다.
  • 다른 곳에 비슷한 문제는 없는가? 꼭 이 페이지가 아니더라도, iframe 을 이용하는 곳이라면 window에 바인드된 이벤트가 전달되지 않을 위험이 있다는 것을 알았다. 찾아보니 당장은 그런 부분이 더 없지만 앞으로 이 문제가 생길 수 있다는 사실을 기억해야 한다.

해결책

  • 이벤트가 전달되지 않는 이유는 iframe 위에 마우스가 있을 때는 이벤트가 부모 window 가 아닌 iframe 으로 가기 때문이다. 어떻게 해야 이벤트가 원하는대로 부모 window 로 전달될까?
  • 가장 먼저 생각난 것은 부모 window 가 자식 iframe 에서 생긴 이벤트를 전달받을 수 있는가? 였다. 찾아보니 가능하긴 했지만 이렇게 하려면 자식 iframe 을 내가 통제할 수 있어야 했는데, iframe 은 대개 완전히 외부 URL을 불러오는 데 쓰기 때문에 부적절했다. 무엇보다 이 방법은 개별 iframe 마다 이벤트를 전달하는 코드를 삽입해야 할텐데 전혀 마음에 들지 않았다.
  • 다음은, iframe 을 무시하는 방법을 생각해봤다. ‘리사이즈 핸들을 클릭한 상태’는 알 수 있었다. 이미 Resizable 컴포넌트에서 핸들을 클릭하면 bodygrabbed--horizontal 따위의 클래스를 추가하게 되어있었기 때문이다. 그렇다면 이 상태에서 CSS를 뭔가 조절해서 iframe 을 무시할 수 있지 않을까.
  • 먼저 드래그중일 때 iframez-index 를 낮춰봤다. 그랬더니 리사이즈는 잘 됐으나 iframe 이 아예 보이지 않았다. 뷰에 영향을 미치지 않으면서 iframe을 무시할 방법이 필요했다.
  • 잠깐의 구글링 후 pointer-events: none 을 활용하는 방법이 떠올랐다. 이 속성이 정의되어있으면 해당 요소와 그 하위 요소에서 생기는 hoverclick 마우스 이벤트는 무시되고 그 뒤에 있는 요소에서 실행된다. 따라서 iframe 안에서 mousemove 이벤트도 부모로 전달될 것이라는 가정이 있었고, 실험해보니 원하는 대로 동작했다.
  • caniuse 에서 검색해보니 Opera mini를 제외한 모든 브라우저에서 pointer-events 속성이 적용될 수 있었고, 알려진 이슈도 내 문제와는 관련이 없었다. 그래서 bodygrabbed--something 클래스를 가질 때 하위에 있는 모든 iframepointer-events: none 을 가지도록 하는 해결책을 적용하기로 결정했다. 이렇게 하면 앞으로 iframe 을 사용하는 다른 컨텐츠가 생겨도 문제없다. 그리고 특수한 조건에서만 해당 속성이 추가되게 했기 때문에 다른 부작용은 없을 것이라고 생각했다.

환경 개선

  • 이 해결책을 적용할 당시에는 우리 앱에 마우스 움직임에 대한 테스트를 만들기는 어려운 환경이었기 때문에 테스트코드를 따로 만들지는 않았다. 이런 문제를 인지하고, 원래 유닛 테스팅 프레임워크로 사용하던 jest + enzyme 에 더해서 testcafe 를 UI 테스팅 프레임워크로 사용하기로 했다.
  • 보통은 윈도우에 이벤트가 전달되지 않을 수 있다는 생각은 전혀 하지 않았었는데 iframe 과 같이 그런 케이스가 있을 수 있다는 사실을 알게 됐다. 이런 지식은 개발팀 전체가 공유하면 좋겠다고 생각하여, Resizable 컴포넌트에 주석으로 해당 문제에 대한 설명을 추가하고 이 문제의 해결 과정을 자세하게 기록해뒀다. (당시 더 정리해서 회사 위키에 남겨놔야겠다고 생각했었는데 이 글을 쓰는 시점까지 까먹고 있었다.)

마치며

정리하면, 디버깅을 체계적으로 하는 프로세스는 이렇다. 대부분 눈치채셨겠지만 레거시 코드에 테스트를 추가하는 과정과 비슷하다.

  • 실패하는 테스트 케이스 만들기: 문제 현상에 대한 명확한 재현조건을 찾는다.
  • 왜 실패하는지 살펴보기: 문제를 일으키는 코드를 찾는다.
  • 테스트 케이스 통과시키기: 장단점을 고려하여 팀 상황에 맞는 해결책을 적용한다.
  • 리팩토링: 다음에 비슷한 문제가 발생했을 때 더 쉽게 해결할 수 있도록 환경을 개선한다.

결국 중요한 건 (정훈님이 말씀하신 것처럼) 근본적인 환경 개선이다. 버그가 전혀 없는 앱을 만들어내는 것은 불가능하다. ‘버그 리포팅이 적다’는 ‘앱에 결함이 적다’와 다르다. 유저가 버그를 겪어도 버그인 줄 모르거나, 버그를 찾아도 리포트하기 어렵거나, 또는 앱 유저 수가 너무 적어서일 수도 있다. 아니면 버그의 존재가 CS팀에서 개발팀으로 전달되지 않았을지도 모른다. 어쨌든 ‘앱을 완벽한 상태로 배포하기’보다는 ‘버그를 빠르게 인지하고 해결할 수 있는 환경 구축하기’가 좀 더 생산적인 접근이라고 본다.

이런 환경 구축 또한 처음부터 완벽하게 하기는 당연히 어렵다. 다만 버그를 만날 때마다 이걸 피드백 삼아 조금씩 더 나은 환경으로 만들겠다는 마음가짐이면 된다고 생각한다. 그러면 버그에 짜증이 아니라 감사하게 될 수도 있고, 개발이 훨씬 더 즐거워질 수 있다. 어차피 우리가 매일 수행하는 개발 시간의 상당 부분은 유지보수니까.

블로깅을 본격적으로 시작한지 얼마 되지 않았지만, 학습을 위한 정리 도구로서 블로그가 상당히 효과적이라고 느낀다. 이 템플릿도 글로 정리해보면서 더 명확해진 부분이 많다. 비록 지금의 나는 이렇게 문제해결 과정을 몇 단계로 나눠야 스스로 생각 점검이 되지만, 경험이 더 쌓이면 단계를 건너뛰거나 합치거나 하면서도 자연스럽게 문제해결이 가능할 것이다. 이 글을 씀으로써 내 템플릿이 내 몸에 좀 더 체화되었기를 바란다.

p.s. 글을 마무리해갈 때쯤 Debug It!: Find, Repair, and Prevent Bugs in Your Code (번역: Debug It! 실용주의 디버깅)이라는 짧고 재미있는 책을 읽었다. 약 9년 전에 나와서인지 내용이 살짝 오래되긴 했지만, 여전히 실용적인 조언으로 가득찬 좋은 책이었다. 내 생각과 비슷한 내용도 많아 더 공감하며 읽었다. 디버깅을 좀 더 즐겁게 하고 싶으신 분들께 추천한다.