2. 다이어그램으로 작업하기

UML을 어떻게 쓰기보다는 '왜' 쓰는지에 초점을 맞춰서 생각해보자. 잘못 쓰면 어마어마한 시간낭비를 할 수 있다.

왜 모델을 만들어야 하는가?

'엔지니어'는 '모델'을 만든다. 그 이유는 무엇일까? 토목 엔지니어가 실제 사용되는 다리보다 훨씬 작은 다리의 모델을 만드는 이유는 뭘까? 그건 다리가 제대로 작동할지 확인하기 위해서이다. 가장 간단하게는 내가 생각한 만큼 하중을 견뎌줄 수 있을지가 궁금할 것이다.

모델은 어떤것이 실제로 잘 작동하는지 알아보기 위해서 만드는 것이다. 또 일반적으로 모델을 만드는 이유는 모델을 만드는 비용이 실제 물건을 만드는 비용보다 훨씬 적어서이다.

모델은 반드시 시험해볼 수 있어야 한다. 그리고 시험할 때 적용할 기준이 있어야 한다. 만일 모델을 만들었더라도 모델을 평가할 수 없다면 그 모델은 가치가 없다. 모델을 시험해보고 이 모델이 작동할 것 같다고 생각하면 그 때 모델의 설계대로 다리를 만드는 것이다.

왜 소프트웨어 모델을 만들어야 하는가?

소프트웨어의 모델은 일반적으로 UML 다이어그램이다. 그렇다면 위의 모델을 만들어야 하는 이유들을 소프트웨어에 적용할 수 있을까?

  • 모델에 따라 만든 소프트웨어가 실제로 잘 작동하는지 알아볼 수 있는가?

  • 모델을 만드는 비용이 실제 소프트웨어를 만드는 비용보다 적은가?

  • 평가 기준에 따라 모델을 시험해볼 수 있는가?

우리는 이에 명확하게 대답하기 힘들다. 다이어그램은 소프트웨어가 실제로 잘 작동할지에 대해 보장해주지 않으며, 실제 소프트웨어를 만드는 비용보다 클 수도 있다. 또한 모델 시험 평가도 가능하긴 하지만, 실제 동작하는 프로그램이 아니므로 굉장히 주관적으로 평가하게 된다.

시험해볼 구체적인 것이 있고, 그것을 코드로 시험하는 것보다 UML로 시험해보는 쪽이 비용이 덜 들 때 UML을 사용한다.

이를테면 설계 아이디어가 떠올랐을 때, 우리팀의 다른 개발자들이 그 아이디어를 괜찮게 생각하는지 시험하기 위해 칠판에 UML 다이어그램을 그려놓고 팀에게 어떻게 생각하는지 물어볼 수 있다.

코딩을 하기 앞서 포괄적인 설계를 해야 하는가?

설계는 미리 계획을 짜고 수행하는 것이 훨씬 비용이 적게 들기 때문에 하는 일이다. 잘못된 설계를 버리는 것은 쉽지만, 잘못된 설계가 들어간 생산품을 버리는 일은 비용이 크다.

그러나 소프트웨어의 경우는 코드를 작성하는 것보다 UML 다이어그램을 그리는 것이 훨씬 비용이 적은지 명확하지 않다.

코드를 작성하기에 앞서 포괄적인 UML 설계를 만들면 드는 비용만큼 효과가 있는지 명확하게 바로 알 수는 없다.

UML을 효과적으로 사용하려면?

소프트웨어의 모델은 다른 엔지니어링의 모델과는 결이 다르다는 사실을 알았다. 그렇다면 언제 사용하면 최적의 효율을 낼 수 있을까?

다른 사람과 의사소통할 때

소프트웨어 개발자들끼리 설계 개념에 대한 의견을 주고받을 때 굉장히 편리하다. 몇몇 개발자가 칠판 주위에 모여서 상당히 많은 일을 할 수 있게 해준다. 만약 다른 사람에게 말해줄 아이디어가 있다면, UML은 큰 도움이 될 수 있다.

위와 같은 LoginServlet 다이어그램은 의미가 매우 명확하다.

  • LoginServlet 클래스는 Servlet 인터페이스를 구현하였다.

  • LoginServlet 클래스는 내부적으로 UserDatabase 객체를 끌어쓴다.

  • Servlet 인터페이스는 HTTPRequestHTTPResponse 객체를 사용한다.

