본문 바로가기

Clean Code

[리팩터링 2판 - 기초편] 01장 ~ 04장 몰아보기

 


마틴파울러 리팩터링 2판 정주행 스터디 - 기초편

 

서론

처음부터 완벽한 설계를 갖추기보다는 개발을 진행하면서 지속적으로 설계한다. 시스템을 구축하는 과정에서 더 나은 설계가 무엇인지 배우게 된다. 

1장은 설계가 조금 아쉬운 작은 프로그램을 리팩터링해서 더 나은 객체지향 프로그램으로 만드는 과정을 보여준다.
2장은 리팩터링의 일반 원칙, 정의, 당위성을 설명한다.
3장은 코드에서 나는 악취를 찾아내는 방법과 리팩터링을 통해 문제의 부분을 말끔히 제거하는 방법을 설명한다.
4장에는 테스트를 작성하는 방법을 수록했다.

특수한 예시 몇 개를 제외하고는 이 책에 등장하는 '클래스', '모듈', '함수' 등의 용어는 (자바스크립트 언어 모델이 아닌) 일반적인 프로그래밍 언어에서의 의미로 사용했다.

+ 마틴 파울러와 켄트 백은 9km 거리에 살며 각자의 작업실에서 페어프로그래밍을 한 적이 있다.
+ 코드 스멜이라는 말은 켄트 백이 만들어낸 개념이다.

 

01장 리팩터링: 첫 번째 예시

프로그램의 구조가 빈약하다면 대체로 구조부터 바로잡은 뒤에 기능을 수정하는 편이 작업하기가 훨씬 수월하다. 프로그램이 새로운 기능을 추가하기에 편한 구조가 아니라면, 먼저 기능을 추가하기 쉬운 형태로 리팩터링하고 나서 원하는 기능을 추가한다.

어떤 방식으로 정하든 반드시 6개월 안에 다시 변경하게 될 것이다. 새로운 요구사항은 수색 대원처럼 한두 명씩이 아니라, 한 부대씩 몰려오기 마련이다.

리팩토링이 필요한 이유는 바로 이러한 변경 때문이다.

 

리팩터링의 첫 단계는 항상 똑같다. 리팩터링할 코드 영역을 꼼꼼하게 검사해줄 테스트 코드들부터 마련해야 한다. 아무리 간단한 수정이라도 리팩터링 후에는 항상 테스트하는 습관을 들이는 것이 바람직하다. 사람은 실수하기 마련이다.

나는 함수의 반환 값에는 항상 result라는 이름을 쓴다. 그러면 그 변수의 역할을 쉽게 알 수 있다.

좋은 코드라면 하는 일이 명확히 드러나야 하며, 이때 변수 이름은 커다란 역할을 한다.

지역 변수를 제거해서 얻는 가장 큰 장점은 추출 작업이 훨씬 쉬워진다는 것이다. 유효 범위를 신경 써야 할 대상이 줄어들기 때문이다.

단번에 좋은 이름을 짓기는 쉽지 않다. 따라서 처음에는 당장 떠오르는 최선의 이름을 사용하다가, 나중에 더 좋은 이름이 떠오를 때 바꾸는 식이 좋다. 흔히 코드를 두 번 이상 읽고 나서야 가장 좋은 이름이 떠오르곤 한다.

반복문이 중복되는 것을 꺼리는 이들이 많지만, 이 정도 중복은 성능에 미치는 영향이 미미할 때가 많다. 경험 많은 프로그래머조차 코드의 실제 성능을 정확히 예측하지 못한다. 똑똑한 컴파일러들은 최신 캐싱 기법 등으로 무장하고 있어서 우리의 직관을 초월하는 결과를 내어주기 때문이다. 또한 소프트웨어 성능은 대체로 코드의 몇몇 작은 부분에 의해 결정되므로 그 외의 부분은 수정한다고 해도 성능 차이를 체감할 수 없다.

리팩터링 중간에 테스트가 실패하고 원인을 바로 찾지 못하면 가장 최근 커밋으로 돌아가서 테스트에 실패한 리팩터링의 단계를 더 작게 나눠 다시 시도한다.

 

복잡하게 얽힌 덩어리를 잘게 쪼개는 작업은 이름을 잘 짓는 일만큼 중요하다.

처음보다 코드량이 부쩍 늘었다. 원래 44줄짜리 코드가 지금은 70줄이나 된다. 늘어난 주된 원인은 함수로 추출하면서 함수 본문을 열고 닫는 괄호가 덧붙었기 때문이다. 추가된 코드 덕분에 전체 로직을 구성하는 요소 각각이 더 뚜렷이 부각되고, 계산하는 부분과 출력 형식을 다루는 부분이 분리됐다. 이렇게 모듈화하면 각 부분이 하는 일과 그 부분들이 맞물려 돌아가는 과정을 파악하기 쉬워진다. 간결함이 지혜의 정수일지 몰라도, 프로그래밍에서만큼은 명료함이 진화할 수 있는 소프트웨어의 정수다.

