본문 바로가기
  • Jetpack 알아보기
Java & Kotlin/ʕ•ᴥ•ʔ

자바의 객체 지향적 특징 (INTRO) - 객체 지향 프로그래밍과 SOLID 원칙

by 새우버거♬ 2021. 5. 27.

INTRO

Java 프로그램은 객체를 생성하고, 메소드를 호출해서 통신하는 대표적인 객체 지향(Object Oriented) 언어입니다. 따라서 Java는 객체 지향 대표적인 특징인 캡슐화, 상속, 다형성, 추상화 등을 지원합니다. 이제부터 객체 지향 프로그래밍의 개념을 다지고, Java가 가지는 객체 지향적 특징을 하나하나 알아보려고 합니다.


객체 지향 프로그래밍 (Object Oriented Programming, OOP)

객체 지향 프로그래밍(OOP)에 대한 개념은 면접 단골 질문입니다. Java 개발자라면 OOP가 무엇인지 알고 있지만, 막상 설명을 하려고 하면 입이 잘 안 떨어지는 경우가 있습니다. (제가 그렇습니다.) 

 

먼저, 위키 백과에서 정의된 OOP의 정의는 다음과 같습니다.

객체 지향 프로그래밍이란?
객체 지향 프로그래밍은 컴퓨터 프로그래밍의 패러다임 중 하나이다. 객체 지향 프로그래밍은 컴퓨터 프로그램을 명령어의 목록으로 보는 시각에서 벗어나 여러 개의 독립된 단위, 즉 "객체"들의 모임으로 파악하고자 하는 것이다. 각각의 객체는 메시지를 주고받고, 데이터를 처리할 수 있다.

 

여기서 객체는 하나의 역할을 수행하는 '메소드와 변수(데이터)'의 묶음입니다. 객체는 데이터를 처리할 수 있고, 또 다른 객체와 서로 메시지를 전달할 수 있습니다. 객체는 독립적인 기능을 가지고 있는 단위라고 볼 수 있습니다.

 

따라서, 객체 지향 프로그래밍은 객체가 가지고 있는 핵심적인 개념 또는 기능만을 추출하는 추상화(abstraction)를 통해 프로그램을 개발하는 방식입니다. 

 

유지보수가 쉽고, 확장성에 유리하며 보다 유연하다는 장점이 있기 때문에 대규모 프로젝트에 널리 사용되고 있습니다.


객체 지향 프로그래밍 설계 5대 원칙 : SOLID

객체 지향 5대 원칙은 로버트 마틴이 명명한 객체 지향 프로그래밍 설계의 5 원칙을 두문자어 기억술로 소개된 것입니다. 애자일 소프트웨어 개발 방법론 의 전반적인 전략의 일부이기도 하며, 소프트웨어 개발자가 코드를  읽기 쉽고 확장하기 쉽게 될 때까지 소프트웨어 소스 코드를 리팩토링할 때, 적용할 수 있는 지침입니다.

 


1. 단일 책임 원칙 : SRP (SingleResponsibility Principle)

모든 클래스는 하나의 책임만 가지며, 그 책임을 변경하려는 이유는 하나여야만 한다.

 

예를 들면, 아래와 같은 SonyCamera 클래스가 있습니다. SONY 카메라와 관련된 데이터로 시리얼 번호, 이름, 타입, 무게, 가격 이 주어집니다. 만약, 시간이 지나서 카메라의 이름이나 가격이 변경되어 nameprice 값을 수정해야 합니다. 이러한 변화가 발생하면 항상 SonyCamera 클래스를 수정해야 하는 부담이 생깁니다. 이 부분은 SRP 적용 대상이 됩니다.

public class SonyCamera {
    String serialNumber; // 시리얼 넘버
    String name;
    String type;
    double weight;
    double price;

    public SonyCamera(String serialNumber,
                      String name,
                      String type,
                      double weight,
                      double price){
        this.serialNumber = serialNumber;
        this.name = name;
        this.type = type;
        this.weight = weight;
        this.price = price;
    }
}

 

