규칙 1: 메서드당 들여쓰기 한 번
어디서 시작할지 몰라 덩치크고 오래된 메서드를 노려보고 있다면? 거대한 메서드는 응집력이 떨어진다. 한 지침은 메서드 길이는 5줄로 제한하는 것이지만, 그런 종류의 이전(transition)은 코드가 500줄짜리 괴물들로 어질러져 있으면 의욕이 꺾이기 쉽다. 대신, 각 메서드가 정확히 한 가지 일을 하는지, 즉 메서드당 하나의 제어 구조나 하나의 문장 단락(block)으로 되어 있는지를 지키려고 노력한다. 한 메서드 안에 중첩된 제어구조가 있다면 다단계의 추상화를 코드로 짠 것이며, 고로 한 가지 이상의 일을 하고 있다는 뜻이다.
정확히 한 가지 일을 하는 메서드들로 작업을 하면 코드가 달라지기 시작한다. 애플리케이션의 각 단위가 더 작아짐에 따라 재사용의 수준은 기하급수적으로 상승하기 시작한다. 100줄로 구현된 5가지 작업을 맡은 하나의 메서드 안에서 재사용의 가능성을 골라내기란 어려울 수 있다. 주어진 컨텍스트에서 단일 객체의 상태를 관리하는 3줄짜리 메서드라면 많은 다양한 컨텍스트에서 쓸 수 있다.
예를 들어 아래와 같이 통합개발환경(IDE)의 메서드 추출(Extract Method) 기능을 써서 메서드에 들여쓰기가 1단계만 남을 때까지 동작 코드를 뽑아낸다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
class Board { ... String board() { StringBuffer buf = new StringBuffer(); for (int i = 0; i < 10; i++) { for (int j = 0; j < 10; j++) buf.append(data[i][j]); buf.append("\n"); } return buf.toString(); } }
class Board { ... String board() { StringBuffer buf = new StringBuffer(); collectRows(buf); return buf.toString(); }
void collectRows(StringBuffer buf) { for (int i = 0; i < 10; i++) collectRow(buf, i); }
void collectRow(StringBuffer buf, int row) { for (int i = 0; i < 10; i++) buf.append(data[row][i]); buf.append("\n"); } } |
이 리팩터링으로 다른 효과도 발생한다는 사실에 주목하자. 각 메서드는 구현을 이름에 맞추다 보니 사실상 평이해졌다. 이렇게 더 적어진 코드에서 버그의 존재를 판별하기란 대체로 훨씬 쉽다.
첫 규칙의 마무리인 이 시점에서 반드시 언급하고 싶은 점은 규칙을 적용하는 연습을 많이 할수록, 더 많은 결실을 거두게 된다는 점이다. 처음 여기에 소개된 방식으로 문제를 분해하다 보면 매우 어색하게 느껴지고 별로 얻는 게 없다고 생각할 수도 있다. 하지만, 규칙을 적용하는 데도 기술이 있으며, 이는 프로그래머의 솜씨를 한 차원 높여준다.
규칙 2: else 예약어 금지
모든 프로그래머는 if/else 구문을 이해한다. 이 구문은 거의 모든 프로그래밍 언어에 들어 있고, 간단한 조건 논리는 누구나 쉽게 이해한다. 거의 모든 프로그래머는 따라가기 불가능할 정도로 지저분한 중첩 조건문이나 몇 페이지씩 되는 case 문을 본 적이 있을 것이다. 더욱이 리팩토링보다는 그냥 기존 조건문에 분기를 하나 더 치기가 무척 쉽다는 점이다. 조건문은 또한 곧잘 코드 중복의 원흉이기도 하다. 예를 들어 아래 status 플래그는 자주 이런 문제에 빠진다.
1 2 3 4 5 6 7 |
public static void endMe() { if (status == DONE) { doSomething(); } else { // 다른 코드 } } |
else를 빼고 코드를 다시 짜는 데는 몇 가지 방법이 있다. 간단하게, 이렇게 짠다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
public static void endMe() { if (status == DONE) { doSomething(); return ; } // 다른 코드 }
public static Node head() { if (isAdvancing()) { return first; } else { return last; } }
public static Node head() { return isAdvancing() ? first : last; } |
위에서 4줄이 한 단어가 늘면서 1줄로 줄어버렸다. return 문을 일찍 쓰는 것을 너무 많이 하면 간결함을 저해하기 쉽다는 점을 간과해서는 안 된다. 디자인 패턴[GHJV95] 책의 Strategy 패턴을 보면 상태 인라인(status inline)에 분기를 막기 위해 다형성(polymorphism)을 쓰는 예제가 나온다. 상태에 대한 분기가 몇 군데 걸쳐 중복돼 있을 때 Strategy 패턴은 특히 유용하다.
객체지향 언어는 다형성이라는 강력한 도구를 통해 복잡한 조건문을 처리할 수 있다. 간단한 경우라면 보호절(guard clause)과 조기 반환(early return)으로 대체 가능하다. 다형성을 채택한 설계는 읽고 유지하기 쉬우며 더욱 분명히 코드의 의도를 표현하기에 이른다. 그렇게 하기가 늘 쉽지는 않으며, 특히 뒷주머니에 else가 있을 때 특히 그렇다. 그래서 이 훈련의 일환으로 else 사용은 금지다. 널 객체 패턴(Null Object Pattern)을 시도해 보면 특정 상황에서 도움이 될 것이다. 다른 방법들도 마찬가지로 else를 제거하는 데 도움될 수 있다.
규칙 3: 원시값과 문자열의 포장
int 값 하나 자체는 그냥 아무 의미 없는 스칼라 값일 뿐이다. 어떤 메서드가 int 값을 매개변수로 받는다면 그 메서드 이름은 해당 매개변수의 의도를 나타내기 위해 모든 수단과 방법을 가리지 않아야 한다. 만약 똑같은 메서드가 시간을 매개변수로 받을 경우, 무슨 일이 생기는지는 훨씬 자명하다. 이런 작은 객체가 프로그램의 유지보수성을 높일 수 있는 것은 시간 값을 매개변수로 받는 메서드에게 연도 값을 넘길 수는 없기 때문이다. 원시형 변수로는 컴파일러가 의미적으로 맞는 프로그램 작성을 안내할 수 없다. 객체로라면 아주 사소하더라도 컴파일러와 프로그래머에게 그 값이 어떤 값이며, 왜 쓰고 있는지에 대한 정보를 전하는 셈이다.
또한 시간이나 돈과 같은 작은 객체는 행위를 놓을 분명한 곳을 마련해 줘서, 그렇지 않았다면 다른 클래스의 주위를 겉돌았을지도 모르는 사태를 방지한다. 이는 특히 게터와 세터에 관련된 규칙을 적용하고 그런 작은 객체만이 값에 접근할 수 있을 때 그렇다.
규칙 4: 한 줄에 한 점만 사용
종종 하나의 동작에 대해 어떤 객체가 맡고 있는지 구분하기 어려울 때가 있다. 여러 개의 점이 들어 있는 코드 몇 줄을 들여다보기 시작하면 책임 소재의 오류를 많이 발견하기 시작한다. 어떠한 코드 한 줄에서라도 점이 하나 이상 있으면 그른 곳에서 동작이 일어나고 있다는 뜻이다. 어쩌면 객체는 다른 두 객체를 동시에 다루고 있을지도 모른다. 이 경우 그 객체는 중개상, 즉 너무 많은 사람들에 대해 지나치게 알고 있는 꼴이다(마치 유통에 있어 중개상을 배제하고 직거래하듯). 문제의 동작을 관련 객체 가운데 하나로 옮겨보자.
그 모든 점들이 연결돼 있다면 대상 객체는 다른 객체에 깊숙이 관여하고 있는 셈이다. 이런 중복된 점들은 캡슐화를 어기고 있다는 방증이기도 하다. 객체가 자기 속을 들여다보려 하기보다는 뭔가 작업을 하도록 만들어야 한다. 캡슐화의 주 요점은 클래스 경계를 벗어나 알 필요가 없는 타입으로 진입하지 않는 것이다.
디미터(Demeter)의 법칙(“친구하고만 대화하라”)이 좋은 출발점이긴 하지만, 이런 식으로 생각하자. 자기 소유의 장난감, 자기가 만든 장난감, 그리고 누군가 자기에게 준 장난감하고만 놀 수 있다. 하지만 절대 장난감의 장난감과 놀면 안 된다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
class Board { ...
class Piece { ... String representation; } class Location { ... Piece current; }
String boardRepresentation() { StringBuffer buf = new StringBuffer(); for (Location l : squares()) buf.append(l.current.representation.substring(0, 1)); return buf.toString(); } }
class Board { ...
class Piece { ... private String representation; String character() { return representation.substring(0, 1); }
void addTo(StringBuffer buf) { buf.append(character()); } } class Location { ... private Piece current;
void addTo(StringBuffer buf) { current.addTo(buf); } }
String boardRepresentation() { StringBuffer buf = new StringBuffer(); for (Location l : squares()) l.addTo(buf); return buf.toString(); } } |
이 예제에서 자세한 알고리즘 구현은 더 헷갈리는데, 한눈에 코드를 이해하기 조금 더 어렵게 할 수 있다. 어쨌든 그냥 Piece를 문자로 변환하는 메서드를 만들어 이름(character)을 붙였다. 이 메서드는 강한 응집력의 이름과 작업을 띠며 꽤 잘 재사용될 것 같은데, 실제 representation.substring(0, 1)이 프로그램의 군데군데 반복적으로 나오는 것들을 극적으로 줄여준다. 메서드 이름은 이 멋진 신세계에서 주석의 자리를 대신하니, 자 이제 이름 짓기에 시간을 투자하자. 이런 식의 구조를 지닌 프로그램을 이해하는 것이 진짜 더 어려운 것이 아니라, 단지 살짝 다른 접근이 필요한 것뿐이다.
규칙 5: 축약 금지
누구나 실은 클래스, 메서드, 또는 변수의 이름을 줄이려는 유혹에 곧잘 빠지곤 한다. 그런 유혹을 뿌리쳐라. 축약은 혼란을 야기하며, 더 큰 문제를 숨기는 경향이 있다.
왜 축약을 하고 싶은지 생각해보라. 계속 반복해서 똑같은 단어를 치기 때문이 아닐까? 만일 그 경우라면 아마도 메서드가 너무 대대적으로 사용되어 중복을 없앨 기회를 놓치고 있는 것이다. 메서드 이름이 길어지고 있기 때문일까? 이는 책임 소재의 오류나 클래스의 부재라는 신호탄일지도 모른다.
클래스와 메서드 이름을 한두 단어로 유지하려고 노력하고 문맥을 중복하는 이름을 자제하자. 클래스 이름이 Order라면 shipOrder라고 메서드 이름을 지을 필요가 없다. 짧게 ship()이라고 하면 클라이언트에서는 order.ship()라고 호출하며, 간결한 호출의 표현이 된다.
이 훈련을 위해 모든 엔티티는 한두 단어로 된 이름을 축약 없이 가져야 한다.
규칙 6: 모든 엔티티를 작게 유지
이 말은 50줄 이상 되는 클래스와 파일이 10개 이상인 패키지는 없어야 한다는 뜻이다.
50줄 이상의 클래스는 보통 한 가지 일 이상을 하는 것이며, 따라서 코드의 이해와 재사용을 점점 더 어렵게 끌고 간다. 50줄짜리 클래스는 스크롤 하지 않고도 한 화면에 볼 수 있다는 부가적인 혜택도 있으며 한눈에 파악하기도 쉽다.
클래스를 작게 작성할 때 난감한 경우는 같이 있어야 말이 되는 동작의 묶음이 있을 때다. 이는 패키지를 최대한 활용해야 하는 곳이기도 하다. 클래스가 점점 작아지고 하는 일이 줄어들며 패키지 크기를 제한함에 따라 패키지가 하나의 목적을 달성하기 위해 모인 연관 클래스들의 집합을 나타낸다는 사실을 알아차리게 된다. 패키지도 클래스처럼 응집력 있고 단일한 목표가 있어야 한다. 패키지를 작게 유지하면 패키지 자체가 진정한 정체성을 지니게 된다.
규칙 7: 2개 이상의 인스턴스 변수를 가진 클래스 사용 금지
대부분의 클래스가 간단하게 하나의 상태 변수를 처리하는 일을 맡아 마땅하지만 몇몇 경우 둘이 필요할 때가 있다. 새로운 인스턴스 변수를 하나 더 기존 클래스에 추가하면 클래스의 응집도는 즉시 떨어진다. 보통, 이런 규칙 아래서 프로그래밍을 하는 동안 클래스는 두 종류, 즉 인스턴스 변수 한 개의 상태를 유지하는 종류와 두 개의 독립된 변수를 조율하는 종류가 있음을 파악하게 된다. 일반적으로 그 두 종류의 책임을 혼용하면 안 된다.
그 차이를 잘 아는 독자라면 규칙 3(원시값과 문자열의 포장)과 규칙 7은 같은 형태로 간주할 수 있다는 사실을 눈치챌지도 모르겠다. 더 일반적인 의미로, 많은 인스턴스 변수를 지닌 클래스를 대상으로 응집력 있는 단일 작업을 설명할 수 있는 경우는 거의 없다.
아래는 분할이 필요한 경우의 예인데, 한번 쪼개보기 바란다.
1 2 3 4 |
String first; String middle; String last; } |
위 코드는 다음과 같이 두 클래스로 분해할 수 있다.
1 2 3 4 5 6 7 8 9 10 11 12 |
class Name { Surname family; GivenNames given; }
class Surname { String family; }
class GivenNames { List<String> names; } |
분해 방법에 있어 (많은 법적 주체 확인에 쓰이는) 성(family name)의 판별은 다른 이름 요소(중간 이름이나 이름 등)와는 근본적으로 다르다. 여기 GivenNames 객체에는 이름 리스트가 있는데, 이름(first name), 중간 이름(middle name), 또 그 밖의 이름을 가진 사람을 흡수할 수 있다. 자주, 인스턴스 변수의 분해는 여러 개의 관련 인스턴스 변수의 공통성을 이해하게 해준다. 때때로 여러 관련 인스턴스 변수가 실은 일급 콜렉션(first-class collection) 안에서 연관된 삶을 살고 있다.
속성의 집합에서 객체를 협력 객체의 계층구조로 분해하면 더 직접적으로 효율적인 객체 모델에 이른다. 이 규칙을 이해하기 전에 대형 객체를 통해 데이터 흐름을 쫓으려고 많은 시간을 할애했다. 하나의 객체 모델을 족집게처럼 뽑아내는 일이 가능은 했지만, 동작과 관련된 그룹과 그 결과를 이해하는 일은 고통스러운 과정이었다. 반면, 이 규칙을 재귀적으로 적용하면 복잡하고 덩치 큰 객체를 훨씬 간단한 모델로 매우 빠르게 분해하게 된다. 동작은 자연스레 인스턴스 변수를 적절한 곳으로 따르며, 컴파일러와 캡슐화에 대한 규칙은 그 밖의 경우를 허용하지 않을 것이다. 분해 과정을 진행하다가 막히게 되면, 객체를 상관 있는 반으로 쪼개는 식으로 내려가거나 두 인스턴스 변수를 골라 그로부터 하나의 객체를 만드는 식으로 올라간다.
규칙 8: 일급 콜렉션 사용
이 규칙의 적용은 간단하다. 콜렉션을 포함한 클래스는 반드시 다른 멤버 변수가 없어야 한다. 각 콜렉션은 그 자체로 포장돼 있으므로 이제 콜렉션과 관련된 동작은 근거지가 마련된
셈이다. 필터가 이 새 클래스의 일부가 됨을 알 수 있다. 필터는 또한 스스로 함수 객체가 될 수 있다. 또한 새 클래스는 두 그룹을 같이 묶는다든가 그룹의 각 원소에 규칙을 적용하는 등의 동작을 처리할 수 있다. 이는 인스턴스 변수에 대한 규칙의 확실한 확장이지만 그 자체를 위해서도 중요하다. 콜렉션은 실로 매우 유용한 원시 타입이다. 많은 동작이 있지만 후임 프로그래머나 유지보수 담당자에 의미적 의도나 단초는 거의 없다.
규칙 9: 게터/세터/속성 사용 금지
앞의 규칙의 마지막 문장은 거의 바로 이 규칙으로 이어진다. 만약 객체가 지금 인스턴스 변수의 적당한 집합을 캡슐화하고 있지만 그 설계가 여전히 어색하다면, 좀 더 직접적인 캡슐화 위반을 조사해볼 때다. 그냥 단순히 현재 위치에서의 값을 물을 수 있는 동작이라면 해당 인스턴스 변수를 제대로 따라가지 못할 것이다. 강한 캡슐화 경계의 바탕에 깔린 사상은 동작의 검색과 배치를 위해 남겨둔 코드를 만질 다른 프로그래머를 위해 객체 모델의 단일한 지점으로 유도하려는 것이다. 이는 많은 긍정적인 하부효과를 가져다 주는데, 중복 오류의 극적 축소와 새 기능의 구현을 위한 변경의 지역화 개선 등이 있다.
이 규칙은 흔히 “말을 해, 묻지 말고”라고 일컫는다.
결론
지금까지 다룬 9가지 규칙 가운데 8가지는 간단히 말해 객체지향 프로그래밍이라는 성배(Holy Grail), 즉 데이터의 캡슐화를 가시화하고 실현하기 위한 방안이다. 더불어, 또 하나는 다형성의 적절한 사용(else를 쓰지 않고 모든 조건 논리를 최소화하는 것)을 유도하며, 또 하나는 간결하고 직설적인 명명 표준을 장려하는, 일관성 없이 적용되며 발음하기 어려운 축약어를 배제한 명명 전략이다.
최종 요점은 코드나 아이디어의 중복이 없게 코드를 빚는 것이다. 목표는 우리가 온종일 풀어야 하는 일상의 복잡다단함에 대한 단순하고 우아한 추상화를 간결하게 표출하는 코드다.
궁극적으로는 결국 어떤 경우 규칙들 간에 모순을 일으키며 퇴보된 결과를 낳는 것을 보게 될 것이다. 그러나 이 연습을 위해 20시간을 써서 이 규칙에 100% 부합하는 코드 1000줄을 짜보라. 낡은 습관을 버리고 그동안 프로그래머로의 인생을 통틀어 지녀왔을지 모르는 규칙에 변화를 가해야만 하는 자기 자신을 발견하게 될 것이다. 각 규칙은 그렇게 엄선했고, 따라서 규칙을 따르면 자기에게는 쓸 수 없는 분명한 (그러나 어쩌면 틀린) 해답이 으레 존재하는 상황을 조우할 것이다.
이들 규칙을 훈려과 더불어 따르면 객체지향 프로그래밍을 더 많이 이해할 수 있는 더 탄탄한 문제 해결책을 자기 것으로 만들게 되리라 본다. 이 모든 규칙을 따른 1000줄짜리 코드를 짜면 예상했던 것과는 완전히 다른 무언가를 짰다는 것을 깨닫게 된다. 규칙을 따르고, 어디서마쳤는지를 확인하라. 계속 그렇게 일한다면 작성 중인 코드는 어떤 의식적인 노력을 스스로 기울이지 않아도 규칙에 따르지 싶다.
마지막 정리로, 혹자는 이들 규칙을 지나친 것으로 여기거나 실제 운영 시스템에 적용 불가능하다고 볼지 모르겠다. 그 생각은 틀렸다. 나는 이 책이 출간될 즈음 100,000줄을 이런 식으로 작성한 시스템을 마무리하고 있다. 이 시스템 구축 작업을 하는 프로그래머는 일상적으로 이들 규칙을 따르며 심오한 간결성(deep simplicity)을 온몸으로 끌어안을 때 얼마나 지겨운 개발이 덜어질 수 있는지를 목도하며 서로 너무 즐거워하고 있다.
'JAVA' 카테고리의 다른 글
[JAVA] POI 엑셀파일 생성시, "XXX의 내용에 문제가 있습니다." (2) | 2020.07.28 |
---|---|
[JAVA] 이미지 업로드하기 (0) | 2020.06.12 |
[Java] Rest API (0) | 2020.02.21 |
[Java] DAO,DTO,Entitiy Class (0) | 2020.02.16 |
[Java] Unit Test - 3.테스트 작성 (0) | 2020.02.06 |