프로그래밍

[TIL] 객체 지향 프로그래밍(OOP)이란 ? (feat. 객체지향 5원칙 - SOLID)

손가든 2024. 3. 30. 18:41

지금까지 비즈니스 코드를 유지보수한 경험이 없어, 객체 지향 프로그래밍에 대한 필요성을 느끼지 못했다.

 

집중적으로 파고 들면서 객체 지향 프로그래밍을 학습한 적도 없어서,

대충 뭔지 한마디로 정의할 수는 있겠지만 정확히는 잘 모르겠다.

 

그래서 궁금해서 집중적으로 공부해보았다.

 


 

객체 지향 프로그래밍(OOP)이란?

OOP = Object-Oriented Programming

 

객체 지향 프로그래밍은 컴퓨터 프로그래밍 패러다임 중 하나로,

프로그램 내의 데이터를 추상화한 뒤, 상태와 행위를 가지는 객체로 선언 후 사용하는 프로그래밍이다.

 

과거에는 데이터의 시간 절차 순으로 프로그래밍하는 절차 지향 프로그래밍을 사용했지만,

코드의 양이 많아지면서 코드의 유지 보수를 위해 객체 지향 프로그래밍을 사용하게 되었다.

 


 

객체 지향 프로그래밍, 왜 사용하나?

아무 생각 없이 코드짜기도 힘든데, 왜 객체 지향적인 프로그래밍을 설계해야 할까?

 

1. 유지 보수

 

클래스 단위로 체계화 되어있는 객체 지향 코드는 의존성이 낮아진다. 따라서 코드의 수정, 기능 추가 시에 관련이 없는 기존 코드를 수정할 필요가 없어 업무 효율이 높아진다.

 

2. 협업

 

코드가 클래스 단위로 체계화 되어 있으면 다른 사람의 코드를 보더라도 대충 코드의 목적 및 역할을 유추하기 쉽다.

새로운 인력이 충원되거나, 변동이 발생하는 기업 입장에서는 협업 효율성도 매우 중요한 고려사항이다.

 

3. 코드 재사용성

 

클래스 단위로 쪼개져 있는 타인의 코드를 클래스 단위를 옮겨 사용하기 매우 유리해진다.

또한, 상속을 통해 확장해서 사용할 수 있다.

 

오픈 소스 활용이 매우 다방면에서 활용되는 S/W 세상에서 코드 재사용성은 매우 중요하다.

 


 

객체 지향 프로그래밍 키워드 5가지

 

1. 클래스 + 인스턴스(객체)

 

클래스란 어떤 문제를 해결하기 위한 데이터를 만들기 위해 추상화를 거쳐 집단에 속하는 속성(attribute)과 행위(behavior)를 변수와 메서드로 정의한 것으로 객체를 만들기 위한 메타정보라고 볼 수 있다.

 

인스턴스(객체)란 클래스에서 정의한 것을 토대로 실제 메모리에 할당된 것으로 실제 프로그램에서 사용되는 데이터이다.

 

객체 지향 언어를 학습하면 반드시 듣게 되는 가장 유명한 예시

클래스 = 붕어빵 틀

객체 = 붕어빵

 

붕어빵 틀은 붕어빵을 추상화 한 클래스이고, 붕어빵 틀을 통해 객체로 선언하면 붕어빵이 완성된다.

 


 

2. 캡슐화

 

캡슐화란 객체 지향 프로그래밍에서 기능과 특성의 모음을 "클래스"라는 "캡슐"에 분류해서 넣는것을 말한다.

 

캡슐화의 목적은 다음 두가지이다.

 

- 코드를 재수정 없이 재활용

- 접근 제어자를 통한 정보 은닉

 

절차 지향 프로그래밍에서도 라이브러리를 통해서 변수와 함수를 재활용할 수는 있었지만,

코드의 수정이 일어났을 때 영향 범위를 예상하기 어려운 문제가 있었다.

 

그러나 객체 지향 프로그래밍은 코드의 수정이 일어났을 때 책임이 있는 객체만 수정하면 되기에

영향 범위를 예측하는데 수월하고, 관련된 기능과 특성을 한 곳에 모으고 분류하기 때문에 객체 재활용이 원활해졌다.

 

뿐만 아니라 캡슐화를 통해 객체가 외부에 노출되지 않아야할 정보 또는 기능을 접근제어자를 통해 적절한 제어 권한이 있는 객체만 접근하도록 하는 것이 가능하다.

 


 

3. 상속

 

절자 지향 프로그래밍에서도 "라이브러리"를 통해서 남이 짜놓은 소스 코드를 가져와 사용할 수 있었다.

 

하지만 내 의도에 맞게 수정하게되면 버전에 따라 동작하지 않을 수 있고 불필요한 코드의 수정작업이 필요했고,

이런 문제를 해결하기 위해 [상속]이라는 것을 도입하였다.

 

상속부모클래스의 속성과 기능을 그대로 이어받아 사용할 수 있게하고 기능의 일부분을 변경해야 할 경우 상속받은 자식클래스에서 해당 기능만 다시 수정(정의)하여 사용할 수 있게 하는 것이다.

 

