본문 바로가기

Computer Science/기타

객체지향 설계 연습(SOLID, GRASP) - 좌표평면 도형 그리기 (Java)

들어가며

객체지향 언어인 자바를 사용할 때, 객체 지향 설계의 중요성에 대해 정말 많이 들었습니다. 그래서 책도 샀습니다. '객체지향의 사실과 오해'라는 책도 읽어보았고 앞으로 '오브젝트'라는 책도 읽어볼 예정인데요, 사실 책 한 권을 읽었음에도 아직 객체지향적으로 사고하고 구현하는 것이 익숙하지 않습니다. 그리고 설계를 했을 때 이 설계가 맞는 것인지에 대한 확신도 없습니다. 물론 설계에 정답은 없겠지만 오답은 존재한다고 생각하기에 이 구조가 좋은 구조인지에 대한 답을 얻기 위해 객체 지향 설계에 대한 이해도가 필요하다는 생각이 자주 드는 요즘입니다. 이번 포스팅의 주제는 따라서 1. 기본 용어 정리 2. 객체 지향적 설계를 위한 5가지 원칙(SOLID) 3. GRASP 패턴 소개 4. 개념을 적용한 예제 프로젝트 구현입니다.


기본 용어 정리


클래스란?

객체를 만들기 위한 설계도, 틀로 비유합니다. 연관된 변수와 메서드의 집합을 클래스라고 합니다.

객체란?

  • 소프트웨어 세계에서 구현할 대상으로 비유합니다.
  • 물리적으로 존재하거나 추상적으로 생각할 수 있는 것, 자신의 속성을 가지며 식별가능한 모든 것은 객체가 될 수 있습니다.
  • 클래스로 인해 생성된 실체를 의미하며, 객체는 인스턴스를 대표하는 포괄적인 의미를 지닙니다.

인스턴스란?

  • 클래스를 기반으로 구현된 구체적인 실체를 의미합니다.
  • 즉, 객체를 실체화한 것이 인스턴스입니다.
  • 추상적 개념과 구체적 개념 사이의 관계에 초점을 맞출 때 사용되기도 합니다.
    • myCoffee는 커피 클래스의 인스턴스입니다.
  • 인스턴스는 어떤 원본(추상 개념)으로부터 생성된 복제본을 의미합니다.

객체와 인스턴스는 엄격하게 구분하기 힘든 추상적인 개념이며, 주로 인스턴스는 원본으로부터 생성되었다는 것을 강조하는 의미로 많이 쓰입니다. 사실 위의 의견은 객체 지향의 사실과 오해에서 읽은 인스턴스와 객체의 구분에 대한 정의인데요, 이 부분에 대해서는 객체와 인스턴스를 완전히 구분하는 의견도 있는 것 같기에 참고만 해주시면 될 것 같습니다.


JAVA의 객체지향 특징


다음으로 객체 지향 언어(여기에서는 JAVA)에서 가지는 객체지향적 특징들을 알아보겠습니다.

메소드

가장 기본적으로, 자바에서는 함수를 메서드라고 부르죠. 객체지향 프로그래밍에서 객체와 관련된 서브 루틴(함수)를 의미하기 때문입니다. 객체의 데이터, 멤버 변수에 대한 접근 권한을 가지며. 클래스 기반 언어(Java 등)에서는 내부에 정의됩니다. 굳이 메서드라고 하는 이유는, 객체를 정의할 때 객체의 상태(내부 변수)와 행동(메서드)을 묶어서 정의하기 때문입니다. 아래의 캡슐화와 연관이 있습니다.

캡슐화

클래스 내부 변수와 메소드를 하나로 패키징하는 특징을 캡슐화라고 정의합니다. 내부 변수와 메소드를 패키징하여 내부 상태의 조작을 외부에 개방된 메소드를 통해서만 가능하도록 만듭니다. 이는 접근 제어자의 사용(public, private, protected 등)을 통해 내부 변수나 메소드를 은닉함으로써 가능해지는데, 은닉과 캡슐화를 통해 객체의 응집도와 독립성을 높일 수 있습니다.

상속

객체들 간의 관계를 구축하는 방법입니다. 상속을 통해 기존 클래스로부터 속성, 동작을 물려받거나, 추상 메서드, 추상 클래스를 구현할 수 있습니다. 상속의 가장 큰 특징은 계층관계를 구축한다는 것입니다. 상속관계는 아래에서 소개할 리스코프 치환 원칙에 의해서 확인할 수 있습니다.

다형성

하나의 객체가 여러 가지 타입을 가질 수 있는 것을 의미합니다. 자바에서는 부모 클래스 타입의 참조 변수로 자식 클래스의 인스턴스를 참조할 수 있도록 함으로써 구현합니다. 이러한 경우 자식 클래스 고유의 멤버를 호출할 수 없으므로 사용할 수 있는 멤버의 개수가 같거나 적어지게 됩니다.

