LINE Corporation이 2023년 10월 1일부로 LY Corporation이 되었습니다. LY Corporation의 새로운 기술 블로그를 소개합니다. LY Corporation Tech Blog

Blog


코드 가독성에 대해 - 4. 의존성

들어가며

안녕하세요. 커뮤니케이션 앱 LINE의 Android 클라이언트 팀 Ishikawa입니다. 이 글은 '코드 가독성에 관한 프레젠테이션'을 소개하는 비정기 연재 블로그의 네 번째 편입니다. 지난 글은 여기(1편2편3편)를 참고하세요. 

이번 글은 유형 간의 '의존성'과 관련된 이야기로, 6장과 7장에 관해 설명하겠습니다. 

설계나 코딩할 때 유형 간의 의존성이 발생하는 것은 피할 수 없습니다. 예를 들어 어떤 유형을 상속하는 인스턴스를 인자로 받거나, 반환값으로 사용하거나, 메서드를 호출하면 그 유형에 대한 의존성이 발생하는데요. 이때 의존성을 계획 없이 발생시키면 코드의 가독성과 견고함이 저하됩니다. 이번 글에서는 의존성을 어떻게 다루면 좋을지에 관해 결합도와 방향, 중복, 명시성의 네 가지 관점에서 설명하겠습니다.

결합도(coupling)

결합도란 의존성의 강도를 나타내는 지표입니다. 이 글에서는 특히 피하거나 강도를 완화해야 하는 3가지 결합인 내용 결합과 공통 결합, 제어 결합에 대해 설명하겠습니다.

1. 내용 결합

내용 결합이란 대상의 내부 구현에 직접 의존하는 것을 말합니다. 극단적인 예로, 외부에서 어떤 로직의 특정 라벨로 직접 점프하는 것과 같은 것을 생각해 볼 수 있습니다. 근대적인 프로그래밍 언어에서는 이런 점프를 제한하고 있기 때문에 점프로 내용 결합을 발생시키는 게 어렵긴 한데요. 다만 어떤 오브젝트의 내부 상태에 의존하는 코드를 작성하게 되면, 내용 결합과 마찬가지로 강한 결합이 됩니다.

예를 들기 위해 비정상적인 상태가 존재하는 설계를 보여드리겠습니다. 아래와 같이 Calculator라는 수치 계산 클래스를 만든다고 합시다. 만약 이 Calculator를 사용할 때 전처리나 후처리에 특별한 절차가 필요하거나, 사전 혹은 사후 조건이 필요하다면, 호출하는 측은 Calculator의 내부 상태에 의존하게 됩니다. 

class Caller {
    fun callCalculator() {
        calculator.parameter = 42
 
        calculator.prepare()
        calculator.calculate()
        calculator.tearDown()
 
        val result = calculator.result
        // result를 사용하는 코드
    }
}

위 예시에서는 값을 얻거나 결과를 반환할 때 프로퍼티를 사용하여 전처리나 후처리에 각각 prepare()와 tearDown()을 호출하고 있습니다. 이처럼 사용 절차가 정해져 있는 클래스를 만들면, prepare() 호출을 잊어버리는 것과 같이 잘못된 방법으로 클래스를 사용했을 때 버그의 원인이 됩니다. 또한 불필요한 상태 때문에 이 함수를 병렬적으로 여러 번 호출했을 때 경쟁 상태가 발생할 가능성이 있습니다.

내부 상태를 삭제 혹은 은닉하거나, 값을 주고받을 때 인자나 반환값을 사용하는 방법으로 이런 형태의 결합을 없앨 수 있습니다.

2. 공통 결합

공통 결합이란 전역 상태를 사용할 때 발생하는 의존성입니다. 구체적으로는 전역 변수나 가변적인 싱글톤 객체, 혹은 파일 등의 단일 외부 자원을 사용하여 상태를 공유했을 때 발생합니다. 파일 등의 자원은 사용할 수밖에 없는 경우가 많지만, 전역 변수나 가변 싱글톤 객체는 가능한 한 사용하지 말아야 합니다.

