상세 컨텐츠

본문 제목

레거시 코드 리팩토링은 무엇이며 왜 필요할까

IT/프로그래밍

by James Lee. 2015. 12. 6. 21:50

본문

이하 정리는 제가 이해한 방식을 설명드리는 식으로 정리하며, 실제 강의의 내용과 제가 이해한 내용이 틀릴 수 있음을 미리 양해 부탁드립니다.

Legacy Code란?

Legacy Code의 정의가 무엇일까? Legacy (낡은, 기존의) Code

강의를 듣기 전날, 네이버에 검색을 해봤더니 Legacy Code란 (자기가 작성을 했거나, 남이 작성을 한) 기존에 개발되어 있던 코드라고 나온다.

하지만 오이사님께서는 여기에서 보다 구체적으로 Test Code로 검증되지 않은 Code라는 말씀을 덧붙이셨다.

이러한 Legacy Code의 문제점을 같이 알아보도록 한다.

Legacy Code의 문제점

내가 이해한 Legacy Code의 문제점은 이렇다.

첫번째, 코드의 수정이 어렵다.

Legacy Code는 단위 테스트가 되어있지 않기 때문에 한 기능을 수정하거나 추가하면 이 영향이 프로그램의 어느 부분까지 미칠지 예상하기가 어렵다.

프로그램의 기능을 추가했을때, 기존에 잘 실행되던 기능이 실행이 되지 않는 경우가 있다.

따라서 기능을 추가할때마다 전체 프로그램이 잘 실행되는지 전부 다 일일히 실행해봐야 한다.

또한 수정 후, 프로그램의 어느 부분이 실행이 되지 않는다면? 기능을 추가한 부분이 프로그램에 어떻게 영향을 미쳤는지 알 수 없기 때문에 추가한 기능을 다 제거해버려야 되는 경우도 생긴다.

즉, 단위 테스트가 되어있지 않은 Legacy Code는 방금 추가한 기능이 프로그램의 어느 부분에 영향을 미쳤는지 알 수 없다.

두번째, 코드를 이해하기 어렵다.

Legacy Code는 제대로 리팩토링 되어있지 않은 경우가 대부분이다. (리팩토링에 대해서는 뒤에서 설명)

제대로 리팩토링 되어있는 코드는 같은 레벨의 추상화된 메소드를 가지고 있다. 그렇기 때문에 메소드만 봐도 여기에 어떤 input을 넣으면 어떤 output이 나오는지를 짐작 할 수 있다.

하지만 Legacy Code는 이러한 추상화가 제대로 되어있지 않기 때문에 남이 작성해놓은 코드는 물론이고 자기가 작성한 코드도 시간이 지난 이후에는 현저히 가독성이 떨어진다.

따라서 나중에 코드를 분석할때 상당히 어려움을 겪을 것이다.

Legacy Code의 예시

이전에 내가 만들었던 Legacy Code의 예를 들어본다. 나는 2학년 2학기때 만들었던 자바 프로젝트를 3학년때 교수님이 수업 샘플로 쓰고 싶으신데, 몇가지 기능을 추가해달라고 요청하신 적이 있다.

그런데, 작성할 당시 주석을 달았음에도 불구하고 코드의 대부분을 이해하기가 상당히 어려웠다. (그 당시 1 클래스에 굉장히 많은 기능이 있었고, 대부분의 메소드는 전역 변수로 엉켜있었다.)

그래서 코드를 해석하는 데에도 상당한 시간이 걸렸을 뿐 아니라, 기능을 추가할때마다 기존의 프로그램이 잘 실행되는지 일일히 돌려봐야 했다. 어떤 기능은 추가하면 프로그램 자체가 멈춰버리는 경우도 있었다.

결국 1~2가지 기능을 추가하는데에 프로젝트를 작성하는데 걸린 시간보다 더 많은 시간을 소비하고 말았다.

만약 Legacy Code에 테스트가 있었다면?

프로그램이 단위별로 테스트되어있기 때문에 변경을 하더라도 각 단위의 input과 output만 보장된다면 프로그램의 전체 흐름에는 지장이 없을 것이고, 새로 추가하는 기능과 관련된 단위들만 수정을 해주면 되기 때문에 기능 추가가 훨씬 수월했을 것이다.

