티스토리 뷰

728x90

 

 

우테코에서 진행했던 방과후 수업 자료를 업로드한다.

엘레강트 오브젝트 스터디를 하면서 인상 깊었던 내용과, 이를 우테코 미션에 어떻게 적용했는지에 대해 얘기했다.

발표에 대한 강박을 깨기 위해 수업을 진행했는데, 머리 속 뒤죽박죽 했던 지식들을 정리할 수 있어서 좋았다 🤗

 

 

 


명령형 프로그래밍 vs 선언적 프로그래밍

명령형 프로그래밍 :컴퓨터처럼 연산을 차례대로 실행한다. 주로 static 메서드로 구성된다.

선언적인 프로그래밍 : 객체와 객체 사이의 관계로 구성된다.

 

예를 들어, 세 개의 수 중 중간 값(between)을 찾는 기능을 구현한다고 하자.

 

 

명령형 프로그래밍

public static int between(int l, int r, int x) {
    return Math.min(Math.max(l, x), r);
}

static 메서드인 Math.min(), Math.max()를 이용해서 새로운 static 메서드 between()을 구현했다.
필요할 때마다 그 자리에서 즉시 계산이 수행되기 때문에 명령적인 방식이다.

 

int x = Math.between(5, 9, 13);

다시 말해 메서드를 호출하자마자 계산 결과를 즉시 얻게 된다.

 

 

선언적 프로그래밍

class Between(left: Number, right: Number, x: Number) : Number {
    private val num: Number = Min(Max(left, x), right)

    override fun intValue(): Int {
        return num.intValue()
    }
}

val y: Number = Between(5, 9, 13)

Between이 무엇인지만 정의하고, 실제 between 값을 계산하지 않았다.
변수 y를 사용하는 측에서 값을 계산하는 시점을 결정한다.

명령형 프로그래밍에서는 계산이 즉시 수행되기 때문에, 항상 한 번만 계산된다.
반면에 선언적 프로그래밍은, 클라이언트가 intValue()를 호출할 때마다 계산되기 때문에 더 비효율적이라고 생각할 수 있다.

 

지금부터 선언적 프로그래밍이 명령형 프로그래밍 보다 좋은 이유를 말해보려 한다.

 

 

1. 더 빠르다.

int x = Math.between(5, 9, 13)
if (/* x가 필요한가? */) {
    println("x: $x")
}

명령형 프로그래밍은 값이 필요하건 필요하지 않건 간에 무조건 값을 계산한다.

 

val y = Between(5, 9, 13)
if (/* y가 필요한가? */) {
    println("y: ${y.intValue()}")
}

선언적 프로그래밍은 모든 것을 계산하지 않고, 필요한 경우에만 계산을 실행한다.
변수를 사용하는 클라이언트에게 결과를 계산하는 시점을 위임한다.
즉, 더 최적화되어 있다.

 

2. 다형성

val x: Number = Between(5, 9, 13)

val y: Number = Between(
    IntegerWithMyOwnAlgorithm(5, 9, 13)
)

선언적인 방식은 새로운 부생성자를 추가하여 다른 객체와 조합해서 사용할 수 있다.
즉, 객체 사이의 결합도를 낮출 수 있다.

 

val x = Math.between(5, 9, 13)

명령적인 방식인 Math.between()에서 새로운 계산 방식을 추가하려면 어떻게 해야할까?
유일한 방법은 메서드를 전체적으로 다시 구현하는 것 뿐이다.
내부에서 static 메서드 Math.min()과 Math.max()를 사용하고 있고, static 메서드는 생성자로 전달할 수 없기 때문이다.

즉, Math.between()은 Math.min()과 Math.max()와의 결합도가 높다.

 

3. 표현력

선언적인 방식은 결과를 표현한다. 반면에 명령적인 방식은 수행 과정을 표현한다.

짝수를 리스트에 저장하는 코드를 예시로 들어보자.

val evens = mutableListOf<Int>()
numbers.forEach {
    if (it % 2 == 0) {
        evens.add(it)
    }
}

먼저, 명령적인 방식이다.
예상 결과를 파악하기 위해 머릿속에서 코드를 실행해야 하기 때문에, 덜 직관적이다.

 

val evens = Filtered(
    numbers,
    { it % 2 == 0 },
)

같은 기능을 선언적인 방식으로 작성한 것이다.
구현과 관련된 세부 사항은 감춰져있고, 언제 계산되는지도 모른다.
오직 객체의 행동만 선언 한다.

알고리즘(algorithm)과 실행 방식(execution) 대신, 객체(object)와 행동(behavior)의 관점에서 생각해야 한다.
여기서 알고리즘과 실행 방식은 명령적인 방식이고, 객체와 행동은 선언적인 방식이다.

 

4. 코드 응집도(Cohesion)

val evens = mutableListOf<Int>()
numbers.forEach {
    if (it % 2 == 0) {
        evens.add(it)
    }
}

아까 본 명령적인 방식의 예제다.
코드를 이어주는 '접착제'가 없다. 다른 개발자가 코드의 순서를 쉽게 변경할 수 있다. 즉, 순서에 따라 실행 결과가 달라지기 때문에 개발자가 순서를 기억하고 있어야 한다.

예제는 짧은 코드지만, 만약 50줄이 넘어가는 코드라면, 순서를 모두 기억하기 어려울 것이다.

 

