메뉴 바로가기 검색 및 카테고리 바로가기 본문 바로가기

한빛출판네트워크

한빛랩스 - 지식에 가능성을 머지하다 / 강의 콘텐츠 무료로 수강하시고 피드백을 남겨주세요. ▶︎

IT/모바일

리팩토링(6) - 후각을 발달시키기

한빛미디어

|

2005-11-04

|

by HANBIT

13,458

저자: 임백준
출처: 임백준의 소프트웨어 산책(2005, 한빛미디어) 중 제3장 리팩토링



* 리팩토링(1) - 과거와 대결하는 프로그래머의 무기
* 리팩토링(2) - 복잡성에 대한 두려움
* 리팩토링(3) - 단순함의 미학
* 리팩토링(4) - 리팩토링의 탄생
* 리팩토링(5) - 리팩토링 맛보기

<좀머씨 이야기>나 <깊이에의 강요> 등으로 알려진 파트리크 쥐스킨트의 소설 중에 <향수>라는 작품이 있다. 그 소설을 읽고 나서 다른 작품에 비해서 좀 떨어진다는 느낌이 들었지만 소재는 독특했다. 소설은 후각이 비현실적일 정도로 발달했지만 정작 자기 자신은 아무런 체취도 갖지 않은 남자가 겪는 음울한 인생을 다루고 있다. 초중반의 신선한 긴장감에 비해서 다소 싱겁고 황당한 결말은 종잡을 수 없는 느낌을 주었지만, 상상을 초월하는 ‘후각’ 능력 때문에 펼쳐지는 주인공의 엽기적인 인생은 흥미로웠다.

리팩토링에서 가장 어려운 부분은 사실 리팩토링 자체가 아니다. 그것은 무엇을 리팩토링 할 것인가, 그리고 언제 할 것인가를 결정하는 판단을 내리는 것이다. 리팩토링은 새로운 프로그램을 구현하거나 디버깅을 하는 것과 달라서 리팩토링이 성취한 결과를 객관적으로 테스트하고 평가할 방법이 존재하지 않는다. 이것을 다시 말하면 리팩토링을 하지 않는다고 해서 프로그램이 동작을 멈추거나 프로젝트가 위기에 빠지는 것은 아니라는 이야기가 된다. 즉, 적어도 겉에서 볼 때에는 리팩토링이란 것이 해도 그만, 안 해도 그만인 ‘하나마나’인 존재라는 것이다.

그렇지만 리팩토링은 필요하다. 눈에 보이지 않는다고 해서 속으로 시들어가는 나무에 물을 주지 않을 수는 없는 것이다. 하지만 소프트웨어 프로젝트의 예산을 편성하는 경영진이나 관리자들은 나무의 속을 보지 못하기 때문에 리팩토링의 필요성을 느끼지 못하는 경우가 대부분이다. 그렇다고 해서 소프트웨어 개발을 책임진 프로그래머마저 리팩토링의 필요성을 외면하는 것은 사용자들이 발견하지 못했다고 해서 눈에 보이는 버그를 외면하는 것과 조금도 다르지 않다. 리팩토링의 필요성을 가장 정확하게 판단할 수 있는 것은 어디까지나 프로그래머이기 때문에 경영진과 관리자를 설득하는 것은 프로그래머 자신의 책임이다.

하지만 리팩토링이 필요한 시점과 그 정도를 측정할 수 있는 방법이 없다는 것이 문제이다. 정작 프로그래머 자신도 문제의 절박성을 느끼지 못한다면 그가 누구를 설득할 수 있을까. 파울러와 벡은 이와 같이 리팩토링의 대상과 시점을 포착하는데 필요한 기준이 없다는 사실을 누구보다 잘 알고 있었다. 그리하여 켄트 벡이 내놓은 방법이 바로 유명한 ‘냄새론’이었다. 그의 냄새론에 따르면 <향수>의 주인공처럼 냄새를 잘 맡는 프로그래머가 리팩토링의 대상과 시점을 적절하게 포착할 수 있다. 여기에서 냄새란 코로 맡는 것이 아니라 프로그래머가 온몸을 이용해서 오감으로 느끼는 것이다. 그가 <리팩토링>의 3장에서 설명한 냄새의 종류는 다음과 같은 내용을 포함한다.