한 클래스가 여러 부모클래스를 상속하는 다중상속은 불가한데, 이는 클래스의 상속 관계에서 혼란을 방지하기 위함이다.

필요에 따라 인터페이스를 활용하여 다중 상속 역할을 대체할 수 있다.

 


 

4. 다형성

 

다형성이란 같은 변수명을 가진 변수, 함수 등이 다른 용도로 사용하는 것이 가능한 특성을 말한다.

 

이는 오버라이딩, 오버로딩 특성이라고 한다.

 

오버라이딩은 부모 클래스의 메서드와 같은 이름, 매개변수를 재 정의하여 사용하는 것을 말한다.

 

오버로딩은 같은 이름의 함수를 여러개 정의하고, 매개변수의 타입과 개수를 다르게 하여 매개변수에 따라 다르게 호출하여 사용할 수 있게 하는 것을 말한다.

 

자바에서 생성자를 통해 객체를 생성할 때, 여러개의 생성자를 정의할 수 있는 것이 오버로딩 기능이 있기 때문이다.

 


 

 

객체 지향 개발 5원칙 - SOLID에 대해

 

객체 지향적인 프로그래밍 설계는 매우 주의가 필요하고 많은 정성이 필요한 설계 방법이다.

 

따라서 요구 원칙을 정리하여 이 원칙에 위배되는지 파악하며 객체 지향적인 프로그래밍을 설계하기 위해

SOLID 원칙이라는 것이 생겨났다.

 

SOLID 원칙이란 객체지향 설계에서 지켜줘야 할 5개의 소프트웨어 개발 원칙( SRP, OCP, LSP, ISP, DIP )을 말한다.

 

Single Responsibility Principle (SRP , 단일 책임의 원칙)

Open Closed Priciple (OCP , 개방/폐쇄 원칙)

Listov Substitution Priciple (LSP , 리스코프 치환 원칙)

Interface Segregation Principle (ISP , 인터페이스 분리 원칙)

Dependency Inversion Principle (DIP , 의존성 역전 원칙)

 

앞글자를 따서 SOLID 원칙이라고 부른다.

 


 

단일 책임의 원칙 - SRP

 

단일 책임의 원칙이란 클래스(객체)는 단 하나의 책임(기능)만을 가져야 한다는 원칙이다.

 

예를 들어, 하나의 클래스가 주문을 처리하고 동시에 이메일을 보내는 책임을 가진다고 가정해 보자.

이 경우에는 주문 처리와 이메일 발송이 각각의 책임이므로 이를 분리하는 것이 적절하다.

 

단일 책임의 원칙을 준수한다면 주문 처리에 관한 클래스와 이메일 발송에 관한 클래스를 각각 만들어야 한다.

이렇게 함으로써 주문 처리 로직이 변경되더라도 이메일 발송 로직에 영향을 주지 않고 유지보수하기가 쉬워진다.

 

만약 단일 책임의 원칙을 위배하여 주문과 이메일의 책임을 모두 가지는 클래스를 설계하게 되면,

주문 처리 로직을 수정 시 이메일을 보내는 기능에도 수정이 필요하게 될 수 있다.

 


 

개방 폐쇄 원칙 - OCP

 

개방 폐쇄 원칙은 확장은 열려있어야 하지만, 수정(변경)은 닫혀있어야 한다는 원칙이다.

즉, 기능을 확장하려 할 때 기존의 코드는 수정하지 않고 확장이 가능한 설계 상태를 말한다.

 

예를 들어 도형의 넓이를 계산하는 코드를 설계했다고 가정해보자.

 

삼각형의 도형의 넓이를 계산하는 인터페이스에 원의 넓이를 계산하는 기능을 추가하고자 한다면,

삼각형의 넓이를 계산하는 코드에는 영향없이 추가할 수 있어야 한다.

 

abstract class Shape {
    abstract double area();
}

class Circle extends Shape {
    private double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    double area() {
        return Math.PI * radius * radius;
    }
}

class Triangle extends Shape {
    private double base;
    private double height;

    public Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }

    @Override
    double area() {
        return 0.5 * base * height;
    }
}

 

이런식으로 추상 클래스인 Shape를 상속받은 뒤 area() 메소드를 오버라이드하여 각각의 도형의 넓이를 계산하는 기능을 추가함으로써 가능해진다.

 


리스코프 치환 원칙 - LSP

 

리스코프 치환 원칙은 리스코프라는 사람이 제시한 원칙으로,

서브 타입은 반드시 부모 타입으로 변경할 수 있도록 설계해야 한다는 원칙이다.

 

이는 다형성 원리를 이용하기 위한 원칙 개념으로 보면 된다.

 

다형성의 특징을 이용하기 위해 상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 받으면,

업캐스팅된 상태에서 부모의 메서드를 사용해도 동작이 의도대 흘러가도록 설계해야 한다.

 

