코드 가독성에 대해 – 3. 상태와 절차

들어가며

안녕하세요. 커뮤니케이션 앱 LINE의 Android 클라이언트 팀 Ishikawa입니다. 이 글은 ‘코드 가독성에 관한 프레젠테이션’을 소개하는 비정기 연재 블로그의 세 번째 편입니다(지난 글은 여기(1편2편)를 참고하세요). 이번 글은 특정 유형에 한정된 이야기로, 4장 ‘상태(state)’와 5장 ‘절차(procedure)’에 관해 설명하겠습니다.  

 

4장: 상태

 

프로그램에서 실행 상태(state)의 수나 상태 변환을 줄이면 프로그램 전체 동작을 이해하는 게 쉬워지는데요. 특히 잘못된 상태나 변환을 줄이면 코드의 가독성과 견고성을 크게 향상시킬 수 있습니다(단, 상태를 줄이는 작업은 어디까지나 가독성과 견고성을 향상시키기 위한 수단으로 생각해야지 그 자체가 목표가 되어서는 안된다는 점을 유의하시기 바랍니다).

이번 장에서는 가독성과 견고성을 향상시키는 방법으로 ‘잘못된 상태 줄이기’와 ‘상태 변환 단순화하기’, 이 두 가지에 관해서 설명하겠습니다. 

 

1. 잘못된 상태 줄이기

 

이번 장에선 두 변수 간의 관계에 ‘직교(orthogonal)’와 ‘비직교(non-orthogonal)’라는 개념을 도입하겠습니다. 두 개의 변수가 있다고 가정할 때, 한쪽의 치역이 다른 한쪽 값의 영향을 받지 않으면 두 변수의 관계를 ‘직교’라고 합니다. 반대로 어느 한쪽의 치역이 다른 한쪽의 영향을 받는다면 ‘비직교’라고 합니다. 예를 들어 userId와 layoutVisibility는 서로 독립적인 값을 가지고 있으므로 둘의 관계는 직교입니다. 반면, userId와 userName의 관계는 userId를 변경하면 동시에 userName도 업데이트해야 하는 관계이니 비직교입니다. 이 비직교 관계를 없애면 값이 잘못 조합되는 것을 줄일 수 있습니다.

비직교 관계를 없애는 방법은 크게 2가지로 나눌 수 있습니다. 첫 번째는 getter와 같은 함수를 사용하는 것이고, 두 번째는 대수적 데이터 유형을 사용하는 것입니다.

 

1-A: getter 함수

한 쪽 값을 다른 쪽 값으로 계산할 수 있는 경우에는 그 값을 getter 같은 함수로 치환할 수 있습니다. 예를 들어 userName의 값이 ‘Alice’고, welcomeMessage의 값이 ‘Hello Alice’라면, welcomeMessage는 ‘Hello $userName‘(= ‘Hello ‘ + userName)으로 바꿔 변경 가능한 값을 userName으로만 제한할 수 있습니다. 이렇게 하면 userName이 ‘Alice’인데도 welcomeMessage가 ‘Hello Bob’이 되어버리는 값의 잘못된 조합을 제거할 수 있습니다. 

 

1-B: 대수적 데이터 유형

두 개의 값 사이에 관련이 있지만 한 쪽으로 다른 쪽 값을 계산할 수는 없는 경우, 대수적 데이터 유형이 유효할 수 있습니다. 예를 들어 쿼리의 결과로 널(null)값이 가능한 resultMessage와 errorCode,  2개의 값이 있다고 가정하겠습니다. 여기서 쿼리가 성공하면 resultMessage에 값이 들어가면서 errorCode는 이 되고, 반대로 쿼리가 실패하면 errorCode에 값이 들어가고  resultMessage는 널이 됩니다. 이 두 변수에서 양쪽 모두 널이 되거나 양쪽 모두 값을 가지는 경우는 비정상적인 경우입니다. 이런 비정상적인 조합을 Kotlin에서는 아래와 같이 sealed 클래스를 사용해서 제거할 수 있습니다. 

sealed class Result {
  class Success(message: String): Result()
  class Error(errorType: ErrorType): Result()
}

위 Result는 Success 또는 Error, 둘 중 하나인 것이 보장되고, 이에 따라 message 또는 errorType, 어느 쪽이든 단 하나만 존재하는 것이 보장됩니다. 

