추상화 수준을 활용해 좋은 코드 작성하기


제목부터 어려운 단어가 등장했다. 추상화 수준. 개인적으로 abstract라는 영단어가 추상화라는 단어로 온전히 표현되지 않는 감이 있지만, 통상적으로 사용되므로 이 글에서도 추상화라고 다룬다. 이 글에서는 추상화 수준에 대해 알아보고, 어떻게 코드로 적용할 수 있을지 설명한다.

Java는 추상화를 통해 발전해 왔다. 추상 클래스, 인터페이스, 레이어의 분리 모두 객체지향과 책임 분리에서 시작됐다. 각 객체의 공통된 특성을 뽑아 나타낸다. 특성을 일반화하면 보다 쉽게 표현할 수 있고, 사람의 수준에서 문장을 읽듯 매끄러운 코드를 작성할 수 있다. 인간은 구체적인 것을 모두 담아둘 수 있을 정도의 단기 기억 저장소를 가지고 있지 않다. 따라서 코드 한 줄 한 줄이 어떤 구체적인 연산을 하는 지보다는, 전체적인 흐름을 이해하는 것이 우선이다.

추상화 수준이라는 것은 뭘까?

우아한테크코스 체스 미션을 진행하던 도중, 리뷰어로부터 위와 같은 리뷰를 받았다. 체스판 Board 객체가 너무 많은 일을 하고 있다는 이야기다. 저 때의 체스판은 기물을 움직일 때 아래와 같은 상황을 검증했다.

  1. 출발 위치에 기물이 있는지 확인한다.
  2. 도착 위치에 아군 기물이 존재하는지 확인한다.
  3. 경로 중간에 다른 기물이 존재하는지 확인한다.
  4. 기물이 해당 경로를 통해 이동할 수 있는지 확인한다.

처음 코드를 짤 때에는 모든 게 체스판의 역할이라고 생각했다. 체스판이 기물을 가지고 있고 이를 관리한다. 기물의 움직임과 관련된 역할은 체스판이 가지는 것이 자연스러웠다. 다른 곳에서 위치를 사용하고 있지 않기 때문이었다.

‘일을 많이 한다? 위치는 체스판만 알고 있으니 위치와 관련된 책임은 체스판이 가지는 게 자연스러운데?’
‘나의 설계가 잘못 된 건가? 위치를 어떻게 다른 곳에서 계산할 수 있지?’

여러 생각이 머리를 스친다. 어느 정도 이해가 된 시점인 지금은 추상화 수준이 이번 미션에서, 어쩌면 이번 레벨에서 가장 크게 얻어가는 부분이라고 생각해 글로 남기려고 한다. 복잡한 개념이라고 생각될 수 있지만, 한 번 머릿속에 자리잡으면 코드를 작성할 때 좋은 이정표가 되리라고 생각한다 😉

❓ 추상화 수준이 뭐예요

프로그래밍 언어에서의 추상화구체화의 정반대에 위치한 단어다. 구체적이라는 것은 무엇을 의미할까? 간단한 상황을 통해 이해해 보자. 여러분은 심부름을 받아 마트에서 계란을 사 와야 한다. 집에서 출발해 마트에서 계란을 사 오는 과정을 어떤 식으로 그려낼 것인가?


🛒 마트에서 계란 사기 🥚

  1. 마트로 간다.
  2. 장바구니를 잡는다.
  3. 계란을 장바구니에 넣는다.
  4. 계산대에서 장바구니의 물건을 계산한다.
  5. 집으로 돌아온다.
  1. 마트로 간다.
  2. 계란을 산다.
  3. 집으로 돌아온다.

왼쪽은 자세하게 적은 것이고, 오른쪽은 간략하게 적은 것이다. 왼쪽의 2-4번의 행동을 계란을 산다는 행동으로 추상화한 것이다. 심부름하는 사람의 입장에서는 왼쪽처럼 모든 것을 기억하는 것이 더 효율적일까, 오른쪽처럼 어떤 물건을 사는 것에 초점을 맞추는 것이 더 효율적일까?

보통의 사람이라면 왼쪽처럼 모든 행동을 자세하게 적어두지는 않을 테다. 행동 목록에서 중요한 것이 흐려질 수 있기 때문이다. 실제로 인간은 행동을 추상화하는 것을 좋아한다. 구체적인 것은 그때 생각하고, 전체적인 흐름을 중요하게 생각한다.