다이어그램은 이렇게 코드의 구조를 설명할 때는 매우 유용하다.

그렇다면 알고리즘을 설명할 때는?

public class BubbleSorter {
    static int operations = 0;

    public static int sort(int[] array) {
        operations = 0;

        if(array.length <= 1) {
            return operations;
        }

        for(int nextToLast = array.length - 2; nextToLast >= 0; nextToLast--) {
            for(int index = 0; index <= nextToLast; index++) {
                compareAndSwap(array, index);
            }
        }

        return operations;
    }

    private static void compareAndSwap(int[] array, int index) {
        if (array[index] > array[index+1]) {
            swap(array, index);
        }

        operations++;
    }

    private static void swap(int[] array, int index) {
        int temp = array[index];
        array[index] = array[index+1];
        array[index+1] = temp;
    }
}

알고리즘을 표현할 때는 위와 같이 보기에도 깔끔하지 않고, 흥미로운 세부사항을 반영하지도 않는다. 사실 코드의 내용이 굉장히 많이 들어가있다. 이런 목적으로 UML을 사용하면 얻는 것보다 잃는 것이 많다. 차라리 코드로 표현하는 편이 좋다.

로드맵

UML은 대규모 소프트웨어 구조의 로드맵(road map)을 만들 때 유용하다. 어떤 클래스가 다른 클래스에 의존하는지 개발자가 빨리 파악할 수 있게 해주고 전체 시스템의 구조에 대한 참조 도표로도 활용 가능하다.

이것을 문서로 보관하기 위해 많은 노력을 들이는 것은 대개 별로 의미는 없다. 이런 다이어그램의 가장 좋은 사용법은 칠판에 그렸다가 지웠다가 하면서 사용하는 것이다.

백엔드(back-end) 문서

설계에 대한 문서를 작성하기에 가장 적당한 때는 프로젝트 막바지에 팀의 마지막 작업으로 하는 것이 가장 좋다. 작성한 문서가 팀이 프로젝트를 떠나는 시점의 설계 상태를 정확하게 반영할 것이므로, 다음에 이 프로젝트를 맡을 팀에게도 분명히 유용하다.

UML 다이어그램은 주의 깊게 생각한 뒤에 그려야 한다. 아무도 몇천장짜리 시퀀스 다이어그램을 원하지 않는다.

무엇을 보관하고 무엇을 버려야 하는가

  • 시스템 안에서 자주 사용되는 설계상의 해결 방법

  • 코드에서 알아내기 힘들지만 꼭 지켜야 하는 복잡한 절차

  • 시스템에서 자주 드나들지 않는 영역의 로드맵

  • 설계자의 의도를 코드보다 더 잘 표현할 수 있는 방식

위와 같은 경우가 아닌 모든 다이어그램은 버리는 편이 좋다.

다이어그램은 반복을 통해 다듬어야 한다.

다이어그램은 한 순간 번뜩이는 일필휘지로 그리는 것이 아니다. 대부분의 사람들은 작은 단계를 하나씩 밟아가며 단계마다 무엇을 했는지 평가하는 방식으로 진행되는 일을 잘 한다. 사람들은 단계마다 크게 도약하며 이루어지는 일을 잘하지 못한다.

쓸모 있는 UML 다이어그램을 만들 때도 마찬가지다. 작은 단계를 하나씩 밟아나가자.

행위를 제일 먼저

꼼꼼하게 끝까지 생각하는 일에 UML이 도움이 된다고 느껴지는 문제들을 다룰 때 간단한 시퀀스 다이어그램으로 그리며 그 문제를 풀기 시작한다.

휴대전화를 제어하는 소프트웨어에서 전화를 걸기 위해 소프트웨어가 어떻게 해야 하는지 설계해보자.

1단계

버튼이 눌릴 때마다 이를 감지해서 다이얼을 돌리는 일을 제어하는 객체에 메시지를 보낼 것이라고 상상해보자. 버튼(Button) 객체와 다이얼(Dialer) 객체를 그리고 버튼이 다이얼에 번호 메시지를 여러 개 보내는 것도 고려하여 그렸다.

2단계

번호 메시지를 받으면 무엇을 해야 할까? 화면에 번호를 표시해야 할 것이다. 그러므로 Screen 객체를 만들었고, displayDigit이라는 메시지도 만들었다.