대략적으로 설명하자면, 어떤 추상 유형이 ‘열거된 유형 중 어느 하나’인 것이 보장된다면, 그 추상 유형을 ‘대수적 데이터 유형’이라고 합니다. 대수적 데이터 유형은 Scala에서는 sealed 클래스, Swift에서는 associated value, C++에서는 variant로 구현할 수 있습니다. 대수적 데이터 유형이 지원되지 않는 언어(예: Java)도 있는데요. 그럴 땐 잘못된 값 조합을 숨기기 위한 작은 데이터 유형을 만들고, 생성자(constructor)나 세터(setter)를 제한하여 대수적 데이터 유형에 가깝게 만들 수 있습니다.

상황에 따라 잘못된 값을 제거할 때 대수적 데이터 유형을 사용하기 어려운 경우가 있습니다. 그럴 때는 열거형을 사용할 수 있는지 고민해보기 바랍니다. 예를 들어 두 개의 불린(boolean) 값이 있고 양쪽 모두 true가 될 수 없다면, 대수적 데이터 유형 대신 세 가지 상태가 있는 열거형을 사용합니다. 

 

2. 상태 변환의 단순화

 

상태 수를 줄이는 것 외에 상태 변환을 단순화하는 것으로도 가독성과 견고성을 향상시킬 수 있는데요. 여기서는 상태 변환의 루프, 특히 값 재사용에 관해 이야기하겠습니다. 값을 재사용하면 성능 향상에는 도움이 되지만 가독성과 견고성은 떨어질 수 있습니다. 예를 들어 VideoPlayer라는 클래스가 있다고 가정하겠습니다. 이 클래스의 작동 방식으로 아래 두 가지를 생각해 볼 수 있습니다.

  1. VideoPlayer의 인스턴스를 한 번만 생성하고, 재생할 동영상을 바꾸고 싶을 땐 play 메서드에 영상 경로를 전달한다.
  2. VideoPlayer의 인스턴스를 생성할 때 동영상의 경로를 지정하고, 경로 값은 바꾸지 않는 걸로 한다. 재생할 동영상을 바꾸고 싶은 경우에는 인스턴스를 새로 생성한다.

1번은 play를 부르기 직전의 상태 관리가 복잡해지기 때문에 성능에 문제가 없는 한 2번을 골라야 합니다. 만약 모든 상태(로드 중이나 완료, 에러 등)에서 play를 호출할 수 있도록 하면 play의 동작이 복잡해집니다. 반면 play를 호출할 수 있는 상태를 제한하면(로드 중인 play 호출을 금지하는 등) VideoPlayer의 사용법이 복잡해지고, 결국 버그의 원인이 될 수 있습니다. 또한 양쪽 모두 구현 수단에서 상태 수가 늘어나면 코드 변경량이 많아집니다. 

문제는 비동기 처리를 관리하는 면에서도 발생합니다. 예를 들어 ‘일정 시간 동영상을 정지한다’라는 처리를 넣은 경우, 정지하는 작업의 대상이 되는 동영상과 현재 재생하고 있는 동영상이 동일한지 확인하거나, play를 호출할 때 올바른 작업을 취소할 필요가 있습니다. 그리고 멀티스레드로 처리할 때 경쟁 상태를 검증하는 게 더 어려워집니다.

다만, 상태 변환 루프를 써야 할 때도 있습니다. VideoPlayer 예시에서 생각해 보면, 동영상을 일시 정지하거나 일시 정지 후 재생하는 기능을 생각해 볼 수 있습니다. 그런 경우에는 상태 변환 루프를 가능한 한 작게 만들고, 거시적으로 보았을 때는 루프가 없는 것처럼 동작하게 만들면 가독성과 견고성이 저하되는 것을 줄일 수 있습니다. 

 

5장 절차

 

절차의 가독성을 확인하는 수단으로 요약을 써보는 방법이 있습니다. 제가 2편에서 말씀드린 것과 같은 요약을 쓰기 어려울 때는, 절차의 책임과 흐름이 명확한지 다시 한 번 확인하면 좋을 것 같습니다.

 

1. 절차의 책임 범위

 

한 절차의 책임 범위는 해당 절차의 동작을 한 마디로 설명할 수 있는 정도가 바람직합니다. 이를 실현하기 위한 원칙 중 하나로 ‘커맨드-쿼리 분리(command-query separation)’를 들 수 있습니다. 이 원칙은 값을 변경할 때 실행하는 ‘커맨드(command)’와 어떤 값을 반환하는 ‘쿼리(query)’로 절차를 분리하고, 둘 다 실행하는 절차는 만들지 말라는 원칙입니다. 