결국 코드에서의 구체화는 한 줄 한 줄에서 프로그램이 어떻게 돌아가는지를 직접 서술하는 것이고, 추상화는 이를 조금 더 간략하게 나타내는 것이라고 할 수 있겠다. 나아가 메서드가 하나의 일만 하도록 분리해내는 것도 추상화를 지키기 위한 하나의 방법이 된다. <Clean Code>에서 잠깐 반짝이고 사라지는 키워드다. 조금 더 깊게 알아보자!

🤔 상대적인 추상화 수준

아래 짧은 영상은 시리얼 먹기를 부모에게 한 줄 한 줄 명령하고, 부모는 그 명령대로’‘ 행동해 시리얼을 먹을 수 있을까? 에 대한 실험 영상이다.

아이들이 적어내려간 명령들은 시리얼을 먹는 것을 달성할 수 있을 것이라는 기대가 있다. 다만 절차지향적으로 생각하도록 유도하는 실험카메라 특성 상, 부모에게 충분한 정보를 제공하지 못해 시리얼을 올바르게 못 먹는 경우가 많았다.

객체의 입장에서 보면 어떨까? 행동을 수행하는 부모의 입장에서는 작은 디테일 하나하나를 신경쓰지 않아도 된다는 배경지식이 어릴 때부터 갖춰져 있다. 아이들은 서툴렀지만 구체화와 추상화를 어느 정도로는 할 수 있었다고 보았다.

마트 예시로 다시 돌아와 보자. 심부름하는 나의 입장에서는 장바구니가 어떻고, 계란을 찾아서 장바구니에 담아 계산하는 과정은 불필요하다. 어떻게 보면 나에게 어울리지 않는 추상화 수준을 담고 있다. 내가 생각하는 장보기는 마트에 가서, 물건을 구매한 뒤 돌아오는 것이다. 실생활에서 자체적으로 움직일 수 없는 물건들도 코드 세계에서는 객체가 돼 다른 물건들과 상호작용할 수 있음을 떠올리자. 각 객체에게 책임을 부여할 수 있다.

사람의 입장에서 본 심부름

  1. 마트로 간다.
  2. 계란을 산다.
  3. 집으로 돌아온다.

각 객체가 가지는 책임

  • (마트) 장바구니를 대여해준다.
  • (장바구니) 물건이 주어지면, 담는다.
  • (계산대) 장바구니가 주어지면, 계산한다.

사람의 입장에서는 마트가 어떻고, 장바구니가 어떻고를 신경쓰고 싶지 않다. 결국 자신의 목적을 위한 간략한 내용을 담고, 구체적인 구현은 아래 세부 객체에게 행동할 것을 기대한다. 마트의 입장을 볼까? 마트에는 계산대와 장바구니가 있지만, 마트라는 객체의 입장에서는 계산을 어떻게 하고, 장바구니에 어떤 것을 담는 것은 더 구체적이다.

이처럼, 각 객체마다의 추상화 수준이 서로 다르고, 이는 상대적이다. 사람의 입장에서는 마트와 장바구니 모두 구체화된 행동을 한다고 생각하지만, 마트와 장바구니 사이에도 추상화 수준의 차이를 찾아볼 수 있다. 이 추상화 수준의 간극은 계층Layer을 만든다. 추상화 수준이 높은 객체가 보다 낮은 객체에게 구체적인 일을 시키는 식이다. 나의 행동에 더 집중할 수 있게 된다.

♟️ 미션에 적용하는 추상화 수준

자 그럼, 처음 체스를 다시 생각해 보자. 나의 체스판은 너무 많은 검증을 하고 있었다. 기존 체스판의 코드에서 기물의 이동을 담당하는 메서드를 살펴보자.

public class Board {
    private final Map<Position, Piece> pieces;
    // ...
    public void move(Position source, Position destination, Color currentTurnColor) {
        validatePosition(source, destination);
        validateNoPiecesBetween(source, destination);

        Piece piece = pieces.get(source);
        validateTurn(piece, currentTurnColor);
        validateMovable(source, destination, piece);
        replacePiece(source, destination, piece, currentTurnColor);
    }
}

어떻게 보면 각 검증은 체스판이 하는 게 맞지만, 체스판의 추상화 수준에서는 아래와 같은 연산만을 하는 것이 충분하다고 보여진다.