시간이 조금 걸리더라도 나중을 생각하면 오히려 시간을 더 단축시키고 우수한 품질의 소프트웨어를 만들기 위해서는 작성할때부터 테스트를 여러번 거친 코드를 만드는 것이 더 좋다는 생각이 든다.

그렇다면 대체 왜 Legacy Code를 수정하지 않는 것일까?

간단하다. 비용 문제이다. 

기존의 Legacy Code는 작동을 하기 때문에 수익을 만들어 낼 수 있다.

하지만 수정을 하는데에 드는 비용이 이 Legacy Code가 창출하는 수익보다 높다면 과연 수정의 필요가 있을까?

예를들어 100만원의 수익을 내는 게임이 있다. 하지만 이 게임의 잠재적 결함이 발견되어 수정을 하려고 한다.

그런데 수정을 하는데 들어가는 비용이 500만원이다. 과연 게임회사의 사장은 이 게임을 수정하려고 할까? 그냥 내버려둬도 100만원의 수익은 보장되는데 ..

그래서 오이사님께서는 이 비율을 맞추는 것이 중요하다고 말씀하셨다. (지금 생각하면 역시 만들때부터 잘 만드는것이 중요하다는 생각이 든다.)

만약 Legacy Code를 Test Code가 전부 감싸고 있다고 가정을 한다면 각 기능마다 테스트 코드가 존재하여 어떤 기능이 추가시, 자동으로 모든 테스트코드를 거친다.


A와 B라는 두가지 기능을 추가한다.

A 기능을 추가할때, 프로그램의 모든 테스트 코드가 자동적으로 실행된다.

기댓값이 원하는 값과 다르게 나오고, 어떤 부분에서 문제가 생겼는지 알 수 있다.

B 기능을 추가할때도, 프로그램의 모든 테스트 코드가 자동적으로 실행된다.

기댓값이 원하는 값과 일치한다면? 이 기능을 추가해도 문제없이 잘 실행된다는 것이 보증된다

우리가 프로그램에 어떤 기능을 추가할때, 같은 기능이라도 무수히 많은 방법으로 구현을 할 수 있다.

테스트 코드들은, 이 많은 방법들 중 잘못된 방법을 그 즉시 피드백하여 준다. 

즉, 피드백의 수단이라고 볼 수도 있다.

피드백이란 학습에서 매우 중요한 요소라고 볼 수 있다.

우리가 코딩을 하면, 수시로 컴파일을 해보고 오류가 생기면 그 부분을 수정한다.

하지만 컴파일 기간이 하루가 걸린다면 제대로 코딩을 할 수 있을까?

공부를 하는데, 내가 공부하고 있는 내용이 맞는지 아닌지, 아무도 알려주지 않는다면 과연 제대로 된 공부를 하고 있는 것일까?

무언가를 학습할때, 이것이 올바른 방향인지 아닌지를 빠르게 피드백받는것은 학습과 전진에 있어서 매우 중요하다.

그런 의미에서도 테스트코드는 매우 중요하다고 볼 수 있다.

잘못되면 바로 알려주기 때문이다.(자동화 테스트)

애자일 개발 방법론에서의 피드백

이러한 피드백의 중요성은 애자일 개발 방법론에서도 강조하고 있다.

애자일의 핵심은 '학습'이라고 한다.

학습의 종류는 여러가지가 있을 수 있다. 

위에서도 설명하였듯이, 애자일에서는 학습을 하기 위하여 다양한 종류의 피드백을 받는다. (일일미팅, 리뷰, 회고 등)

이렇게 보면, 피드백은 소통과 밀접한 관련이 있다고도 볼 수 있겠다. 어떤 액션을 취하면 피드백이 되돌아오는 방식이기 때문이다.

그렇다면 피드백의 안좋은 예시는 무엇일까? 바로 문서다.

documentation, documents, files, papers icon

나는 아직 경험이 없어서 모르지만 개발자들은 다음과 같은 이유로 요구사항 문서를 잘 읽지 않는다고 한다.

  1. 너무 길다
  2. 따라서 이해하기 힘들다
  3. 코드와 문서가 일치해야 한다. (만들때와 변경사항이 있으면 수정이 불가피하다, 이를 현행화의 문제라고 한다.)
  4. 심지어 만들어도, 고객은 맞지 않는다고 한다.(고객도 자신이 무엇을 요구하는지 정확하게 모름)

