3장 함수를 읽으면서 내용에 대해 보충하거나, 개인적인 의견으로 반박하거나, 고민해 볼 부분에 대해 적어보려 한다.
일단 해당 장에서는 함수나 메서드를 모두 함수라고 통칭하도록 하겠다. (굳이 메서드로 구분해서 언급해야 할 때는 구분하여 이야기하도록 하겠다.)
처음 예시로는 FitNesse의 길게 작성된 코드를 짧게 줄임으로서 가독성을 확보할 수 있는 부분을 보여준다. (줄바꿈이나 들여쓰기를 원본 그대로 쓰기엔 불편한 부분이 있어 일부 수정했다.)
|
|
|
|
일단 기존 코드는 코드의 내용이 길고, 문맥 안에 조건문의 조건문 중첩이 너무 많고, 비슷한 코드 패턴이 자주 나타난다. 전체적인 코드 내용은 함수를 추출하여 맥락 위주로 설명하여 코드의 길이를 줄였고, 그 과정에서 문맥의 조건문 중첩을 없앴으며, 아마 각 함수 안에 중복되는 코드를 줄이는 내부 함수가 새로 작성되었을 것이다.
작게 만들어라
함수를 작게, 여러 개로 잘 나눠 작성할 수록 가독성이 올라간다. 객체의 설계나 메서드/멤버 변수의 관계에 대한 규칙은 대표적으로 SOLID가 있다. 응집도나 결합도를 측정하여 코드의 품질을 측정하고 개선할 수 있지만, 함수의 경우 품질 측정을 위해 쓸만한 수치라고는 함수의 코드 길이밖에 없을 것이다.
책에서는 과거 VT100 같이 80x24 글자 표현이 가능하던 시절에 화면을 넘어가지 않는 것을 기준으로 잡았다고 한다. (보통 그때 시절에 4줄 정도는 관리 용도로 사용했으므로 20줄을 넘기지 말라는 뜻이다.) 이건 과거부터 공통적인 기준인지 커널 코딩스타일에서도 비슷한 식으로 언급한다. (여기선 화면 2번 안에 나오는, 대략 40~50줄 정도를 권장한다.)
물론 함수가 매번 짧아질 수는 없다. 함수가 해야 할 일이 많다면 당연히 길어질 수 밖에 없다. 함수의 길이를 줄이기 위해 가능한 방법은 함수 내부 흐름을 다른 작은 함수로 추출할 수 있을까 고민하는 것이다. 서론에서 본 예제도 코드의 흐름에서 하고자 하는 목적에 따라 함수로 추출하여 표현한 것이다. 보통 프로그래밍을 처음 배울때는 중복되는 부분을 줄이기 위해 함수로 추출해야 한다고 가르쳤을 것이다. 하지만 코드의 흐름을 표현하기 위해 함수로 분리해도 된다.
보통 함수를 자주 분리할 때 걱정하는 것이 성능일 것이다. (Java쪽은 생산성을 위해 성능을 포기하거나, JVM을 믿는 것 같다.) 시스템 프로그래밍 등의 강의를 수강하면 배우는데, 함수 호출을 위해 스택 프레임을 쌓는 과정 등에 생기는 부하를 걱정하게 되는데, 최근 컴파일러의 최적화 기술이 많은 부분을 보조해 주기 때문에 큰 걱정 없이 사용해도 될 것이다. 물론 모든 상황에서 무분별하게 사용하면 안되지만, 실행했을 때 걸리는 사소한 부하보단 가독성이 더 중요할 것이다.
대표적으로 인라인 함수로 선언되지 않아도 함수를 인라인화 한다던가, 재귀 호출이 함수의 마지막에 이뤄진다면 스택 프레임을 쌓지 않고, 반복적인 형태로 구현하는 등 컴파일러 최적화 기술은 무궁무진하다.
블록과 들여쓰기
책에서는 if
, else
, while
문 아래 있는 코드들도 가능하면 함수 호출 한 줄로 표현하라고 한다. 즉, 조건에 따른 동작은 각각 함수로 추출하고, 조건문은 각 함수를 호출하기 까지 분기를 결정하는 역할로 사용하라는 것이다. 함수로 추출하면서 서술적인 이름으로 함수를 명명하면 간접적으로 코드에 대한 문서화도 가능할 것이라고 이야기하고 있다. 게다가 이 과정에서 자연스럽게 중첩 블록 (if
블록 안에 if
문)을 줄일 수 있다고 이야기한다.
완전 틀린 이야기는 아닌 것 같지만, 동의하지 않는 부분은 있다. 분명 함수로 추출하면 각 함수별로 좀 더 간단한 단계로 표현이 가능한 것은 사실이다. 하지만 실무적인 가독성 면에서 오히려 더 불편하다 생각한다. 실무적인 가독성이란 표현은 내가 지어낸 표현인데, 한 실행 흐름을 완전히 분석하는 과정의 가독성이라 해석하면 될 것 같다.
내가 조금 코드를 깊이 열어보는 경향이 있긴 한데, 특정 프로젝트를 개발 및 디버깅 하다보면 코드 흐름에 따른 내용을 모두 읽게 되는 것 같다. (외부 라이브러리의 경우 상황에 따라 열어볼 수도 있고, 문서만 참고하기도 한다.) 보통 코드를 다 읽으려면 함수 정의를 찾아 들어가야 하는데, 정의를 찾아 들어가는건 IDE 등으로 지원이 되지만, 매번 들어갔다 나왔다 하는 부분에서 피로도가 증가한다 생각한다. 특히 코드의 난이도가 좀 있는 경우(코드의 난이도와 가독성은 별개라고 생각한다.) 큰 흐름의 코드를 읽고 나왔다가 다시 들어가면 앞의 내용을 까먹는 등 불편함이 존재한다 생각한다.
앞의 함수 크기 설명 기준을 참고하여, 내 생각에는 코드 블럭이 한 화면을 넘어가면 함수로 추출하는 것이 적절하다 생각한다. 조건문/반복문 안의 코드 블럭이 길어지면 앞의 조건을 까먹을 수도 있다. 물론 책에서 설명한 대로 조건문/반복문이 중첩이 심해지면 각 들여쓰기 별 흐름이 헷갈리기 시작한다.
한 가지만 해라
처음에 나온 예제 코드는 한 함수에서 너무 많은 일을 수행한다. 첫 리팩토링을 한 코드는 코드의 상세한 구현을 함수로 추출함으로서 가독성에 대한 추상화가 이뤄졌다. 아래와 같이 추가 리팩토링을 한 코드에서는 이 추상화도 한 가지 이상의 일을 하고 있으므로 (WikiPage
앞, 뒤로 includeSetupPages()
, includeTeardownPages()
를 수행하고, 문자열 버퍼에 넣었다가 다시 HTML로 변환하는 등) 이걸 한 가지 일로 추상화했다.
|
|
|
|
나는 1차 리팩토링 정도로도 충분하다고 생각한다. 2차 리팩토링 코드에 비해 긴 함수긴 하지만, 그렇게 길지도 않다. 또한 includeSetupPages()
와 includeTeardownPages()
를 includeSetupAndTeardownPages()
로 추상화했는데, 함수 이름에 And
가 들어가는 시점부터 깔끔하지 않다고 생각한다.
위 예시는 부적합하다고 생각하지만, 한 가지 일만 해야 한다 는 부분에는 동의한다. 이미 객체지향에서 클래스에 대해 단일 책임 원칙이 존재하며, 상세한 내용은 조금 다르지만 객체가 한 역할만 맡는다 는 관점에서 비슷하다 생각한다.
함수 내 섹션
아래 예시를 보면 (소수를 생성하는 함수) 변수 선언, 초기화, 동작(에라토스테네스의 체) 단위로 섹션이 구분되어 있는 것을 확인할 수 있다. 섹션이 구분되어 있다는 것은 해당 함수가 한 가지만 하고 있지 않다는 증거라고 볼 수 있다고 한다. 함수가 한 가지 일만 한다면 섹션을 의미있게 구분하기 힘들 것이라고 한다.
|
|
예시가 부적절한 것 같다. 변수를 선언하는 것은 함수에서 일을 하기 위한 준비 과정인데, 이건 당연히 섹션을 분리 할 수밖에 없다고 생각한다. 차라리 소수 판정을 위한 f
배열의 플래그 초기화, 소수 판정, f
배열로부터 primes
배열을 채우는 과정이 명시적으로 서술되어 있기 때문에 각 함수로 추출해야 한다고 주장해야 할 것 같다.
함수 당 추상화 수준은 하나로
첫 예시 코드에서 나온 추상화 수준은 getHtml()
같이 높은 수준의 추상화와, StringBuffer
를 사용하는 구현 수준의 낮은 추상화가 섞여있다. 이렇게 추상화 수준이 섞여 있으면 각 코드 라인의 표현이 개념 을 나타내는 것인지, 구현 을 나타내는 것인지 구별하기 힘드므로 추상화 수준을 맞추라고 이야기한다.
의도는 좋은데 실제 코드 작성에서 이런 식으로 추성화 수준을 맞추는 게 가능할지 의문이 든다.
위에서 아래로 코드 읽기: 내려가기 규칙
함수의 동작 흐름을 하향식(Top-down)으로 읽기 위해, 함수의 동작 흐름을 TO
(~~하려면
으로 번역했을 것 같다.) 단위로 서술해서 추상화 수준을 조절하는 기법을 설명한다. 보통 프로그래밍을 처음 배울 때 큰 함수를 단계별로 나누고, 그 단계들을 더 자세히 나누면서 코드를 작성해야 한다고 배우는데, 이게 원하는 수준의 추상화 단계 조절로 이뤄질지 의문이 든다.
Switch 문
당연하게도 switch
문을 사용하는 함수에선 하나에 하나만의 동작을 수행하기 힘들다. 특정 변수의 값에 대해 조건문의 개수가 발산하는 상황에서 사용되기 때문이다. 책에서는 switch
문을 통해 동작을 결정하는 코드를 추상 팩토리 패턴으로 숨기는 방법을 제안한다.
public Money calculatePay(Employee e)
throws InvalidEmployeeType {
switch (e.type) {
case COMMISSIONED:
return calculateCommissionedPay(e);
case HOURLY:
return calculateHourlyPay(e);
case SALARIED:
return calculateSalariedPay(e);
default:
throw new InvalidEmployeeType(e.type);
}
}
public abstract class Employee {
public abstract boolean isPayday();
public abstract Money calculatePay();
public abstract void deliverPay(Money pay);
}
/*-----------------*/
public interface EmployeeFactory {
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType;
}
/*-----------------*/
public class EmployeeFactoryImpl implements EmployeeFactory {
public Employee makeEmployee(EmployeeRecord r) throws InvalidEmployeeType {
switch (r.type) {
case COMMISSIONED:
return new CommissionedEmployee(r) ;
case HOURLY:
return new HourlyEmployee(r);
case SALARIED:
return new SalariedEmploye(r);
default:
throw new InvalidEmployeeType(r.type);
}
}
}
객체지향적으로 프로그래밍 하는 입장에서 틀린 리팩토링은 아니다. 하지만 내가 생각하는 switch
문의 문제는 객체지향이 아닌 상황에서도 여러 이슈가 있는 문법이다. 위키의 설명을 참고하면, switch
문은 크게 두 종류로 나뉘는데, Pascal과 같은 언어에서는 값의 각 조건마다 분기하고 무조건 아래로 내려가는 방식이고, C언어 등에서는 goto
문과 비슷한 방식으로 동작한다. 현대적으로 인기 있는 대부분의 언어는 C언어의 동작 방식을 따라간다. 문제는 이런 goto
식 동작은 초보자들이 잘못 이해하기 좋고, 경력자들도 가끔씩 실수로 break
를 생략하여 버그를 만드는 경우도 있다. 이렇게 잘못 사용하는 경우만 있다면 다행이겠지만, 문제는 의도적으로 break
를 생략하는 경우도 있다는 것이다. goto
형 switch
의 특성을 극대화해서 루프 풀기를 적용한 Duff's device
란 기법이 대표적이다. (현대 코딩 가이드라인 기준에서는 위험하다 판단하여 사용하지 않을 것을 권고/강제한다.) 이렇게 switch
문에서는 실수
와 의도
를 구분하기 위해 코드의 의도를 정확히 이해해야 한다.
아래 예시는 내가 참여했던 Hanjp 프로젝트에서 한글 자모를 카나 50음도표로 변환하는 오토마타의 코드 중 일부다. 아래 코드의 일부를 예시로 보면 ㄱ
, ㅋ
, ㄲ
을 K
행 자음으로 변환해야 하며, ㄱ
의 경우 탁음 처리를 위해 추가 변환이 필요하다. 동일한 동작을 시키려고 일부러 break
를 생략하고, 예외 처리인 ㄱ
을 앞에 배치해서 먼저 처리하는 식으로 코드를 작성했다. 이렇게 특정 변수의 여러 값을 한번에 처리하는 것은 효율적이긴 하지만, 읽다보면 어디서 지금 조건의 흐름이 종료된 것인지(break
), 아래 흐름과 연결되는지 (ㄱ
의 탁음 처리만 하고, 자음 행 결정은 그 아래서 수행) 직관적으로 이해하기 힘들다.
보통 여러 조건을 묶는 경우는 case
가 여러번 묶어서 나타나므로 의도를 쉽게 이해할 수 있을 것이다. 그리고 ㄱ
탁음 처리 후 아래로 내려가는 부분같은 경우 보통은 fallthrough
를 사용하여 아래 흐름으로 내려가야 한다고 표기해 줄 수 있다. 그리고 break
에서 빈 줄을 넣어 문맥을 구분해주면 좀 더 가독성이 좋아진다 생각한다.
|
|
|
|
리눅스 커널의 경우 fallthrough
를 매크로로 설정해서 컴파일러가 의도된 break
생략인지 분석할 수 있게 한다. Go 언어의 경우 아예 Pascal의 경우처럼 break
가 기본 동작하되, fallthrough
키워드를 통해 아래 코드 블럭까지 수행할 수 있게 문법을 구성했다.
서술적인 이름을 사용하라
일반적으로 함수의 작성 목적은 중복된 흐름/기능을 추출하여 일반화된 흐름으로 압축하기 위해 사용한다 생각한다. 일반화가 목적인 만큼 과도하게 서술적인 이름은 오히려 안 어울린다고 생각한다. (책에서는 이름을 길게 쓰는 것을 두려워 하지 말라고 하는데, 나는 이전 장에서 언급했듯, 과도한 서술적인 이름은 시간/공간 낭비라고 생각한다.) 서술적으로 함수 이름을 지어서 좋다고 생각하는 예외는 테스트 코드 를 작성할 때라고 생각한다.
앞서 설명한대로 함수 작성이 일반화 과정이라면, 테스트 코드는 함수의 안정성 확인 등을 위해 역으로 예시를 집어넣어 구체적인 입출력을 비교하는 행위라 생각한다. 이 책에서 이야기하는 이 함수가 무슨 일을 하는가? 를 표현하기에 가장 적합하다 생각한다.
참고로 몇몇 환경의 테스트 프레임워크는 함수 이름 외에도, 추가적으로 해당 테스트를 설명하는 메시지로 표현할 수 있다.
import "testing"
func TestSquare9ShouldBe81(t *testing.T) {
result := square(9)
if result != 81 {
t.Errorf("square(9) should be 81")
}
}
@DisplayName("square(9) should be 81")
void assertSquare_9_ShouldBe_81 {
assertEquals(81, square(9))
}
함수 인수
책에서는 함수에 인수가 적을 수록 좋다고 한다. 예를 들어 1개 인자를 받는 includeSetupPageInto(newPageContent)
보단 includeSetupPage()
처럼 인자를 없애는 것이 더 이해하기 쉽다는 것이다. 코드의 이해 뿐만 아니라, 테스팅을 할 때도 유리하다. 인자 개수가 늘어나면 테스트 커버리지 확보를 위해 여러 인자에 대한 조합들을 다 확인해야 하므로 테스팅이 더 어려워 진다는 것이다.
게다가 함수는 기본적으로 인자들을 입력으로 받고 결과를 반환(return)하는 것이 일반적인 흐름인데, 인자에 부수 효과(Side effect)를 일으키는 함수는 반환되는 값이 2개 이상이 되므로 더 어려워진다고 한다. 그런데 이건 Java같이 객체의 생성(할당), 반환(해제)가 유연하고, 예외처리가 자연스러운 언어의 이야기일 뿐, C/C++같은 언어에서는 현실적으로 감수해야 하는 문제라고 생각한다.
책에서는 마치 함수의 인자 개수가 늘어날수록 큰 문제가 생기는 것 처럼 경고하지만, Google의 함수 인자 개수와 코드의 오류 비율에 대한 관계를 분석한 연구에 따르면, 인자를 잘못 넣는 실수는 인자가 5개 이상일 때부터 증가한다고 한다.
많이 쓰는 단항 형식
함수의 인자가 한개 들어가는 대표적인 예시로는 boolean fileExists("MyFile")
처럼 인자에 대한 질문을 하는 경우, InputStream fileOpen("MyFile")
처럼 인자를 다른 형태로 변환하는 경우가 있다고 한다. 각 용도를 잘 구분하기 위해 함수 이름을 잘 지어야 한다는데, 보통 질문에 해당하는 함수는 isFileExists()
같은 식으로 앞에 is
, has
같이 boolean
한 결과라는 것을 예측할 수 있는 이름으로 구분하면 충분할 것 같다.
플래그 인수
boolean
형의 플래그 인자를 사용하는 함수는 한 가지 일만 하지 않는다는 뜻 이므로 좋지 않다고 한다.
격하게 동의한다. MFC 개발할 때, UpdateData(BOOL bSaveAndValidate = TRUE)
함수가 있는데, 이 함수는 다이얼로그의 컨트롤(GUI의 버튼 같은 요소)과 변수를 연결했을 때, 컨트롤과 변수의 상태를 반영하는 함수다. 아쉽게도 MFC는 MVVM의 data binding같은 방식이 아니라서, 명시적으로 View(MFC의 컨트롤)와 View-Model(연결된 변수)의 변화를 수동적으로 반영해 줘야 한다. UpdateData(TRUE)
를 호출하여 컨트롤의 상태에 맞게 변수 값을 변경시키고, UpdateData(FALSE)
를 호출하면 변수 값에 맞게 컨트롤의 상태를 변경한다.
함수의 상세한 정의나 MFC의 동작 원리를 잘 알지 못하면 각 함수를 잘못 사용할 가능성이 높다. UpdateData(TRUE)
는 UpdateDataFromCtrl()
로, UpdateData(FALSE)
는 UpdateCtrlFromData()
로 리팩토링한다면 사용법을 헷갈릴 일은 없을 것이다.
이항 함수, 삼항 함수, 인수 객체
책에서는 이항 함수(인자를 2개 받는), 삼항 함수(인자를 3개 받는), 인수 객체(객체를 인자로 전달하는)를 나눠 설명하고 있지만, 핵심적인 이야기는 초반에 말했던 것 처럼 인자의 개수가 많아질수록 함수를 이해하기 어려워지니까 어떻게든 함수의 인자 개수를 줄여야 한다는 것이다. 예를 들어 함수에 전달하는 인자들이 호출마다 자주 바뀌는지, 각 인자들이 정말 함수 안에서 의미있게 사용되는지 등을 고려하여 제외할 수 있다면 제외하라는 것이다. 아니면 전달하는 여러 인자들의 관계가 하나의 객체로 묶을 수 있다면, 객체로 표현해 인자 개수를 줄이라는 것이다. 물론 불가피하게 줄일 수 없는 경우는 괜찮다고 한다.
writeField(outputStream, name)
와 같은 메서드에서 값을 출력시킬 outputStream
이 자주 변경되는 것이 아니라면, 해당 인자를 멤버 변수로 승격하여 인자의 개수를 줄이라는 것이다. 물론 정상적인 동작을 유지시키려면, 승격한 멤버 변수를 변경할 수 있는 다른 함수를 만들거나, 내부 메서드 호출 과정에서 미리 직접적으로 변경을 해야 할 것이다.
다른 예시로 단위 테스트에서 자주 사용되는 형태인 assertEquals(message, expected, actual)
의 경우, message
를 얼마나 중요하게 보느냐는 것이다. 만약 실패를 알리는 이 message
가 너무 단순한 정보만 표현한다면 생략할 수 있을 것이다. 만약 단위 테스트가 서술적으로 잘 작성되었다면, 실패한 테스트 함수의 이름만 확인하고 상세한 부분은 코드를 보면 된다.
혹은 Circle makeCircle(double x, double y, double radius)
와 같은 삼항 함수에서 double x, double y
는 원의 중심점에 대한 직교좌표계 값을 표현하는 것이다. 엄밀하게 수학적 정의를 따진다면 x
와 y
는 독립적이지만, 원의 중심점을 표현한다는 입장에서는 두 좌표의 값을 묶어 하나의 객체로 표현할 수 있다. 이를 통해 Circle makeCircle(Point center, double radius)
로 인자의 개수를 줄일 수 있다. (원의 중점과 반지름까지 묶어 원을 표현하면 그게 Circle
객체인 것이고, 그 객체를 생성하는 과정이니 더 이상 줄일 수 없다고 봐야 한다.)
물론 함수 인자가 적은게 더 읽기 쉬운건 맞고, 줄이는 근거도 충분히 논리적이다. 다만 어차피 같은 맥락인데 굳이 이항, 삼항, 객체로 나눠 설명할 필요가 있었을까 하는 의문이 든다.
인수 목록
C언어에서 보통 처음 접하는 printf(const char* fmt, ...)
나, 책에서 나오는 Java의 String.format(String format, Object... args)
함수는 대표적인 가변 인자 함수다.
이 형태 함수의 가장 큰 특징은 인자의 개수가 몇개가 될 지 알 수 없기 때문에 기본 인자(%d
등을 포함하는 형식 문자열)만 정확히 정의되어있고, 나머지 변수의 개수는 0개부터 무한하게(이론상 무한인데, 실무적으로는 이런 식으로 인자를 전달하지 않을 뿐더러, 메모리 제한 등으로 한계는 있을 것이다.) 전달할 수 있다.
책에서는 이런 식의 가변 인자 부분은 같은 자료형의 리스트와 같고, 결과적으로 가변 인자 부분을 묶어서 하나의 인자로 취급할 수 있다고 이야기한다.
사실 String.format()
같은 함수는 특수한 목적을 가진 함수이기 때문에 존재 자체는 부정할 수 없으나, 안심하고 사용해도 된다는 식의 언급은 좋지 않다고 생각한다. 대표적으로 String.format()
만 해도, 인자의 개수가 늘어나다 보면 변수의 위치를 실수하는 경우가 잦은 편이라 생각한다. 함수의 정의 자체에서 인자를 줄이는 것도 중요하지만, 함수를 사용할 때도 인자의 개수를 줄이는 것 또한 같은 이유로 중요하다 생각한다. 책의 의도대로 설명하려면 차라리 가변 인자 부분을 정말 List
등으로 묶어서 전달하는 것이 맞다고 생각한다.
동사와 키워드
2장에서 이름짓기의 연장에 해당하는 내용이라 생각하는데, 전반적으로 서술적인 이름 작성법과 같은 이야기다.
의도를 잘 드러내기 위해 write(name)
보단 writeField(name)
을 추천하고, assertEquals()
보단 assertExpectedEqualsActual(expected, actual)
과 같이 더 서술적으로 작성하여 전달 인자에 대한 부가 정보를 더 제공하라는 이야기다.
부수 효과를 일으키지 마라
함수형 프로그래밍 패러다임의 주요 철학 중 하나와 겹치는 부분이라 생각한다. 부수 효과를 완전히 배제하면서 프로그래밍을 하는 것은 거의 불가능에 가깝겠지만, 일반적인 함수의 경우 순수 함수(pure function)로 작성하여 부수 효과를 없앰으로서 의도치 않은 버그를 없앤다는 것이 핵심이다.
책에서 설명하듯 부수 효과는 의도치 않은 함수와 변수 간의 커플링이 생기기 때문에 안 좋다는 것이다. 책의 예시를 보며 이야기해보자.
public class UserValidator {
private Cryptographer cryptographer;
public boolean checkPassword(String userName, String password) {
User user = UserGateway.findByName(userName);
if (user != User.NULL) {
String codedPhrase = user.getPhraseEncodedByPassword();
String phrase = cryptographer.decrypt(codedPhrase, password);
if ("Valid Password".equals(phrase)) {
Session.initialize();
return true;
}
}
return false;
}
}
위 예시 코드는 사용자를 인증하기 위해 아이디/비밀번호에 대한 검증을 시도하는데, 만약 올바른 아이디/비밀번호로 인증되었다면 마음대로 Session.initialize()
를 호출하여 세션을 미리 초기화해버린다. 함수 이름대로 checkPassword()
라면 아이디와 비밀번호만 검사해야하니, 세션 초기화 과정은 부수 효과인 것이고, 함수 외부로 추출해야 한다. (checkPassword()
함수의 결과를 바탕으로 Session.initialize()
가 호출되어야 한다.)
출력 인수
기본적으로 함수의 인자는 입력 값으로 생각하므로, 함수의 인자 값을 변경하거나 인자 값으로 결과를 반환하는 형태를 지양하라는 이야기를 하고 있다.
사실 함수의 인자를 통해 값을 변환하는 문제는 Java가 제안되기 전의 C/C++ 등의 언어의 한계로 인해 사용되던 기법 중 하나다. 이 언어들은 예외 처리에 대한 오버헤드가 크거나, 객체의 동적 생성/반환에 대한 추가적인 코드 작성이 필요하기 때문이다. 프로그래밍 언어의 발전에 따라 출력 인수를 사용하는 방식은 도태될 수도 있지만, 무시하거나 완전히 배제할 수는 없는 기법이다.
특히 C++의 경우 객체를 인자로 전달하는 경우, 객체가 복사되어 전달하는 오버헤드를 없애기 위해 레퍼런스 변수로 인자를 선언하기도 한다. 이 때, 위 얘기처럼 출력 인수화 되는 것을 막고, 부수 효과 발생을 방지하기 위해 const
한정자를 붙인 레퍼런스 변수를 사용하는 기법이 자주 사용된다.
명령과 조회를 분리하라
함수는 어떤 일을 수행하거나, 혹은 질문에 대한 답을 주는 기능 중 하나만 선택하여 제공해야 한다고 한다. 사실 앞의 부수 효과를 일으키지 말라는 부분의 연장으로 보이는데, 위 예시에서는 아이디-비밀번호 인증(질의 응답)과 세션 시작(다른 일을 수행)이 한 함수에서 이뤄진다는 점이 문제라는 것이다. 반면 아래서 설명하는 예시는 뭔가 부족한 부분이 보인다.
boolean set(String attribute, String value)
와 같은 함수가 있고, 해당 속성의 값을 설정하는 데 성공하면 true
를 반환하는 함수라 가정하자.
함수 이름의 모호성을 언급하려면 위의 동사와 키워드 부분에서 언급되었어야 했다.
명령과 조회의 분리를 집중하고 싶었다면 진작에 함수 이름이setAttribute()
로 변경한 채 설명했어야 했다.set
의 실패 원인에 집중하고 싶다면, 아래 예외 처리가 더 먼저 이야기되었어야 했다.
해당 함수에서 생길 수 있는 주요 시나리오는 다음과 같다.- 속성 자체가 존재하지 않았을 때, 실패를 시킬 것인가?
- 해당 속성의 값을 바꾸는데 성공/실패했는가?
- 이미 요청된 속성 값이 설정되어 있을 때 성공인가? 실패인가?
여기서 2
3번은 함수의 설계와 관련된 문제고 여기서 언급하는 것이 맞다. 하지만 1번의 경우 아래서 설명할 예외 처리 등으로 취급이 가능하다.3번 문제도 아래 오류 코드 문제의 확장선이라 생각한다. (ex. 성공:
20
, 실패:-1
, 변동사항 없음:1
)
오류 코드보다 예외를 사용하라
앞서 출력 인수 부분에서 언급했듯, C/C++의 경우는 예외 처리에 대한 오버헤드가 큰 편이라 오류 코드 반환을 사용하는 방식을 선호하지만, Java의 경우 예외 처리를 통한 방식이 일반적으로 사용된다. 물론 책은 Java 위주로 서술하기 때문에 무조건 예외 처리를 선호하는 방향으로 설명한다.
C의 경우 아예 예외 처리 방식을 언어적으로 지원하지 않기 때문에, API상 문제가 되는 부분이 몇가지 있다. 멀티쓰레딩이 일반화 되기 전에는 errno
변수를 사용해 최근 오류 정보를 저장했는데, 멀티쓰레딩이 일반화되면서 각 쓰레드에서 발생한 오류가 덮어써져서 동기화 문제가 발생할 수도 있다.
커널의 경우에는 함수의 성공 여부에 오류 코드를 반환하고, 원래 함수의 반환 값을 출력 인수로 전달하는 방식으로 함수를 구성한다.
Try/Catch 블록 뽑아내기
사실 내가 예외 처리를 가장 기피하는 이유는 이 try
/catch
블록의 더러움이다. 예외 발생을 잡기 위해 try
블록으로 묶어야 하고, catch
블록에서 예외 처리 작업을 해야 한다는 점이 마음에 들지 않는다. 필요 이상으로 들여쓰기를 유도하게 되고, catch
에서도 적합한 예외 처리보단 기계적으로 로그 정도만 찍고 끝나는 점이 마음에 안든다.
책에서도 try
/catch
블록이 코드를 더럽게 만드는 점을 동의하고 있으며, 이 블록 들여쓰기 상에 생기는 더러움을 최소화 하기 위해 try
문에서 시도하는 부분도 함수로 추출하고, catch
에서 처리하는 코드도 함수로 추출하는 방법을 제안하고 있다.
public void delete(Page page) {
try {
deletePage(page);
registry.deleteReference(page.name);
configKeys.deleteKey(page.name.makeKey());
}
catch (Exception e) {
logger.log(e.getMessage());
}
}
public void delete(Page page) {
try {
deletePageAndAllReferences(page);
}
catch (Exception e) {
logError(e);
}
}
private void deletePageAndAllReferences(Page page) throws Exception {
deletePage(page);
registry.deleteReference(page.name);
configKeys.deleteKey(page.name.makeKey());
}
private void logError(Exception e) {
logger.log(e.getMessage());
}
오류 처리도 한 가지 작업이다
위에서 설명한 catch
블록의 예외 처리 과정도 하나의 작업이라는 것이다. 그러므로 함수는 한 가지 일만 해야하니, 예외 처리 과정을 하나의 함수로 추출해야 한다고 한다.
게다가 위 주장의 확장으로, 한 함수에서는 try
문이 한 번만 등장하는 것이 좋다고 한다. 논리적으로 틀린 이야기는 아닌 것 같은데, 너무 많은 함수 추출로 코드가 복잡해 지진 않을까 의문이 들긴 한다.
Error.java 의존성 자석
예외를 처리하지 않고, 오류 코드를 사용하다 보면 오류의 종류에 따라 오류 코드를 선언해야 하고, 결국 오류 코드들을 선언한 (선언할) Error.java
파일에 점점 의존성이 추가될 것이라는 것이다. 간접적으로 SOLID의 단일 책임 원칙을 위반할 가능성이 높다 해석할 수 있다.
반복하지 마라
3장 초반의 코드를 보면 SetUp
, SuiteSetUp
, TearDown
, SuiteTearDown
과 같이 4개에 대한 WikiPage
존재 여부를 확인하고, buffer
에 넣는 과정이 반복된다. 하지만 코드의 흐름이 완벽하게 동일한 형태로 반복되지 않기 때문에 함수로 추출해야 한다는 생각을 하기 힘들 수도 있다. (책에서는 결국 해당 부분을 클래스로 추출한 예시를 보여주긴 하는데, 중요한 예시 코드는 아닌 것 같아 생략한다.)
만약 중간에 TestSetUp
, TestTearDown
같은 WikiPage
처리가 추가된다면 이전의 코드를 바탕으로 새로 복사해 넣어야 할 것이다. 이렇게 복사하는 과정에서 수정해야 할 부분을 누락하면 버그가 발생하기 마련이다. ("!include -setup"
과 같이 옵션을 그대로 두거나, buffer.append(setupPathName)
처럼 인자를 변경하지 않는 등) 코드를 짤 때는 편의를 위해 급하게 복사-붙여넣기를 하지만, 본인도 모르게 위험한 행동을 하는 것이다. 책에서 언급하듯, 중복 문제를 해결하기 위해 여러 이론과 실무적 기법이 제안되었다. 데이터베이스 정규화, 객체지향 프로그래밍 기법과 디자인 패턴 등 다양한 분야에서 여러 기법을 볼 수 있다.
구조적 프로그래밍
길찾기 알고리즘으로 잘 알려져있고, 세마포어로도 유명한 에츠허르 데이크스트라는 구조적 프로그래밍이란 규칙을 제안했는데, 함수/코드 블록의 시작점과 끝점을 하나로 만들라는 것이다. 즉, 함수의 return
문은 제일 마지막에 하나만 있어야 하고, 반복문 내에 continue
나 break
를 사용하지 말라는 것이다. (goto
는 당연히 사용하면 안된다.)
책에서는 함수를 작은 단위로 잘 작성할 수 있다면 구조적 프로그래밍의 장점보다 단점이 더 많아지므로 이런 접근법도 있다 정도로만 언급하고 따르지 않을 것을 권고하고 있다.
나도 이 의견에 동의하지만, 여기서 이야기 하는 구조적 프로그래밍의 생각과 비슷한 관점으로 MISRA-C의 비슷한 규칙을 경험한 적이 있어 소개한다. 해당 규정은 MISRA-C:2012 Rule 15.5
인데 해당 문서는 유료 문서이므로 원문을 첨부하는 대신 간단한 예시와 함께 설명하겠다.
보통 Java 등의 언어는 GC(Garbage Collection)가 지원되므로 자원할당(객체 생성)에 대한 해제 작업을 매번 명시적으로 해 줄 필요가 없다. (C++의 delete
같은 명시적 자원 해제는 불가능하고, System.gc()
같은 함수 호출로 GC 동작 시기를 앞당길 수 있는 것으로 알고있다.) 하지만 C나 C++의 경우 GC가 지원되지 않는 것이 일반적인데, 한 함수의 종료 지점(return
)이 여러 곳으로 나뉠 경우, 모든 지점마다 자원 해제가 정상적으로 이뤄졌는지 잘 확인해야 한다. 함수의 종료 흐름을 하나로 통일함으로서 자원 해제에 대한 추적을 쉽게 하기 위해 이런 규칙이 작성되었다. (권고사항 수준의 규정이긴 하다.)
참고로 코드 상의 자원 관리는 직관적으로 변수에 대한 메모리 관리만 생각하겠지만, 그 외의 자원 관리도 필요하다. 다른 자원 관리의 대표적인 예시로 작업 쓰레드를 종료시키거나, 파일 입출력을 위해 열었던 파일을 닫는 등이 있을 것이다. 이런 자원 관리의 경우 명시적으로 수행되어야 하므로, 코드의 종료 흐름마다 관리하는 것 보다 한 곳으로 종료 흐름을 강제하는 것이 더 편리할 수도 있을 것이다.
참고로 Python의 경우 with
키워드를 통해 객체/자원의 생명주기를 제한할 수 있고, Go 언어의 경우 함수의 종료 시에 처리될 코드를 defer
키워드를 통해 미리 선언할 수 있다. 아래는 파일 입출력 이후 자원 반환을 간편하게 작성하는 코드의 예시다.
def read_log():
# Start reading "log.txt" file
with open("log.txt", 'r') as f:
# Do something #1
# Early return condition
if exception:
# Early return will invoke f.close() internally
return
# Do something #2
# At the end of with block, it internally calls f.close()
# No need to explicitly close file
# the "log.txt" file read done, it's closed.
func read_log() {
// Start reading "log.txt" file
f, err = os.Open("log.txt")
if err != nil: {
painc(err)
}
// At the end of current function, it will call f.Close()
defer f.Close()
// Do something #1
// Early return condition
if exception {
// f.Close() (deferred code) will invoked before end of current function
return
}
// Do something #2
// At the end of function, f.Close() (deferred code) will invoked
}
함수를 어떻게 짜죠?
함수 작성시 고려할만한 사항, 적절한 규칙들을 앞에서 많이 알아봤다.
책에서 설명하는 방식은 상향식 접근법(Bottom-up)에 가까운 것 같다. 함수를 먼저 의도대로 작성하고, 함수를 분리하고, 이름을 바꾸고, 중복을 제거한다는 것이다.
TDD 식으로 접근한다면 오히려 하향식 접근법(Top-down)으로 코드를 작성하게 될 것이라 생각한다. 한 함수 내부에서 이뤄져야 하는 일을 막연하게 순차적으로 정의하고, 그 함수 내부를 구현하는 방식으로 접근할 것이라 생각한다.
사실 어느 방법으로 접근하던, 위에서 봤던 규칙들이나, 그 철학을 반영하여 가독성을 높이는 것이 중요하다.