003. (Clean Code) 3. 함수 - Functions

3. 함수 - Functions

어떤 프로그램이든 가장 작은 단위는 함수이다.

먼저 아래 코드를 살펴보자.

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
@Throws(Exception::class)
@JvmStatic
fun testableHtml(
pageData: PageData,
includeSuiteSetup: Boolean,
): String? {
val wikiPage: Wikipage = pageData.getWikiPage()
val buffer = StringBuffer()
if (pageData.hasAttribute("Test")) {
if (includeSuiteSetup) {
val suiteSetup: WikiPage = PageCrawlerlmpl.getlnheritedPage(SuiteResponder.SUITE_SETUP_NAME, wikiPage)
if (suiteSetup != null) {
val pagePath: wikiPagePath = suiteSetup.getPageCrawler().getFullPath(suiteSetup)
val pagePathName: String = PathParser.render(pagePath)
buffer.append("include -setup .").append(pagePathName).append("\n")
}
}
val setup: WikiPage = PageCrawlerlmpl.getInheritedPage("SetUp", wikiPage)
if (setup != null) {
val setupPath: WikiPagePath = wikiPage.getPageCrawler().getFullPath(setup)
val setupPathName: String = PathParser.render(setupPath)
buffer.append("!include -setup .").append(setupPathName).append("\n")
}
}
buffer.append(pageData.getContent())
if (pageData.hasAttribute("Test")) {
val teardown: WikiPage = pageCrawlerlmpl.getInheritedPage("TearDown", wikiPage)
if (teardown != null) {
val tearDownPath: WikiPagePath = wikiPage.getPageCrawler().getFullPath(teardown)
val tearDownPathName: String = PathParser.render(tearDownPath)
buffer.append("\n").append("!include -teardown .").append(tearDownPathName).append("\n")
}
if (includeSuiteSetup) {
val suiteTeardown: WikiPage = PageCrawlerlmpl.getlnheritedPage(SuiteResponder.SUITE_TEARDOWN_NAME, wikiPage)
if (suiteTeardown != null) {
val pagePath: Wikipagepath = suiteTeardown.getPageCrawler().getFullPath(suiteTeardown)
val pagePathName: String = PathParser.render(pagePath)
buffer.append("!include -teardown .").append(pagePathName).append("\n")
}
}
}
pageData.setContent(buffer.toString())
return pageData.getHtml()
}

위 함수는 코드의 길이도 길고, 중복된 코드와 문자열도 난립하고 모호한 타입과 Api가 존재한다.

이 코드를 리팩토링해서 아래 형태의 함수를 만들었다고 가정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Throws(Exception::class)
@JvmStatic
fun testableHtml(
pageData: PageData,
isSuite: Boolean,
): String? {
val isTestPage: Boolean = pageData.hasAttribute("Test")
if (isTestPage) {
var testPage: WikiPage = pageData.getWikiPage()
var newPageContent = StringBuffer()
includeSetupPages(testPage, newPageContent, isSuite)
newPageContent.append(pageData.getContent())
includeTeardownPages(testPage, newPageContent, isSuite)
pageData.setContent(newPageContent.toString())
}
return pageData.getHtml();
}

위와 아래의 코드 중 어떤 코드가 이해하기가 더 쉬울까?

당연히 아래에 있는 코드이다.

어째서 이해가 쉬워졌을까?

함수를 작성하는 규칙들을 통해 하나씩 익혀보도록 하자.

3.1. 작게 만들어라

함수를 만드는 첫 번째 규칙은 작게 만드는 것 이고, 두 번째 규칙은 더 작게 만드는 것 이다.

그렇다면 얼마나 작게 작성야 할까?

위의 예시로 살펴본 코드보다는 짧아야 한다.

더욱 줄여보면 아래와 같이 줄일 수 있다.

1
2
3
4
5
6
7
8
9
10
11
@Throws(Exception::class)
@JvmStatic
fun renderPageWithSetupAndTeardowns(
pageData: PageData,
isSuite: Boolean,
): String? {
if (isTestPage(pageData)) {
includeSetupAndTeardownPages(pageData, isSuite)
}
return pageData.getHtml()
}