업캐스팅, 다운캐스팅

다형성과 연관있는 개념입니다. 다형성은 자바에서 업/다운 캐스팅을 통해 구현됩니다.

  • 업캐스팅 : 하위 클래스를 상위 클래스로 타입 변환하는 것을 의미합니다.
    • CaffeineBeverage beverage = new Coffee(); (O)
  • 다운캐스팅 : 상위 클래스를 하위 클래스로 타입 변환하는 것을 의미합니다.
    • Coffee coffee = (Coffee)beverage;

객체지향 개발 5대 원리


1. 단일 책임의 원칙(Single Responsibility Principle)

정의

  • 클래스는 하나의 기능만 가지며, 모든 서비스는 하나의 책임을 수행하는데 집중해야한다는 원칙입니다.
  • 책임 영역이 확실해지므로 하나의 변경사항으로 인해 다른 책임을 변경해야하는 연쇄작용의 발생을 막을 수 있습니다.
  • 가독성, 유지보수성 향상됩니다.

적용방법

  • 분리된 클래스의 책임이 유사하면 SuperClass를 추출합니다.
  • 책임을 모으거나, 새로운 클래스를 생성합니다.

2. 개방 폐쇄의 원칙(Open Close Principle)

정의

  • 소프트웨어 구성요소는 확장에는 열려있고, 변경에는 닫혀있어야 한다는 원리입니다.
  • 요구사항의 변경이나 추가사항이 발생하더라도, 기존 요소의 변경은 일어나지 않고, 기존 요소를 확장해서 재사용할 수 있도록 설계되어야합니다.
  • 추상화와 다형성을 통해서 구현 가능합니다.

적용방법

  • 인터페이스를 정의할 때, 가능하면 변경되지 않도록 다양한 경우의 수를 고려해야합니다.
  • 적절한 수준의 예측능력으로 적당한 추상화 레벨을 선택해야 합니다.

3. 리스코브 치환의 원칙(The Liskov Substitution Principle)

정의

  • 서브 타입은 항상 기반 타입으로 교체할 수 있어야 합니다.
  • 아래 두 문장대로 구현된 프로그램은 리스코프 치환 원칙을 지키고 있는 프로그램입니다.
    • 하위 클래스 is a kind of 상위 클래스
    • 구현 클래스 is able to 인터페이스
  • 하위 클래스의 인스턴스는 상위형 객체 참조 변수에 대입해 상위 클래스의 인스턴스 역할을 하는데 문제가 없어야 합니다.
  • 즉, 올바른 상속 관계에 대한 규약을 정의하는 원칙입니다.

적용방법

  • 두 개체가 같은 일을 한다면, 하나의 클래스로 표현하고 구분하는 필드를 둡니다.
  • 똑같은 연산을 제공하지만 약간 다르게 하면 공통의 인터페이스를 만들고 각자 구현합니다.
  • 두 개체가 하는 일에 추가로 무언가를 한다면 구현 상속을 사용합니다.
  • 공통된 연산이 없다면 별개인 2개의 클래스를 만듭니다.

4. 인터페이스 분리의 원칙(Interface Segregation Principle)

정의

  • 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다는 원리입니다.
  • SRP는 클래스 분리를 통해 변화의 적응성, ISP는 인터페이스 분리를 통해 같은 목표에 도달합니다.
  • SRP가 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조합니다.
  • 그 인터페이스를 사용하는 클라이언트를 기준으로 분리하여, 클라이언트 입장에서 사용하는 기능만 제공하도록 해야합니다.

적용방법

  • 클래스 인터페이스를 통한 분리 - Inheritance
    • 상속받는 순간 Hard coupling이 발생합니다.
  • 객체 인터페이스를 통한 분리 - Delegation
    • 다른 클래스의 기능을 사용해야하지만 변경하고 싶지 않은 경우에 사용합니다.

5. 의존성 역전의 원칙(Dependency Inversion Principle)

정의

  • 추상화된 것은 구체적인 것에 의존하면 안됩니다.
  • 그러한 경우, 구체적인 것을 추상화하여 추상에 의존하도록 변경해야합니다.

적용방법

  • 구체적인 것에 의존하는 경우, 인터페이스에 의존하도록 변경하여 의존관계를 역전시킵니다.

GRASP 패턴


정의

객체 지향 설계는 객체에 역할을 설정하고, 역할에 따른 책임을 부여하며, 객체 간 주고받는 메시지를 정의함으로써 협력을 정의하는 과정입니다. 객체가 어떤 책임을 수행하는 지가 중요한데, GRASP 패턴은 General Responsibility Assignment Software Patterns의 축약어로, 객체 지향 디자인 시 각 객체에 책임을 할당하는 것에 대한 원칙들입니다. 일반적으로 디자인 패턴으로 불리는 것과 같은 구체적인 구조가 아니고, 디자인 패턴이 GRASP 패턴을 각각 구체적으로 구현한 것이라 볼 수 있습니다. 아래의 9가지 패턴을 알아봅시다.

