본문 바로가기
공부/springboot

좋은 객체지향이란?

by 샤샤샤샤 2023. 5. 10.

객체지향이란?

 현실 세계에서 객체란 물질적으로 존재하는 어떤 사물이나 관념을 의미하나, 코드에서 객체는 메모리에 할당된 클래스(인스턴스) 를 의미한다.

 따라서 객체지향적이라는 의미는, 클래스가 하나의 객체, 즉, 개념을 구성한다고 생각하면 편하다.

 예를 들어보자.

 자동차라는 객체는 어떤 차종 하나를 가르키지 않는다. 네 바퀴가 달리고 사람이나 물건을 실을수 있으며 엔진으로 구동되는 이동수단이라는 개념 그 자체를 의미한다.

자동차의 개념
1. 네바퀴              -- 속성
2. 물건이나 사람을 운송 -- 기능
3. 엔진으로 구동        -- 속성

이를 코드로 치환하면 다음과 같이 된다.

public class Car{
    private int wheel = 4;                  // 바퀴의 갯수
    private String powerSource = "엔진";   // 동력원
    
    public void transport(String thing){ // 운송 기능
        System.out.println( thing + " 을 운송합니다." );
    }
}

 객체지향언어는, 현실의 객체(사물, 개념) 처럼 코드를 표현할수 있다는 생각에서 시작된 언어다.

 흔히들을 객체지향언어란 현실 세계의 모방이라고 하는 것도 이런 점 때문이다. 객체지향을 이해하기에 좋은 표현이나, 자칫 잘못하면 현실세계의 규칙으로부터 자유로운 코드를 개발하는데 지장이 될수 있어 주의가 필요하다.

 

 객체지향의 장점

 현실세계에서 객체라는 의미는 사물도 포함이 되지만, 컴퓨터 세계에서는 개념만을 의미한다. 따라서 개발이란 조그마한 개념 여러개를 모아 하나의 거대한 개념을 만드는 것이다.

 앞서 자동차의 예로 설명하면, 자그마한 볼트와 너트, 바퀴, 핸들, 엔진같은 역할을 하는 각기 다른 독립된 개념을 만들어내고, 서로 적절하게 연결시켜(협력) 자동차의 역할을 수행하도록 하는 것이 바로 객체지향언어의 특징이다. 따라서 만약 엔진 부분에 문제가 생겼다면 바퀴나 핸들같은 다른 부품은 그대로 두고 엔진만 수정하면 간단하게 문제를 해결할수 있는 유연성이 존재한다.

 

 객체의 다형성

 Car클래스는 자동차라는 개념만 나타낼뿐, 어떤 구체적인 구현체가 아니다.  만약 Car 객체를 그대로 구현체로 만들면 그것은 오직 '바퀴가 4개이고 엔진으로 구동되는 운송수단' 이라는 의미만 지니게 되고, 제조사, 이름, 크기, 연비등 섬세한 속성 부여가 불가능해질것이다. 즉, 자동차라는 개념만 가지고서는 아반떼, 소나타, 모닝같은 구현이 안된다는 의미다.

 현실 세계에서 자동차라는 개념의 구현체는 아반떼, 소나타, 모닝 등이 있다. 그렇다면 코드 세계에서는 어떻게 상위의 개념을 구체적인 구현체로 표현할수 있을까? 일일이 아반떼, 소나타, 모닝의 클래스를 만들고, Car 클래스의 내용을 모두 똑같이 작성해야 할까?

 이런 문제를 해결하기 위해 상속이라는 기능이 존재한다.

public class Morning extends Car { // 자동차라는 개념을 상속받은 Morning이라는 개념

    private String maker = "KIA";         // 제조사
    private String maxDistance = "774km"; // 최대 주행 거리
    private String kmPerOil = "Good";     // 연비
    private String sort = "소형";         // 크기