결론적으로 if, else , while 등에 주어지는 조건문은 한 줄로 끝나는 것이 좋다.

이 말은 중첩 구조가 생길 정도로 함수를 크게 작성하지않아야 한다는 뜻이다.

3.2. 한 가지만 해라

함수는 한 가지를 해야 한다. 그 한 가지를 잘해야 한다. 그 한 가지만을 해야 한다.

함수가 하나의 동작만을 표현하고 수행해야한다는 이야기는 꽤나 많이 들어보았을 것이다.

어떻게 동작을 구분하고 함수를 추출해야할까?

특정 함수의 내용을 추상화하였을 때, 해당 수준이 하나인 단계만 수행하는 경우 하나의 작업만 한다고 간주할 수 있다.

다른 방법으로는

함수 내의 특정 코드를 다른 의미를 가진 함수로 뽑아낼 수 있는지 검증해보는 것이다.

3.3. 함수 당 추상화 수준은 하나로!

함수가 확실하게 한 가지 작업만 하려면 함수 내의 모든 문장의 추상화 수준이 동일해야한다.

위의 예시 코드에서 getHtml() 함수는 추상화 수준이 아주 높고,

1
val pagePathName: String = PathParser.render(pagePath)

위의 코드는 중간 수준의 추상화 수준을 가진다.

물론 append("\n")과 같은 코드는 추상화 수준이 아주 낮다.

위 예시처럼 한 함수 내에 추상화 수준이 섞이면 코드를 이해하기 어려워진다.

이는 특정 표현이 어떠한 근본적인 개념인지 세부사항을 명세한 것인지 구분하기 어렵기 때문이다.

우리는 코드를 어떤 순서로 읽을까?

기본으로 위에서 아래로 읽을 것이다.

이때 이 코드를 마치 이야기하듯이, 책을 보듯이 읽혀야 한다.

하나의 함수 다음에는 추상화 수준이 한 단계 낮은 함수를 배치하여, 읽을 수록 추상화의 수준이 하나씩 내려가게끔 작성해야 한다.

참고 저자인 로버트 C. 마틴은 이를 내려가기 규칙 이라고 부른다.

3.4. When 문

when 절은 작게 만들 기 어려울까?

분기가 2개인 when도 길다고 볼 수 있으며, 분기 자체의 특성 때문에 한 가지 작업만 한다고 보장하는 것도 힘들다.

아래 예제를 보자.

1
2
3
4
5
6
7
8
9
@Throws(InvalidEmployeeType::class)
fun calculatePay(e: Employee): Money? {
return when (e.type) {
COMMISSIONED -> calculateCommissionedPay(e)
HOURLY -> calculateHourlyPay(e)
SALARIED -> calculateSalariedPay(e)
else -> throw InvalidEmployeeType(e.type)
}
}

위 예제는 직원의 유형에 따라 다른 값을 반환해주는 코드이다.

이때 직원의 유형이 추가되면 계속해서 함수도 변경해야하므로 개방 폐쇄 원칙을 위배하며,

또한 한 가지의 작업만을 수행하지도 않으므로 단일 책임 원칙을 위배한다.

이를 해결하려면 분기 자체를 아래와 같이 추상 클래스로 이관해야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
abstract class Employee {
abstract val isPayday: Boolean
abstract fun calculatePay(): Money?
abstract fun deliverPay(pay: Money?)
}

interface EmployeeFactory {
@Throws(InvalidEmployeeType::class)
fun makeEmployee(r: EmployeeRecord?): Employee?
}

class EmployeeFactoryImpl : EmployeeFactory {
@Throws(InvalidEmployeeType::class)
override fun makeEmployee(r: EmployeeRecord): Employee {
return when (r.type) {
COMMISSIONED -> CommissionedEmployee(r)
HOURLY -> HourlyEmployee(r)
SALARIED -> SalariedEmploye(r)
else -> throw InvalidEmployeeType(r.type)
}
}
}

