-
코드 작성 가이드 - 가독성 높은 코드를 작성하는 방법IT/책 2025. 5. 5. 04:13SMALL
코드의 가독성을 높이기 위해서는 한가지 지표에 집착하지 말고 여러관점에서 코드의 가독성을 평가해야 합니다. 예를들면 코드의 길이라는 지표는 가독성과 관련이 있지만 이것에 집착해서는 안됩니다. 덩치가 큰 함수가 읽기 어려운것은 당연하지만, 그렇다고 너무 세분화하면 오히려 전체구조를 파악하기 어려운 코드가 될 수 있습니다.
즉, 가독성을 평가하려면 여러 추상도를 결정하는 다각적인 지표를 활용해야 합니다.
[단순함, 명확한 의도, 독립성, 구조화] 네가지 지표로 살펴보겠습니다.
1. 단순한 코드
코드 자체가 단순하면 동작을 쉽게 이해할 수 있습니다. True/False 값 연산을 예로 들어보면 isA && isB , !(!isA || !isB) && isB 는 둘다 결과는 동일하지만, 전자가 어떤 조건에서 True 가 되는지를 더 쉽게 이해할 수 있습니다.
2. 의도가 명확한 코드
코드의 가독성을 높이려면 코드의 동작뿐만 아니라 대략적인 의미와 작성 이유도 추측하기 쉬워야 합니다. 예를들면 flag 라는 변수 이름을 보면 많은 사람들들이 이것이 어떤 상태를 나타내기 위한 참/거짓, 어떤 동작을 위한 비트열 이다 라고 짐작할 수 있습니다. 그러나 flag 라는 이름에서 그 이상의 정보를 찾을 수는 없습니다. 반면에 isVisible 이라는 변수를 사용한다면 무언가 표시할 대상이 있고, 값이 true 일때만 표시된다 까지도 짐작할 수 있습니다.
3. 독립성이 높은 코드
코드 규모가 커질수록 모든 코드를 세밀하게 파악하는것은 어렵습니다. 그렇다면 함수, 클래스, 모듈 과 같은 코드 집합별로 역할과 동작을 높은 추상도로 파악할 필요가 있습니다. 그러기 위해서는 a. 각 코드 집합의 책임범위를 명확히 하는것 과 b. 다른 집합과의 의존관계를 제한하는 것. 이 두가지가 중요합니다.
a. 코드의 책임 범위를 명확하게 함으로써 관련성 없는 기능이 코드에 포하되는 것을 방지할 수 있습니다. 결과적으로 세부적인 내용을 읽지 않아도 코드가 어떻게 동작하는지 대략적으로 파악할 수 있습니다.
b. 다른 집합과의 의존 관계를 제한하면 코드를 이해하기 위해 다른 집합의 코드를 읽어야 하는 상황을 줄일 수 있습니다. 특히 두 코드 집합이 서로 의존하는 상황은 가급적 피하는 것이 좋습니다. 만약 A 와 B 두 클래스가 서로 의존한다면 A 를 이해하기 위해 B를 이해하야하고 그 반대도 되어야한다 라는 식의 순환이 생길 수도 있기 때문입니다.4. 구조화된 코드
가독성을 높이기 위해서는 들여쓰기, 줄바꿈 형식 등 코드 외적인 면을 통일하는것도 중요하지만 외적인 면만 잘 정돈되어 있다고 해서 가독성을 높일수 있는것은 아닙니다. 외형의 일관성 뿐 아니라 함수, 클래스, 모듈의 구조에서도 주의를 기울여야 합니다. 만약 함수 하나가 코드 수백줄로 이루어져 있다면 이 함수의 동작을 짧은 시간에 이해하기는 쉽지 않을것입니다. 이러한 큰 함수를 작은 함수 몇개로 나누면 코드를 구조화할 수 있어 동작을 더 쉽게 이해할 수 있습니다.
마찬가지로 클래스 간 의존관계나 추상화 계층의 구성 등 여러 측면에서 코드를 구조화하면 가독성이 높은 코드를 구현할 수 있습니다.
가독성을 높이기 위해 주의할 점
가독성을 지표 하나만으로 측정할 수 없는 것처럼, 가독성 높은 코드를 작성하기 위해 주의해야할 사항도 다양합니다. 그중에서도 필자가 특히 중요하게 생각하는 부분은 다음과 같습니다.
1. 지식과 기술의 선택
코드나 설계의 품질을 향상시키는 효과적인 수단으로, 소프트웨어 아키텍쳐나 프로그래밍 기법을 채택하는 것을 꼽을 수 있습니다. 이를 채택할때 중요한것은 채택하는 목적과 채택에 적합한 조건 두 가지를 명확히 구분하는 것입니다. 수단과 목적을 혼동하여 무턱대고 신기술을 도입하면 오히려 코드나 설계의 품질이 떨어질 수 있습니다.
또한, 프로그래밍 패러다임과 환경은 시대에 따라 변하기 때문에 그에 따라 유효한 아키텍쳐와 기법도 달라집니다. 이전에는 유효했던 아키텍쳐라 하더라도 새로운 패러다임 에서는 코드의 품질을 떨어뜨리는 원인이 될 수 있습니다. 무엇을 위해 그 아키텍쳐를 사용하는지, 어떤 환경에서 그것이 유효하게 작동할지를 잘 이해하고 있다면, 아키텍쳐가 새로운 시대에도 유효할지를 판단할 수 있을 것입니다.
2. 가치와 복잡도의 균형
새로운 기능을 구현할 때는 해당 기능이 제공하는 가치와 기능 구현으로 증가하는 코드베이스의 복잡한 정도를 신중하게 고려해야 합니다. 보통 기능이 늘어날수록 코드베이스는 점점 복잡해지고, 복잡해질수록 가독성은 떨어집니다. 이러한 현상을 완화하기 위해서라도, 단지 복잡도만 높일 뿐 가치가 떨어지는 기능을 구현하는 일은 피해야 합니다. 만약 비슷한 수준의 가치를 더 간단한 방법으로 제공할 수 있다면 그 방법을 선택하는것이 더 합리적입니다. 예를 들자면 요구하는 사양을 기존의 기능에 맞추거나 기능을 새로 구현하는 대신 플랫폼에서 제공하는 표준 기능을 활용하는것을 생각해 볼 수 있습니다. 이처럼 가치를 크게 바꾸지 않는 선에서 요구사항을 변경하여 구현에 필요한 코드의 양을 줄이는것은 매우 중요한 기법 중 하나입니다.
개발자는 현재 코드베이스의 복잡한 정도를 잘 알고 있으니 요구사항을 수립하는 자리에서 복잡도 관점에서 의견을 적극적으로 제시해야 합니다. 경우에 따라서는 제품에 대한 복잡도 상한선을 정해놓고, 새로운 기능 구현에 대한 요청을 수렴할 때 제거할 기능을 함께 제시하는 방법 도 있습니다.
3. 검증의 자동화
개발자가 코드의 정확성을 직접 확인하는 것 보다 컴파일러나 정적 분석 도구를 활용해야 가독성 높은 코드를 작성하기 수월하고, 버그가 발생할 가능성도 줄일 수 있습니다. 예를 들면 잘못된 데이터를 필터링하는 코드를 작성한다고 했을 때 되레 필터 때문에 코드가 복잡해지기 쉽고, 필터 조건이 충분하지 않아 버그가 발생하는 경우를 놓칠 수도 있습니다. 또한, 잘못된 데이터를 필터링하는 것보다 원천적으로 잘못된 데이터가 만들어지지 않도록 해야합니다.
이를 좀더 구체적으로 생각하기 위해 쿼리를 수행하는 함수 queryPage 에 쿼리 매개변수를 전달한다고 가정해 보겠습니다. 쿼리 매개변수르 코드 1-1 과 같은 이 문자열 맵을 사용한다면 어떤 정보를 인수로 전달해야 하는지 알 수 없고, 필수 매개변수를 전달하는 것을 누락하는 버그가 발생할 수 있습니다. 또한, queryPage 함수 내의 오류 처리 코드도 복잡해 질 것입니다.
1-1 fun queryPage(parameters: Map<String, String>) 1-2 fun queryPage(val pageIndex:Int, val isAscendingOrder: Boolean)
반면에 코드 1-2 는 쿼리 매개변수를 함수의 인수로 명시하여 페이지번호와 정렬 매개변수가 필수라는것을 쉽게 이해할 수 있습니다. pageIndex 를 지정하는것을 잊어버린 경우에도 함수를 호출하는 곳에서 컴파일 에러를 발생시키기 때문에 오류를 쉽게 발견할 수 있습니다. 또한, 쿼리 함수 내에서 불필요한 오류 처리를 작성할 필요가 없기때문에 함수 자체의 가독성도 향상됩니다.
대표적인 프로그래밍 원칙
1. 보이스카우트 원칙
코드를 변경할 때는 주변을 둘러보고 소소한 개선이 필요한 부분을 함께 살펴보아야 한다는 원칙입니다. 이 원칙은 보이스카우트 운동의 창시자 로버트 페이든파월 의 생각을 소프트웨어 개발에 적용한 것으로, 로버트 마틴 형님이 제안한 것입니다. 마틴의 설명에 따르면 보이스카우트에는 '캠프장을 사용한 후에는 사용하기 전보다 더 깨끗이 청소하고 떠나라' 라는 규칙이 있다고 합니다. 소프트웨어 개발에서도 마찬가지로 코드를 변강할 때는 변경 전보다 코드베이스르 더 나아진 상태로 만들려고 노력해야 한다는 점을 강조하며, 이렇게 점진적으로 코드를 조금씩 개선하는 일이 코드의 가독성과 작업 환경을 지속적으로 개선할 수 있는 방법이라고 말합니다.
보이스카우트 원칙을 적용할 수 있는 대상은 피상적인 변경부터 구조적 변경이 필요한 것까지 종류가 매우 다양합니다.
추가: 누락된 주석이나 테스트 추가하기
삭제: 불필요한 의존 관계, 구성요소, 조건문 삭제하기
리네이밍: 클래스, 함수, 변수 등의 이름을 적절하게 바꾸기
세분화: 너무 큰 클래스나 함수, 지나치게 깊은 계층 구조, 연쇄적인 호출을 잘게 나누기
구조화: 의존 관계, 추상화 레이어, 상속의 계층 구조를 적절히 구성하기이와는 반대로 문제가 있는 코드를 방치하면 나중에 코드를 변경할 때 상황이 더 악화될 수 있습니다. 대표적인 예로 거대한 구조가 방치된상태에서 새로운 요소가 추가되어 구조거 다욱 비대해지는 경우입니다. 예를 들어 어떤 클래스가 메서드 100개를 가지고 있을 때 메서드 1개정도가 추가 되더라도 크게 달라지지 않겠지 라는 생각으로 별다른 고민 없이 새로운 메서드를 추가하는 경우를 볼 수 있습니다. 또는 클래스에 다수의 메서드가 있을 때 이런 구조여야 할 어떤 이유가 있겠지 라는 생각으로 새로운 메서드를 추가하는 경우도 있을 것입니다. 하지만 당연하게도 여러 요소로 구성된 거대한 구조를 이해하는 것은 매우 어렵습니다. 더욱이 구성요소가 많아질 수록 추후에 할 리팩터링의 비용도 증가합니다.
명확하지 않고, 복잡하며, 구조적이지 않은 코드를 변경하고자 할 때는 변경하기 전에 명확하고, 단순하며, 구조적인 코드가 되도록 리팩터링 해야 합니다. 예를 들어 열거형 ViewType 에 새로운 열거자 Z 를 추가한다고 가정해 보겠습니다. 이 코드에는 이미 거대한 when 분기가 있고 각 분기에서 공통적으로 view1.isVisible view2.text 를 업데이트 합니다.
enum class ViewType {A,B,..., Y} fun updateViewProperties(viewType: ViewType){ when (viewType) { ViewType.A -> { view1.isVisible = true view2.text = "Case A" } ViewType.B -> { view1.isVisible = false view2.text = "Case B" } ... ViewType.Y -> { view1.isVisible = true view2.text = "Case Y" } } }
이 때 피해야 할 것은 열거자 Z와 해당 조건 분기를 단순히 덧붙이는 것입니다. 열거자 Z 를 추가하기 전에 우선 조건분기의 구조부터 리팩터링 해보겠습니다. 각 조건 분기에서 서로 다른 요소는 대입하는 변수 뿐이므로 해당 변수값을 열거자 속성으로 분리하는것이 좋습니다. 먼저 ViewType 을 변경하여 view1.isVisible 와 view2.text 에 해당하는 값을 isView1Visible, view2Text 속성으로 갖게 합니다.
// 열거자 속성활용 리펙터링 enum class ViewType(val isVisible: Boolean, val view2Text: String){ A(true, "Case A") B(false, "Case B") ... Y(true, "Case Y") } // 조건분기 제거 fun updateViewProperties(viewType: ViewType){ view1.isVisible = viewType.isVisible view2.text = viewType.view2Text } // 리팩터링 후 Z 추가 enum class ViewType(val isVisible: Boolean, val view2Text: String){ A(true, "Case A") B(false, "Case B") ... Y(true, "Case Y") Z(false, "Case Z") }
열거자 속성을 활용함으로써 조건분기의 when 절이 사라지고 단순화 되었으며 리팩토링 후에 Z 열거자를 추가할때도 단순히 enum 클래스에 값을 추가하고 updateViewProperties 는 변경되지 않았습니다.
2. YAGNI You aren't gonna need it)
YAGNI 는 그런 건 필요하지 않아 를 뜻하는 영문 약자로, Extreme Programming 원칙 중 하나입니다.
1. XP(eXtreme Programming)
- XP는 수시로 발생하는 고객의 요구사항에 유연하게 대응하기 위해 고객의 참여와 개발 과정의 반복을 극대화하여 개발 생산성을 향상시키는 방법.
2. XP(eXtreme Programming)의 5가지 핵심 가치
의사소통(Communication)
단순성(Simplicity)
용기(Courage)
피드백(Feedback)
존중(Respect)
3. XP(eXtreme Programming)의 기본원리
Pair Programming(짝 프로그래밍): 다른 사람과 함께 프로그래밍을 수행함으로써 개발에 대한 책임을 공동으로 나눠 갖는 환경Test-Driven Development(테스트 주도 개발): 개발자가 실제 코드를 작성하기 전에 테스트 케이스를 먼저 작성하므로 자신이 무엇을 해야할지 정확히 파악, 테스트가 지속적으로 진행될 수 있도록 자동화된 테스팅 도구 사용
Whole Team(전체 팀): 개발에 참여하는 모든 구성원들은 각자 자신의 역할이 있고 책임을 가져야 함
Desgin Improvement(디자인 개선) 또는 Refactoring(리팩터링): 프로그램 기능의 변경 없이, 단순화, 유연성 강화 등을 통해 시스템을 재구성
Small Releases(소규모 릴리즈): 릴리즈 기간을 짧게 반복함으로써 고객의 요구 변화에 신속히 대응
Continuous Integration(계속적인 통합): 모듈 단위로 나눠서 개발된 코드들은 하나의 작업이 마무리 될 떄마다 지속적으로 통합불확실한 미래를 위해 현재 필요하지 않은 기능을 미리 구현해도 정작 사용되지 않는 경우가 많다는 의미입니다. 미래를 위한 코드 때문에 구현이 복잡해지면 기능을 확장하기 어려워집니다. 기능은 필요할 때 구현해야 합니다.
YAGNI 를 준수하지 않는 코드의 대표적인 예로는 사용되지 않는 코드나 과도학 확장된 코드를 들 수 있습니다.
사용되지 않는 코드 예시
참조되지 않는 변수
호출되지 않는 함수
주석 처리된 코드
과도하게 확장된 코드 예시
정수 하나만 전달되는 가변 인수
호출하는 곳이 단 하나뿐인 공개 유틸리티 함수
자식 클래스가 하나뿐인 추상클래스
미래를 위해 작성된 코드가 새로운 기능의 구현을 어렵게 만드는 사례를 코드 1-7 의 Coordinate 라는 모델 클래스로 살펴보겠습니다. Coordinate 클래스는 뷰의 표시 위치 등 좌표를 UI 에 픽셀 단위로 표시하는 클래스입니다.
class Coordinate(val x:Int, val y: Int)
이 코드가 유용할지도 모른다는 이유로 픽셀뿐만 아니라 포인트(1/72인치) 로도 좌표를 지정할 수 있도록 기능을 확장해 보았습니다. 단위는 UnitType 으로 지정하기 때문에 밀리미터와 같은 새로운 단위를 추가하고 싶다면 다음 코드에 열거자만 추가하면 됩니다. 이렇게 변경된 Coordinate 는 언뜻 보면 마치 확장성이 뛰어난 것처럼 보입니다.
enum class UnitType { PIXEL, POINT } class Coordinate(val x:Int, val y:Int, val unitType:UnitType)
결국 포인트로 좌표를 지정하는 기능은 사용되지 않고 대신 Coordinate 를 이용한 연산이 필요하다고 가정하겠습니다. 좌표의 덧셈과 뺄셈은 렌더링 범위 계산이나 UI 요소의 크기, 각 요소 사이의 여백 계산에도 사용되기 때문에 이러한 가정은어느 정도 합리적이라고 할 수 있습니다.
Unit Type 이 있는 상태에서 plus 메서드를 정의하면 코드 1-9 와 같이 복잡한 함수가 됩니다. 이때 두 Coordniate 인스턴스의 UnitType 이 다르면 더하기 전에 단위를 변환해야 하므로 보조함수 convertType 을 별도로 저으이해야 합니다. 또한 픽셀가 포인트의 비율은 해상도에 따라 달라지므로 convertType 내에서 디스플레이 장치의 환경이나 설정 값을 가져와야 합니다. 여기에 연산 결과를 픽셀과 포인트 중 어느 쪽으로 반환할 것인지도 정의해야 하므로 plus 의 인수도 복잡해집니다.
// 코드1-9 class Coordinate(val x:Int, val y:Int, val unitType:UnitType){ fun plus( that: Coordinate, environment: DisplayEnvironment, resultUnitType: UnitType ): Coordinate { val newX = convertType ... val neY = convertType ... return Coordinate(newX, newY, resultUnitType) } private fun convertType( value: Int, from: UnitType, to: UnitType, environment: DisplayEnvironment ): Int { if (from == to) return value return when (to) { ... } }
하지만 UnitType 이 없다면 아래 코드와 같이 간단한 함수로 덧셈을 구현할 수 있습니다. 이를 살펴본다면 기존기능이 새로운 기능을 구현하는데 방해가 될 수 있음을 알 수 있습니다.
class Coordinate(val x:Int, val y:Int) { operator fun plus(that: Coordinate): Coordinate = Coordinate(x+that.x, y+that.y) }
실제로 포인트가 필요할 때 까지 UnitType 을 추가하지 않고 기다렸다면 1-9 처럼 복잡한 구현을 피할 수 있었을 것입니다. 픽셀과 포인트가 모두 필요할때는 두 단위를 변환하는 유틸 함수가 함께 필요한 경우가 많습니다. 이럴경우, Coordinate 의 속성으로 UnitType 을 설계 대신 Coordniate 는 어디까지나 픽셀을 표현하는 모델로 두고, 단위 변환을 위한 함수를 별도로 정의하는 설계를 생각할 수 있습니다. 또한 Coordniate 를 제네릭으로 하고 UnitType 을 매개변수로 하는 방법도 생각해볼 수 있습니다. 하지만 포인트가 필요할지도 모른다는 추측만으로 구현하면 실제 쓰임을 고려하지 못하기 때문에 다른 설계아 비교되지 않은 채 구현이 복잡해질 수 있습니다. 적절한 설계는 코드가 어떻게 사용되는지에 따라 달라지기 때문에 YAGNI 를 위반하면 그때는 괜찮을 것 같았지만 결과적으로 잘못된 설계였다 는 상황이 발생할 수도 있습니다.
3. KISS (Keep it simple stupid)
KISS 는 록히드사의 엔지니어였던 켈리 존슨 이 주장했으며 바보스로울 정도로 단순하게 만들어라 라는 의미를 담고 있습니다. 단순할수록 신규기능을 쉽게 추가할 수 있다는 뜻입니다. 코드를 단순하게 유지하기 위해서는 YAGNI 에서 주장하는 불필요한 기능은 구현하지 않는다는 것 외에도 사양을 변경하거나 동일한 기능을 구현할 때 단순한 방식을 채택하는 등의 노력이 필요합니다.
반드시 지양해야 할 것은 자기 만족을 위해 코드를 복잡하게 만드는 해우이입니다. 예를 들어 라이브러리나 프레임워크, 디자인패턴 등을 사용하고 싶어서 사용한다 는 식으로 수단이 목적이 되어서는 안됩니다. 또 이미 사용하는 프레임워크나 표준 라이브러리에서 제공하는 기능을 직접 재구현 하는 것도 코드를 복잡하게 만듭니다. 불필요한 것을 추가하지 않는 것이 단순함을 향한 지름길입니다.
ReactiveX 의 자바용 라이브러리인 RXJava 를 사용하는 코드로 살펴보겠습니다. 코드 1-11 의 getActualDataSingle 은 ioScheduler 의 스레드에서 dataProvider::provide 를 호출하는 함수입니다.
코드 1-11 fun getActualDataSingle(): Single<List<Int>> = Single .fromCallable(dataProvider::provide) .subscribeOn(ioScheduler)
여기서 getAcutalDataSingle 함수를 간단하게 테스트하기 위해 테스트용 값을 반환하는 getStubDataSingle 함수를 구현해 보겠습니다.
코드 1-12 는 1,10,100 의 리스트 값을 Single 로 반환하는 간단한 테스트 코드입니다. 코드가 단순한 만큼 테스트 값을 확인하기도 쉽습니다.
코드 1-12 fun getStubDataSingle(): Single<List<Int>> = Single.just(listOf(1,10,100))
코드 1-11 은 fromCallable 와 subscribeOn 으로 구현된 반면, 코드 1-12 는 just 를 사용합니다. 만약 외관상의 일관성을 중시한다면 테스트 구현을 코드 1-13 과 같이 작성할 수 있습니다.
코드 1-13 fun getStubDataSingle(): Single<List<Int>> = Single .fromCallable {listOf(1,10,100} .subscribeOn(ioScheduler)
외관상으로는 일관됩니다. 하지만 코드 1-13 을 이용한 테스트에서 무엇을 검증해야 하는지가 모호해지는 문제가 있습니다. 단순히 subscribeOn 이 추가되었을 뿐이라면 getStubDataSingle 은 여전히 단순하지만, 테스트코드 측면에서는 테스트용 값을 검증하기 위한것인지, 아니면 스케줄러로 ioScheduler 을 사용해도 문제가 발생하지 않는 것 을 검증하기 위한것인지가 헷갈립니다. 또한 실제 구현에서 ioScheduler 가 사용된다는 사실은 getActualDataSingle 에 숨겨져 있어야 합니다. 따라서 스케줄러의 테스트는 호출자가 아닌 호출 대상이 되는 getActualDataSingle 의 테스트 코드에서 이루어져야 합니다. 그러므로 getStubDataSingle 에서 ioScheduler 를 실행하는 것은 바람직하지 않습니다. 코드 1-13 와 같이 작성하면 테스트의 책임 범위가 모호해집니다.
조금 더 극단적인 예를 들어 RXJava 를 지나치게 적용하면 어떻게 되는지 살펴보겠습니다. 코드 1-13 에서 RXJava 는 값을 생성할 스레드를 지정하기 위해 사용되었습니다. 하지만 RXJava 는 스레드 전환 뿐 아니라 스트림, 이벤트 처리, 오류처리 등 다양한 용도로 사용될 수 있습니다. 그래서 코드 1-14 는 RXJava 를 사용해 1,10,100 의 리스트를 만들었습니다. 결국, 테스트용 값으로 무엇을 검증할 것인지 더욱 이해하기 어려운 코드가 되어 버렸습니다.
코드 1-14 fun getStubDataSingle(): Single<List<Int>> = Observable .range(1, 2) .reduce(listOf(1)) {list, _ -> list + list.last() * 10} .subscribeOn(ioScheduler)
코드 1-14 와 같이 라이브러리 하나로 여러 가지 일을 하는 코드는 보기에 따라서는 아름답다고 할 수도 있습니다. 적은 규칙으로 모델을 구축하거나, 일관된 라이브러리나 프레임워크로 모든 요소를 표현하는 것이 기술적으로 우아하다고 느끼는 사람들도 적지 않습니다. 하지만 코드를 작성한 본인이 우아한 코드라고 느낀다고 해서 다른 사람에게도 읽기 편한 코드가 되는 것은 아닙니다. 아름답고 우아한 코드가 반드시 가독성이 높다고는 할 수 없습니다. 자신이 작성하기 편한 코드가 아닌 다른사람이 읽기 편한 코드인가에 주목해야 합니다.
4. 단일책임 원칙
클래스 하나에 부여하는 책임과 역할은 오직 하나뿐이어야 한다는 의미를 가진 단일책임원칙 도 보이스카우트 원칙과 마찬가지로 로버트 마틴 형님이 제시했습니다. SOLID 라는 객체지향의 원칙 중 하나이며, 번역하면 클래스가 변경되는 이유는 단 한가지여야 한다 입니다.
클래스의 책임과 역할은 하나뿐이나 라는 말은 클래스가 가진 메서드가 적을수록 좋다 라는 의미로 받아들여질 수 있지만, 실제로는 그렇지 않습니다. 공개 메서드가 하나만 있더라도 클래스의 책임은 커질 수 있습니다. 가장 대표적인 예는 코드 1-15 와 같이 공개 메서드 하나에서 모든 작업을 수행하는 경우입니다. 이 코드를 보면 한 클래스가 가지는 메서드 수나 코드의 행 수가 반드시 책임의 크기에 비례하지 않는다는 것을 알 수 있습니다. 이러한 피상적인 숫자는 책임의 크기를 가늠하는 데 참고 정도로만 활용하는 것을 추천합니다.
코드 1-15 class FOO { fun doEverything(state: UniverseState){ ... } }
기존 클래스가 가진 책임이 큰 경우, 여러 클래스로 나누어 개별 클래스의 책임을 줄일 수 있습니다. 이를 도서관의 도서 대출 상태를 관리하는 클래스 (코드 1-16) 를 사용하여 설명하겠습니다.
코드 1-16 class LibraryBookRentalData( val bookIds: MutableList<BookId>, val bookNames: MutableList<String>, val bookIdToRenterNameMap: MutableMap<BookId, String>, val bookIdToDueDateMap: MutableMap<BookId, Date> ) { fun findRenterName(bookName: String): String? fun findDueDate(bookName: String): Date? }
이 클래스는 반납 기한, 이용자 등 대출 상태를 나타내는 정보 외에도 보유 도서 목록, 전체 이용자 목록 정보도 가지고 있습니다. 이처럼 클래스 하나가 다수의 정보를 관리하는 상황에서 사양을 변경해야 할 때 영향 범위를 확인하기가 복잡합니다. 예를 들어 보유한 도서에 저자명을 추가한다고 가정해 봅시다. 저자명을 추가하는 것은 현재 책을 빌리고 있는 이용자나 반납 기한과는 아무 관련이 없습니다. 하지만 같은 클래스 내에서 데이터를 관리하고 있다면 추가한 데이터가 이용자나 반납 기한에 영향을 미치지 않는지 확인해야 합니다. 이러한 상황을 피하기 위해서 한 클래스의 책임과 역할은 하나로 한정하는 것이 좋습니다.
이 경우에는 코드 1-17 과 같이 나눌 수 있습니다.
코드1-17 class BookModel(val id:BookId, val name:String, ...) class UserModel(val name:String, ...) class CirculationRecord( val onLoanBookEntries: MutableMap<BookModel, Entry> ) { class Entry(val renter: UserModel, val dueDate: Date) ... }
도서 정보를 BookModel, 이용자 데이터를 UserModel 로 분리함으로써 대출을 관리하는 CirculationRecord 의 책임이 줄어들었습니다. 도서 목록이나 전체 이용자 목록은 CirculationRecord 의 외부에 만들 수 있습니다. 이렇게 객체별로 모델을 나누는 방법 외에도 로직을 레이어와 컴포넌트로 나누거나, 유틸리티 메서드를 별도의 클래스로 분리하는 것은 책임 범위를 한정하는 좋은 방법입니다.
클래스가 지금 얼마나 많은 책임을 가지고 있는지 확인하려면 해당 클래스가 무엇을 하고있는지 파악하고 요약을 작성해 보는것도 도움이 됩니다. 만약 요약이 어렵거나 요약이라고 할 수 있을정도로 간결하게 정리되지 않는다면 그 클래스는 나누어야 한다고 판단해도 좋습니다. 가장 중요한것은 클래스가 변경되어야 하는 이유는 오직 한가지 라는 것입니다.
5. 섣부른 최적화는 만악의 근원
연산 시간 단축이나 메모리 사용량 감소와 같은 최적화를 결코 가볍게 생각해서는 안 됩니다. 컴퓨터 과학의 유명한 거장 도널드 커누스는 최적화의 97% 는 쓸모없는 일이라고 주장합니다. 충분한 효과를 기대할 수 없는 최적화는 쓸모없을 뿐만 아니라 오히려 코드를 복잡하게 만들고 가독성을 떨어뜨리는 결과를 가지고 온다고 말합니다. 효과가 미미한 최적화는 오버헤드 비용으로 되려 성능 저하를 초래할 수 있으며, 컴파일러나 옵티마이저의 최적화를 방해할 수 있습니다. 경우에 따라서는 최적화를 컴파일러나 옵티마이저에게 맡기는것이 더 큰 효과를 얻을 수 있습니다.
코드를 최적화할때는 최적화 대상과 개선 기대치를 명확하게 정의해야 합니다. 작업을 수행하기 전에 개선하고자 하는 것이 연산 시간인지, 메모리 또는 기타 리소스의 사용량인지 등을 먼저 정합니다. 그다음 기존 코드가 어느정도 성능을 내고있는지 측정하여 최적화의 필요성을 시각화 하는 작업이 이루어져야 합니다. 최적화한 이후에도 마찬가지로 측정하여 성능 개선과 코드의 복잡성 사이의 균형을 확인해야 합니다. 또한, 최적화 대상이 되는 기능이 얼마나 자주 사용되는지도 확인해야 합니다. 기능의 성능을 큰폭으로 개선한다 하더라도 많이 사용되지 않는 기능이라면 최적화의 효과가 미미할 수 있습니다.
LIST