3단계

번호를 눌렀다는 삑 메시지를 스피커를 통해 출력하기 위해 Speaker 객체를 만들고, tone 메시지를 만들었다.

4단계

번호를 다 누르고 나면 (1의 과정이 끝나고 나면) 통화 버튼을 눌러서 전화를 해야 한다. 그게 2의 프로세스이다.

send : Button이라는 객체를 하나 생성하고, Radio라는 객체를 생성했다. 그리고 각 send, connect라는 메시지를 만들었다.

5단계

전화걸기 버튼을 눌렀을 때, 화면에 전화를 걸었다는 표시를 해주어야 하고, 이 메시지를 보낼 때는 다른 제어 스레드를 사용할 가능성이 굉장히 높기 때문에 시퀀스 번호 앞에 글자를 붙여 이 사실을 표현해준다.

구조 점검하기

방금 작은 연습을 통해 아무것도 없는 상태에서 어떻게 협력(collaboration)을 만드는지 알게 되었다. 이 과정에서 우리는 여러 객체를 만들었으며, 우리는 객체들이 생겨날 것을 처음에는 짐작하지 못했다. 단지 어떤 일이 일어나야 하는지만 생각하면서 그 필요한 일을 하게끔 객체들을 만들어냈을 뿐이다.

하지만 더 진행하기 전에 이 협력이 코드 구조에서 어떤 의미인지 조사해야 한다. 그래서 이 협력을 만들 수 있는 클래스 다이어그램을 그려볼 것이다. 이 클래스 다이어그램에서 협력에 참여하는 객체마다 클래스가 하나씩 생기며, 협력의 연결마다 연관이 하나씩 생긴다.

1단계

현재 일부러 집합(agreegation)합성(composition)을 계속 무시하고 있다. 설계 단계에서는 일부러 그렇게 하는 것이다. 앞으로도 이 관계 중 어떤 것이 적합한지 고려할 시간은 충분하다.

당장 중요한 것은 의존관계를 분석하는 일이다. 왜 ButtonDialer에 의존해야 하는가? 이 질문을 곰곰히 생각해보면, 이 의존 관계가 매우 나쁘다는 사실을 알 수 있다.

public class Button {
  private Dialer itsDialer;
  public Button(Dialer dialer) {
    itsDialer = dialer;
  }

  ...
}

버튼은 버튼일 뿐, 반드시 다이얼의 숫자 버튼이어야 할 이유는 없다. 휴대전화에는 종료 버튼, 볼륨 조절 버튼, 홈 버튼 등 많은 버튼이 존재한다. 그 때마다 새로운 버튼 클래스를 만들고 싶진 않다.

위와 같이 중간에 인터페이스를 끼워넣어 다이얼과 버튼의 의존성을 조금 더 줄일 수 있다. Button은 저마다 고유한 식별자 토큰을 하나씩 가진다. Button 클래스는 자기가 눌렸다는 사실을 감지하면, ButtonListener 인터페이스의 buttonPressed 메서드를 호출하면서 자기 식별자 토큰을 인자로 넘긴다.

아마 Dialer를 간단하게 코드로 표현하면 다음과 같이 구현될 것이다.

class Dialer implements ButtonListener{
  private Screen screen;
  private Speaker speaker;
  private Radio radio;

  public Dialer(Screen screen, Speaker speaker, Radio) {
    this.screen = screen;
    this.speaker = speaker;
    this.radio = radio;
  }

  @Override
  public void buttonPressed(String token) {
    /*
    TODO: 숫자가 들어온다면 숫자를 Screen에 표기하고, Speaker에서 삐 소리를 출력
    만일, 통화 버튼이 들어온다면 Radio를 통해 통화연결을 해주어야 함
    */
  }
}
...

class Button {
  String token;
  // 여기에 Dialer 클래스를 주입해주면 됨
  ButtonListener buttonListener;

  Button(ButtonListener buttonListener, String token) {
    this.buttonListener = buttonListener;
    this.token = token;
  }

  public buttonPressed() {
    buttonListener.buttonPressed(this.token);
  }
}
...

public static void main() {
    Button zeroButton = new Button(new Dialer(), "0");
}

이렇게 하면 ButtonDialer에 의존하지 않으며, 버튼이 눌렸다는 사실을 알아야 하는 거의 모든 경우에 Button을 사용할 수 있다.