이후 추가되는 직원의 유형은 다형성을 기반으로 파생 클래스에서 처리할 수 있게 된다.

3.5. 서술적인 이름을 사용하라!

좋은 이름이 주는 가치는 아무리 강조해도 지나치지않ㄴ다.

코드를 읽으면서 짐작했던 기능을 각 루틴이 그대로 수행한다면 이는 클린 코드라고 볼 수 있끼 때문이다.

특히 함수가 작고 단순할수록 서술적인 이름을 고르기도 쉬워진다.

길고 서술적인 이름이 짧고 어려운 이름보다, 길고 서술적인 주석보다 좋다.

3.6. 함수의 인수

함수가 가질 가장 이상적인 인수의 개수는 당연히 0개이다.

그 다음은 1개, 그 다음은 2개이다.

즉, 인수는 적을수록 좋다.

참고 4개 이상의 인수를 가지는 경우는 최대한 회피하는 것이 좋다.

많이 쓰는 단항 형식

함수를 인수 1개를 넘기는 이유는 크게 두 가지로 나뉜다.

하나는 인수에 질문을 던지는 경우, 또 하나는 인수를 뭔가로 변환해 결과를 반환하는 경우이다.

위 두 경우가 아리라면 단항 함수는 회피하는 것이 좋다.

플래그 인수

특정 플래그값을 인수를 넘기는 것은 매우 끔찍한 일이다.

이는 함수 내부적으로 분기를 강제하며, 여러 가지 일을 하게끔 만들기 때문이다.

이항 함수

인수가 2개인 함수는 당연히 1개인 함수보다 이해하기 어렵다.

불가피한 상황도 있겠지만, 이항 함수부터는 인수의 개수에 비례해새서 위험이 생긴다는 사실을 인지하고 코드를 작성해야한다.

참고 물론 관념적으로 x,y값을 가지는 좌표계 등은 예외이다.

3.7. 부수 효과를 일으키지 마라!

사이드 이펙트없는 코드를 짜는 것은 개발자의 지상과제이다.

함수에서 한 가지 작업을 하겠다고 정의한 후, 몰래 다른 짓을 하면 어떻게 될까?

많은 경우 시간적인 결합이나 순서 종속성을 초래하여 프로그램의 유연성을 저해하게 된다.

아래 예제 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class UserValidator {

private val cryptographer: Cryptographer? = null

fun checkPassword(userName: String?, password: String?): Boolean {
val user: User = UserGateway.findByName(userName)
if (user != User.NULL) {
val codedPhrase: String = user.getPhraseEncodedByPassword()
val phrase: String = cryptographer.decrypt(codedPhrase, password)
if ("Valid Password" == phrase) {
Session.initialize()
return true
}
}
return false
}
}

위 클래스는 표준 함수를 통해 userNamepassword를 검증한다.

얼핏보면 괜찮아보이나, Session.initialize()이 사이드 이펙트를 발생시킨다.

호출시에 함수를 호출하는 사용자를 인증하면서 기존의 세션 정보를 소거시킬 수 있기때문이다.

이러한 코드가 시간적인 결합을 초래한다.

여기서의 시간적인 결합이란 checkPassword() 함수를 특정 상황에서만 호출해야 정상 동작이 보장된다는 것을 뜻한다.

따라서 아래와 같이 이름을 교체하는 것이 더욱 좋다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class UserValidator {

private val cryptographer: Cryptographer? = null

fun checkPasswordAndInitializeSession(userName: String?, password: String?): Boolean {
val user: User = UserGateway.findByName(userName)
if (user != User.NULL) {
val codedPhrase: String = user.getPhraseEncodedByPassword()
val phrase: String = cryptographer.decrypt(codedPhrase, password)
if ("Valid Password" == phrase) {
Session.initialize()
return true
}
}
return false
}
}