- 중복된 코드 (Duplicated Code)
- 긴 메쏘드 (Long Method)
- 커다란 클래스 (Large Class)
- 기다란 인수의 리스트 (Long Parameter List)
- 스위치 명령문 (Switch Statements)
- 병렬적인 상속 구조 (Parallel Inheritance Hierarchies)
- 추측에 근거한 일반화 (Speculative Generality)
- 임시 필드 (Temporary Field)
- 설명문 (Comments)

프로그램을 읽다가 이러한 항목에 해당하는 대상을 발견하면, ‘냄새’를 맡은 것이다. 냄새를 맡았으면 그냥 넘어가지 말고 잠깐이라도 생각을 하는 습관을 갖는 것이 책임감 있는 프로그래머의 자세이다. 예를 들어서 앞에서 이야기했던 경험을 다시 언급하자면 필자의 코드에서 for 루프를 발견한 제3의 프로그래머는 리스트를 비우기 위해서 루프를 돌릴 필요가 있을까, 하고 의심하는 순간 모종의 냄새를 맡은 셈이었다. 그는 그 냄새를 외면하는 대신 루프를 돌리지 않고도 똑같은 목적을 이룰 수 있는 방법을 생각해 보았다. 그리하여 인덱스를 거꾸로 돌리는 부자연스러운 루프가 간단한 할당문으로 대체될 수 있었던 것이다. 좋은 프로그램은 이렇게 사소하게 보이는 작은 노력이 모이고 쌓여서 만들어지는 것이다.

여기에서 재미있는 것은 ‘설명문’조차 냄새를 피우는 주범으로 분류된다는 사실이다. 필자는 한동안 인라인(inline) 설명문을 강조하는 ‘학파’에 속했었다. 코딩을 하면서 틈나는 대로, 코딩이 끝난 다음에 시간이 나는 대로 소스 코드 안에 풍부한 설명문을 집어넣는 것을 대단히 중요한 일로 여기고 그것을 늘 강조했다. 설명문이 충분하게 들어있지 않은 코드를 성의 없는 코드로 간주하기도 했고, 모든 메쏘드의 서두에 메쏘드의 목적, 인수의 타입과 목적, 리턴 되는 값의 타입과 목적이 빠짐없이 적혀있지 않으면 큰일이라도 나는 것처럼 여기기도 했다. 하지만 소스 코드 안에 빼곡하게 적힌 설명문을 누가 읽는가, 그리고 얼마나 자주 읽는가, 라는 의문에 답을 찾을 수 없게 되면서 사실상 ‘설명문 학파’에서 이탈하게 되었다.

켄트 벡이 ‘설명문’조차 냄새의 원인이라고 말한 것은 다른 까닭이 아니다. 프로그램의 코드가 ‘단순성’과 ‘간결성’을 담보하고 있다면 설명문이 필요 없기 때문이다. 코드 자체가 할 말을 한다면 그것이 최선이라는 것이다. 설명문이 필요하다는 것은 코드가 할 말을 못하기 때문이며 따라서 그런 코드는 리팩토링을 필요로 하고 있을 가능성이 높다는 것인데, 개인적인 경험으로 보았을 때 이것은 사실이라고 생각된다. 알고리즘 도중에 구구절절한 설명을 붙일 필요성을 느끼는 것은 “지금은 시간이 없어서 이렇게 하지만, 나중엔 이러저러하게 고칠 필요가 있다”는 식의 변명을 늘어놓고자 할 때가 대부분이기 때문이다.