예를 들어 List에 append(List)라는 메서드가 있다고 가정하겠습니다. 만약 이 메서드의 반환 값이 void나 Unit이라면 이 메서드는 리시버(receiver) 자체를 변경하는 ‘커맨드’로 예측할 수 있습니다. 반면, 반환 값이 List라면 이 메서드는 리시버는 변경하지 않고 새롭게 결합한 리스트를 생성해서 반환하는 ‘쿼리’ 메서드라고 예상할 수 있습니다. 여기서 List를 반환하는데 리시버까지 변경한다면, 많은 개발자가 예상하지 못할 것이고, 이는 버그의 원인이 될 수 있습니다. 

하지만 이 원칙도 과잉 적용하면 오히려 가독성과 견고성을 떨어뜨릴 수 있습니다. 예를 들어 어떤 값을 변경하는 커맨드가 있고 이 값을 변경하는 작업이 성공했는지 여부를 확인하는 경우라면, 성공 여부를 나타내는 불린 값을 반환해도 문제없습니다. 이 불린 값을 별도 쿼리로 나누려면 어딘가에 그 값을 저장해야 할 필요가 생기기 때문에 결과적으로 코드가 더 복잡해집니다. 따라서 변경이 성공했는지를 나타내는 불린 값이나 에러 코드, 메타데이터와 같은 부차적인 값에 관해서는 커맨드에 포함되는 반환 값으로 허용됩니다. 단, 그와 같은 절차는 꼭 문서화해야 합니다.  

그 밖에도 절차의 형식이 사실상 표준으로 널리 알려진 경우에는 반환 값을 포함하는 커맨드를 생성하는 것이 허용됩니다. 예를 들어 많은 스택 구현체에서 pop 메서드는 최신 값을 제거하는 커맨드이면서 동시에 제거된 값을 반환하는 쿼리입니다.  

 

2. 절차의 흐름

절차의 흐름을 명확하게 하기 위해서 아래 3가지 원칙에 주의하면 좋습니다.

  1. ‘정의 기반 프로그래밍(definition based programming)’을 한다
  2. 최대한 빨리 리턴(early return)한다
  3. 로직을 케이스로 나누지 않고 대상으로 나눈다

여기서는 특히 해당 프레젠테이션에서 새롭게 도입한 개념인 정의 기반 프로그래밍에 대해 설명하겠습니다. 

 

정의 기반 프로그래밍은 익명 함수나 실행 인자, 리시버, 콜체인이라는 것을 이름이 있는 로컬 변수나 프라이빗 함수로 치환할 수 있는 프로그래밍 스타일입니다. 이 개념이 어떻게 가독성에 영향을 미치는지를 실행 인자가 중첩되어 있는 예를 들어 설명하겠습니다. 

showImage(convertImage(cropImage(loadImage(imageUri), Shape.CIRCLE), ImageFormat.PNG))

이 코드는 이미지를 원형으로 잘라서 PNG 파일로 변환한 뒤 표시하는 코드입니다. 하지만 이 코드에는 두 가지 문제가 있습니다. 우선 최종적으로 무엇이 표시되는지 알기 위해선 코드를 자세히 읽어봐야 합니다. 또한 ‘어떤 형식으로 변환할지’나 ‘무슨 형태로 잘라낼지’ 등 특정 값을 조사하거나 변경하고 싶을 때 전체 코드를 파악해야 합니다. 

만약 아래와 같이 로컬 변수를 사용해서 코딩하면 이런 점들이 알기 쉬워집니다.

val originalBitmap = loadImage(imageUri)
val croppedBitmap = cropImage(originalBitmap, Shape.CIRCLE)
val croppedPng = convertImage(croppedBitmap, ImageFormat.PNG)
showImage(croppedPng)

코드의 좌측만 봐도 관련 내용을 금방 파악할 수 있고, 자세한 사항을 알고 싶을 때는 우측까지 보면 됩니다. 그리고 최종적으로 무엇을 표시하는 것인지도 코드의 세부 사항을 보지 않고 알 수 있습니다. 

이 정의 기반 프로그래밍을 수행할 때는 어떤 범위의 코드를 치환할 수 있는지 깊이 생각할 필요가 있습니다. 예를 들어 프라이빗 함수를 생성할 때 부작용은 호출한 쪽에서 처리하게 하고 새로운 함수는 투명하게(transparently) 참조하면 가독성이 향상될 것입니다.

 

마치며

이번 글, ‘상태와 절차’ 편에선 특정 유형에 한정된 범위의 구조에 관하여 설명했습니다. 특히 ‘직교, 비직교’와 ‘정의 기반 프로그래밍’은 해당 프젠테이션에서 새로 도입한 개념이라 조금 자세히 설명했습니다. 

다음 편은 ‘종속성’편으로 6장과 7장의 내용을 소개하겠습니다.