Information Expert

  • 역할을 수행할 수 있는 정보를 가지고 있는 객체에 역할을 부여합니다.
  • 데이터와 처리 로직을 함께 Binding하고, 자신의 처리 로직에서 처리한 후 외부에 그 기능을 제공해야합니다.

Creator

  • 객체의 생성은 생성되는 객체의 컨텍스트를 알고 있는 다른 객체에 부여합니다.
    • B 객체가 A 객체를 포함하는 경우
    • B 객체가 A 객체의 정보를 기록하는 경우
    • A 객체가 B 객체의 일부인 경우
    • B 객체가 A 객체를 긴밀하게 사용하는 경우
    • B 객체가 A 객체의 생성에 필요한 정보를 가지고 있는 경우

Controller

  • 시스템 이벤트(사용자 요청)을 처리할 객체를 만듭니다.
  • 외부와 시스템 간에 요청을 처리해주는 중간 객체로 시스템 객체의 수정이 발생할 때, 외부에 주는 충격을 완화할 수 있습니다.

Low Coupling

  • 객체들간 상호의존도가 낮게 역할을 부여합니다.
  • 추상적인 것에 의존할 수록 상호의존도가 떨어지는데, 위의 의존 역전 원칙과도 일맥상통합니다.

High Cohesion

  • 각 객체가 밀접하게 연관된 역할만 가지도록 부여합니다.
  • 한 객체가 자신의 역할을 잘 수행하도록 구성된다면, 다른 객체를 참조할 일이 적어집니다. (협력이 단순해집니다.)
  • 즉, 객체간의 결합도는 낮추고 객체의 책임의 응집도는 높아야 합니다.

Polymorphism

  • 종류에 따라 행동양식이 바뀐다면, 다형성 기능을 이용합니다.
  • 조건문 대신 상속, 인터페이스 구현을 통한 다형성으로 바꿔볼 수 있습니다.

Pure Fabrication

  • 기능적인 역할을 한 곳으로 모읍니다.
  • 예로, 데이터베이스 정보, 로그 정보를 기록하는 역할의 경우 각 객체가 기록하는 역할을 DB 객체로 모아 종속성을 제거합니다.
  • 객체를 생성하는 역할을 팩토리 객체로 모아 종속성을 제거합니다.
  • 공통적인 기능을 제공하는 역할을 모아 가상의 객체를 만드는 것이 핵심입니다.

Indirection

  • 두 객체 사이 직접적 Coupling을 가상의 다른 객체로 제거합니다.
  • 주로 인터페이스를 이용합니다. 위의 의존 역전 원칙의 예를 참고하세요.

Protected Variations

  • 변경 여지가 있는 곳에 안정된 인터페이스를 정의해서 사용합니다. 위의 원칙과 유사합니다.

구현 실습 - 도형 그리기


마지막으로 구현을 통해서 위의 내용을 복습해보았습니다. 구현할 과제는 2차원 평면에 직선, 삼각형, 사각형, 다각형을 plot하는 것인데요, 다만 재사용성을 높이기 위해 상속과 인터페이스를 활용할 수 있도록 한정했습니다. 최종적인 목표는 아래처럼 임의의 개수의 좌표에 대해 직선, 삼각형, 사각형, 다각형인지 판별하고 좌표평면에 plot하고 넓이를 계산할 수 있어야합니다.

 

 

제일 먼저 추상적으로 어떤 역할들이 필요할 지 생각해봅시다. 다각형의 넓이를 계산하고 위치를 찍어야하니, 위치 정보를 받아 출력할 좌표평면 역할이 있어야할 것 같고, 직선, 삼각형, 사각형, 다각형 역할이 필요할 것 같습니다. 그리고 가운데에서 흐름을 통제해주는 컨트롤러 역할이 하나 있으면 좋을 것 같습니다.

 

역할을 만들었으니 각 역할이 가지는 책임들을 생각해봅시다. 제일 먼저 좌표평면 역할은 좌표의 정보를 저장하는 역할을 가져야할 것 같네요. 직선, 삼각형, 사각형, 다각형은 상태(위치, 좌표)를 저장하고 있으므로, 위의 GRASP 원리 Information Expert에 따라 좌표에 대한 정보를 가진 객체에서 처리해주는 것이 좋을 것 같습니다. 넓이, 거리 계산을 해주는 책임을 다른 객체에게 부여하면, 캡슐화된 좌표의 정보를 꺼내서 다른 객체에게 메시지로 보내주어야겠죠? 그것보다는 객체에서 바로 수행해서 결과를 반환하도록 하는 것이 응집성 측면에서 좋아보입니다.

 