따라서, SonyCamera 를 변화할 수 있는 데이터와 고유 데이터로 나누어봅니다. (아마 serialNumber 가 고유 데이터가 될 것입니다.) 이제 카메라 기능과 관련된 데이터에 변화가 생기면 SonyCameraSpec 클래스만 수정하면 되기 때문에 유지보수가 용이해지고, 한눈에 보기에도 좋아졌습니다.

class SonyCamera {
    String serialNumber; // 시리얼 넘버
    SonyCameraSpec sonyCameraSpec;

    public SonyCamera(String serialNumber,
                      SonyCameraSpec sonyCameraSpec) {
        this.serialNumber = serialNumber;
        this.sonyCameraSpec = sonyCameraSpec;
    }
}

class SonyCameraSpec {
    String name;
    String type;
    double weight;
    double price;
}

 

 

2. 개방-폐쇄 원칙 : OCP (Open/Closed Principle)

소프트웨어의 구성요소(모듈, 클래스, 함수)는 확장에는 열려 있고, 수정에는 닫혀있어야 한다.

 

현재 프로그램에 수정 사항이 발생하면 기존 구성 요소를 수정하는 것이 아니라 쉽게 확장해서 재사용이 가능해야 한다는 원칙입니다. OCP 원칙은 객체 지향 프로그래밍에서 핵심 원칙이며, 이를 가능하게 하는 중요 메커니즘은 추상화다형성입니다. 간단한 예제 코드를 작성해봅니다. 

 

방금 전, SRP 원리를 적용해서 SonyCamera 에서 변화할 수 있는 데이터를 모아 SonyCameraSpec 클래스를 생성했습니다.  만약 여기에 그치지 않고, 새로운 카메라 CanonCamera 가 등장했습니다. 같은 방법으로 아래처럼 코드를 작성할 수는 있습니다. 하지만 앞으로 니콘, 후지필름, 코닥 등 새로운 카메라가 등장할 때마다 계속해서 같은 코드를 작성하면 좋은 방법은 아닐 것입니다.

class SonyCamera {
    String serialNumber; 
    SonyCameraSpec sonyCameraSpec;
}

class SonyCameraSpec {
    String name;
    String type;
    double weight;
    double price;
}

class CanonCamera{
    String serialNumber;
    CanonCameraSpec canonCameraSpec;
}

class CanonCameraSpec {
    // ...
}

 

먼저, 앞으로 추가될 카메라들을 추상화하는 작업이 필요합니다. 카메라의 공통 데이터를 가지고 있는 Camera 클래스와 카메라의 공통된 기능을 가지고 있는 CameraSpec 클래스를 생성합니다.

class Camera {
    String serialNumber;
    CameraSpec cameraSpec;

    public Camera (String serialNumber,
                   CameraSpec cameraSpec){
        this.serialNumber = serialNumber;
        this.cameraSpec = cameraSpec;
    }
}

class CameraSpec {
    String name;
    String type;
    double weight;
    double price;
}

 

위를 반영하여 SonyCamera 클래스는 아래처럼 작성할 수 있습니다. 새로운 카메라가 추가되면서 변경이 발생하는 부분을 추상화하여 분리했습니다.

class SonyCamera extends Camera {
    public SonyCamera(String serialNumber,
                      SonyCameraSpec cameraSpec) {
        super(serialNumber, cameraSpec);
    }
}

class SonyCameraSpec extends CameraSpec {
}

 

공통된 동작을 묶어 추상화를 적용하면 구성 요소는 추상화에 의존하기 때문에 수정에 대해 닫혀 있을 수 있습니다. 반대로, 추상화의 새 파생 클래스를 만드는 것을 통해 확장도 가능합니다. 이렇게 해서 코드의 결합도를 줄이고, 응집도를 높이는 효과를 볼 수 있습니다.

응집도와 결합도
응집도란?  모듈 내부의 요소들이 하나의 책임/목적을 위해 연관되어 있는 정도입니다. 모듈 A와 a 기능이 있습니다.
1. a의 파생된 기능들이 모듈 A에만 있는 것
2. a의 파생된 기능들이 모듈 A, B, C... 에 분산되어 있는 것
둘 중에서 수정이 더 용이한 것은 아무래도 1번일 것입니다. 모듈 A 내부에 a 라는 기능이 모여있고, 긴밀하게 연결되어 있다면 모듈 A는 응집도가 높다고 할 수 있습니다.