우리가 getPrice 메쏘드를 가지고 수행할 ‘메쏘드 추출’ 기법은 하나의 메쏘드 안에서 너무나 여러 가지 일이 수행되고 있다는 ‘냄새’를 맡았을 때 쓸 수 있는 기본적인 리팩토링 방법이다. 원리는 간단하다. 현재 메쏘드에 속할 이유가 없는 부분을 추려내서 과감하게 뚝 잘라낸 다음 하나의 독립적인 메쏘드로 만드는 것이다. 이렇게 하면 원래 메쏘드는 간단해져서 ‘단순성’과 ‘간결성’에 한걸음 다가서게 되고, 새로 독립한 메쏘드는 다른 알고리즘에서 호출해서 사용할 수도 있으므로 코드의 재사용성이 증가되는 일석이조의 효과가 있다. 이러한 메쏘드 추출 기법을 염두에 두고 보았을 때 getPrice 메쏘드에서 우선 주목하고 싶은 부분은 다음 코드이다.

        boolean isValidStock = false;
        for (int i = 0; i < myStockList.length; i++)
        {
            if (myStockList[i].equals (stock.getSymbol()))
            {
                isValidStock = true;
                break;
            }
        }

이 코드가 수행하는 일은 인수로 주어진 주식이 Broker 객체가 관리하는 주식 중의 하나인지 확인하는 것이다. 이러한 확인 작업은 굳이 getPrice 메쏘드 내부에서 수행될 이유가 없다. 이것은 가격을 산출하는 일과는 완전히 독립적인 일이므로 이 코드를 뚝 잘라서 별도의 메쏘드로 만들 필요가 있다. 이 때 코드의 재사용성을 고려한다면 새로 독립하는 메쏘드를 퍼블릭(public)으로 만드는 것도 고려할 만한데, 여기에서는 일단 프라이빗(private)으로 선언했다.

    private boolean isValidStock (Stock stock)
    {
        for (int i = 0; i < myStockList.length; i++)
        {
            if (myStockList[i].equals (stock.getSymbol()))
            {
                return true;
            }
        }
        return false;
    }

원래의 getPrice 메쏘드는 이 새로운 메쏘드를 다음과 같이 호출한다. 만약 결과가 거짓이면 원래대로 -1을 리턴 한다. 즉, 원래 존재하는 알고리즘의 흐름에는 아무런 변화가 없다.

        if (!isValidStock(stock))
        {
            return -1;
        }

다음에 주목할 부분은 무려 3 단계에 이르기까지 깊숙하게 중첩(nested)되어 있는 if-else의 미로이다. 이와 같은 코드는 실전 프로그램에서도 나타나기 쉬운 모습인데, 이렇게 한 자리에 모여 있는 if-else의 미로는 음침한 곳을 좋아하는 버그가 가장 반가워하는 장소에 해당한다. 이렇게 한 곳에 모여 있는 if-else는 시간이 흐르면서 OR 그리고 AND와 같은 조건이 추가되어 더더욱 복잡한 미로로 ‘발전’해 나아간다. 그런 곳이 있다는 소문을 들은 버그들은 미로에 모여서 알을 낳아 번식을 도모한다. getPrice 메쏘드에 포함된 if-else 구문의 구조는 다음과 같다.

        if (stock.isActive())
        {
            if (stock.isPremium())
            {
                if (this.clientType == SELLER)
                {
                    price = Market.getPrice (symbol) * SELLER_PREMIUM;
                }
                else
                {
                    price = Market.getPrice (symbol) * BUYER_PREMIUM;
                }
            }
            else
            {
                price = Market.getPrice (symbol);
            }
        }

실력이 있는 프로그래머라면 이런 모습의 if-else 구문을 보면서 뭔가 머리에 떠오르는 것이 있을 것이다. 생각해보자. 처음에 나오는 두 개의 if가 확인하는 내용은 stock 객체가 유효한 주식인지(stock.isActive()), 그리고 그것이 프리미움의 적용 대상인지(stock.isPremium()) 여부를 확인하는 것이다. 여기서 문제의 핵심은 그 확인의 대상이 broker 객체의 상태와 관련된 것이 아니라 stock 객체의 상태와 관련되어 있다는 점이다. stock 객체의 상태에 대한 점검이 왜 broker 객체의 getPrice 메쏘드 안에서 이루어져야 하는가. stock 객체의 상태에 대한 확인은 어디까지나 stock 객체 내부에서 이루어져야지 이렇게 밖으로 튀어나오면 곤란하다. 그것은 객체 지향 프로그래밍의 원리 중 하나인 캡슐화(encapsulation)에 대한 예의가 아니다.