나는 항상 리팩터링과 기능 추가 사이의 균형을 맞추려고 한다. 현재 코드에서는 리팩터링이 그다지 절실하게 느껴지지 않을 수 있지만, 그래도 어느 정도 균형점을 잡을 수 있다. '항시 코드베이스를 작업하기 전보다 더 건강하게 고친다'라는 캠핑 규칙의 변형 버전을 적용한다. 완벽하지 않더라도, 분명 더 나아지게 한다.

 

조건부 로직은 코드 수정 횟수가 늘어날수록 골칫거리로 전락하기 쉽다. 조건부 로직을 명확한 구조로 보완하는 방법은 다양하지만, 여기서는 객체지향의 핵심 특성인 *다형성(ploymorphism)을 활용하는 것이 자연스럽다. 같은 타입의 다형성을 기반으로 실행되는 함수가 많을수록 이렇게 구성하는 쪽이 유리하다.

*다형성: 프로그램 언어의 다형성은 그 프로그래밍 언어의 자료형 체계의 성질을 나타내는 것으로, 프로그램 언어의 각 요소들이 다양한 자료형에 속하는 것이 허가되는 성질을 가리킨다. 반댓말은 단형성으로, 프로그램 언어의 각 요소가 한가지 형태만 가지는 성질을 가리킨다. (출처: 위키백과)

 

좋은 코드를 가늠하는 확실한 방법은 '얼마나 수정하기 쉬운가'다. 건강한 코드베이스는 생산성을 극대화하고, 고객에게 필요한 기능을 더 빠르고 저렴한 비용으로 제공하도록 해준다. 코드를 건강하게 관리하려면 프로그래밍 팀의 현재와 이상의 차이에 항상 신경쓰면서, 이상에 가까워지도록 리팩터링해야한다.

 

사람들에게 내가 리팩터링하는 과정을 보여줄 때마다, 각 단계를 굉장히 잘게 나누고 매번 컴파일하고 테스트하여 작동하는 상태로 유지한다는 사실에 놀란다. 리팩터링을 효과적으로 하는 핵심은, 단계를 잘게 나눠야 더 빠르게 처리할 수 있고, 코드는 절대 깨지지 않으며, 이러한 작은 단계들이 모여서 상당히 큰 변화를 이룰 수 있다는 사실을 깨닫는 것이다.

'컴파일-테스트-커밋' 하기가 귀찮아서 소홀해질 때가 있다. 그러다 실수하면 정신이 번쩍 들면서 다시 꼬박꼬박 수행하게 된다.

 

 

02장 리팩터링 원칙

리팩터링(refactoring)은 '소프트웨어의 겉보기 동작은 그대로 유지한 채, 코드를 이해하고 수정하기 쉽도록 내부 구조를 변경하는 기법'이다. 수많은 사람이 코드를 정리하는 작업을 모조리 '리팩터링'이라고 표현하고 있는데, 특정한 방식에 따라 코드를 정리하는 것만이 리팩터링이다.

한 번에 바꿀 수 있는 작업을 수많은 단계로 잘게 나눠서 작업하는 모습을 처음 접하면 오히려 비효율적이라고 생각하기 쉽다. 하지만 이렇게 잘게 나눔으로써 오히려 작업을 더 빨리 처리할 수 있다. 디버깅하는 데 시간을 뺏기지 않기 때문이다.

리팩터링하기 전과 후의 코드가 똑같이 동작해야 한다. 리팩터링 과정에서 발견된 버그는 리팩터링 후에도 그대로 남아 있어야 한다.

원래 하려던 작업과 관련 없는 일에 너무 많은 시간을 빼앗기긴 싫을 것이다. 그렇다고 쓰레기가 나뒹굴게 방치해서 나중에 일을 방해하도록 내버려두는 것도 좋지 않다. 간단히 수정할 수 있는 것은 즉시 고치고, 시간이 좀 걸리는 일은 짦은 메모만 남긴 다음, 하던 일을 끝내고 나서 처리한다.

리팩터링은 기능 추가와 밀접하게 엮인 경우가 너무나 많기 때문에 굳이 나누는 것은 시간 낭비일 수 있다. 또한 해당 리팩터링을 하게 된 맥락 정보가 사라져서 왜 그렇게 수정했는지 이해하기 어려워진다. 리팩터링 커밋을 분리한다고 해서 무조건 좋은 것은 아님을 명심하고, 여러분의 팀에 적합한 방식을 실험을 통해 찾아내야 한다.

리팩터링하면 안 되는 상황도 있다. 지저분한 코드를 발견해도 굳이 수정할 필요가 없다면 리팩터링하지 않는다. 리팩터링하는 것보다 처음부터 새로 작성하는 게 쉬울 때도 리팩터링하지 않는다. 

