나는 그동안 소프트웨어 개발자와 소프트웨어 엔지니어를 구분해서 다른 역할이라고 생각하지는 않았다. 왜냐면 현실에서 내가 개발자로 불리든 엔지니어로 불리든, 하는 일은 늘 똑같았기 때문이다. 아니 똑같다고 생각하고 살아왔다. 하지만 “구글 엔지니어는 이렇게 일한다” 라는 책을 읽으면서 조금 다르게 인식하는 개기가 되었다.
팀에서 이 책을 함께 읽기 시작한 건 2022년 6월 말이었다. 입사하고 얼마 되지 않아 한창 개발 프로세스를 개선하고 있던 때 였는데, 책을 읽으면서 그동안 해왔던 일들이 헛짓꺼리가 아니었음을 위로받고, 공감하면서 책을 봤던것 같다. 이때부터 퇴직하는 순간까지 팀으로 많은 일을 해왔다. 이 글을 쓰고 있는 이유는 단순히 그때 했던 일을 기록하기 위함이 아니다. 그때 나눴던 이야기! 그때 그 결정에 기반이 됐던 팀의 멘탈리티는 지금껏 경험하지 못한 것들이었고 앞으로도 지속하고 싶은 것들이라 잊지 않기 위해 그리고 그 손에 잡히지 않는 그 무엇을 소개하고 싶었다.
지속 가능한 엔지니어링
아무튼 나는 경험으로 배우는 성향탓인지 그동안 자연스럽게 해온 일들이 누군가에 의해서 이론으로 정립되면 아하! 하고 무릎을 탁치게 되는데, 이 책을 읽는 내내 그랬다. 특히 프로그래밍과 소프트웨어 엔지니어링의 차이를 명확하게 정의하고 써내려간 부분에서 확 끌려버렸다. 왜냐면 그동안 팀에서 해왔던 의사결정들이 모두 장기적인 관점에서 지속가능여부를 늘 함께 고민하며 결정했기 때문이다.
우리가 개발 환경 개선에 목메는 이유
우리가 제품을 지속적으로 개발하기 위해 제일 먼저 한 일은 개발환경개선이었다. 이에 대한 자세한 얘기는 다른 글에서 소개했으니 여기선 왜 이 개선이 중요한지 얘기해보고자한다. 당신이 개발자라면 굳이 아래 설명을 안 읽어봐도 알겠지만 비 개발자라면 개발자들이 왜 개발 환경(개발장비포함) 개선에 미쳐있는지 꼼꼼히 그래프를 한번 보시라!
개발자가 만들어낸 버그의 85%는 코딩하는 과정에서 발생한다.
그리고 생산된 결함이 발견될 확률은 제품의 릴리즈 시기에 다가올수록 높아진다. 하지만 한번 제품이 릴리즈되면 유저로 부터 버그 리포팅이 쉽게 오지 않기 때문에 결함을 발견할 확률은 급격히 떨어진다. (주황색선)
반면에 결함 수정 비용은 제품 사이클 뒤로 갈수록 기아급수적으로 증가한다.(빨간색선) 왜냐하면 QA 담당자가 개발자를 위해 테스트를 해주고 재현 스탭을 꼼꼼히 적어주고, 이슈를 생성해서 전달하면 개발자는 수정하고 다시 테스트를 반복하기 때문에 이 모든 과정이 비용으로 잡힌다.
따라서 결함을 찾는 노력을 앞으로 땡길수록 수정 비용도 줄고 결과적으로 결함도 더 적게 발생한다. (주황색선)
그림의 구체적인 수치를 몰라도 개발자라면 경험적으로 익히 알고 있는 내용이다. 프로젝트를 시작할때 개발 환경을 제일 먼저 설정하는 이유도 위와 같은 맥락에서 결함을 좀 더 빨리 찾기 위함이다. 하지만 이미 운영중인 프로젝트에 참여하게 되면 어떤 개선이든 쉽게 되는건 하나도 없다.
특히나 배경에 대해 물어볼 사람 하나 없는 환경에 처하면 더욱 난처해지는데 우리에겐 되려 약이 되었다. 한번은 연말 회고 중에 팀원들과 이런 이야기를 나눈적이 있다. 만약에 기존 팀 멤버가 한명이라도 존재했다면 우리가 이렇게 과감하게 개선을 할수있었을까? 아마도 더 어려웠을꺼라는 의견이 지배적이었다. 이유는 질의를 통해 파악된 과거 맥락에 시각이 좁아지는 것도 문제지만 그것보다 사람 일이라는게 다 그렇다. 감정을 건드리면 쉽게 될일도 어렵게 된다.
과거의 내 경험도 그러했다. 보통 새로운 사람이 합류하면 변화가 시작되는데, 아이러니하게 개선이라는 작업이 때론 누구가의 업적을 지우는 행위가 되기 때문에 팀의 유대나 신뢰가 충분하지 않으면 개선은 곧 개인에 대한 공격으로 인식되기도한다는 사실을 그땐 몰랐다. 그래서 사람이 하는 일 이라는게 참으로 어렵지만 그래서 또 더 나은 사람이 되기도한다
여튼 물어볼사람이 없고, 무식하면 용감하다고 일단 해보게 되는데 그렇게 접근했다 몇번 롤백을 하다보니 이제는 어떤 개선이든 아래와 같은 질문으로 시작한다.
- 외부 의존성 없이 팀 안에서 해결할 수 있는가? 의존성이 있다면 의존성을 먼저 제거한다.
- 예정된 릴리즈 안에 해결할 수 있는 문제인가? 큰 사이즈면 계획을 세워 쪼갠다.
- 내가(우리가) 욕심을 부리고 있는 것은 아닌가? 욕심이라 판단되면 하지 않는다!
- 내가 욕심을 내야 할 만큼 장기적인 큰 이득이 있는가? 큰 이득이 확실하면 도전한다!
전략적으로 ESLint 적용하기
대표적으로 쉽게 봤다 피 본 사례가 ESLint 적용이었다. 모던 개발환경에선 상상도 할 수 없는 일이겠지만 이런게 현실이다. 특히 개발 환경은 한번 설정하면 잘 건드리지 않기 때문에 지속 관리 대상에서 잊혀지기도 쉽다. 프로젝트 초기에 설정한 가장 모던(Morden)했던 개발 환경이 5년지나고 10년 지난 뒤에도 모던 할 리가 없다.
기술의 발전은 늘 과거의 문제를 해결하기 때문에 새로운 도구가 나오면 관심을 가져야한다. TSLint 개발팀도 여러가지 이유로 TSLint를 2019년에 ESLint 에 통합했다. React 공식 문서에 더이상 create-react-app 을 추천하지 않는 이유도 다 기술 발전의 산물이다. 이런 의미에서 타입스크립트 버전도 올리고 ESLint 도 적용하고 싶었다. 그런데 타입스크립트 문법이 바뀌는 복병을 만났다.
try {
}catch(ex) {
여기서 ex 는 any 타입에서 unknown 타입으로 변경됨.
즉, 개발자가 직접 ex 의 값을 캐스팅해야함.
=> 한땀한땀 수정하는데만 하루 꼬박 걸림.
}
타입스크립트 4.7버전부터 Exception 에러를 더 이상 any 로 캐스팅 할 수 없게 됐다. 이게 은근 스트레스인게 코드를 한땀 한땀 바꾸기도 해야하지만 에러 처리 로직이 변경될 확률도 높아진다. 처음엔 단순히 개발 환경을 개선하려고 한 것 뿐인데 어느 순간 로직이 변경되면서 갈등의 순간이 찾아온다. 결론은 롤백!
그렇게 6시간을 날렸다. 한번 실패하면 전략을 바꾸게된다. 일단 타입스크립트 버전업을 포기하고 ESLint 부터 적용한다. 적용하고나면 역시나 줄줄이 빨간팬 선생님의 첨삭 지도를 받게 되는데 이게 또 아무 생각 없이 고치다보면 필연적으로 로직을 변경하게 된다. 그러면 또 이걸 PR 해야하나 롤백 해야하나 갈림길에 놓인다. 아무런 기능 변경이 없는데 로직 변경으로 QA 팀에게 테스트를 요청할 순 없다. 이때는 과감히 빨간팬 구독을 취소한다. 미안한데요. 저는 당분간 그냥 이렇게 살께요.
"rules": {
// 모듈 권고사항
"import/no-unresolved": 0,
"import/namespace": 0,
"import/default": 0,
"import/export": 0,
// 모듈 권장사항
"import/no-duplicates": 0,
"import/no-named-as-default": 0,
"import/no-named-as-default-member": 0,
// JS 권고사항
"for-direction": 0,
"prefer-const": 0,
"prefer-spread": 0,
"prefer-rest-params": 0,
"no-empty": 0,
"no-var": 0,
"no-empty-pattern": 0,
"no-self-assign": 0,
"no-useless-catch": 0,
"no-prototype-builtins": 0,
"no-inner-declarations": 0,
"no-fallthrough": 0,
"no-extra-boolean-cast": 0,
"no-useless-escape": 0,
"no-case-declarations": 0,
"no-constant-condition": 0,
"no-unsafe-optional-chaining": 0,
"no-unsafe-finally": 0,
"no-async-promise-executor": 0,
// TS 권고사항
"@typescript-eslint/ban-types": 0,
"@typescript-eslint/no-empty-function": 0,
"@typescript-eslint/no-inferrable-types": 0,
"@typescript-eslint/no-this-alias": 0,
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/no-unsafe-finally": 0,
"@typescript-eslint/no-non-null-asserted-optional-chain": 0,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/triple-slash-reference": 0,
"@typescript-eslint/adjacent-overload-signatures": 0,
"@typescript-eslint/ban-ts-comment": 0,
// TS 권장사항
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 0,
"@typescript-eslint/no-non-null-assertion": 0,
// 리액트 권고사항
"react/display-name": 0,
"react/prop-types": 0,
// 웹접근성 권고사항
"jsx-a11y/click-events-have-key-events": 0,
"jsx-a11y/alt-text": 0,
"jsx-a11y/anchor-is-valid": 0,
"jsx-a11y/no-static-element-interactions": 0,
"jsx-a11y/no-noninteractive-element-interactions": 0,
// 그밖에 권고사항
"etc/no-implicit-any-catch": 0,
"etc/no-assign-mutated-array": 0,
// 그밖에 권장사항
"etc/no-deprecated": 0
},
예외 조항이 한 바닥이지만 ESLint 가 일단 적용된 상태기 때문에 여유가 생길때마다 예외사항을 하나씩 제거하는 전략을 취할 수 있게 됐다.
Sonar Cloud 로 코드 리뷰 받기
최근엔 Sonar Cloud를 깃헙에 적용했는데 이것도 신세계다. 2010년도 쯤만해도 코드 퀄러티를 위해 소나큐브를 적용하겠다고 하면 개발자들의 불만이 쏟아졌다. 당시에 소나가 주는 코드 점수가 평가의 잣대로 쓰일것만 같은 분위기가 실제로 있었다. 고백하건데 나도 사실 부정적이긴했다. 그런데 10년만에 나도 변했고 세상도 변했다.
코파일럿과 더불어 소나 정도는 충분히 투자할만한 가치가 있다. 소나 클라우드를 적용하면 아래와 같이 PR 할때마다 정적분석해서 문제점을 리포팅해준다. PR 단계에서 변경된 코드만 정적분석을 하기 때문에 마치 코드 리뷰를 받는 느낌도 든다.
타입 추론을 기반으로 기존 구현을 검증하기
타입스크립트의 타입 추론 능력이 점점 개선되어 이제는 타입을 굳이 지정하지 않아도 될 만큼 추론 능력이 뛰어나다. 타입 추론 능력에 대한 상세한 내용은 GTP 에게 각자 물어보자.
// 이제 return 타입을 안 써도 된다.
function add(a: number, b: number): string {
return a + b;
}
자바스크립트 프로젝트를 타입스크립트로 전환한 프로젝트라면 타입 검사를 철저하게 지정해서 쓰기가 쉽지가 않다. 예를 들면 any 사용을 막는다던지 strictNullChecks 를 강제한다든지 이런 컴파일 옵션을 켜는 순간 수많은 에러를 만나기 때문에 프로젝트 진행 자체가 안되는 경우도 많다. 이런 경우 위에서 살펴본 ESLint 적용과 마찬가지로 기술부채를 안고 현재 프로젝트에 여러가지 예외 사항을 지정해서 점진적 개선 계획을 세우게 되는데, 점진적 개선 계획이 틀어지는 순간(팀원 이탈 혹은 팀의 해체) 코드는 지뢰밭이 되고 만다.
예를 들면 함수에 지정한 반환 타입이 TS가 추론한 반환 타입과 다른 경우가 여러가지 이유로 존재할수있다. 예를 들면 any 를 강제하지 않고, any를 사용했다가 다른 타입으로 캐스팅을 했다면 추론 내용이 달라지기도 한다. 협업자들과 인터페이스를 맞추고 구현을 미뤄두는 경우에도 비슷한 양상으로 흐르게 되는데, 예를 들면 인터페이스를 맞추기 위해 any로 강제 캐스팅 한다거나 any를 다시 다른 타입으로 캐스팅하게 되는 순간 지뢰를 하나 심는 꼴이 된다.
레거시 코드에서 이런 지뢰를 발견할 때마다 지정된 반환 타입을 의심하게 된다. 이 함수가 정말로 문자열(string)을 반환하는가? 아니면 프로미스(Promise<string>)를 반환하는가? 구현 내용을 읽어봐도 확신이 서지 않으면, 반환 타입을 지우고 추론된 타입과 같은지 교차검증을 한다. 단순히 타입이 제대로 지정되어 있는지 확인하려던 일이 어느새 원인을 알 수 없었던 오래된 문제의 핵심에 다가가고 있었다.
이와 관련된 대표적인 사례가 불완전한 파이 저장 문제였다. 당시 “원인을 알 수 없는 오염된 파이”라는 제목으로 오랜동안 해결하지 못한 고객 문의가 있었는데… 이 문제는 파이를 저장 할때 데이터와 리소스를 zip 파일로 묶는 로직에서 발생했다. 리소스 파일중에는 현재 프리뷰 화면을 캡쳐한 파일이 있는데, 캡처 로직은 별도의 랜더러에서 동작하기 때문에 명확하게 비동기로 동작한다. 하지만 비동기함수 임에도 반환 타입이 프로미스로 지정되어 있지 않았기 때문에 동기식으로 동작하는 zip 파일 엔트리 과정에서 타입 오류를 발생시키지 못했다. 이로 인해 저장 로직은 화면 캡쳐가 얼마나 지연 되는지에 따라 성공 여부가 갈리는 치명적인 결함을 갖게된다.
기존 개발팀은 잘못 쓰인 반환 타입이 원인이란 사실을 쉽게 찾지 못했던것 같다. 이런 현상은 내가 지난 2년 내내 팀에서 겪었던 어려움이기도 했다. 보통 개발자는 자신이 구현한 코드에 갇히기 마련이라 좀처럼 새로운 시간으로 문제를 접근하지 못한다. 그래서 우리팀은 너나 할것 없이 일정시간 투자해서 문제를 해결하지 못하면 동료들을 소환했다.
반복되는 개발 과정(Coding)을 더 빠르게 개선하기
앞에서 우리가 발견하게되는 결함의 85%는 코드를 작성하는 과정에서 만들어진다고 했다. 한마디로 코드를 잘못 짜서 버그가 만들어진다는 얘기다. 그렇기 때문에 일단 문제가 생기면 모두 다 내탓이요! 라고 생각하면 대부분 맞다. 그렇다면 애초에 결함없는 코드를 작성해야하는데, 그 방법은 역설적으로 그 결함을 내가 더 빨리 찾으면 된다.
결함을 빠르게 확인하는 가장 원시적인 방법은 코드를 작성하고 런타임 에러를 눈으로 확인하는 방법이다. 이 방법의 종착지는 결국 코드 변경시 눈으로 바로 확인할수있는 모던한 개발 환경을 갖추는 일이된다.
테스트 코드를 통해서도 결함을 쉽게 찾을 수 있는데, TS로 코드를 작성하다보면 테스트 코드도 TS로 작성하고 싶은 욕구가 생긴다. 하지만 테스트 코드를 TS로 작성하게 되면 매번 테스트할때마다 컴파일을 먼저 해야하기 때문에 상당히 느리다. 우리팀은 이 문제 해결을 위해 SWC를 도입하게 됐다. 멀티 코어를 쓸수있는 SWC 러스트 컴파일러는 확실한 성능을 보장한다. 실제 우리 테스트 코드도 내 로컬 환경(맥스튜디오 M2 Max)에서 전체 풀 테스트가 6분 이상 걸렸는데, SWC를 적용하고 1분 내외로 6배 빨라졌다.
다만 SWC 컴파일러가 속도에 중점을 둔 만큼 예민하다. 예를 들면 순환 참조가 있으면 컴파일을 제대로 하지 못하는데, SWC를 적용하면서 자연스럽게 우리 코드의 순환 참조 문제가 해결되었다.
불필요한 논쟁피하기
개발하다보면 사실 별것 아닌것 가지고 논쟁하는 경우가 많은데, 그 중에 하나가 바로 브랜치 전략이다. 아마도 혼자 개발해본 사람이라면 대부분 공감하는 이야기라 생각하는데 브랜치 따고 머지하는 일이 다 비용이기 때문이다. 혼자 개발할 땐 역시 main 브랜치 하나에 커밋을 직접 쌓는게 가장 효율적인데 놀랍게도 여럿이 개발해도 대부분 이 전략이 유효하다는 사실이다.
경험적으로 main 브랜치 하나를 유지하고, 기능 브랜치(PR 브랜치라고도 부른다)를 main 에서 따서 PR 후에 다시 main 으로 스쿼시 머지하면 main 브랜치의 모든 커밋이 PR 단위로 보기좋게 쌓이게 된다. 이때 릴리즈가 필요하면 main 브랜치에서 배포하고 추가로 버전을 태깅한다.
이것을 기본 골자로 여러 상황에 응용할수있는데 모든 의사결정은 비용을 최소화하는 방향으로 결정한다. 예를 들어, 어떤 기능 A를 개발하기 시작했고, 작은 이슈 3개로 나눠서 2개의 PR을 이미 main 에 반영했다. 그런데 이번 릴리즈에서 기능 A의 마지막 PR을 마무리 짓지 못해 배포에서 제외하기로 했다. 이런 경우 취할수있는 방법을 생각해보자.
- main 브랜치에 반영된 커밋 2개를 먼저 리버트한 후, 새로운 기능 브랜치에 체리픽해서 남은 기능을 개발한다.
- 마무리짓지 못한 마지막 PR에 기능 플래그를 달아서 main 병합하고, 남은 기능을 새로 브랜칭해서 개발한다.
1번은 기능 A를 릴리즈에서 제외하는 아주 깔끔한 방법이지만 기능 A 개발이 미뤄지면서 다른 기능과 충돌할 가능성이 더 높아진다. 반면 2번은 미완성 코드가 함께 배포되는 문제가 있지만 플래그를 달아두기 때문에 A 기능이 빠지는 효과는 동일하고 충돌가능성은 사라진다. 비용 측면에서 보면 플래그를 추가하는 일이 나중에 충돌났을때 해결하는 비용보다 보통 더 작기 때문에 우리는 거의 모든 상황에서 2번을 택했다.
또 다른 상황을 가정해보자. 한참 개발중에 이전에 배포한 버전에서 치명적인 문제가 발생해 핫픽스를 해야하는 상황이 생겼다. 이런 경우에도 최소 비용을 따져본다. 이전에 배포한 해쉬에 태깅을 해뒀으므로 이전 태그 버전에서 release/v7.x 형태로 브랜칭하고 문제를 해결한다. 해결된 후에는 바로 릴리즈 브랜치에서 핫픽스 배포를 진행한다. 이렇게 하면 일단 치명적인 문제는 해결된 상황이다.
이후 작업은 다시 비용을 따져본다. 왜냐면 핫픽스 배포는 늘 시간에 쫒겨서 진행되기 때문에 또다른 핫픽스를 만들어내는 경우도 있다. 혹은 이전 릴리즈 버전에만 해당 문제가 발생하고 이미 main 브랜치에 문제가 해결된 경우도 있기 때문에 핫픽스 변경 내용을 다시 main 에 체리픽 하지 않아도 되는 상황이 있을수도 있다. 때로는 릴리즈 브랜치에서 핫픽스 한 후에 릴리즈 브랜치를 지우는것 자체가 비용으로 느껴지기로 했다. 그래서 우리는 릴리즈 브랜치를 다음 릴리즈 브랜치가 생기기 전까지 남겨두기로 결정했다.
이렇듯 우리의 브랜치 전략은 어떤 원칙을 세우고 무조건 그 흐름을 지킨다기 보다는 단일 브랜치 유지라는 대전제 하나만 두고 나머지는 최소비용으로만 관리했다. 물론 이렇게 결정한 이유에는 팀의 두 시니어가 브랜치도 비용이라는 생각이 같았기 때문에 비교적 논쟁없이 합의할 수 있었다. 그리고 놀랍게도 이런 브랜치 전략을 Trunk-Based Development 라고 부른다는 사실을 나중에서야 알았다. ㅎㅎㅎ
나머지 이야기는 2탄에서… To be continue…