    @Override
    public void transport(String thing) {  // 운송 기능 + 운송 최대 인원
        super.transport(thing);
        System.out.println("최대 4인까지 탑승 가능합니다.");
    }
}

 위 코드는 Car라는 개념을 Morning이라는 객체(클래스)가 상속받아 Car의 속성과 기능을 모두 가지고 있으면서도 자기만의 세부적인 속성과 기능을 가지고 있음을 나타낸다. 따라서 Morning은 Car의 객체인 동시에 Morning의 객체가 된다.

 벤다이어그램으로 표현하면 아래와 같다.

만약 아반떼를 표현하고자 하면 새로운 클래스를 만들거나, 필드값을 사용자가 직접 설정할수 있게끔 만들면 될것이다.

public class CarModel extends Car { // Car라는 개념을 상속받은 CarModel

    private String modelName;     // 모델명
    private String maker;         // 제조사
    private String maxDistance;  // 최대 주행 거리
    private String kmPerOil;     // 연비
    private String sort;         // 크기

    @Override
    public void transport(String thing, int maxNumber) {  // 운송 기능 + 운송 최대 인원
        super.transport(thing);
        System.out.println("최대 " + maxNumber + "인까지 탑승 가능합니다.");
    }
    
    // 생성자 함수를 통한 필드값 설정
    public Morning(String modelName, String maker, int maxDistanceKm, String kmPerOil, String sort) {
        this.modelName = modelName;
        this.maker = maker;
        this.maxDistanceKm = maxDistanceKm;
        this.kmPerOil = kmPerOil;
        this.sort = sort;
    }
}

 

 객체지향의 구성요소

 앞서 봤듯, 객체지향의 가장 큰 장점은 두가지다. 1. 부품을 갈아끼우듯 객체(개념) 변경할수 있다. 이때 다른 부품(객체)들은 영향받지 않는다. 2. 상위의 개념이 될 수 있다(다형성).

 이 장점을 유지하고 지키기 위해서는 세가지 원칙을 잘 지켜야 한다..

 첫번째 : 객체는 역할과 책임, 협력으로 구성된다.

 두번째 : 객체와 객체간의 소통은 오로지 협력으로만 이뤄져야 하며, 한 객체가 다른 객체에 영향을 줘선 안된다.(독립성)

 세번째 : 객체의 역할이 지나치게 광범위해서는 안되며, 객체가 어떤 일을 맡는지 책임소재가 분명해야 한다.

 

객체의 역할과 구현 구분

 잘 설계된 객체지향은 역할과 구현이 구분되어야 한다. 객체의 역할이 무엇인지를 먼저 만들어주고, 그 역할을 수행하게끔 해야하는 것이다.

 이 구분은 인터페이스를 통해 가능하다. 예를 들어보자.

 

public interface CarInterface {

    int wheel = 4;
    String powerSource = "엔진";

    public void transport();
}

 위의 인터페이스는 "바퀴가 4개이고 엔진으로 구동되며 운동하는 기능이 있다" 라는 역할을 정의한다. 따라서 이 역할을 충족시키는 구현체 Car는 다음과 같이 만들수 있다.

public class Car implements CarInterface{

// public으로 설정된 부모의 변수값들은 재정의하지 않아도 저절로 자식 클래스에 포함된다.

    @Override
    public void transport(String thing) {
        System.out.println("바퀴 갯수: " + wheel);
        System.out.println("동력원: " + powerSource);
        System.out.println( thing + " 을 운송합니다." );
    }
}

 

상속받는 Car 클래스에서 transport라는 함수가 구현된다. CarInterface는 자동차라는 역할을, Car는 자동차의 기능을 구현한 것이다.

 위의 예시에서는 역할을 자동차로 한정지었지만, 만약 운송수단이라는 역할을 부여한다면, 구현체는 비행기, 기차, 자동차등이 될수 있을 것이다. 역할과 구현체를 구분하는 것만으로도 다형성을 통한 확장, 변경이 쉬워지는 것이다.

 

좋은 객체지향 설계의 5원칙 (SOLID)

 

SRP: 단일 책임 원칙(single responsibility principle) 

