oop

4 개의 포스트

코드 품질 개선 기법 28편: 제약 조건에도 상속세가 발생한다 (새 탭에서 열림)

코드의 불변성을 보장하기 위해 설계된 클래스가 상속을 허용할 경우, 자식 클래스에서 해당 제약을 위반함으로써 시스템 전체의 안정성을 해칠 수 있습니다. 특히 `Immutable`이라는 이름을 가진 클래스가 가변적인 자식 클래스를 가질 수 있게 되면 개발자의 의도와 다른 런타임 동작이 발생할 위험이 큽니다. 따라서 특정 제약 조건을 강제하고 싶다면 클래스를 상속 불가능하게 설계하거나, 공통의 '읽기 전용' 인터페이스를 활용하는 구조적 접근이 필요합니다. ### 불변성 보장을 방해하는 상속 구조 * Kotlin의 `IntArray`를 래핑하여 성능과 불변성을 동시에 잡으려는 `ImmutableIntList` 예시를 통해 상속의 위험성을 설명합니다. * 클래스를 상속 가능(`open`)하게 설정하면, `Immutable`이라는 명칭에도 불구하고 이를 상속받아 내부 상태를 변경하는 `MutableIntList`와 같은 자식 클래스가 생성될 수 있습니다. * 외부에서는 `ImmutableIntList` 타입으로 참조하더라도 실제 인덱스 값이 변할 수 있는 객체를 다루게 되어, 불변성을 전제로 한 로직에서 오류가 발생합니다. ### 멤버 오버라이딩을 통한 제약 조건 우회 * 내부 데이터 구조를 `private`이나 `protected`로 보호하더라도, 메서드 오버라이딩을 통해 불변성 제약을 우회할 수 있습니다. * 예를 들어 `get` 연산자를 오버라이딩하여 내부 배열이 아닌 가변적인 외부 필드 값을 반환하도록 재정의하면, 클래스의 핵심 규약인 '불변 데이터 제공'이 깨지게 됩니다. * 범용적인 클래스일수록 예상치 못한 곳에서 잘못된 상속이 발생할 가능성이 높으므로, 어떤 멤버를 노출하고 오버라이딩을 허용할지 엄격하게 제한해야 합니다. ### 가변·불변 객체의 올바른 상속 관계 * 가변 객체가 불변 객체를 상속하면 불변성 제약이 깨지고, 불변 객체가 가변 객체를 상속하면 불필요한 변경 메서드(`add`, `set`)로 인해 런타임 에러가 발생할 수 있습니다. * 가장 이상적인 구조는 가변 객체와 불변 객체가 모두 '읽기 전용(Read-only)' 인터페이스나 클래스를 상속받는 형태입니다. * 가변 객체는 읽기 전용 부모의 메서드 집합을 확장하고, 불변 객체는 읽기 전용 부모의 제약 조건을 확장하는 방식(예: Kotlin의 `List` 구조)이 안전합니다. 특정 제약 조건(불변성 등)이 핵심인 클래스를 설계할 때는 기본적으로 상속을 금지(`final`)하고, 확장이 필요하다면 상속 대신 독립된 타입을 정의하거나 읽기 전용 인터페이스를 통한 계층 분리를 권장합니다.

코드 품질 개선 기법 24편: 유산의 가치 (새 탭에서 열림)

