리팩토링 1 - 중복된 코드, 긴 메소드

subutai·2021년 5월 29일
0

리팩토링

리팩토링은 겉의 동작 변경없이 내부 구조를 바꾸는것이다.

더 자세히는, 소프트웨어를 더 이해하기 쉽고, 수정하기 쉽도록 만드는 것이다.
그러면서, 겉으로 보이는 소프트웨어의 기능을 변경하지 않는것이다.

그러면 어떤 코드를 리팩토링해야하는가?
마틴 파울러의 Rafactoring 에서는 리팩토링 해야하는 코드를
냄새나는 코드라고 묘사하고는하며, 냄새나는 코드들은 아래와 같다

1. 중복된 코드

중복된 코드는, 리팩토링에 대해 진지하게 생각해보지 않았어도
직감적으로, 매우 위험하고 나쁘다는 생각이 든다.
Refactoring 에는 중복된 코드가 왜 나쁜지 이유를 구체적으로 설명하지 않는데
다른 책과 자료를 보며 개인적으로 정리한 바는

설계의 응집도가 낮다는 증거이기 때문이다

객체지향 설계의 가장 핵심은 '높은 응집도 낮은 결합도' 이다.
코드가 다른 곳에서 반복되어 나타난다는 것은
객체의 책임과 역할이 제대로 정해지지 않았다는 것과 같다.
그렇기에, 하나의 객체에 뭉쳐져야 할 책임이 여러 객체에 흩뿌려지고
낮은 응집도라는 결과로 나타나는 것이다.
그리고 그 낮은 응집도를 갖는 객체들의 애매한 역할
서로 다른 객체에서 반복되어 등장한다

2. 긴 메소드

긴 메소드가 안 좋은 이유를 예제코드를 통해 알아본다.

1. 긴 메소드인 경우

/*
@ 정수형 배열을 받아서, 홀수인 원소는 0으로 만든 후
  변형된 배열에서 홀수 인덱스의 원소를 더한 값을 반환한다
*/
public int doSomethingWithArr(int[] arr) {
   
    int answer = 0;
    
    for (int i=0; i < arr.length; i++) {
    	int curr = arr[i]; 
        boolean exit = true;
    	for (int i=2; i*i <= curr; i++) {
        	if (num % i == 0) {
            	exit = false;
                break;
            }
        }
        if (exit) {
        	arr[i] = 0;
        }
    }
    
    for (int i=0; i < arr.length; i++) {
    	if (i % 2 != 0) {
        	answer += arr[i];
        }
    }
	return answer;
}

위에 주석이 써있지 않다면, 코드를 읽으면서 해야하는 생각은 이렇다

첫 번 째 반복문이 하는 일이 뭐지?
현재 원소를 가지고, 어 반복문이 하나가 더 나오네?
i가 2부터 시작하고.. i*i 니깐 i의 제곱이 curr 이하일때까지 반복..
curr 이 뭐지? 아 현재 원소구나. num % i == 0 일 때 break 해버리네
i 의 제곱이 curr보다 이하일때까지, 이 조건을 계속 체크하는걸 보니
현재 원소가 소수인지 확인하는 코드구나!
그러면 소수가 아닐 때는 분기에 들어가서 원소를 0으로 바꾸네
아! 배열에 모든 소수인 원소를 0으로 바꾸는거구나 오케이
다음 반복문은.. 배열 전체에 대해서 i%2 != 0 일때니까..
아 홀수 인덱스인 원소만 누적해서 더하는거구나
그리고 answer을 반환하네

꽤나 많은 생각을 하고서, 메소드의 역할을 파악할 수 있다.
이제, 위의 큰 반복문들을 2개의 메소드로 나눠보면

  1. 소수인 원소를 0으로 바꾸는 메소드 : int[] setZeroForAnyPrimeNumberElement(int[]);
  2. 홀수 인덱스의 원소를 더한 결과를 반환하는 메소드 : int sumOfOddIndexElement(int[]);

이렇게 나눈 메소드로 코드를 리팩토링 하면

2. 짧은 메소드로 바꾼 경우

/*
@ 정수형 배열을 받아서, 홀수인 원소는 0으로 만든 후
  변형된 배열에서 홀수 인덱스의 원소를 더한 값을 반환한다
*/
public int doSomethingWithArr(int[] arr) {
    	return sumOfOddIndexElement(setZeroForAnyPrimeNumberElement(arr));
}

이 코드를 읽으면서 아래와 같은 생각을 할 것이다

setZeroForAnyPrimeNumberElement 가 먼저 호출되고,
입력받은 배열을 넘기네, 이름으로 보니까..
배열에서 소수 원소는 다 0으로 바꿔버린 결과를 리턴하는구나
이제 외부 함수는 이름을 보니 홀수 인덱스의 원소값을 더해서 리턴하나보네

doSomethingWithArr 메소드의 코드는 1줄로 줄어들고
해야하는 생각은 소설이나 신문을 읽는것과 동일하다
각 메소드 이름을 실행순서대로 읽게되면
이 메소드의 역할을 파악할 수 있다.

이것은 단지 하나의 효과고, 읽기 쉽다 라는 장점으로 생기는
낙수효과는 어마어마하다

1. 코드 수정이 쉽다

