Study/object

[엘레강트 오브젝트] 3-2장 정적 메서드를 사용하지 마세요.

에디개발자 2021. 9. 15. 21:00
반응형

나를 닮았다고 한다...

이 글은 엘레강트 오브젝트 새로운 관점에서 바라본 객체지향 도서를 보며 스터디한 글입니다.

책에서 주장하는 내용을 정리하였으며 예제들은 모두 코틀린 코드로 변환하여 작성하였습니다.

 

목차

  1. 정적 메서드 vs 객체
  2. 선언형 스타일 vs 명령형 스타일
  3. 유틸리티 클래스
  4. 싱글톤 패턴
  5. 함수형 프로그래밍
  6. 조합 가능한 데코레이터
  7. 주관적인 생각

1. 정적 메서드 vs 객체

정적메서드는 어플리케이션이 실행될 때 메모리에 올라가기 때문에 새로운 클래스를 생성하지 않고 빠르게 실행할 있습니다.

class WebPage {
    companion object {
        fun read(url: String): String {
            // HTTP 요청 생성
            // UTF-8 문자열로 변환
            return ""
        }
    }
}

// use
val html = WebPage.read("http://www.java.com")

 

가비지 컬렉션에 신경 필요가 없습니다. 보통 Utils 클래스로 정적 메서드를 모아서 사용합니다. 이럴 경우 명확하게 유틸에서 어떤 기능을 하는지 있습니다. 하지만 이 방법은 유지보수성을 매우 떨어뜨리게 됩니다. 아래는 정적 메서드를 객체화 시킨 코드입니다. 

class WebPage(
    private val url: String
) {
    fun content(): String {
        // HTTP 요청 생성
        // UTF-8 문자열로 변환
        return ""
    }
}

// use
val html = WebPage("http://www.java.com").content()

두 가지 방식엔 크게 차이가 없어보이지만 하나씩 살펴보겠습니다.

 

2. 선언형 스타일 vs 명령형 스타일

  • 명령형: 프로그램의 상태를 변경하는 문장을 사용해서 계산 방식을 서술
  • 선언형: 제어 흐름을 서술하지 않고 계산 로직을 표현

선언형 스타일을 사용하면 여러가지 장점을 갖습니다. 

  • 최적화
  • 다형성
  • 표현력
  • 응집도

 

최적화

명령형인 경우

class YongMath {
    companion object {
        fun between(l: Int, r: Int, x: Int) =
            Math.min(Math.max(l, x), r)
    }
}

// use
val y = YongMath.between(5,9, 13) // 9

명령형은 between 메서드를 호출하는 즉시 계산을 시작합니다.

 

val y = YongMath.between(5,9, 13)

if ( /* y가 필요한가? */ )
    println("y=$y")

조건문에 y가 필요없는 경우일지라도 이미 계산처리를 수행합니다.

 

선언형인 경우

class Between(
    private val num: Number
): Number() {
    constructor(l: Int, r: Int, x: Int): this(Min(Max(l, x), r))

    override fun toInt(): Int =
        this.num.toInt()
}

val y = Between(5, 9, 13)  // 아직 계산하지 않는다.

선언형은 객체를 생성하는 단계에서는 계산하지 않습니다. 

 

val y = Between(5, 9, 13)

if ( /* y가 필요한가? */ )
    println("y=$y")

조건문에 y가 필요없는 경우에는 계산하지 않고 필요한 경우에만 계산처리를 수행합니다. ( 최적화 )

 

다형성

코드 블록 사이의 의존성을 끊는 것을 말합니다. 위 예제에서 새로운 기능을 추가한다고 생각해보겠습니다. 

명령형인 경우

// 새로운 메서드 추가하는 경우
class YongMath {
    companion object {
        fun between(l: Int, r: Int, x: Int) =
            Math.min(Math.max(l, x), r)

        // 새로운 메서드 추가
        fun between(num: Number) =
            // 새로운 기능
    }
}

// 기존 메서드에 새로운 파라미터 추가
class YongMath {
    companion object {
        fun between(l: Int, r: Int, x: Int, num: Number) =
            // if-else문을 통한 분기 처리
    }
}

위 처럼 새로운 기능을 추가한다면 새로운 메서드를 추가하거나 기존 메서드에 새로운 파라미터를 추가해야합니다.

 

선언형인 경우

class Between(
    private val num: Number
): Number() {
    constructor(l: Int, r: Int, x: Int): this(Min(Max(l, x), r))

    // 생성자만 새로 추가
    constructor(num: Number): this(num)

    override fun toInt(): Int =
        this.num.toInt()
}

새로운 기능이 추가된다면 생성자만 추가하여 처리할 수 있습니다. 

 

표현력

선언형 방식은 결과를 이야기하지만 명령형 방식은 수행 가능한 한 가지 방법을 이야기합니다.

명령형인 경우

val numbers = listOf(1,2,3,4,5,6,7,8,9,10)
        
val evens = mutableListOf<Number>()
for (number in numbers) {
    if (number % 2 == 0) {
        evens.add(number)
    }
}

위 코드를 분석하려면 코드의 실행경로를 추적해야 합니다. CPU가 수행해야 하는 일을 코드를 읽는 사람도 동일하게 수행해야 합니다.

 

선언형인 경우

val numbers = listOf(1,2,3,4,5,6,7,8,9,10)