여기까지 보았을 때, 직선, 삼각형, 사각형, 다각형의 역할이 사뭇 비슷해보입니다. 직선은 거리를 계산해주어야하고, 삼각형은 삼각형의 넓이를 계산, 다각형은 임의의 n각형에 대해 넓이를 계산해야합니다. 무언가의 값을 계산한다는 것은 동일한데, 각자의 구현 방식이 조금씩은 다르겠군요. 이러한 경우에 상속을 적용하기는 힘듭니다. 직선은 두 점 사이의 거리를 계산, 삼각형은 세 점의 면적을 계산하는 것이니 따로 구현이 필요합니다. 이런 경우에는 계산한다는 점은 동일하기에 공통의 인터페이스로 추출할 수 있습니다. 저는 인터페이스에서 calculate 추상 메서드로 선언해서 각각 클래스에서 개별적으로 구현했습니다. 그리고 컨트롤러가 직선, 삼각형에 의존하게 된다면 직선의 변경사항이 생긴다면 컨트롤러도 같이 바꿔줘야합니다. 의존 역전 원칙에 해당하는데요, 구체적인 것에 의존하게 되면 유지보수에 취약해집니다. 되도록 추상적인 것에 의존할 수 있도록 아까 공통적으로 뽑았던 인터페이스에 의존하게 했습니다.

 

public interface Drawable {

    void init(int[] numbers);
    double calculate();
    String toString();

}

 

하지만 상속은 전혀 필요가 없는 것일까요? 계산 메서드는 객체에서 각자 구현되지만 공통적으로 적용할 수 있는 메서드들이 있습니다. getter나 직선에서 거리를 구하는 메서드는 하위 메서드인 삼각형에서 그대로 활용할 수 있죠. 삼각형의 넓이는 직선들의 거리를 통해서 계산할 수 있습니다. 이런 메서드들은 상속을 통해서 확장해줄 수 있습니다.  저는 직선을 상속의 최상위 개념으로 두었고, 직선을 상속받아 삼각형, 삼각형을 상속받은 사각형, 삼각형을 상속받은 다각형으로 구성했습니다. 적절한 상속인지는 리스코브 치환 원칙에 의거해서 확인할 수 있다고 했죠?

 

삼각형은 직선의 한 종류이다. (?) - 하위 클래스 is a kind of 상위 클래스.

삼각형은 다각형의 한 종류이다.(O) 

 

적절한 상속으로 판단하기엔 사실 애매합니다. 사실 삼각형, 사각형, 다각형으로 상속을 정의했더라면 더 적절했을 것 같습니다. 아래와 같이 결국 마무리했습니다.

public class Polygon extends Triangle implements Drawable {

    public Polygon(List<Dot> dots, int[] numbers) {
        super(dots, numbers);
    }


    @Override
    public double calculate() {
        double sumArea = 0;
        sort();
        for (int i = 1; i < dots.size() - 1; i++) {
            sumArea += calculateTriangleArea(0, i, i + 1);
        }

        return sumArea;
    }

 

공통의 기능을 한 군데로 모아줍니다. 위의 Pure Fabrication에 해당합니다. 삼각형, 사각형 등을 생성하는 PolygonFactory를 정의해서 객체의 생성 기능을 전담하도록 했습니다. 비슷하게, 출력은 OutputView로, 입력은 InputView로 모아주어 입/출력의 기능을 각각 응집시켰습니다.

public class PolygonFactory {

    public Line createPolygon(int[] numbers) {
        List<Dot> dots = new ArrayList<>();
        if (numbers.length == 4) {
            return new Line(dots, numbers);
        }
        if (numbers.length == 6) {
            return (Line) new Triangle(dots, numbers);
        }
        if (numbers.length == 8) {
            return (Line) new Rectangle(dots, numbers);
        }
        if (numbers.length >= 10 && numbers.length % 2 == 0) {
            return (Line) new Polygon(dots, numbers);
        }
        throw new IllegalArgumentException("인자의 개수가 홀수입니다.");
    }
}

 

마지막으로, 다형성을 활용해서 분기를 최소화 할 수 있습니다. 예를들면, 입력받은 좌표의 개수에 따라 직선, 삼각형, 사각형 객체를 생성해야하고, 인스턴스에 따라 계산, 출력 방식이 바뀌게 됩니다. 이 모든 것을 분기문으로 처리하지 않고 다형성을 이용한다면 간단하게 처리할 수 있습니다. 제 경우는 계산과 출력을 동일한 인터페이스에서 각자 구현하므로 컨트롤러에서는 상위 인터페이스로 메서드를 실행해주면 각자 인스턴스 타입에 맞게 자신의 책임을 수행하게 됩니다.

 

이상으로 객체지향적 설계를 연습해보았습니다.