참고 사실 개선한 코드도 한 가지 작업만을 해야한다는 규칙을 위배한다.

3.8. 명령과 조회를 분리하라!

함수는 뭔가를 수행하거나 뭔가에 답하거나 둘 중 하나만 해야한다.

좀 더 명세해보면, 객체의 상태를 변경하거나 객체의 정보를 반환하거나 둘 중 하나의 작업만 수행해야한다는 것이다.

아래 코드를 살펴보자.

1
fun set(attribute: String, value: string): Boolean

위 함수는 이름이 attribute인 속성을 찾아 value로 설정한 후, 설정이 성공하면 true 실패하면 false를 반환하는 코드이다.

여기까지만 봐서는 문제가 없다.

문제는 외부에서 호출하는 경우에 발생한다.

1
2
3
if (set("username", "unclebob")) {
// ...
}

위의 코드를 마주쳤을 때, usernameunclebob으로 바꾸는 것일까 아니면 unclebob인지 검증하는 것인지 헷갈린다.

외부에서 혼란을 초래하는 함수이므로 잘못 작성되었다고 볼 수 있는 것이다.

따라서 아래와 같이 명령과 조회를 분리하는 것이 좋다.

1
2
3
if (attributeExist("username")) {
setAttribute("username", "unclebob)
}

3.9. 오류 코드보다 예외츨 사용하라.

명령 함수에서 오류 코드를 반환하는 코드는 바로 위에서 언급한 명령과 조회의 분리 규칙을 살짝 위반한다.

아래와 같이 명령을 표현식으로 사용하기 쉬운 탓이다.

1
if (deletePage(page) == E_OK)

위와 같이 오류 코드를 반환하면, 아래와 같이 코드의 개수에 맞추어 전부 처리해줘야하는 문제점이 생긴다.

1
2
3
4
5
6
7
8
9
10
11
if (deletePage(page) == E_OK) {
if (registry.deleteReference(page.name) == E_OK) {
if (configKeys.deleteKey(page.name.makeKey()) == E_OK) {
// ...
}
} else {
// ...
}
} else {
// ...
}

따라서 오류 코드보다는 예외를 이용해 코드를 깔끔하게 정리하는 것이 좋다.

1
2
3
4
5
6
7
try {
deletePage(page)
registry.deleteReference(page.name)
configKeys.deleteKey(page.name.makeKey())
} catch (e: Exception) {
// ...
}

다만, try-catch 블록 또한 권장되는 방식은 아니다.

코드의 블록 구조에 혼란을 일으시켜, 정상 동작과 오류 처리 동작을 뒤섞기 때문이다.

따라서 아래와 같이 별도 함수로 분리하는 것이 좋다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Throws(Exception::class)
fun delete(page: Page) {
try {
deletePageAndAllReferences(page)
} catch (e: Exception) {
// ...
}
}

fun deletePageAndAllReferences(page: Page) {
deletePage(page)
registry.deleteReference(page.name)
configKeys.deleteKey(page.name.makeKey())
}

3.10. 반복하지 마라!

코드의 중복은 항상 문제가 된다.

코드의 길이가 늘어날 뿐 아니라 알고리즘이 변하면 모든 중복 위치에 코드를 수정해야하며,

하나라도 빠뜨리면 이는 장애로 이어지기 때문이다.

많은 원칙과 기법이 이 중복을 제거하기위한 목적으로 작성되었을 정도이다.

3.11. 결론

결론적으로 소프트웨어를 개발하는 행위는 글을 작성하는 행위와 비슷한다.

함수를 짤때도 마찬가이다.

처음에는 길고 복잡하며, 들여쓰기 단계고, 중복도, 반복문도 많을 것이다.

따라서 코드를 다듬고, 별도의 함수로 추출하고, 이름을 명확하게 작성하고, 중복을 제거하는 작업을 습관화해야한다.

때로는 전체 클래스를 쪼개기도 해야한다.

참고 당연하겠지만 모든 개선 과정에서 단위 테스트를 통과하는 것이 좋다.