결합도란?  어떤 모듈이 다른 모듈에 의존하는 정도입니다. 모듈 A와 모듈 B, C, D, ...가 있고, 다른 모듈은 모듈 A와 연관되어 있습니다. 만약, 모듈 A에 있는 기능을 수정하게 된다면 모듈 A 말고도 다른 모듈(B, C, D, ...)의 소스도 확인해서 수정해야 합니다. 따라서, 모듈 A는 다른 모듈과 결합도가 높다고 할 수 있습니다. 

 

3. 리스코프 치환 원칙 : LSP ( Liskov Substitution Principle)

자식 클래스는 언제나 부모 클래스로 교체(치환)할 수 있어야 한다.

 

바바라 리스코프가 컨퍼런스에서 처음 소개한 내용입니다. 자식 클래스는 부모 클래스 속성에 일관성이 있어야 하고, 자식 클래스는 부모 클래스의 책임(public 인터페이스, 메소드 예외처리 등)을 무시하거나 재정의 하지 않고, 확장만 수행해야 합니다.

 

아까의 Camera 클래스에서 카메라 렌즈를 장착하는 setLens() 메소드가 추가되었습니다. 기본은 단렌즈네요.

class Camera {
    String serialNumber;
    CameraSpec cameraSpec;
    Lens len = new PrimeLens(); // 렌즈

    public Camera(String serialNumber,
                  CameraSpec cameraSpec) {
        this.serialNumber = serialNumber;
        this.cameraSpec = cameraSpec;
    }

    public void setLens(Lens lens){
        this.lens = lens
    }
}

 

Camera 를 상속받는 SonyCamera 에서 changeLens() 를 통해 줌렌즈로 변경할 수 있습니다.

class SonyCamera extends Camera {
    public SonyCamera(String serialNumber,
                      SonyCameraSpec cameraSpec) {
        super(serialNumber, cameraSpec);
    }

    public void changeLens(Lens lens) {
        super.setLens(new ZoomLens());
    }
}

 

이제 CameraSonyCamera 모두 setLens() 를 호출하면 렌즈의 값은 단렌즈로 동일할 것입니다. 

Camera camera = new Camera()
camera.setLens(new PrimeLens())

SonyCamera sonyCamera = new SonyCamera()
sonyCamera.setLens(new PrimeLens())

 

하지만 만약에 SonyCamera 클래스에서 setLens( )재정의하면 어떻게 될까요? CameraSonyCamera setLens() 를 호출하고나서 렌즈의 값은 서로 달라질 것입니다. 이와 같은 경우, 자식 클래스와 부모 클래스의 일관성이 깨지므로 LSP 원칙에 어긋납니다.

class SonyCamera extends Camera {
    public SonyCamera(String serialNumber,
                      SonyCameraSpec cameraSpec) {
        super(serialNumber, cameraSpec);
    }

    public void setLens(Lens lens) {
        super.setLens(new ZoomLens());
    }
}

 

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

한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다.

 

큰 덩어리의 인터페이스를 구체적이고 작은 단위로 분리시켜야 한다는 원칙입니다. '하나의 일반적인 인터페이스보다는 여러 개의 구체적인 인터페이스가 낫다' 라고 정의할 수 있습니다. SRP 원칙이 클래스의 단일 책임을 강조한다면, ISP 원칙은 인터페이스의 단일 책임을 강조합니다. 

 

JTable 클래스는 ISP 원칙을 잘 보여주는 예입니다. JTable 클래스는 많은 기능을 가지고 있습니다. 하지만 단일 인터페이스가 아닌 TableModelListener, Scrollable, TableColumnModelListener, ListSelectionListener, CellEditorListener, Accessible 등 여러 인터페이스를 구현하여 기능을 제공합니다.

public class JTable extends JComponent implements TableModelListener, Scrollable,
    TableColumnModelListener, ListSelectionListener, CellEditorListener,
    Accessible, RowSorterListener
{
    // ...
}

 

 

5. 의존 관계 역전 원칙 : DIP (Dependency Inversion Principle)

상위 객체와 하위 객체 모두 구체적인 클래스가 아니라 인터페이스나 추상 클래스에 의존해야 한다.

 

 


※ 참고자료