고객도 자신이 무엇을 요구하는지 정확하게 모른다는 것은 큰 문제이다. 그래서 이사님께 요구사항 테이블에 관한 내용을 들었는데 이부분은 나중에 따로 정리해서 업로드한다.

우리는 문서에 질문을 할 수 없다.

문서는 피드백이 없다. 그냥 정보를 전달만 할 뿐이다. 

이는 소통의 질을 안좋게 한다.

어떤것이 질 좋은 소통일까? 

바로 얼굴을 맞대고 얘기하는 것이다. (Face to Face)

애자일에서는 같은공간에 모여서 얼굴을 맞대고 얘기를 한다. 

그렇기 때문에 의사소통의 대역폭이 커지고(이 말은 잘 모르겠지만 좋은 의사소통이라는 뜻인 것 같다..) 변화되는 요구사항에 즉시 대처할 수 있다.

이러한 애자일의 방식은 개발 뿐 아니라 삶의 핵심이라고 한다.

세상은 빠르게 변하고 있다.

그러므로 우리는 세상에 대처하기 위하여 기민한 움직임 (빨리 지식을 습득)이 필요하고.

빠른 피드백을 받을 필요가 있다는 말이라고 한다.

자,  다시 테스트 코드로 돌아온다.

테스트코드는 이러한 장점도 있다.

우리는 프로그램을 짤 때, 이러한 질문을 종종 받을 때가 있다.

'얼마나 되 가세요?'

그러면 우리는 어떻게 대답할까? 

'음...한 80%정도? 85%?'

이러한 대답이 과연 객관적일까? 이것은 감에 의존한 대답이다. 진행률이 85%라고 해도, 예상치 못한 버그가 발생하여 시간이 지연된다면, 85%보다 낮아진다고 볼 수 있다.

테스트 코드를 이용하면 프로그램을 얼마나 작성했냐는 질문에 객관적으로 대답할 수 있다.

기능을 10개의 테스트 코드로 나눴는데, 8개의 테스트가 완료되었다면 진행률이 80%라고 볼 수 있는 것이다.


이제 Legacy Code의 개념에 대한 설명이 거의 끝이 났다.(물론 본인이 이해한 부분만..)

사람들은 언제나 레거시 코드를 짜고 있는데 이것을 '인식'하는 것이 중요하다고 한다.

좀더 넓은 범위로 확장하면 자기가 어떤 행동을 하는지, 왜 하고 있는지를 인식하는 것이 중요하다고 하셨다. 

내가 코드를 짜면 어떤 부분을 짜는지, 어느 부분에서 시간을 낭비하고 있는지

낭비를 하고 있다면 왜 낭비를 했는지를 '인식'하면 그 부분에 대한 지속적인 피드백을 통하여 생산성이 향상된다고 하셨다.

이와 비슷한 맥락으로 자기 개발을 위해서는, 무슨 행동을 하든지 항상 '왜?'라는 질문을 하는 것이 중요하다고 생각한다.

자신이 아는 것과 모르는 것을 구별하는 것, 생각하는 것에 대하여 생각하는 것은 자기 실력을 늘리는 것에 중요하다고 생각한다.(이것은 메타생각이라는 책을 읽으며 깨달은 것이다.)

다음으로 리팩토링에 대하여 설명하도록 한다.

Refactoring의 정의

외부의 행동(기능)을 바꾸지 않고 내부의 구조를 바꾸는 것(개선)..

이 그림은 원숭이가 사람으로 변해가는 단계를 표현하고 있다.

5개의 단계로 표현되어 있다. 잠깐 질문 하나.

각 단계로 넘어가는데 10만년씩 총 50만년이 걸렸다고 가정한다.

1단계에 99999년동안 똑같은 모습으로 있다가 10만년이 되는 날, 갑자기 모습이 바뀌었을까?

아닐것이다. 아마 수많은 작은 변화들이 있었고, 그 변화들이 결국 현재의 인간을 만들었을것이다. 

이 이야기와 리팩토링은 어떤 관계가 있을까?