두 칸이 주어졌을 때, 출발 칸의 말을 도착 칸으로 옮긴다.

각자의 추상화 수준을 가지도록 하고, 만약 적절한 객체가 존재하지 않는다면 새로운 객체를 도출해낼 수 있다. 나는 각 칸에 해당하는 Square 객체, 경로를 가지도록 하는 Path 객체를 만들어 책임을 덜었다. 기존 네 가지 검증은 하위 객체에게 책임을 전가했다.

  1. Square 출발 위치에 기물이 있는지 확인한다.
  2. Square 도착 위치에 아군 기물이 존재하는지 확인한다.
  3. Path 경로 중간에 다른 기물이 존재하는지 확인한다.
  4. Path 기물이 해당 경로를 통해 이동할 수 있는지 확인한다.

이제는 체스판이 ‘움직여!’ 라고 하면, 하위 객체의 협력을 통해 기물이 움직이게 된다. 추상화 계층이 한 눈에 들어오게 되었다. Board의 입장에서는 세세한 검증을 하기 적합하지 않다. 심부름할 때 장바구니를 신경쓸 필요 없었던 것과 같은 맥락이다. 움직이는 것에 집중하니 아래와 같이 변경되었다. 세세한 검증은 하위 객체에 위임했다.

public class Board {
    private final Map<Position, Square> squares;
    // ...
    public void move(Position source, Position destination, Color currentTurnColor) {
        Path path = createPathBetween(source, destination);
        Square destinationSquare = path.traverse(currentTurnColor);
        squares.put(destination, destinationSquare);
    }
}

👨🏻‍🏫 예시를 통해 이해하기

지금까지 우리는 추상화 수준에 대해서 알아보았으니, 예시를 보면서 어색한 부분을 느껴 보자. 코드에서 냄새를 맡아 보는 거다! 아래 자동차 객체는 정수 값을 생성하는 powerGenerator로부터 생성된 파워값이 5 이상이면 앞으로 전진한다. 이 메서드 이름을 moveOnGeneratedPowerExceedsThreshold라고 하자.

public class Car {
    private static final int SPEED = 1;
    private static final int THRESHOLD = 4;
    
    private final IntSupplier powerGenerator;
    private int position;
    // ...
    
    public void moveOnGeneratedPowerExceedsThreshold() {
        if (powerGenerator.getAsInt() > THRESHOLD) {
            position++;
        }
    }
}

하나씩 코드를 발전시켜나가 보자. 추상화 수준에서 바라보았을 때, 해당 메서드는 생성한 파워값이 임계치 초과인 경우 앞으로 가는 역할이다. 직접 position을 다루는 코드는 메서드 이름에 비해 구체적이다. 속한 메서드의 시그니처와 어색하게 어울리는 느낌이 든다. 이를 분리해 보자. 우선 메서드를 분리할 수 있겠다.

public class Car {
    // ...
    public void moveOnGeneratedPowerExceedsThreshold() {
        if (powerGenerator.getAsInt() > THRESHOLD) {
            move();
        }
    }
   
    private void move() {
         position++;
    }
}

이제는 코드의 흐름이 한 눈에 들어온다. 이번에는 클래스 내부에서의 전체적인 추상화 수준을 확인해 보자. private 메서드로 각각의 메서드의 추상화 수준을 맞춰주긴 했지만, 클래스 내부에서 추상화 수준이 다양하게 존재한다. moveOnGeneratedPowerExceedsThreshold는 상대적으로 높은 추상화 수준을 가지고 있고, move는 위치를 직접 조작하는 방식으로 구체화돼 있다.

한 단계 더 나아가서, private 메서드로 분리한 내용을 객체의 상호작용을 통해 책임을 적절히 분배할 수 있을까? position을 다루는 것이 너무 구체적이므로, 이를 담당하는 객체를 가지면 좋을 듯하다. 위치를 나타내는 객체에게 책임을 나눠 보자!

public class Position {
    private final int distance;
    // ...
    public Position increase(int amount) {
         return new Position(distance + amount);
    }
}