리팩터링의 궁극적인 목적은 개발 속도를 높여서, 더 적은 노력으로 더 많은 가치를 창출하는 것이다. 

개발 속도 저하를 이유로 리팩터링을 금하는 비생산적인 문화를 관리자 탓으로 돌리는 사람이 많지만, 나는 오히려 개발자 스스로가 그렇게 생각하는 경우도 많이 봤다. 심지어 관리자가 리팩터링에 호의적임에도 리팩터링하면 안 되는 줄 아는 사람도 있다. 개발팀을 이끌고 있다면 코드베이스가 더 건강해지는 것을 추구한다는 사실을 팀원들에게 명확히 밝혀야 한다.

사람들이 빠지기 쉬운 가장 위험한 오류는 리팩터링을 '클린 코드'나 '바람직한 엔지니얼이 습관'처럼 도덕적인 이유로 정당화하는 것이다. 리팩터링의 본질은 코드베이스를 예쁘게 꾸미는 데 있지 않다. 오로지 경제적인 이유로 하는 것이다. 리팩터링은 개발기간을 단축하고자 하는 것이다. 기능 추가 시간을 줄이고, 버그 수정 시간을 줄여준다. 

 

03장 코드에서 나는 악취

마땅한 이름이 떠오르지 않는다면 설계에 더 근본적인 문제가 숨어있을 가능성이 높다. 그래서 혼란스러운 이름을 잘 정리하다 보면 코드가 훨씬 간결해질 때가 많다.

코드가 비슷하긴한데 완전히 똑같지는 않다면 먼저 문장 슬라이드하기로 비슷한 부분을 한 곳에 모아 함수 추출하기를 더 쉽게 적용할 수 있는지 살펴본다. 

예전 언어는 서브루틴을 호출하는 비용이 컸기 때문에 짧은 함수를 꺼렸다. 하지만 요즘 언어는 프로세스 안에서의 함수 호출 비용을 거의 없애버렸다. 

뒤엉킨 변경(Divergent Change)는 단일책임원칙(SRP)이 제대로 지켜지지 않을 때 나타난다. 즉, 하나의 모듈이 서로 다른 이유들로 인해 여러가지 방식으로 변경되는 일이 많을 때 발생한다.

산탄총 수술(Shotgun Surgery)는 비슷하면서도 정반대다. 변경할 부분이 코드 전반에 퍼져있다면 찾기도 어렵고 꼭 수정해야 할 곳을 지나치기 쉽다.

  뒤엉킨 변경
(Divergent Change)
산탄총 수술
(Shotgun Surgery)
원인 맥락을 잘 구분하지 못함
원리 맥락을 명확히 구분
발생과정 한 코드에 섞여 들어감 여러 코드에 흩뿌려짐
해법 맥락별로 분리 맥락별로 모음

 

기능 편애(Feature Envy)는 어떤 함수가 자기가 속한 모듈의 함수나 데이터보다 다른 모듈의 함수나 데이터와 상호작용할 일이 더 많을 때 풍기는 냄새다. 함수가 데이터와 가까이 있고 싶어한다는 의중이 뚜렷이 드러나므로 소원대로 데이터 근처로 옮겨주면 된다. 함수가 사용하는 모듈이 다양하다면 어느 모듈로 옮겨야할까? 이럴 때는 가장 많은 데이터를 포함한 모듈로 옮긴다. 함수 추출하기로 함수를 여러 조각으로 나눈 후 각각을 적합한 모듈로 옮기면 더 쉽게 해결되는 경우도 많다.

데이터 뭉치(Data Clumps)인지 판별하려면 값 하나를 삭제해보자. 그랬을 때 나머지 데이터만으로는 의미가 없다면 객체로 환생하길 갈망하는 데이터 뭉치라는 뜻이다. 

기본형 집착(Primitive Obsession). 전화번호를 단순히 string 집합으로만 표현하기엔 아쉬움이 많다. 최소한 사용자에게 보여줄 때는 일관된 형식으로 출력해주는 기능이라도 갖춰야 한다. 이런 자료형들을 문자열로만 표현하는 악취는 아주 흔해서, 소위 'stringly typed' 변수라는 이름까지 붙었다.

성의없는 요소(Lazy Element). 본문 코드를 그대로 쓰는 것과 진배없는 함수도 있고, 실질적으로 메서드가 하나뿐인 클래스도 있다. 이런 구조는 나중에 본문을 더 채우거나 다른 메서드를 추가할 생각이었지만, 어떠한 사정으로 인해 그렇게 하지 못한 결과일 수 있다. 사정이 어떠하든 이런 프로그램 요소는 고이 보내드리는 게 좋다. 이 제거 작업은 흔히 함수 인라인하기나 클래스 인라인하기로 처리한다.