소프트웨어 개발은 크게 small step과 big step으로 2가지 종류가 있다.

small step은 프로그램을 여러단계로 나누어 개발과 테스트를 반복하며 진행하는 방식이고

big step은 한번에 모든 요구사항을 끝내는 것이다. (지금 생각해보면 small step은 애자일, big step은 waterfall 개발 방법론인것 같다.)

이 big step은 한번에 모든 것을 끝낸다고 하니 빨라서 좋을 것 같지만 문제가 있다. 중간 테스트가 없기 때문에 기능에 문제가 생겼을시, 처음으로 다시 돌아가야 할 확률이 높다.

big step & big back이라는 것이다.

반대로 small step은 개발 도중 문제가 생겨도 중간중간 테스트와 백업을 해놓기 때문에 문제가 생기기 바로 전 단계로 돌아가고, 다른 방법으로 문제를 해결할 수 있다.

훨씬 안정적인 방법이고 수시로 변화하는 요구사항에도 대처하기 수월하다.


위에 소개한 원숭이가 사람으로 변해가는 과정도 이와 연관지어서 생각해보면 이해할 수 있다.

원숭이가 하루아침에 사람처럼 진화해버렸다면, 변화하는 기후환경등에 생존하기 쉽지 않았을 것이다.

하지만 조금씩 조금씩 서서히 진화해왔기 때문에 모든 변화에 적응하여 현재의 사람이 있는 것이다. (물론 진화는 아직까지 현재진행형이다)


여기까지는 리팩토링의 추상적 개념이었다.

보다 구체적으로 리팩토링에 대하여 알아보도록 한다.

위에서 리팩토링의 정의로 외부의 동작(기능)을 바꾸지 않고 내부의 코드를 개선하는 것이라고 했는데, 이를 좀더 자세히 설명하면 기능은 수정하지 않고 내부의 코드를 개선시킨다는 말입니다.

이 정의를 들었을때 처음엔 좀 의아했다. TDD의 과정에서 리팩토링을 배울때, 오이사님께서 리팩토링의 기준은  '중복을 무자비하게 제거하는 것'이라고 하셨기 때문이다.

하지만 생각해보니 대부분 TDD를 학습할때에는 간단한 예제를 다뤘기 때문에 Legacy Code가 주어지지 않고, 요구사항을 듣고 처음부터 끝까지 만들어내는 형식이었다.

따라서 기존에 있던 코드를 수정하는 것이 아니라 만들어내는 과정에서 다듬는 과정이 대부분이었기 때문에 리팩토링에서 '중복을 제거하는 것'만을 보았던 것이다.

하지만 더 정확하게 표현하자면 외부의 동작을 바꾸지 않으면서 내부의 코드를 개선하는 것이라는 명제 아래 그 수단중 하나가 '중복 제거'였던 것이다.

이제 리팩토링의 개념이 조금 더 명확해 진 것 같다.


리팩토링에서 왜 외부의 동작을 바꾸지 말아야 할까?

기능 추가에서 간과하기 쉬운 것 중 하나는, 단순히 기능만 만들어서 붙이는 것이 아니라

기존의 동작도 보장되어야 한다는 것이다.

기존에 존재하는 기능의 동작을 바꾸게 된다면 기존 프로그램의 로직에 영향을 줄 수 있다.

그렇기 때문에 기존의 기능이 정상적으로 동작하도록 하기 위해서는 외부의 동작은 바꾸지 않되 내부의 로직을 개선하는 것이 필요한 것이다.

테스트코드가 해당 코드를 감싸고 있으면 기존 동작이 잘못된다면 바로 검증이 되기 때문에 테스트 코드가 감싸고 있는 코드는, 안전한 기능 추가를 보증할 수 있다.

즉, 레거시 코드에 기능을 추가할때, 기존의 동작을 보장하려면 현재 레거시 코드를 테스트 코드로 감싸면 되는것이다.

하지만 실제로는 그렇게 간단하지 않다.

애초에 리팩토링을 거친 코드는 테스트 코드가 감싸고 있기 때문에, 기능을 보장할수가 있다고 했다.

테스트 코드에서 기능을 테스트하기 위해서는 인스턴스를 생성해야 한다.

