ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Code Readability] 5장 읽기 쉬운 함수(function)
    kotlin/[Book] Code Readability 2025. 11. 9. 12:07

    Objective


    **“읽기 쉬운 함수(Readability)”**란,이름만 봐도 동작을 예측할 수 있고,

    한 번 훑기만 해도 전체 흐름이 보이는 함수를 말한다.

    이 문서는 함수의 책임, 흐름, 구조화 원칙을 통해 이를 달성하는 방법을 설명한다.


    Key Results

    • [KR1] 한 문장으로 요약되지 않는 함수는 반드시 분리한다.
    • [KR2] Command와 Query는 분리하여 예측 가능한 동작을 보장한다.
    • [KR3] 중첩 호출이나 체이닝을 줄이고, 의미 있는 이름으로 정의 기반 프로그래밍(Definition-based Programming) 을 수행한다.
    • [KR4] Happy Path 중심 구조로 설계하여, 비정상 케이스를 초기에 걸러낸다.
    • [KR5] 조건이 아닌 객체(Object) 를 기준으로 분기와 책임을 분리한다.

    Part 1. 읽기 쉬운 함수의 기본 개념

    개념

    “함수(Function)”는 서브루틴, 메서드, 초기화 블록 등 로직을 담은 모든 단위 코드를 의미한다.

    읽기 쉬운 함수의 특징

    • 이름과 동작이 일치한다.
    • 문서화를 쉽게 쓸 수 있다. (즉, 책임이 명확하다.)
    • 예외 케이스가 적고, 제약 조건이 단순하다.

    문제

    함수의 책임이 모호하면, 이름과 실제 동작이 불일치하게 된다.

    이는 문서화가 어려워지고, 유지보수자가 “이 함수가 도대체 무슨 일을 하는지” 추측해야 한다.


    Part 2. 함수의 책임(Responsibility) 명확히 하기


    개념

    “한 함수는 한 가지 일만 해야 한다.”

    즉, 함수의 행동을 한 문장으로 요약할 수 없다면, 이미 너무 많은 일을 하고 있는 것이다.


    문제 예제

    fun handleMessage(messageData: MessageData) {
        messageView.text = messageData.contentText
        doOnTransaction {
            messageDatabase.insertNewMessage(messageData)
        }
    }
    

    이 함수는 “UI 업데이트”와 “DB 저장” 두 가지 역할을 동시에 수행한다.


    해결 예제

    fun bindMessageViewData(messageData: MessageData) { ... }
    
    fun saveMessageDataToDatabase(messageData: MessageData) { ... }
    

    해결 풀이

    • 함수를 분리하면 문서화가 쉬워지고 테스트 가능성이 높아진다.
    • “UI와 데이터”는 서로 다른 관심사이므로,
    • 이 둘이 섞여 있으면 수정 시 사이드이펙트(예: DB 수정 시 UI 갱신 누락)가 발생할 수 있다.

    Part 3. Command / Query 분리 (Command-Query Separation)


    개념

    • Command: 객체나 상태를 변경하는 함수 (ex: saveUserData())
      • 값을 반환하지 않음 (Unit 또는 void 반환)
    • Query: 객체나 상태를 조회하는 함수 (ex: getUserData())
      • 상태를 변경하지 않음, 대신 값을 반환함

    이 둘은 반드시 명확히 분리되어야 예측 가능한 동작이 가능하다.


    문제 예제

    class IntList(vararg elements: Int) {
        infix fun append(others: IntList): IntList = ...
    }
    
    val a = IntList(1, 2)
    val b = IntList(3, 4)
    val c = a append b
    

    → 예상: a={1,2}, b={3,4}, c={1,2,3,4}

    → 실제: a까지 변경됨 (명령과 조회가 섞임)

    a={1,2,3,4}, b={3,4}, c={1,2,3,4}


    문제 풀이

    • Command와 Query를 혼합하면 함수 호출의 부작용(Side Effect) 을 예측할 수 없게 된다.
    • “리턴값이 있는 함수는 상태를 변경하지 않는다”는 규칙을 지키면 테스트, 병렬 처리, 함수형 변환이 훨씬 안전해진다.
      • 리스트를 연결하는 동작을 하는 함수가 반환값을 가질때는 보통 그 반환값이 연결된 결과일 거라 예상한다.
      • 또한 결괏값을 반환되는 함수인 이상 수신 객체나 인수가 변경되지는 않을 것이라고 예상한다.
      • 즉 반환값이 void나 Unit인 경우에는 수신 객체나 인수의 상태 변화에 따른 결과를 얻는 경우가 많다.

    예외: “서브 결과(sub result)” 반환

    fun enqueue(item: T): Boolean
    

    → 큐에 추가하며, “성공 여부”만 반환하는 것은 허용된다.

    즉, “주요 결과(main result)”를 반환할 때만 불변성을 유지해야 한다.


    Part 4. 함수의 흐름(Flow)을 명확하게 만들기


    개념

    “한눈에 읽히는 코드”는 세부 구현을 몰라도 전체 흐름을 파악할 수 있다.

    즉, 상단에서 하단으로 논리적 진행 방향이 자연스럽고 예측 가능해야 한다.


    4-1. Definition-based Programming

    개념

    복잡한 중첩(Nesting)이나 람다 체인 대신, 이름이 붙은 지역 변수나 함수로 대체하는 프로그래밍 스타일.


    문제 예제

    showDialogOnError(
        presenter.updateSelfProfileView(
            repository.queryUserModel(userId)
        )
    )
    

    → “무엇이 중요한지” 한눈에 파악 불가.

    → 실행 순서가 아래에서 위로 올라가므로 읽기 어렵다.


    해결 예제

    val userModel = repository.queryUserModel(userId)
    val viewUpdateResult = presenter.updateSelfProfileView(userModel)
    showDialogOnError(viewUpdateResult)
    

    해결 풀이

    • 이름이 붙은 변수는 “의미 단위”를 만든다.
    • 이로써 디버깅, 로그 출력, 주석 작성이 훨씬 쉬워진다.
    • “코드를 읽는 사람”은 더 이상 내부 동작을 추적하지 않아도 된다.

    4-2. 메서드 체인(Method Chain) 개선

    문제 예제

    return queryTeamMemberIds(teamId)
        .map { ... }
        .filter { ... }
        .map { it.emailAddress }
    

    → 각 람다 내부의 동작을 모두 열어봐야 전체 의미를 파악할 수 있다.


    ✅ 해결 예제

    return queryTeamMemberIds(teamId)
        .map(::toUserModel)
        .filter(::isOnline)
        .map { it.emailAddress }
    
    private fun toUserModel(id: String): UserModel { ... }
    private fun isOnline(user: UserModel): Boolean { ... }
    

    해결 풀이

    • 람다 대신 이름이 있는 함수로 분리하면, 체인의 각 단계가 도메인 로직 단위로 명확히 드러난다.
    • 단, 과도한 추출은 불필요한 상태나 강결합을 초래할 수 있으므로 내부 상태를 노출하지 않는 “참조 투명성(ref. transparency)”을 유지해야 한다.
    • 추출 범위를 잘 조절해야한다.
      • 외부 상태를 변경하는 코드는 추출 대상에서 제외하는 방법으로 해결한다.

    Part 5. Happy Path 중심 설계


    개념

    • Happy Path: 함수의 정상적 흐름 (주요 목표 달성 경로)
    • Unhappy Path: 에러나 예외 상황 처리

    → 대부분의 함수는 Happy Path를 중심으로 코드 흐름을 구성해야 한다.


    문제 예제 (중첩 if 구조)

    if (isNetworkAvailable()) {
        val result = queryToServer()
        if (result.isValid) {
            // do something
        } else {
            showInvalidResponseDialog()
        }
    } else {
        showNetworkUnavailableDialog()
    }
    

    해결 예제 (조기 반환 Early Return)

    if (!isNetworkAvailable()) {
        showNetworkUnavailableDialog()
        return
    }
    
    val result = queryToServer()
    if (!result.isValid) {
        showInvalidResponseDialog()
        return
    }
    
    // happy path
    

    해결 풀이

    • “조기 반환(Early Return)”은 코드의 주 흐름을 상단부터 직선적으로 유지시킨다.
    • 에러 처리를 미리 걸러내면, 하단의 주요 로직이 불필요한 중첩 없이 깔끔하게 남는다.
    • 다만 when 문의 일부 분기에서만 반환하는 패턴은 발견하기 어려우므로 조건 누락 버그를 유발할 수 있어 주의해야 한다.
      • 조기 반환이 필요한 조건은 함수의 초반부로 옮기고, 성공 경로/ 실패 경로를 구분하는 것이 좋다.

    Part 6. 조건이 아닌 조작 대상(표시할 요소) 기준으로 분리하기


    개념

    “조건별로 분리하는 코드”는 중복을 만들고, 모든 조합을 보장하지 못한다.

    대신 조작 대상(표시할 요소) 기준 단위로 함수 책임을 분리해야 한다.


    문제 예제 (조건 기준 분리)

    fun bindViewData(accountType: AccountType) {
        when (accountType) {
            PREMIUM -> updateViewsForPremium()
            FREE -> updateViewsForFree()
        }
    }
    
    • 조건이 늘어나면 when 문이 계속 커지고, 각 기준마다 추가해야할 요소 누락 위험이 생김.
    • 업데이트할 요소들은 updateViewsFor~ 함수 내부에 은닉되어 있기때문에 무엇을 하는 함수 인지 알 수 없다.

    해결 예제 (조작 대상 기준 분리)

    fun bindViewData(accountType: AccountType) {
        updateBackgroundViewColor(accountType)
        updateAccountTypeImage(accountType)
    }
    
    private fun updateBackgroundViewColor(accountType: AccountType) {
        backgroundView.color = when (accountType) {
            PREMIUM -> PREMIUM_BACKGROUND_COLOR
            FREE -> FREE_BACKGROUND_COLOR
        }
    }
    

    해결 풀이

    • 객체별 책임으로 나누면, 각 함수는 “하나의 역할”만 하게 되어 요약이 명확해진다.
    • “조건 분기”가 아니라 “조작 대상”을 기준으로 나누면 새로운 타입이 추가되어도 안전하게 확장 가능(Open for Extension) 하다.
    • when 표현식을 통해 모든 계정 유형이 포함되는 것을 보장한다.

    조기 반환과 조작 대상에 따른 분할의 우선순위

    • 일반적으로 성공 경로와 실패 경로의 조작 대상이 동일하다면 ‘조작 대상에 따른 분할’을 먼저 적용하고, 둘의 조작대상이 크게 다르다면 ‘조기 반환’을 먼저 적용하는 것이 좋다.
    fun updateProfileLayout(userModel: UserModel) {
        profileImageView.image = getProfileImageBitmap(userModel)
        ...
    }
    
    // 보조 함수 내에서 조기 반환을 적용한 예
    // 프로퍼티가 잘못된 값을 가지면 오류 이미지를 반환
    private fun getProfileImageBitmap(userModel: UserModel): Bitmap {
        val rawBitmap = userModel.profileImageBitmap 
            ?: return ERROR_PROFILE_IMAGE_BITMAP
        
        // 성공 경로 처리
        val profileBitmap = ...
        ...
        return profileBitmap
    }
    
    • 조작 대상의 처리가 성공 경로와 실패 경로의 처리 방식이 크게 다를 때는 조기 반환을 먼저 적용해야 한다.
    fun updateProfileLayout(userModel: UserModel) {
        // 조기 반환을 통해 프로퍼티가 잘못된 값을 가지게 될 상황을 제거한다.
        if(
            userModel.userName == null ||
            userModel.profileImage == null
            ...
        ) {
            showInvalidUserData()
            return
        }
        
        profileImageView.image = getProfileImageBitmap(userModel)
        ...
    }
    

    최종 요약 (OKR Table)

    구분 원칙 이유
    Responsibility 분리 한 문장으로 요약되지 않으면 함수 분리 명확한 책임, 테스트 용이성
    Command/Query 분리 상태 변경과 반환을 동시에 하지 않음 예측 가능성, 부작용 최소화
    Definition-based Programming 중첩 대신 이름 있는 변수·함수 사용 코드 흐름 단순화, 의미 명확화
    Happy Path 중심 조기 반환으로 에러 케이스 제거 중첩 최소화, 핵심 로직 강조
    Split by Object 조건이 아닌 객체 기준으로 함수 분리 확장성, 조합 누락 방지

     

Designed by Tistory.