val evens = Filtered(
    numbers,
    Predicate<Int>() {
        override fun suitable(number: Int): Boolean =
            number % 2 == 0
    }
)

코드에는 구현과 관련된 세부 사항은 감춰져 있고, 오직 행동만 표현할 수 있습니다. 

 

응집도

명령형인 경우 코드의 순서를 쉽게 변경할 수 있고 그로 인해 알고리즘에 오류가 발생할 가능성이 높습니다. 하지만 선언형일 경우 한 줄에 모든 코드가 선언되있으므로 분리할 수 없습니다. 즉, 오류가 발생할 가능성이 줄어듭니다.

 

불가피한 경우

이러한 이유로 정적인 메서드 사용은 기피해야 합니다. 하지만 불가피한 경우( 외부 라이브러리 ) 가 있을 것 입니다. 이와 같은 경우에는 정적 메서드를 감싸는 방식으로 객체화 시키는 방법입니다. 

 

FileUtils를 객체화 시키는 예시입니다. 

class FileLines(
    private val file: File
): Iterable<String> {
    override fun iterator(): Iterator<String> =
        listOf(FileUtils.readLines(this.file)).iterator()
}

// use
val lines = FileLines(f)

 

3. 유틸리티 클래스

이 책의 저자는 정말 싫어한다는 게 느껴질 정도로 작성하였습니다.

끔찍한 안티 패턴, 가까지하지 마세요, 절차적인 프로그래머들이 OOP라는 영토에서 거둔 승리의 상징 등..

 

정적 메서드처럼 단순히 나쁜 요소가 아닌 나쁜 요소들을 모아놓은 집합체라고 설명하고 있습니다. 

 

4. 싱글톤 패턴

싱글톤은 정적메서드와 동일하게 작동합니다.

class User {
    lateinit var name: String

    companion object {
        private val INSTANCE = User()
        fun getInstance() = INSTANCE
    }
}

정적 메서드와 싱글톤의 유일한 차이점은 setInstance()를 할 수 있다는 것입니다. 이 것을 제외한 모든 것이 정적메서드와 동일하기 때문에 싱글턴 또한 안티패턴입니다.

 

논리적인 관점과 기술적인 관점에서 바라보았을 때 싱글톤은 전역 변수 그 자체이기 때문입니다. OOP에는 전역 범위는 존재하지 않습니다. 싱글톤은 객체지향 패러다임을 잘못 사용한 예이며, 오직 정적 메서드가 있었기 때문에 탄생한 개념입니다. 

 

그렇다면 대안은 무엇일까요??  전체 클래스들이 사용해야 하는 기능은 어떻게 구현할까요?? 유틸리티도 안되고 싱글톤도 할 수 없지만 캡슐화를 사용하여 풀어갈 수 있습니다.

 

 

5. 함수형 프로그래밍

함수형 프로그래밍은 결론적으로 OOP와 멀어지게 합니다. 

 

객체형

class Max(
    private val a: Int,
    private val b: Int
) : Number() {

    override fun toInt(): Int =
        if (a > b) a
        else b
}

 

함수형

val max = { a: Int, b: Int -> if (a > b) a else b }

 

객체형에 비해 함수형의 코드는 훨씬 짧은 장점이 있습니다. 하지만 OOP의 표현력이 더 뛰어납니다.

 

 

6. 조합 가능한 데코레이터

이 책의 저자가 새로 고안한 용어입니다. 

데코레이터는 다른 객체를 감싸는 객체입니다. 그리고 이 데코레이터 객체들을 다중계층 구조로 구성할 수 있습니다. 

val names =
    Sorted(  // 정렬
        Unique(  // 유일
            Capitalized(  // 대문자로 변경
                Replaced(  // 정규 표현식으로 치환
                    FileNames(  // 파일 명
                        Directory(  // 디렉토리
                            "/var/users/*.xml"
                        )
                    ),
                    /* 정규식 */,
                    /* 정규식 */
                )
            )
        )
    )

여기서 사용된 객체들의 행동은 내부에 캡슐화되어있습니다.

우리는 앞으로 아래의 절차적인 코드 대신 객체지향적인 코드로 작성해야합니다. 

// 절차지향
var rate: Float
if ( client.age() > 65 ) {
    rate = 2.5f
} else {
    rate = 3.0f
}

// 객체지향
val rate = If(
    GraterThan(AgeOf(client), 65),
    2.5, 3.0
)

순수한 OOP에서는 if, for, switch, while 연산자는 필요 없습니다. 클래스로 구현된 IF, For, Switch, While만 필요합니다. 


7. 주관적인 생각

말 그대로 객체지향이라는 표현은 이 책에서 말한 대로 객체화 한다는 저자의 주관적인 생각이 맞다고 생각하지만... 과연 실무에서 이 책에서 말한 내용을 얼마나 적용할 수 있을까 고민부터 생각나는 장입니다. 

실무에서는 정적 메서드, 유틸리티 클래스, if, for등의 연산자는 정말 많이 사용됩니다. 하지만 이 것들을 전부 객체화하여 사용하려면 모든 팀원과 의견을 맞추어 컨벤션을 정하고 이대로 진행했을 때 이 책에서 말하는 진정한 객체지향의 장점이 두각될 것이라고 생각합니다. 

반응형