따라서 기본적으로 LSP 원칙은 부모 메서드의 오버라이딩을 조심스럽게 따져가며 해야한다.


부모 클래스와 동일한 수준의 선행 조건을 기대하고 사용하는 프로그램 코드에서

예상치 못한 문제를 일으킬 수 있기 때문이다.


인터페이스 분리 원칙 - ISP

 

인터페이스 분리 원칙은 인터페이스를 각각 사용에 맞게 끔 잘게 분리해야한다는 설계 원칙이다.

 

예를 들어 교통수단이라는 인터페이스를 통해 차,트럭 클래스를 설계한 뒤 좌,우회전에 대한 메소드를 구현했다고 가정하자.

 

이때, 교통수단에 기차를 추가하려니 좌,우회전에 대한 기능을 수행하지 못하므로 메소드는 있지만 비워놓아야 하는 불상사가 발생한다.

 

따라서 다음과 같이 인터페이스를 분리하여 설계해야 한다.

interface 교통수단 {
    void 전진();
    void 후진();
}

interface 핸들 {
    void 좌회전();
    void 우회전();
}

class 자동차 implements 교통수단, 핸들 {
    @Override
    public void 전진() {
        System.out.println("자동차가 전진합니다.");
    }

    @Override
    public void 후진() {
        System.out.println("자동차가 후진합니다.");
    }

    @Override
    public void 좌회전() {
        System.out.println("자동차가 좌회전합니다.");
    }

    @Override
    public void 우회전() {
        System.out.println("자동차가 우회전합니다.");
    }
}

class 트럭 implements 교통수단, 핸들 {
    @Override
    public void 전진() {
        System.out.println("트럭이 전진합니다.");
    }

    @Override
    public void 후진() {
        System.out.println("트럭이 후진합니다.");
    }

    @Override
    public void 좌회전() {
        System.out.println("트럭이 좌회전합니다.");
    }

    @Override
    public void 우회전() {
        System.out.println("트럭이 우회전합니다.");
    }
}

class 기차 implements 교통수단 {
    @Override
    public void 전진() {
        System.out.println("기차가 전진합니다.");
    }

    @Override
    public void 후진() {
        System.out.println("기차가 후진합니다.");
    }
}

 

위 코드는 핸들 인터페이스와 교통수단을 분리하여 회전이 가능한 교통수단에만 핸들 인터페이스를 추가하여 사용한다.

이렇게 함으로써 기차 클래스는 필요없는 핸들 기능을 비워놓은 상태로 설계할 필요가 없다.

 

ISP 원칙의 주의해야 할점은 한번 인터페이스를 분리하여 구성해놓고 나중에 무언가 수정사항이 생겨서

또 인터페이스들을 분리하는 행위를 가하지 말아야 한다. 
(인터페이스는 한번 구성하였으면 왠만해선 변하면 안되는 정책 개념이다.)

 


 

의존성 역전 법칙 - DIP

 

의존성 역전 법칙이란 어떤 Class를 참조해서 사용해야하는 상황이 생긴다면, 그 Class를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스 or 인터페이스)로 참조하라는 원칙이다.

 

쉽게 이야기해서 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 뜻으로,

 

의존 관계를 맺을 때 변화하기 쉬운 것 또는 자주 변화하는 것보다는,

변화하기 어려운 것 거의 변화가 없는 것에 의존하라는 것이다.

 

예를 들어, 사람이 전구를 켜는 작업을 수행하는 코드를 다음과 같이 설계했다고 가정하자.

class Switch {
    public void turnOn() {
        System.out.println("전구를 켭니다.");
    }
}

public class Client {
    private Switch mySwitch;

    public Client() {
        this.mySwitch = new Switch();
    }

    public void performAction() {
        mySwitch.turnOn();
    }
}

 

Client(고객)에서 Switch를 직접 필드로 주입받은 뒤 사용하도록 설계된 이 방식은 추상체를 의존하지 않았기 때문에

의존성 역전 법칙에 위배된다.

 

따라서 이 코드를 다음과 같이 수정할 수 있다.

interface Switchable {
    void turnOn();
}

class Switch implements Switchable {
    public void turnOn() {
        System.out.println("전구를 켭니다.");
    }
}

public class Client {
    private Switchable switchable;

    public Client(Switchable switchable) {
        this.switchable = switchable;
    }

    public void performAction() {
        switchable.turnOn();
    }
}

 

수정된 코드는 인터페이스를 의존하여 사용하도록 설계되었고,

인터페이스를 구현한 Switch를 통해 스위치를 켜는 기능을 수행할 수 있도록 변경되었다.

 

이를 통해 모듈의 결합도를 줄이고, 유연한 구조를 만드는 목표를 달성할 수 있다.

 

 

 

 

 

 

 

출처: https://jeong-pro.tistory.com/95 [기본기를 쌓는 정아마추어 코딩블로그:티스토리]

출처: https://inpa.tistory.com/entry/OOP-💠-객체-지향-설계의-5가지-원칙-SOLID [Inpa Dev 👨‍💻:티스토리]