위와 같이 변화가 생겼음에도, 이전에 작성했던 동적 다이어그램에는 아무런 영향도 미치지 않는다. 변한 것은 오직 클래스 다이어그램 뿐이다.

2단계

불행하게도 이번에는 DialerButton에 대해 알아야 하는 일이 생겼다. Dialer가 꼭 ButtonListener에서 입력이 들어올 것이라고 기대해야 하는지는 생각해봐야 할 문제이다.

자그마한 어댑터를 이용하여 이러한 의존성을 제거해보자.

ButtonDialerAdapterButtonListener 인터페이스를 구현한다. 이 어댑터의 buttonPressed 메서드가 호출될 때, 이 어댑터는 Dialerdigit(n) 메시지를 보낸다. Dialer에 전달할 숫자(n)는 어댑터가 기억하고 있다.

코드를 마음속으로 그려보기

코드를 마음속에서 그려볼 수 있는 힘은 다이어그램으로 작업할 때 매우 중요하다. 다이어그램을 사용하는 이유는 아이디어를 간단히 그림으로 나타내고 싶어서이다. 다이어그램으로 코드를 대신할 수는 없다.

다이어그램을 그려놓고 다이어그램을 봤을 때 다이어그램이 나타내는 코드가 생각나지 않는다면, 아무런 실용적 의미가 없다.

'지금 하는 작업을 당장 중단하고 그 다이어그램을 코드로 바꿀 수 있는지 찾아내야 한다.' 다이어그램은 목적이 될 수 없다. 언제나 지금 다이어그램으로 나타내는 코드가 어떤 것인지 스스로 확실히 알고 있어야 한다.

다이어그램의 진화

public class ButtonDialerAdapter implements ButtonListener {
  private int digit;
  private Dialer dialer;

  public ButtonDialerAdapter(int digit, Dialer dialer) {
    this.digit = digit;
    this.dialer = dialer;
  }

  @Override
  public void buttonPressed() {
    dialer.digit(digit);
  }
}

public class SendButtonDialerAdapter implements ButtonListener {
  // TODO: FunctionButton는 enum 타입으로 설계하면 좋을 것 같음 (CALL || HANG_UP || REDIAL || ...)
  private FunctionButton functionButton;
  private Dialer dialer;

  public SendButtonDialerAdapter(FunctionButton functionButton, Dialer dialer) {
    this.functionButton = functionButton;
    this.dialer = dialer;
  }

  @Override
  public void buttonPressed() {
    dialer.function(functionButton);
  }
}

Dialer는 이제 버튼과의 의존성이 사라졌다. 지금까지 여러 다이어그램이 어떻게 반복적인 방식으로 함께 발전하는지 알아보았다.

  • 처음에는 간단한 동적 다이어그램으로 시작

  • 동적인 것들이 정적인 관계에서는 어떤 의미인지 조사

  • 정적 관계를 좋은 설계 원칙에 따르게끔 개선

  • 처음으로 돌아가 동적 다이어그램을 개선

이 단계 하나하나는 아주 작다. 우리는 동적 다이어그램이 암시하는 정적인 구조를 만들기 전에 동적 다이어그램에 5분 이상 투자하지 않을 것이다. 그 대신 두 다이어그램을 아주 짧은 주기로 번갈아 보며 서로 상대를 발판 삼아 함께 발전시키고자 한다.

우리는 칠판에서 이 과정을 진행하고 있을 것이며, 뒷날을 위해 지금 하는 일을 기록하고 있지도 않다는 점을 명심하라. 형식을 아주 잘 지키려 하지도 않고, 아주 정확하게 다이어그램을 만들려고 하지도 않는다.

칠판 앞에서의 작업 목표는 다이어그램 시퀀스 번호에 점 하나를 제대로 찍는 것 따위가 아니다. 칠판 앞에 서 있는 사람이 지금 논의하는 내용을 이해하는 것이 목적이다. 궁극적 목적은 칠판에서 그만 작업하고 사람들이 코드를 작성하기 시작하게 하는 것이다.

미니멀리즘