코드에서 로직은 동일하고 단순히 값만 달라지는 경우, 인터페이스와 상속을 사용하는 대신 데이터 클래스와 인스턴스 생성을 활용하는 것이 더 효율적입니다. 상속은 동적 디스패치나 의존성 역전과 같은 특정한 목적이 있을 때 강력한 도구가 되지만, 단순한 값의 차이를 표현하기 위해 사용하면 불필요한 복잡성을 초래할 수 있습니다. 따라서 객체의 속성 값만 변경되는 상황이라면 상속 불가능한 클래스를 정의하고 값만 다른 인스턴스를 만들어 사용하는 것이 코드의 가독성과 예측 가능성을 높이는 지름길입니다. **상속이 과용되는 사례와 문제점** * UI 테마(색상, 아이콘 등)를 적용할 때 인터페이스를 정의하고 각 테마(Light, Dark)별로 클래스를 상속받아 구현하는 방식은 흔히 발견되는 과잉 설계의 예시입니다. * 바인딩 로직이 모든 테마에서 동일함에도 불구하고 상속을 사용하면, 각 하위 클래스가 자신만의 고유한 로직을 가질 수 있다는 가능성을 열어두게 되어 코드 추적을 어렵게 만듭니다. * 특히 단순히 속성 값을 반환하는 목적일 때는 인터페이스를 통한 동적 디스패치가 성능상 미미한 오버헤드를 발생시킬 뿐만 아니라 구조를 복잡하게 만듭니다. **상속을 사용해야 하는 정당한 경우** * **로직의 동적 변경:** 상황에 따라 실행 시점에 로직 자체를 갈아끼워야 하는 동적 디스패치가 필요할 때 사용합니다. * **합 타입(Sum Types) 구현:** Kotlin의 `sealed class`처럼 정해진 하위 타입들의 집합을 정의해야 할 때 유용합니다. * **추상화와 구현의 분리:** 프레임워크의 제약 조건 대응, 의존성 주입(DI), 또는 빌드 속도 향상을 위해 인터페이스가 필요할 때 활용합니다. * **의존성 역전 법칙(DIP) 적용:** 순환 의존성을 해결하거나 의존성 방향을 단방향으로 유지해야 할 때 상속과 인터페이스를 사용합니다. **인스턴스 기반 모델링의 이점** * 인터페이스 대신 모든 속성을 포함하는 단일 클래스(Model)를 정의하고, 각 테마를 이 클래스의 인스턴스로 생성하면 구조가 훨씬 간결해집니다. * Kotlin의 기본 클래스처럼 상속이 불가능한(`final`) 클래스로 정의할 경우, 해당 인스턴스에 고유한 로직이 포함되지 않았음을 보장할 수 있습니다. * 속성 값이 변하지 않는다면 각 인스턴스는 데이터의 묶음으로서만 존재하게 되어, 상태 변화나 로직의 부수 효과를 걱정할 필요 없이 안전하게 재사용할 수 있습니다. 단순히 값만 다른 여러 타입을 구현해야 한다면 먼저 "상속이 반드시 필요한 로직의 차이가 있는가?"를 자문해 보시기 바랍니다. 로직이 같다면 클래스 상속보다는 데이터 모델의 인스턴스를 생성하는 방식이 유지보수하기 훨씬 쉬운 코드를 만들어줍니다.

코드 품질 개선 기법 21편: 생성자를 두드려 보고 건너라 (새 탭에서 열림)

객체의 상태에 따라 특정 메서드 호출이 제한되는 설계는 런타임 에러를 유발하는 주요 원인이 됩니다. 개발자는 주석이나 문서에 의존하기보다, 언어의 문법적 특성을 활용해 '애초에 잘못 사용할 수 없는 구조'를 설계해야 합니다. 이를 위해 생성 시점에 초기화를 완료하거나, 지연 초기화 또는 상태를 분리한 타입을 활용해 객체의 안전성을 보장하는 것이 핵심입니다. **초기화 시점에 로직 실행과 팩토리 함수 활용** 객체를 생성하는 즉시 필요한 준비 작업을 마치는 방식입니다. * **생성자 및 init 블록 사용**: 모든 속성을 생성 시점에 결정하여 읽기 전용(`val`)으로 선언할 수 있어 객체의 불변성을 유지하기 좋습니다. * **생성자의 제약 사항**: 생성자 내에서는 `suspend` 함수 호출이 불가능하며, 복잡한 로직이나 부작용이 큰 코드를 작성할 경우 초기화되지 않은 속성에 접근하는 버그가 발생할 수 있습니다. * **정적 팩토리 함수**: 생성자를 `private`으로 숨기고 별도의 `createInstance` 같은 함수를 제공하면, 복잡한 준비 로직을 안전하게 처리한 뒤 완전한 상태의 인스턴스만 반환할 수 있습니다. **호출 시점에 실행되는 지연 초기화** 준비 작업의 비용이 크지만 실제로 사용되지 않을 가능성이 있을 때 유용한 방식입니다. * **최초 접근 시 실행**: 메서드 내부에서 준비 상태를 확인하고, 필요한 경우에만 로직을 실행하여 리소스를 효율적으로 관리합니다. * **Kotlin의 lazy 위임**: 수동으로 상태 체크 코드를 작성하는 대신 `by lazy`를 활용하면, 스레드 안전성을 확보하면서도 코드를 더 깔끔하게 유지할 수 있습니다. * **가변성 제어**: 수동 구현 시 속성을 가변(`var`)으로 선언해야 하는 단점이 있지만, `lazy`를 사용하면 이를 완화할 수 있습니다. **정적 타입을 활용한 상태 분리** 준비 전과 후의 상태를 별개의 클래스로 정의하여 컴파일 단계에서 오류를 방지하는 방식입니다. * **타입에 따른 권한 부여**: 준비 전 클래스에는 `prepare()`만 정의하고, 이 함수가 준비 완료된 새로운 타입의 객체를 반환하게 하여 `play()`와 같은 핵심 기능을 준비된 객체만 가질 수 있도록 제한합니다. * **컴파일 타임 안전성**: 호출자가 준비 과정을 거치지 않으면 기능을 아예 호출할 수 없으므로, 런타임 예외 발생 가능성을 원천적으로 차단합니다. * **세밀한 제어**: 호출자가 준비 시점을 직접 결정해야 하거나, 준비된 상태의 인스턴스를 캐싱하여 재사용해야 할 때 특히 효과적입니다. **실용적인 제언** 가장 좋은 설계는 사용자에게 주의를 요구하는 대신, 구조적으로 실수를 방지하는 설계입니다. 초기화 비용이 낮다면 생성자나 팩토리 함수를 통한 **즉시 초기화**를 권장하며, 실행 시점을 제어해야 하거나 안전성을 극대화해야 한다면 **상태별 클래스 분리**를 검토하는 것이 좋습니다.