조건문과 반복문을 분석해서 변경되어야하는 곳을 찾아서 고치는것이 아니라 
문장을 읽어내려가다가, 변경되어야 하는 문장을 찾으면 그것을 다른 문장으로 바꿔주면 된다.

2. 디버깅이 쉽다

메소드가 의도대로 작동하지 않는 경우, 값과 행동에 기반해서
메소드에서 호출하는 메소드의 이름을 보고 역할을 쉽게 추론할 수 있다. 

3. 기능추가가 쉽다

메소드의 코드가 객체의 이름들의 나열되어 있으므로 
복잡한 반복문과 조건문 사이 어느곳에 넣을지 고민하는 노력이 덜 든다

4. 재사용이 쉽다

코드를 이해하기 쉽고, 세부 사항들이 모두 다른 메소드에 위임되어 있으므로 
메소드가 현재 상황에 알맞게 돌아갈 것이라는 확신을 가질 수 있다.   

이렇듯, 짧은 메소드는 많은 장점들이 있고
개인적으로, 모든 리팩토링과 디자인패턴의 뿌리라고 생각한다.

메소드가 알고리즘 구현 로직으로 채워진게 아니라
메소드가 위임 로직으로 채워져야 한다.

물론, 최하위 메소드는 알고리즘을 구현하는 로직이 쓰여지게 된다.
다만, 여러 하위 메소드를 복합적으로 사용하는 상위 메소드는
하위 메소드로 위임하는 로직으로 채워져야한다는 것이다.

이 단계까지오면, 위임에 필요한 의사결정은 그렇게 복잡하지 않다
(why?
1. 대부분 의사결정은 모두 위임받은 객체에 캡슐화 되어있다. (Decompose Conditional 의 효과 )
2. 따라서, 나머지 의사결정은 현재 객체가 가지고있는 정보만을 이용하여 내릴 수 있다)

결국에는 몇 개의 분기와 몇 개의 메소드 호출(Extract Method 의 효과)만이 남는것이다.
여기서 Decompose conditional 과 Extract Method는 모두 Refactoring 의 기법이다

어쨌든 좋다.
그런데.. 뭔가, 앞 뒤가 바뀐 결론이 도출된다.

🎉 짧은 메소드가 좋은게 아니라, 좋은 메소드는 짧을 수 밖에 없는 것이다.

그런데, 이러한 절차로 짧은 메소드를 만드는게 아닌,
일을 위한 일을하는 식으로 짧은 메소드를 만들면
짧고 괴이한 메소드가 생기기 쉽다(많이 만들어봤다)..

결론

Refactoring 에서는 좋은 객체지향 프로그램은
아래와 같은 느낌을 준다고 서술한다.

객체를 처음 접하는 프로그래머는 작업이 어디에서도 일어나는 것 같지 않고
객체 프로그램이 끝없이 다른 객체에 작업을 위임하는 것처럼 느낄 때가 많다

좋은 오픈소스 프로젝트를 분석할 때, 동일한 느낌을 굉장히 많이 받았고
왜 그런지에 대해서 꽤 많이 생각해보았다.
내가 결론내린 이유들은 아래와 같다

2-1. 역할중심의 로직

객체지향 설계의 1원칙은 '높은 응집도 낮은 결합도' 이다.
소프트웨어가 구현하고자 하는 기능은 무수한 책임들로 나누어져
책임을 부여받은 객체들이 수행하게 된다.
그러다보니, 어떤 객체의 메소드가 수행해야하는 일은 구체적이고 복잡한 일이 아니다.
다만, 책임을 가진 객체들에게 메세지를 보내는 것이다.
따라서, 메소드가 수행하는 일이 구체적이고 복잡한 일이 아닌
조건과 순서에 따라 적절한 객체에 메세지를 보내는 일이다.

이렇게 되면, 조건문, 반복문들이 메세지를 받는 객체의
퍼블릭 인터페이스에 숨겨지게 된다.
코드를 읽는 일이 복잡한 조건문의 분기를 따라가는 것이나
반복문의 인덱스와 값을 쫓아가는 것이 아니라
퍼블릭 인터페이스의 이름을 순차적으로 읽어가게 되는 것이다.

앞에서 봤던 doSomethingWithArr 이 그러한 예시이다.

2-2. 다형성

잘 짜여진 객체지향 프로그램에서 사용되는 객체의 타입은
서브타입이 아니라 슈퍼타입이다.

interface DbDialect { public void execute(); } 
class MySQLDbDialect implements DbDialect { public void execute() {..구현..} }
class OracleDbDialect implements DbDialect { public void execute() {..구현..} }
class PostGreDbDialect implements DbDialect { public void execute() {..구현..} }

에서, MySQLDbDialect 같은 DbDialect 의 서브타입이 아니라
DbDialect 의 형태로 불려진다는 의미이다.

	public void doSomethingWithDB(DbDialect dialect) {
    		... 복잡한 로직 ... 
    		if (this.db.isReady()) {
            		dialect.execute()
            	}
    	}

다형성을 활용하지 못하면 아래와 같은 코드가 만들어진다

	if (db.getVendor().equals("MYSQL") {
    		dialect = new MySQLDialect();
    	} else if (db.getVendor().equals("ORACLE") {
	    	dialect = new OracleDialect(); 
	}
    	... 생략 ... 

코드가 길어진것은 물론이고, 컴파일 의존성도 커진다.

0개의 댓글