다이어그램은 원한다면 굉장히 복잡해보이게 만들 수 있다. 엄청나게 많은 세부사항을 넣을 수도 있다. 하지만 이는 복잡성만 가중시킨다. 다이어그램이 가장 유용한 때는 다른 사람과 의사소통을 할 때와 설계에 관한 문제점을 푸는 일에 도움이 될 때이다. 목적을 달성하기에 꼭 필요한 분량만큼 세부사항을 사용하는 것이 가장 중요하다.

다이어그램을 최대한 단순하고 깔끔하게 유지하라.

UML 다이어그램은 소스코드가 아니며, 따라서 모든 메서드나 변수, 관계를 선언하는 장소로 취급해서는 안 된다.

언제 어떻게 다이어그램을 그려야 하는가

다이어그램 그리기는 매우 유용할 수도 있고 시간낭비가 될 수도 있다.

다이어그램을 그려야 할 때는?

모든 것을 다이어그램으로 그려야 한다는 규칙을 만들면 안 된다. 아무도 읽지 않을 다이어그램을 그리는데 엄청난 프로젝트 시간과 에너지를 낭비하지 말자.

  • 여러 사람이 동시에 작업하기 때문에 모두 설계의 특정한 부분에 대한 이해가 필요할 때

    • 모두 이해가 됐다면 필요 없어진다.

  • 두 명 이상이 특정 요소를 어떻게 설계할지 의견을 달리하고, 팀의 의견을 모을 필요가 있을 때

    • 논쟁이 끝나면 필요 없어진다.

  • 설계 아이디어로 이것저것 시도해보고 싶을 때

    • 핵심을 깨닫게 되어 생각을 코드로 옮길 수 있으면 필요 없어진다.

  • 코드 일부분의 구조를 설명할 때

    • 코드를 직접 보는 게 낫겠다고 생각되면 필요 없어진다.

  • 프로젝트 마지막에 고객이 다이어그램을 요구할 때

다이어그램을 안 그려도 될 때는?

  • 공정에서 다이어그램을 그려야 한다고 정했는데 그럴 필요가 없어보일 때

  • 죄책감에 그릴 때 (훌륭한 설계자는 누구나 다이어그램을 그린다는 생각에서 벗어나라. 훌륭한 설계자는 코드를 작성하다 필요에 의해서만 그린다.)

  • 코딩을 시작하기 전 설계 단계의 포괄적인 문서를 만들기 위할 때

  • 다른 사람에게 어떻게 코딩을 해야할지 알려주기 위할 때

CASE 도구는 사야 하는가?

대부분의 경우 사지마라. 생산성 증대가 보장된다면 그 때만 구매해도 된다.

문서화는?

문서는 반드시 작성해야 하지만, 반드시 현명하게 작성해야 한다.

  • 복잡한 통신 프로토콜

  • 복잡한 관계형 데이터베이스의 스키마

  • 재사용 가능한 복잡한 프레임워크

위의 경우 문서를 작성하는 것이 효율이 좋다.

그러나, 문서는 양과 가치가 반비례한다. 양이 적을수록 좋은 문서다. 핵심을 관통해야 한다.

주로 담을 내용은

  • 중요한 모듈의 고차원 구조에 대한 UML 다이어그램

  • 관계형 스키마의 ER 다이어그램

  • 시스템을 빌드하는 방법, 테스트 방법, 소스코드 컨트롤 방법

문서 분량을 적게 가져가려면 많은 노력이 필수적으로 요구될 것이다.

사람들은 짧은 문서는 읽지만 1000쪽짜리 문서는 읽지 않는다.

javadoc은 만들되 짧게 유지하자.

결론

  • UML은 사람들이 칠판 주위에 모여 설계 문제점을 생각할 때 도움이 된다.

  • UML로 만든 다이어그램은 반드시 반복적 방식으로 만들어야 하며, 반복 주기도 무척 짧게 잡아야 된다.

  • 동적 시나리오를 우선 생각하고, 어떤 정적 구조를 함축하는지 결정하는 전략이 가장 좋다.

  • 동적 다이어그램과 정적 다이어그램을 짧은 주기로 반복하며, 서로 발판삼아 동시에 발전시키자.

UML은 도구일뿐 목표로 삼지 말자. 설계 아이디어를 생각하거나 다른 사람들에게 설계 아이디어를 공유할 때 도움이 되지만, 남용하면 상당히 많은 시간을 낭비하기 쉽다. 늘 절제하는 마음가짐으로 사용하자.

Last updated