OCP: 개방-폐쇄 원칙 (Open/closed principle) 

LSP: 리스코프 치환 원칙 (Liskov substitution principle) 

ISP: 인터페이스 분리 원칙 (Interface segregation principle) 

DIP: 의존관계 역전 원칙 (Dependency inversion principle)

 

1. SRP 단일 책임 원칙

 하나의 클래스는 하나의 책임만 가져야 한다. 책임의 크기가 클수도 있고 작을수도 있지만, 클래스 변경시 파급력이 최대한 작게끔 설계하면 된다. 모든 객체가 독립적인 객체지향의 강점을 살리기 위한 규칙이다.

 

2. OCP 개방 - 폐쇄 원칙

 확장에는 열려있으나 변경에는 닫혀있다. 여기서 말하는 변경이란 개발자가 코드를 직접 변경하는 것을 의미한다기 보다는, 역할을 변경하는 것을 의미한다.

 인터페이스를 사용해 구현체를 만들면 사용자는 반드시 인터페이스에서 정의된 메서드를 오버라이딩 해야만 한다. 이는 곧 미리 부여된 역할을 반드시 구현해야 하며, 이 역할을 바꾸지 못함을 의미함으로 변경에는 닫힌 것이 된다. 인터페이스의 코드를 직접 바꿔주면 변경이 가능하니 말이 안되는것 아닌가 할수도 있지만, 그것 역시 결국 역할을 따를수 밖에 없음으로 변경이 불가능한 것이 된다.

 이 문제를 해결하기 위해 스프링에서는 빈컨테이너를 제공한다.

 

3. LSP 리스코프 치환 원칙

 인터페이스에 설정한 메서드의 역할을 수행할수 있게끔 구현해야 한다. 만약 인터페이스 설계당시 "go"라는 메소드를 만들었는데, 실제 구현체에서는 정지해있으면 이는 잘못된 설계다. 리스코프 치환 원칙은 코드 동작 이전의 문제로, 사전에 정한 약속을 지켜야한다는 의미다.

 

4. ISP 인터페이스 분리 원칙

 기능 단위로 쪼게 인터페이스를 분리하는 것을 의미한다.

 자동차의 경우 "자동차 인터페이스" 하나만 만드는 것이 아닌, "운전 인터페이스" + "정비 인터페이스" 로 나눠 상속받아야 한다. 이경우, 어떤 인터페이스에 변경이 일어나더라도 그 파급력이 최소화된다.

 

5. DIP 의존관계 역전 원칙

 "구체화가 아닌 추상화에 의존하라" 라는 명제를 따르기 위한 원칙이다.

 구현체가 아닌 인터페이스에 의존하라는 의미다. 헌데 사용자가 필요로하는 코드는 당연하게도 인터페이스가 아닌 그 구현체일텐데, 구현체가 아닌 의존체에 의존하는 것이 가능할까? 순수 자바코드로 이를 가능케하는 것은 상당히 복잡하고 어려운 일이다.

 

OCP와 DIP는 다형성과 순수 자바 코드 만드로 지키기 매우 어렵다. 개발을 쉽게 하기 위해 만든 규칙이, 도리어 개발을 더 어렵게 만들수도 있는 것이다. 그렇기에 스프링 컨테이너의 의존주입(DI)를 적절히 사용하면 손쉽게 이 원칙들을 모두 지킬수 있다.

 

** 실무적인 관점에서 보자면 반드시 인터페이스를 작성해서 개발하지 않는 것이 더 효율적일수도 있다. 확장할 필요가 없는 어플리케이션이라면 굳이 인터페이스를 작성하지 않아도 된다.

'공부 > springboot' 카테고리의 다른 글

(2) 스프링을 활용한 DI  (0) 2023.05.10
(1) 스프링 사용하지 않고 OCP, DIP 지키기  (0) 2023.05.10
스프링이란?  (0) 2023.05.10
스프링부트: 시큐리티(1)  (0) 2023.02.20
JPA 사용법(2) -- JPQL  (0) 2023.01.26