코드 품질 개선 기법 13편: 클론 가족 (새 탭에서 열림)

두 개의 상속 트리가 서로 암묵적으로 대응하며 발생하는 '클론 가족' 문제는 코드의 타입 안정성을 해치고 유지보수를 어렵게 만듭니다. 이 글은 코드 공통화를 위한 부적절한 상속 사용을 경계하고, 대신 컴포지션이나 제네릭을 활용하여 데이터 모델 간의 관계를 명확히 정의함으로써 런타임 오류 가능성을 줄이는 방법을 제시합니다. ### 클론 가족 현상과 타입 안정성 문제 데이터를 공급하는 클래스 계층과 데이터 모델 계층이 서로 일대일로 대응하지만, 이 관계가 코드상에서 명시되지 않을 때 문제가 발생합니다. * **다운캐스팅의 필요성**: 부모 공급자 클래스가 공통 데이터 모델 인터페이스를 반환할 경우, 실제 사용 시점에서는 구체적인 타입으로 변환하는 다운캐스팅(`as`)이 강제됩니다. * **암묵적 제약 조건**: '특정 공급자는 특정 모델만 반환한다'는 규칙이 컴파일러가 아닌 개발자의 머릿속에만 존재하게 되어, 코드 변경 시 실수로 인한 오류가 발생하기 쉽습니다. * **유연성 부족**: 하나의 공급자가 여러 모델을 반환하거나 구조가 복잡해질 때, 타입 검사만으로는 시스템의 안전성을 보장하기 어렵습니다. ### 상속 대신 애그리게이션과 컴포지션 활용 단순히 로직의 공통화가 목적이라면 상속보다는 기능을 분리하여 포함하는 방식이 더 효과적입니다. * **로직 추출**: 공통으로 사용하는 데이터 획득 로직을 별도의 클래스(예: `OriginalDataProvider`)로 분리합니다. * **의존성 주입**: 각 공급자 클래스가 분리된 로직 클래스를 속성으로 가지도록 설계하면, 부모 클래스 없이도 코드 중복을 피할 수 있습니다. * **타입 명확성**: 각 공급자가 처음부터 구체적인 데이터 타입을 반환하므로 다운캐스팅이 아예 필요 없어집니다. ### 제네릭을 이용한 매개변수적 다형성 적용 여러 공급자를 하나의 컬렉션으로 관리해야 하는 등 상속 구조가 반드시 필요한 경우에는 제네릭을 통해 타입을 명시해야 합니다. * **타입 파라미터 지정**: 부모 클래스에 타입 파라미터 `T`를 도입하여, 자식 클래스가 어떤 타입의 데이터를 반환하는지 컴파일 시점에 명시하도록 합니다. * **상한 제한(Upper Bound)**: 필요한 경우 `T : CommonDataModel`과 같이 제약 조건을 추가하여 최소한의 공통 인터페이스를 보장할 수 있습니다. * **업캐스팅 지원**: 제네릭을 사용하면 부모 타입으로 관리하면서도 각 인스턴스가 반환하는 타입의 안전성을 유지할 수 있어 활용도가 높습니다. 상속은 강력한 도구이지만 단순히 코드를 재사용하기 위한 목적으로 사용하면 의도치 않은 타입 문제를 야기할 수 있습니다. 클래스 간의 관계가 암묵적인 제약에 의존하고 있다면, 이를 컴포지션으로 분리하거나 제네릭을 통해 명시적인 관계로 전환하는 것이 견고한 코드를 만드는 핵심입니다.