이번 글에서는 저장소 클래스의 인스턴스를 전역 변수에 보관하는 예를 들어보겠습니다.

val USER_DATA_REPOSITORY = UserDataRepository()
 
class UserListUseCase {
    suspend fun invoke(): List <User> = withContext(...) {
        val result = USER_DATA_REPOSITORY.query()
        // snip
    }
}

가변 객체를 전역 변수에 보관하면 관리가 어렵습니다. 예를 들어 라이프 사이클이나 참조를 제한할 수 없고, 사양 변경이나 테스트를 위해서 다른 객체로 교체하는 것도 어려워집니다. 

이를 해소하려면 전역 변수를 사용하지 않고, 생성자(constructor) 인자로 대상 객체를 전달하는 것과 같은 방법을 사용할 수 있습니다. 그렇게 되면 대상 객체의 라이프 사이클이나 참조 등을 생성자를 호출한 쪽에서 관리할 수 있습니다. 

3. 제어 결합

제어 결합이란 인자에 맞게 동작을 분기시키는 것 같은 의존성을 말합니다. 예를 들어 불리언(boolean) 값이나 열거형 값을 받고, 그에 맞춰서 각각 다른 동작을 하는 절차를 만들면 제어 결합이 발생합니다. 특히 각 동작 간에 공통적인 부분이 적거나, 분기가 넓은 범위에 걸쳐 있는 경우에는 결합을 완화해야 합니다. 제어 결합을 완화할 수 있는 예로 아래와 같은 방법이 있습니다.

  • 절차 자체를 분리하고 조건 분기를 삭제한다.
  • 로직을 조건으로 나누지 않고 대상으로 나눈다.
  • 전략(strategy) 패턴을 이용한다.

여기서는 '로직을 조건으로 나누지 않고 대상으로 나누는 방법'을 설명하겠습니다. 이 방법은 각 조건의 조작 대상이 공통적인 경우에 유효합니다. 예를 들어 아래와 같이 UI 업데이트를 실행하는 로직이 있다고 합시다.

fun updateView(isError: Boolean) {
    if (isError) {
        resultView.isVisible = true
        errorView.isVisible = false
        iconView.image = CROSS_MARK_IMAGE
    } else {
        resultView.isVisible = false
        errorView.isVisible = true
        iconView.image = CHECk_MARK_IMAGE
    }
}

이 로직은 아래와 같은 두 가지 단점이 있습니다.

  1. 로직의 개요를 파악하기 위해서는 각 조건의 동작과 관련하여 상세한 사항까지 봐야 한다.
  2. 대상과 조건이 늘어나는 경우엔 코드가 복잡해지면서 버그가 발생하기 쉽다.

이 로직은 isError 값에 상관없이 resultView와 errorViewiconView, 이 세 가지 대상을 공통적으로 조작하고 있습니다. 이런 경우엔 isError 분기를 대상별로 분할하여 로직을 간결하면서 이해하기 쉽게 만들 수 있습니다. 아래는 개선 결과입니다. 

fun updateView(isError: Boolean) {
    resultView.isVisible = isError
    errorView.isVisible = !isError
    iconView.image = getIconImage(isError)
}
 
fun getIconImage(isError: Boolean): Image =
    if (!isError) CHECk_MARK_IMAGE else CROSS_MARK_IMAGE

의존 방향

원칙적으로 의존성은 한 방향이어야 합니다. 의존이 순환되면 로직의 흐름을 추적하기 어려워지거나 상태를 관리하는 게 복잡해집니다. 순환 의존성을 피하기 위해서는 개별 의존성의 방향이 올바른지 확인하는 게 좋습니다. 아래는 올바른 의존성의 예시입니다. 

  • 호출하는 쪽이 호출 받는 쪽에 의존한다.
  • 구상이 추상에 의존한다.
  • 복잡한 것이 단순한 것에 의존한다.
  • 알고리즘이 데이터 유형에 의존한다.
  • 자주 변경되는 것이 별로 변경되지 않는 것에 의존한다.