추측성 일반화(Speculative Generality)는 '나중에 필요할 거야'라는 생각으로 당장은 필요없는 모든 종류의 후킹(hooking) 포인트와 특이 케이스 처리 로직을 작성해둔 코드에서 풍긴다. 그 결과는 이해하거나 관리하기 어려워진 코드다. 당장 걸리적거리는 코드는 눈앞에서 치워버리자.

클래스가 제공하는 메서드 중 절반이 다른 클래스에 구현을 위임하고 있다면 어떤가? 이럴 때는 중개자(Middle Man) 제거하기를 활용하여 실제로 일을 하는 객체와 직접 소통하게 하자. 위임 메서드를 제거한 후 남는 일이 거의 없다면 호출하는 쪽으로 인라인하자.

주석(Comments)이 장황하게 달린 원인이 코드를 잘못 작성했기 때문인 경우가 의외로 많다.주석을 남겨야겠다는 생각이 들면, 가장 먼저 주석이 필요없는 코드로 리팩터링해본다.

 

04 테스트 구축하기

모든 테스트를 완전히 자동화하고 그 결과까지 스스로 검사하게 만들자.

테스트를 작성하려면 소프트웨어 제품 본체 외의 부가적인 코드를 상당량 작성해야 한다. 그래서 테스트가 실제로 프로그래밍 속도를 높여주는 경험을 직접 해보지 않고서는 자가 테스트의 진가를 납득하긴 어렵다.

켄트 벡은 테스트부터 작성하는 습관을 바탕으로 테스트 주도 개발(TDD) 기법을 창시했다. TDD에서는 테스트를 작성하고, 이 테스트를 통과하게끔 코드를 작성하고, 결과 코드를 최대한 깔끔하게 리팩터링하는 과정을 짧은 주기로 반복한다.

리팩터링에는 테스트가 필요하다. 그러니 리팩터링하고 싶다면 테스트를 반드시 작성해야 한다.

자주 테스트하라. 작성 중인 코드는 최소한 몇 분 간격으로 테스트하고, 적어도 하루에 한 번은 전체 테스트를 돌려보자.

GUI 테스트 러너가 편하지만 반드시 필요한 것은 아니다. 나는 이맥스에서 키 하나만 누르면 테스트 전체를 실행하도록 만들어 두고, 컴파일 창에 텍스트로 출력된 결과를 확인하는 방식도 많이 쓴다. 핵심은 GUI냐 콘솔이냐가 아니라 모든 테스트를 통과했다는 사실을 빨리 알 수 있다는 데 있다.

일부 프로그래머들이 선호하는 public 메서드를 빠짐없이 테스트하는 방식과는 다르다. 테스트는 위험 요인을 중심으로 작성해야한다! 단순히 필드를 읽고 쓰기만 하는 접근자는 테스트할 필요가 없다. 테스트를 너무 많이 만들다보면 오히려 필요한 테스트를 놓치기 쉽기 때문에 잘못될까봐 가장 걱정되는 영역을 집중적으로 테스트하는데, 이렇게 해서 테스트에 쏟는 노력의 효과를 극대화하는 것이다.

수요가 음수일 때 수익이 음수가 나온다는 것이 이 프로그램을 사용하는 고객 관점에서 말이 되는 소리일까? 수요의 최솟값은 0이어야 하지 않나? 그래서 수요 세터에 전달된 인수가 음수라면 에러를 던지거나 무조건 0으로 설정하는 식으로 정상적이 경우와 다르게 처리해야 하지않을까? 경계를 확인하는 테스트를 작성해보면 프로그램에서 이런 특이상황을 어떻게 처리하는게 좋을지 생각해볼 수 있다. 문제가 생길 가능성이 있는 경계조건을 생각해보고 그 부분을 집중적으로 테스트하자.

에러와 실패를 구분하는 테스트 프레임워크도 많다. 실패(failure)란 검증 단계에서 실제 값이 예상 범위를 벗어났다는 뜻이다. 에러(error)는 검증보다 앞선 과정에서 발생한 예외 상황을 말한다. 프로그램은 이 상황에 어떻게 대응해야할까? 같은 코드베이스의 모듈 사이에 유효성 검사(validation check)코드가 너무 많으면 다른 곳에서 확인한 걸 중복으로 검증하여 오히려 문제가 될 수 있다. 반면, JSON으로 인코딩된 요청처럼 외부에서 들어온 입력 객체는 유효한지 확인해봐야 하므로 테스트를 작성한다. 어떤 경우든 경계 조건을 검사하는 테스트를 작성하다 다보면 이런 고민들을 하게 된다. 

어차피 모든 버그를 잡아낼 수는 없다고 생각하여 테스트를 작성하지 않는다면 대다수의 버그를 잡을 수 있는 기회를 날리는 셈이다.