하지만 레거시 코드에서 생성된 객체가 잘 동작할지 보장할 수 없다.

대부분의 레거시 코드는 Glue라는 문제가 있기 때문이다.

Glue란?

'풀로 덕지덕지 칠해져있다', '떼어내기가 어렵다'라는 뜻으로 코드끼리 단단하게 결합되어 있기 때문에 동작의 수정 및 테스트가 어려운 속성을 말한다.

좀더 자세히 설명한다면 단위 테스트를 작성해야 할 때, 테스트 대상 문제가 커진다는 것을 의미한다. (서로 엉켜있으므로 테스트의 범위가 넓어진다)

이 단위 테스트 대상 문제의 범위가 커지면 테스트해야 할 조합이 폭발적으로 늘어난다. (분기문만 몇번 들어가도 테스트의 조합은 곱셈이 되기 때문에..)

단위 테스트 대상을 가능한 한 좁게 유지하는 것이 좋은 단위 테스트 작성 방법이라고 한다.

이 Glue에는 4가지 종류가 있다.

  1. Singleton
  2. 내부에서 객체 생성
  3. 외부 리소스 참조(파일시스템, API 등)
  4. Concrete class 의존 (구체 클래스에 의존)

코드에 여기에 한가지라도 해당되면 부분이 있으면 Glue라고 볼 수 있고, 한군데에도 해당이 되지 않는다면 바로 테스트코드를 만들어도 된다.

Singleton이 왜 Glue인가?

  1. Singleton은 Constructor을 private로 만든다.
  2. 그렇다면 상속을 이용해서 Singleton class를 FakeClass로 만들기 어렵다.

그렇다면 다음과 같은 문제들이 생깁니다.

  1. TestCode에서 Singleton에 대한 의존성을 제거하기 어렵게 된다.
  2. 테스트 코드에서 Singleton을 얻어오고 동작을 조절해야 한다.
  3. 테스트 설정과 테스트 조합이 더 많아진다.

내부에서 객체 생성이 왜 Glue인가?

처음에 이해가 잘 가지 않았던 부분이다.

객체지향의 핵심은 무엇일까?

바로 객체끼리 협력하여 문제를 푸는 것이다. 여기서 객체는 인스턴스를 의미한다.

협력은 소통이고 객체에서의 소통은 Message를 의미한다.

이러한 Message중 하나로 객체를 생성할때 넘기는 Parameter가 있다.




우리는 테스트코드를 짤때, 단위테스트를 테스트코드 내부에서 할 수 있어야 한다.

즉 테스트 케이스마다 인스턴스에 원하는 값을 넣고 테스트를 해봐야 한다는 말이다. 

하지만 객체(인스턴스)가 클래스 내부에서 자체적으로 생긴다면? 테스트 메소드에서 해당 테스트의 값(객체)을 조절할 수 없기 때문에 테스트 코드의 범위가 매우 길어지게 된다.

테스트 코드 또한 리팩토링의 대상이기 때문에 테스트 코드의 범위가 길어지는 것은 좋지 않다.

이런 이유로 내부 객체 생성또한 Glue의 한 종류라고 보는 것이다.

외부 리소스 참조 (내가 이해한 것)

외부에서 FileSystem이나 API를 참조하면 독립적인 테스트가 어렵지 않을까?

또한 외부 환경은 언제든지 바뀔 수 있기 때문에 안정적이라고 보기 힘들 것이다.

따라서 이러한 이유로 외부 리소스 참조 또한 Glue의 종류라고 생각한다.

ConcreteClass에 의존

ConcreteClass란 AbsctractClass의 반대로, 기존의 클래스를 말한다.

그런데 기존의 클래스에 의존하는 것이 어디가 나쁘다는 말인가? 

이는 OCP(Open-Closed Principle, 개방에는 열려있고, 변경에는 닫혀 있어야 한다는 원칙)을 위배할 수 있기 때문이다.

예를 들어보자

homeguard 프로젝트의 CentralUnit클래스에 getSensorMessage를 작성했었다.

 public String getSensorMessage(Sensor sensor)
 {
  return sensor.getMessage();
 }