Car에 비해 구체적인 객체가 생겼으니, 서로 메시지를 던지며 상호작용하도록 코드를 수정할 수 있게 됐다. 처음에는 차 한 대로 모든 것을 할 수 있을 것처럼 보였다. 실제로 위치를 자동차가 가지고 있으므로 위치를 수정하는 책임이 자동차에게 있는 것도 어느정도는 맞다. 역할을 가질 수 있는 숨겨진 객체를 추상화 수준을 통해 도출해낼 수 있게 되었다.

public class Car {
    // ...
    private final IntSupplier powerGenerator;
    private Position position;
    // ...
    
    public void moveOnGeneratedPowerExceedsThreshold() {
        if (powerGenerator.getAsInt() > THRESHOLD) {
            move();
        }
    }

    public void move() {
        position = position.increase(SPEED);
    }
}

Car 내부의 모든 메서드의 추상화 수준이 적절하게 분배된 것처럼 보인다. 하지만 개인적으로는 한 부분이 더 고쳐졌으면 좋겠다고 생각한다. 필드로 가지고 있는 IntSupplier 부분이다. 필드 변수명이 powerGenerator라서 Car와 비슷한 수준의 추상화 수준을 가지고 있다. 하지만 그 앞에 붙은 클래스명과는 어떠한가? IntSupplier와는 어울린다고 할 수 있을까? 🤔

Java 8이 등장하면서 함수형 인터페이스가 대거 등장했다. IntSupplier, Consumer, UnaryOperator 등 다양한 인터페이스가 등장하면서 다양한 상황에 해당하는 구현체를 추상화했다. 예를 들어, ‘파라미터가 없고 정수를 리턴하는 역할’을 IntSupplier를 통해 구현할 수 있다.

IntSupplier와 같은 인터페이스의 추상화 수준은 매우 높다. 이를 사용하는 클래스 중 하나로 IntStream이 있다. 스트림도 추상화 수준이 높다. 사람이 보았을 때 구체적인 구현 사항을 제공하는 것이 아니라, 선언형으로 코드를 작성해 글을 읽듯이 코드를 훑을 수 있다. IntSupplierIntStream은 비슷한 추상화 수준에 놓여 있으므로, 서로 사용하는 데 큰 거리낌이 없다.

하지만 상대적으로 추상화 단계가 낮고 구체적 연산을 가지는 클래스에서 사용하면 부작용이 발생한다. 두 추상화 수준 사이에 간극이 발생해 코드를 읽을 때 어색하다. IntSupplier의 메서드 이름 getAsInt도 구현체와 어울리지 않아 보인다.

이때 추상화 수준을 맞춰주는 것을 고려할 수 있다. IntSupplier와 행동은 동일하지만, 클래스명과 메서드 시그니처에서 구체화하도록 하는 함수형 인터페이스를 사용하자. 구현체의 추상화 수준과 어울리도록 메서드 시그니처를 만들어 보자.

// PowerGenerator.java
@FunctionalInterface
public interface PowerGenerator {
    int generate();
}

// Car.java
public class Car {
    // ...
    private final PowerGenerator powerGenerator;
    private Position position;
    // ...
    
    public void moveOnGeneratedPowerExceedsThreshold() {
        if (powerGenerator.generate() > THRESHOLD) {
            move();
        }
    }

    public void move() {
        position = position.increase(SPEED);
    }
}

짠! 처음과 비교해봤을 때, 메서드가 하는 일이 눈에 잘 들어온다. 나아가 Car가 하는 일의 수준이 명확하다 🎉 추상화 수준을 통일하면 다른 객체와의 상호작용과 메시지를 던지는 모습을 더 쉽게 확인할 수 있다. 객체의 역할이 명확해진다.

☁️ 마치며

지금까지 추상화 수준이 무엇인지, 어떤 방식으로 객체를 바라봐야 하는지에 대해서 알아봤다. 나도 미션을 진행하면서 가장 어려웠던 개념이라 리뷰어와 긴 호흡으로 DM을 주고받았다. 추상화 수준이 추상적이라서 이해하기가 어렵다 이런 방식으로 생각하다 보면, MVC와도 엮어 각 계층이 어떤 책임을 가져야할지도 명확해진다. 처음에는 와닿지 않을 수 있지만 끊임없이 의식하는 연습을 해 보자. 언젠가 ‘이 객체에게 적당한 역할이 부여되었는가?’에 대한 각자의 기준이 보다 선명하게 잡힐 것이라고 생각한다! 😁

Categories