코드베이스에서 더 이상 쓰지 않기로 한 패턴이 복사·붙여넣기로 늘어나지 않도록, 린트 시점에 단순 텍스트 스캔으로 개수를 고정·감소시키는 ‘래칫’ 스크립트를 운용하는 기법에 대한 글.
2021-11-21 by qntm
그래서, 직장에서 우리가 쓰는 것 중에 내가 _래칫(ratchet)_이라고 부르는 게 있다.
우리 코드베이스에는 한동안 아주 자주 쓰다가, 이제는 쓰지 않기로 결정한 “패턴”들이 있다. 하지만 기존에 있는 모든 사례를 한 번에 제거하는 건 일이 너무 크다. 우리는 결국 이 모든 사례를 없애고 싶고, 그동안은 복사·붙여넣기로 이 패턴들이 절대로 증식하지 않게 확실히 막고 싶다. 그래서 우리가 하는 일은 이렇다. 린트(lint) 시점에 실행되는 스크립트 하나(래칫)가 코드베이스 전체를 훑어 이 “패턴”의 등장 횟수를 센다. 스크립트가 너무 많은 인스턴스를 세면 에러를 내고, 왜 그 “패턴”을 더 늘리고 싶지 않은지 설명한다. 반대로 너무 적게 세면 이번엔 또 에러를 내는데, 이때는 당신을 축하하고 예상 개수를 낮추라고 안내한다.
이 스크립트는 의도적으로 극도로 단순하다. 예상 개수는 스크립트 자체에 하드코딩돼 있다. 그리고 스크립트가 찾는 “패턴”은 추상적이고 고급스러운 Gang of Four식 소프트웨어 디자인 패턴이 아니라, 그냥 평문 텍스트 문자열이다.
글을 쓰는 시점에서 이 문자열들은 대체로 우리가 사용을 탐탁지 않게 여기는 메서드 이름들이다. 문제의 메서드는 우리 메서드가 아니다. 우리가 쓰는 1st-party 및 3rd-party 라이브러리의 공개(public) 메서드들이다. 상위 프로젝트(upstream)에 디프리케이션 경고를 걸 수도 없다. 그리고 설령 가능하더라도 그렇게 하진 않을 것이다. 일반적으로 사람들이 쓰기에 완전히 정상적이고 받아들일 만한 메서드들이기 때문이다. 다만 우리 특정 코드베이스의 범위 안에서만, 우리는 이 메서드들을 끊어보려 하기로 결정했을 뿐이다.
이 스크립트는 극도로 기본적인 문자열 매칭만 한다. 소스 코드 파싱은 없다. 그래서 뻔한 엣지 케이스가 몇 가지 있다. 예를 들어 누군가 주석에 금지된 메서드(THE FORBIDDEN METHOD)에 대해 얘기하고 싶으면? 문자열 리터럴 안에 나타나면? 답은:
중요한 관찰 하나는, 이 기법이 오래된 “패턴”의 제거를 적극적으로 장려하진 않는다는 점이다. 금지된 메서드에 대한 남아 있는 67번쯤의 호출은 계속 어정쩡하게 남아 있다. 하지만 그건 어쩌면 다른 문제일지도 모른다.
때로는 불가피한 사정 때문에, 수동으로 카운트를 다시 올려야 했던 적도 있다. 다만 이것은 가능한 한 피하려고 한다.
아주 가까운 미래에 나는 이 스크립트를 업그레이드해서 단순 문자열 매치뿐 아니라 정규식 매치도 지원하게 만들 생각이다. 지금은 그다지 그럴듯한 에러 메시지를 잘 내지 못한다 — 스크립트 소스 코드에 있는 설명 주석을 읽어야 한다 — 그리고 개발자에게 요구하기만 하는 대신 예상 카운트를 자동으로 아래로 래칫(내려가게)할 수 있다면 좋을 것이다. 이론적으로 이 래칫 스크립트는 전통적인 소스 코드 린터에서 그리 멀리 떨어져 있지 않다. 새로운 휴리스틱을 추가하는 더 일관되고 유연한 인터페이스, 검사하거나 무시할 소스 파일을 설정하는 기능, 자동으로 수정된 코드를 제안하는 기능 등등… 그런 것들이 있으면 좋을 수도 있다.
하지만 다른 한편으로, 이런 종류의 “단순한” 도구를 유지·개선하는 데 무의미한 시간과 에너지를 엄청나게 쏟아붓게 되기 얼마나 쉬운지도 나 역시 잘 알고 있다. 여기서 중요한 건 (내용은, 아니, 공유하지 않겠다) 우리 래칫 스크립트의 구체적인 구현이 아니라, 린트 시점에 기본적인 텍스트 스캔을 사용해 디프리케이트된 관행이 코드베이스 전반에 증식하는 것을 막는 일반적인 기법이다.
전반적으로 나는 코드베이스에 나쁜 관행이 남아 있는 걸 싫어한다. 피하기가 어려울 수 있지만, 신입에게도 매우 오해를 불러일으키고, 외부에서 들어오는 사람들에게도 그렇다 — 즉, 다른 팀의 숙련된 개발자들이 좋은 의도로 PR을 열어줄 때 말이다. “아, 전임자들이 남긴 예시를 성실히 따라 했군요. 잘했어요, 그리고 운이 나빴네요. 변경 요청입니다.” 이 기법이 하는 일은, 이전에는 코드 리뷰에서 내가 “이거 하지 마세요, 이제 안 하기로 했어요”라고 수동으로 말하던 과정을 자동화하는 것이다. 아니면 내가 그 말을 까먹는 과정을. 아니면 신입이 뻔뻔하게도 다른 사람에게 리뷰 요청을 해버려서 내가 변경 사항을 아예 놓치는 과정을.
내가 발견한 또 다른 함정은, 이 기법이 개발 팀에게 불필요하게 엄격한 “표준”을 강제하는 데 악용되기 쉽다는 점이다. 팀은 사실 어느 정도 창의적인 자유를 가져야 한다. 때로는 새로운 규칙을 추가하는 것에 “아니오”라고 말해도 괜찮다.
처음 이걸 도입했을 때는 정말 기본적인 기법처럼 느껴졌지만, 다른 한편으로 린팅, 단위 테스트, 코드 커버리지 측정 같은 기법처럼 어딘가에서 표준 관행으로 똑같은 방식으로 논의되는 걸 들어본 적은 없다. 온라인에서 사람들에게 이 이야기를 했더니, 꽤 많은 사람들이 아이디어가 새롭다며 도입해 보고 싶다고 했다. 한편 거의 같은 수의 사람들은 이미 거의 정확히 이런 일을 하고 있거나, 코드 커버리지나 성능 같은 영역에 비슷한 기법을 적용하고 있다고 말했다.
어쨌든, 꽤 잘 작동하는 듯하다.
Show discussion (14)
문제의 메서드가 뭐죠? (이게 어떤 종류의 용도로 적용되는지 궁금합니다.)
패턴을 새로 추가하는 것을 그냥 금지하는 대신(예: git diff 출력이나 동등한 걸 봐서), 인스턴스를 세는 방식의 장점은 뭔가요?
Max: > 이 기법이 하는 일은, 이전에는 코드 리뷰에서 내가 “이거 하지 마세요, 이제 안 하기로 했어요”라고 수동으로 말하던 과정을 자동화하는 것이다. 또는, 더 가능성 높은 버전으로는, 당신이 정말로 대단히 심각한 짓을 한 게 아니라면, 나나 내가 함께 일했던 대부분의 팀에서 아마 “음, 못생기긴 한데… 뭐, 돌아가긴 하네” 정도의 반응을 받게 될 거라는 점.
구체적인 건 기억 안 나지만, 한 직장에서는 자주 쓰이지만 마음에 들지 않는 방식으로 코드를 추가한 커밋을 거부하는 훅을 넣었던 기억이 납니다. 그 훅은 커밋 메시지에 “나는 <우리가 장려하지 않던 그 것>이 탐탁지 않다는 걸 이해하지만, 이번 경우에는 아주 좋은 이유가 있다:”라는 한 줄을 넣으면 우회할 수 있었어요. 커밋 메시지에서 정확히 일치(exact match)하는 문자열로 검사했기 때문에, 이미 설명을 했던 분들에겐 죄송하지만, 약간 다른 도입 문구를 썼다면 통과하지 못했을 겁니다.
Max: Diff 기반 린팅은 더 복잡하고 깨지기 쉽습니다. 우리도 예전엔 직장에서 그렇게 하다가, ‘사면(amnesty) 린팅’으로 바꿨어요. 기존의 모든 인스턴스에 줄 끝 주석으로 “지금은 OK” 같은 표시를 달아주고, 그 이후에는 주석이 달리지 않은 인스턴스는 린트 실패로 취급하는 방식이죠. 다만 그 주석을 다는 도구는 우리가 직접 써야 했습니다: https://github.com/edx/edx-lint#using-lint-amnesty
이 글 제목을 보고 첫 몇 줄을 읽었을 때, 저는 이게 정반대 방향의 이야기로 갈 줄 알았습니다. 우리 직장에는 서로 충돌하기도 하고, 반쯤만 완성돼 있거나, 과거 어느 시점에 바뀌었거나, 전반적으로 그냥 엉망인 포맷 표준들이 있어요. 그런데 포맷 표준은 항상 한 방향으로만 “래칫”됩니다. 누구도 규칙을 줄이자고 주장하는 아키텍트가 되고 싶어 하지 않죠. 그러면 무슨 카우보이처럼 보일 테니까요!? 아니, 유일하게 “안전한” 선택지는 기존의 모든 규칙을 유지하고, 가끔 더 추가하는 것뿐입니다. 절대 제거하지 않아요. 머리가 깨질 것 같습니다. 어쨌든 지금 당신은 글과 (기껏해야) 접선적으로 관련된 무언가로 불평을 들었습니다. 축하합니다! ((“tangentially(접선적으로)”에 해당하는 표현이 다른 삼각함수에도 있나요?))
@Spwack > ((“tangentially(접선적으로)”에 해당하는 표현이 다른 삼각함수에도 있나요?)) 쌍곡선적으로?
자전거 헛간질(bikeshedding): 저는 코드 패턴을 언어 차원에서 매칭하는 개념을 위해 특별히 만들어진 언어가 있는데 직접 써본 적은 없습니다 — semgrep https://github.com/returntocorp/semgrep. Sam의 사면 카운트 방식 대신, skztr가 말한 기능이 있어요. 린팅 과정에서 개별 라인을 제외할 수 있는 기능이죠. 다만 한편으론, 익숙한 기술이 아니고, 그걸로 작성한 무엇이든 그걸 모르는 사람에게는 난해한 주술(juju)로 남을 겁니다. 저는 낯선 규칙 엔진보다는, 잘 이해되고(또는 이해하기 쉬운) 커스텀 도구 편을 언제든 들겠어요.
Spwack, “tangentially(접선적으로)”는 삼각함수 tangent(탄젠트)와는 (하하하) 접선적으로만 관련이 있습니다. 어떤 직선은 곡선과 같은 방향으로 만나면 그 곡선에 접한다(tangent)고 하죠. 보통 이는 (1) 그 직선이 이후에 곡선이 둘러싸는 영역 안으로 들어가지 않고, (2) 만난 뒤에는 다시 멀어져 나간다는 뜻입니다. 무언가가 은유적으로 이런 식으로 행동하면 “tangential(접선적인)”이라고 합니다. 탄젠트 _함수_가 그렇게 불리는 건 아마 이런 질문에 답하기 때문일 거예요: 단위원 위의 어떤 점에서 시작해 그 점에서의 접선을 따라가다가, 그려진 선분이 원의 중심에서 보았을 때 어떤 주어진 각을 만들게 될 때, 그 선분의 길이는 얼마인가?
우리는 직장에서 D를 씁니다. D에는 심볼(함수, 클래스 등)을 deprecated로 표시하는 방법이 있어요. deprecated란 여전히 사용할 수는 있지만 컴파일러가 경고를 출력한다는 뜻이죠. 그리고 그 경고를 에러로 바꾸는 스위치가 있습니다. 보통 새 프로젝트에서는 그 스위치가 켜져 있어요. 어떤 프로젝트를 급히 업그레이드해야 하는데 무언가가 deprecated됐고 우리가 고칠 수 없으면, 그 스위치를 꺼서 경고를 받아들이면 됩니다. 다만 그건 일종의 수치심의 표식이죠.
이전 직장에서 꽤 큰 코드(정확히는 설정) 베이스가 있었는데, 처음에는 무규칙 상태로 시작했고 우리 모두 “PR 시점에 강제되는, 린트 클린 상태면 좋지 않을까?”라고 얘기하곤 했습니다. 하지만 문제는 기존 코드 거의 전부가 실패한다는 거였죠. 그래서 저는 작은 린트 스크립트를 하나 썼습니다. 항상 린터는 실행해서(리포트는 볼 수 있도록) 파일 이름을 정규식 목록과 비교하되, 매칭되면 PR 리뷰 도구에서의 “안 돼, 이건 못 해” 실패를 발생시키지 않게 했어요. 그런 다음 서브디렉터리 단위로 정리해 나가면서, 정규식 목록을 계속 다듬어, 이미 깨끗한 부분에서의 변경은 계속 깨끗해야만 하게 만들 수 있었죠. 이 방식은 “파일 단위”의 그레뉼러리티까지만 동작하고 그보다 더 작게는 안 됩니다. 그래서 개인마다 적용 가능성은 확실히 다를 거예요.
절반을 약간 넘게 읽고 나서야, 이 글이 겉보기 그대로의 글이고 어떤 은근한 공포 이야기(stelth horror story)가 아니라는 걸 깨달았습니다.
아주 좋은 아이디어이고 글로도 잘 정리하셨네요. 특히 좋은 점은, 개발자들이 이미 (적어도 공동체로서는, 개인으로서 늘 그렇지는 않더라도) 하고 싶어 하고 옳다고 아는 일을 하도록 돕는 보조 수단으로 제대로 바라보고 있다는 점, 과하게 밀어붙이지 않는다는 점, 그리고 특히 악용될 소지가 있다는 걸 이해하고 있다는 점입니다. 저는 좋은 가이드라인이 끔찍한 규칙으로 변해서 코드를 더 나쁘게 만들고, 개발자들이 그 앞에서 무력해 보이는 경우를 너무 많이 봤습니다. 포맷팅 규칙은 특히 이런 일이 생기기 쉬운데, 원래 목표(다른 개발자들이 코드를 더 읽기 쉽게 하자)는 흔히 완전히 잊히고, “가독성이 어떻든 간에, 자동 포맷터가 가능한 제한된 수준에서든 어쨌든 일관된 게 더 낫다”로 바뀌곤 하죠.
저는 이걸 엄청 늦게, 최근의 “누가 카운터를 1 올렸다” 트윗 맥락에서 발견했는데, 오래전부터 프로젝트에서 비슷한 걸 써왔습니다. 다만 제 방식은 현재 브랜치의 카운트가 머지 대상(merge target)의 카운트보다 커지지 않도록 검사하는 거예요. 악용될 가능성도 적고, 정말 드물게 CI를 건너뛸 수 있는 사람이 있어서 모두에게 피해를 주는 상황도 줄일 수 있죠.
Add comment
Hide discussion
Plain text only. Line breaks become <br/>
The square root of minus one:
Cancel
Submit
Search:
© qntm