val evens = Filtered(
    numbers,
    { it % 2 == 0 },
)

반면에 선언적인 방식은 컬렉션을 계산할 책임과 관련된 모든 코드들이 한 곳에 모여 있다. 실수로라도 이를 분리할 수 없다.

 

정리

명령적인 방식 은 주로 static 메서드로 표현되며, 코드의 순서가 결과에 영향을 미치는 절차적인 방식이다.

선언적인 방식 은 객체와 객체 간의 관계로 표현되기 때문에, 더 직관적이며, 객체 사이의 결합도를 낮출 수 있고, 관련된 코드 간의 응집도가 높다.

 

 

 

 


생성자에 코드를 넣지 말 것 (code-free)

선언적인 방식은 즉시 계산을 수행하지 않고, 객체가 어떤 것인지 선언만 하는 방식이라고 얘기했다.
나는 이 부분을 읽고 책 1.3의 "생성자에 코드를 넣지 마세요"라는 챕터를 떠올렸다.

먼저, 생성자에 코드가 있는 예제를 보자.

class Cash {
    private int dollars;

    Cash(String dlr) {
        this.dollars = Integer.parseInt(dlr);
    }
}

생성자 파라미터로 받은 String 타입 dlr를 Int로 변환하는 작업을 수행하고 있다.

 

class Cash {
    private Number dollars;

    Cash(String dlr) {
        this.dollars = new StringAsInteger(dlr);
    }
}

생성자에 코드가 없는 예제다.

이전 예제와 달리, 객체를 실제로 사용할 때까지 문자열을 정수로 변환하는 작업을 연기한다.

 

lazy한 객체

왜 생성자에 코드를 넣지 않아야 할까?
투명한 객체를 만들 수 있기 때문이다. 객체를 이해하고 재사용하기가 쉽다.

객체를 인스턴스화하는 동안은, 객체를 만들기 위한 일 이외의 다른 어떠한 일을 수행하지 않는다.
객체는 요청을 받을 때만 행동하고, 요청을 받기 전까지는 어떠한 일도 하지 않는다.
(-> lazy 하다!)

 

val app = App(Data(), Screen(), XXX(), XXX(), XXX())
app.run()

lazy한 객체를 잘 설명해주는 예제다.
app이라는 인스턴스를 생성하는 동안, app은 어떠한 일도 처리하지 않고, 내부의 객체들을 생성하고 준비시킨다.
run() 메서드를 호출하면, 객체들이 적절한 작업을 수행한다.

 

결국 "생성자에 코드를 넣지 않는 방식"과 "선언적 프로그래밍" 모두 lazy한 객체를 구현할 수 있는 방법이다.

마지막으로 이를 우테코 미션에 어떻게 적용했는지에 대해 설명하고자 한다.

 

 

 

 


내가 적용한 부분, 그리고 Lazy Evaluation

객체는 요청을 받을 때만 행동하고, 요청을 받기 전까지는 어떠한 일도 하지 않는다.
(-> lazy 하다!)

당연히 생성자를 제외한 부분도 lazy하게 계산이 수행되어야 한다.
객체 설계에 가장 기본적인 것일 수 있지만, 나는 이 부분을 간과하고 있었다.

 


블랙잭 미션을 진행하면서, 카드 점수 합계를 계산하는 로직이 있었다.
기존 코드에서는 카드를 받을 때마다 점수 합계를 계산했다.

그런데 리뷰어님께 언제나 Lazy Evaluation을 통해 계산되도록 해주세요라는 코멘트를 받았다.

 

 

 

Lazy Evaluation이 뭔데?

계산의 결과값이 필요할 때까지 계산을 늦추는 기법 이다.
나는 점수 합계를 누군가가 요청하지 않아도, 카드를 받을 때마다 점수 합계를 계산 했기 때문에, Lazy Evaluation을 완전히 위반하고 있었다.

그래서 점수 합계를 요청할 때만 계산하도록 수정했다.


그런데 여러 곳에서 점수 합계를 요청하게 되면서, 같은 값을 여러번 계산하게 되었다.
이는 성능 상 비효율적이라고 생각이 들어서, 리뷰어님께 질문을 했다.

"같은 값을 여러번 계산하지 않기 위해 캐싱을 할 수 있다."
"연산이 많아진 대신, 함수의 역할과 책임이 명확해졌다. 이는 더 쉬운 유지보수를 의미한다."
라는 결론을 얻을 수 있었다.

 

또한 책에서는 컴퓨터의 성능보다, 우리가 코드 유지보수에 드는 비용이 더 크다. 고 강조한다.

 

 

 

 

정리

  • 명령형 프로그래밍
    • 컴퓨터의 관점에서, 연산을 차례대로 실행한다.
    • 행동을 "어떻게" 수행하는지에 집중한다.
  • 선언적인 프로그래밍
    • 우리(개발자)의 관점에서, 행동만을 선언한다.
    • "어떤 행동을" 수행하는지에 집중한다.
  • lazy한 객체
    • 요청을 받을 때만 행동하고, 요청을 받기 전까지는 어떠한 일도 하지 않는 객체
    • Lazy Evaluation

 

 

 

[참고 자료]

- 엘레강트 오브젝트 1장, 3장

- https://en.wikipedia.org/wiki/Lazy_evaluation

- 내 블랙잭 미션 PR💗 멋진 리뷰어님💗

728x90
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
글 보관함