사실 캡슐화에 대해서 이야기하자면 이 코드는 더 근본적인 문제를 안고 있다. 왜냐하면 주식의 가격(price)은 broker 객체의 속성이 아니라 사실은 stock 객체의 속성이기 때문이다. 그런 점을 고려한다면 가격이 stock 객체 안에 저장되도록 하고, broker 객체는 클라이언트의 요청을 받았을 때 단순히 stock 객체로부터 가격을 읽어서 리턴 하도록 설계하는 것이 훨씬 자연스럽다. 이 경우에는 broker 객체가 직접 Market 객체에 접근할 필요가 없기 때문에 getPrice의 알고리즘이 훨씬 간단해진다. 이렇게 하기 위해서 if-else 구문의 일부를 잘라내어 그것을 (broker 객체가 아니라) stock 객체의 내부에 다음과 같이 새로운 메쏘드로 선언하면 된다.

    public double getPrice (double premium) 
    {
        if (isActive())
        {
            if (isPremium())
            {
                return Market.getPrice (symbol) * premium;
            }
            else 
            {
                return Market.getPrice (symbol);
            }
        }

        return 0;
    }

Stock 객체 안에 정의된 getPrice 메쏘드는 고객이 파는 사람(seller)인지 사는 사람(buyer)인지 상관하지 않는다. 다만 필요한 경우에는 가격을 리턴하기 전에 프리미움(premium)을 적용한다. 실제로 생각해보아도 고객이 매수자인지 매도자인지 알아야 하는 것은 주식이 아니라 브로커이기 때문에 이것은 더 자연스럽다. 이러한 리팩토링에 의해서 Market 객체에 접근할 필요성도 broker 객체에서 stock 객체로 이동했다. 즉, 시장을 나타내는 객체에 접근해서 시세를 확인하는 역할이 stock 객체에게 넘어온 것이다. 실전이라면 stock 객체를 관찰자(observer)로 만들고 Market 객체나 내부의 데이터를 관찰가능(observable)한 객체로 만드는 것이 효율적일 것이다.

이제 실제 가격을 구하는 일이 stock 객체에게 위임되었으므로 if-else의 미로는 다음과 같이 간단하게 작성될 수 있다.

        if (this.clientType == SELLER)
        {
            return stock.getPrice (SELLER_PREMIUM);
        }
        else
        {
            return stock.getPrice (BUYER_PREMIUM);
        }

broker 객체는 이제 Market 객체에 접근할 필요가 없는 정도가 아니라 아예 Market 객체의 존재 자체를 알 필요가 없게 되었다. if-else 구문이 구현하고 있는 알고리즘도 한 눈에 금방 이해될 정도로 단순해졌다. 지금까지 수행한 몇 차례의 ‘메쏘드 추출’ 기법을 통해서 다듬어진 broker 객체의 getPrice 메쏘드가 최종적으로 갖는 모습은 다음과 같다.

    public double getPrice (String symbol)
    {
        Stock stock = StockFactory.getStock (symbol);

        if (!isValidStock(stock))
        {
            return -1;
        }

        if (this.clientType == SELLER)
        {
            return stock.getPrice (SELLER_PREMIUM);
        }
        else
        {
            return stock.getPrice (BUYER_PREMIUM);
        }
    }    

리팩토링의 맛을 보기 위해서 일부러 만들어낸 코드를 이용했기 때문에 얼마나 설득력이 있었는지는 모르겠다. 하지만 getPrice 메쏘드의 원래 모습과 리팩토링이 수행된 다음의 모습을 비교해 보면 리팩토링이 왜 필요한지, 어떻게 도움이 되는지 조금쯤은 느낄 수 있었을 것이다. 이런 규모의 리팩토링은 따로 계획을 잡아서 할 필요도 없다. 버그를 잡을 때나 새로운 기능을 더할 때 어디선가 냄새가 나는 것 같으면 그곳으로 달려가서 소매를 걷고 뚝딱 리팩토링을 하면 된다. 리팩토링은 그런 것이다. 평소에는 느끼지 못하지만 그렇다고 없으면 숨을 쉴 수 없는 공기처럼 아무소리 없이 프로그램 속으로 스며드는 그런 것이다.
TAG :
댓글 입력
자료실

최근 본 상품0