이 메서드는 Sensor라는 추상 클래스에 의존하고있다. 그러면 입력값이 다른 센서가 들어와도, 내부의 return sensor.getMessage(); 코드를 바꾸지 않고도 들어오는 센서의 종류에 따른 다른 값을 반환할 수 있다. (다형성)


하지만 이 메서드가 DoorSensor라는 기존클래스에 의존하고 있었다고 가정해보자.

 public String getSensorMessage(DoorSensor sensor)
 {
  return sensor.getMessage();
 }

이 경우에 WindowSensor라는 케이스의 입력이 들어왔을때 동작을 하고 싶은데 메서드의 수정 없이 동작이 가능할까?

힘들것이다. 아마 이렇게 메소드를 새로 만들어야 할 수 있을것이다.

 public String getSensorMessage(DoorSensor sensor) {
  return sensor.getMessage();
 }
 public String getSensorMessage(WindowSensor sensor) {
  return sensor.getMessage();
 }

하지만 이런 다양한 입력의 문제를 기존 클래스(Concrete Class)에 의지하지 않고 추상 클래스로 만들어 놓는다면 다양한 케이스의 동작을 다형성으로 인하여 유연하게 처리할 수 있다.

 public String getSensorMessage(Sensor sensor) {
  return sensor.getMessage();
 }

이것이 확장에는 열려있고 변화에는 닫혀 있다는 OCP의 원칙이다. (기능을 변경할 때에는 오직 한 지점만 변경하라.)

..

그래서 이렇게 생성된 레거시 코드의 인스턴스에는 테스트 코드가 없었기 때문에 Glue문제가 있고, 동작을 제대로 보장할 수 없기 때문에 또 다시 Test Code가 필요하게 된다.

따라서 이 과정이 반복되는 레거시 코드 리팩토링 딜레마가 생기게 된다.

이 문제를 해결하기 위한 것이 Dependency Breaking이라는 리팩토링 기법이다.

기존의 동작을 보장하면서 새로운 기능을 추가하는 것이다.

어떻게 이런것이 가능할까? 

Dependency Breaking을 가능하게 하는 기법 3가지를 소개한다.

  1. Parameterize Constructor
  2. Extract Interface
  3. Testing subclass

Parameterize Constructor

Glue중 하나인 내부에서의 객체 생성을 외부에서 객체를 생성하여 생성자로 주입시켜준다. 즉 외부에서 객체를 주입시키기 위한 기법이다.

기존 Scheduler는 아래와 같이 내부에서 객체를 생성하고 있었다. (Glue)


매개변수 display를 외부에서 받아오는 Scheduler의 또다른 생성자를 정의한다. 

그리고 기존 Scheduler의 기능을 그대로 가져온다.

하지만 display만은 외부에서 가져오도록 수정한다. this.display = display;

 public Scheduler(String owner, Display display) {
  this.owner = owner;
  mailService = MailService.getInstance();
  this.display = display;
 }

기존에 있었던 생성자에서 내부적으로 새로 생성된 생성자를 호출하고 새로운 객체를 생성하여 넣어주도록 한다.

 public Scheduler(String owner) {
  this(owner, new SchedulerDisplay());
 }

이렇게 하면 기존의 동작을 그대로 유지하면서 외부에서 객체를 받아올 수 있게 된다.

이것을 Parameterize Constructor라고 한다.

Extract Interface

두번째로는 위에서 설명했던 Glue중 하나인 Concrete Class에 의존하는 방법을 깨는 Extract Interface라는 기법이다.

왜 Concrete Class를 깨야 하는지는 기존에 설명했으므로, Interface를 추출하는 방법만 살펴본다.

Concrete Class인 SchedulerDisplay

public class SchedulerDisplay {
 public void showEvent(Event event) {
  for (int n = 0; n < 1000; n++) {
   System.out.println("[" + event.getDate() + "]");
  }
 }
}

Alt + Shift + T를 누르면 Refactor 창이 열리는데 단축키 E를 눌러서 Extract Interface 메뉴를 실행한다

아래와 같이 추출할 메소드와 인터페이스 이름을 지정한다.

SchedulerDisplay에서 추출된 Display Interface가 생성되었다.

public interface Display {
 public abstract void showEvent(Event event);
}



강의 필기 내용 사진

 



 

관련글 더보기

댓글 영역