물론 콜백을 사용할 때와 같이 순환 의존성이 필요할 때도 있습니다. 하지만 그런 경우에도 콜백 자체를 삭제하고, 콜백 로직을 전개해 부분적인 순서 관계(partial order relation)로 만드는 프로미스(promise) 패턴을 사용하여 의존성을 완화할 수는 없는지 생각해봐야 합니다. 그리고 어쩔 수 없이 순환 의존성이 발생하더라도 가능한 한 그 범위를 한정하는 게 좋습니다. 

관계의 중복

의존성이 항상 일대일 관계는 아닙니다. 예를 들어 기본적인 유틸리티나 데이터 유형은 여러 유형에서 의존할 수 있습니다. 이런 경우엔 어떤 의존 대상의 조합이 많은 유형에 공통적으로 나타나기도 하는데요. 이때 공통적으로 나타나는 의존 대상을 묶는 레이어를 추가하면, 설계의 가독성과 견고성을 더 높일 수 있습니다. 

예를 들어 사용자 데이터를 제공하는 저장소 클래스를 로컬 캐시용과 리모트 데이터용의 두 가지로 만든다고 가정해보겠습니다. 이때 사용자 이름 표시 기능이나 사용자 이미지 표시 기능 등 사용자와 관련된 다양한 기능은 로컬 캐시와 리모트 데이터, 양쪽의 저장소에 의존합니다. 그 결과 로컬과 리모트 중 어느 쪽 데이터를 사용할지를 선택하는 로직이 각 기능에 중복 구현됩니다. 또한 새로운 기능이나 저장소를 추가할 때도 구현이 복잡해집니다.

이럴 때 로컬 캐시와 리모트 데이터를 은닉하는 새로운 저장소 레이어를 만들면 좋습니다. 그렇게 하면 데이터를 선택하는 로직이 중복 구현되는 걸 피할 수 있고, 새로운 기능이나 저장소를 추가하는 것도 쉬워집니다. 단, 새로운 저장소 레이어를 만들 때 아래 2가지 점에 주의해야 합니다. 

  • 은닉 레이어는 필요할 때 구현한다(YAGNI(You Aint Gonna Need It) 원칙, KISS(Keep It Simple Stupid) 원칙).
  • 은닉된 유형을 노출하는 방법을 제공하지 않는다.

의존의 명시성

클래스 다이어그램 같은 것을 그리면 대부분의 의존성을 확인할 수 있습니다. 하지만 다이어그램에 나타나지 않는 의존성도 존재하는데요. 예를 들어 인자의 유형을 추상형으로 정의해놓고는 실제로는 어떤 구상(concrete)형이 전달된다고 가정하고 로직을 작성하면, 그 구상형에 대해 암묵적으로 의존성이 발생합니다. 또한 로직에서 임의의 문자열을 인자로 받을 때, 모든 실행 인자가 특정 데이터 유형에서 교환된 문자열인 경우, 이 로직은 데이터 유형에 암묵적으로 의존한다고 말할 수 있습니다. 이와 같이 암묵적인 의존성은 클래스 다이어그램에서 나타나지 않고 정적 분석 툴에서도 발견하기 어려운 경우가 많습니다. 

암묵적인 의존성은 로직의 흐름을 따라가기 어렵게 만들기 때문에 코드 가독성을 저하시킵니다. 또한 암묵적으로 의존하는 유형을 변경하면 의존한 쪽이 기대하고 있는 전제가 깨지기 때문에 버그의 원인이 되고, 그렇게 발생한 버그는 쉽게 원인을 찾을 수 없습니다. 암묵적인 의존을 줄이려면 불필요한 상속 관계를 삭제하거나, 인자 및 반환값의 변역을 명시하기 위한 유형을 정의하는 게 좋습니다.

마치며

이번 '의존성' 편에선 의존성을 결합도와 방향, 중복, 명시성의 네 가지 관점에서 설명했습니다. 다음 편은 드디어 마지막 회인 '리뷰와 정리' 편인데요. 코드 리뷰 방법을 설명한 뒤 길게 이어졌던 연재를 정리해보려고 합니다. 많은 기대 부탁드립니다.