Study/Java

[Java] OOP와 4가지 특징

jonghne 2023. 10. 2. 19:11

개요

이번 게시글에서는 객체 지향 프로그래밍(OOP)란 무엇이고 어떤 특징을 가지는지 설명합니다.

 

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

객체 지향 프로그래밍이란 컴퓨터 프로그래밍의 패러다임 중 하나로, 현실 세계의 사물이나 개념을 각각 상태(속성)와 행위(기능)를 가지는 객체로 만들고 이 객체들의 상호작용을 통해 문제를 해결하는 프로그래밍 기법이다.

 

이 OOP는 프로그램의 장점으로는 레고 블럭 조립하듯 프로그래밍 하기 때문에 코드 재사용성과 유지보수성이 높다는 점이 있다.

 

대표적으로 많이 알려진 언어 Java를 포함하여 C++, Python, Kotlin 등이 객체지향 프로그래밍을 지원한다

 

OOP에는 다음과 같은 주요 개념과 특징이 있다.

 

 

개념

  • 클래스 
  • 객체 

주요 특징

  • 추상화
  • 상속 
  • 다형성 
  • 캡슐화
 

 

 

OOP 주요 개념

클래스 (Class)

 

클래스란 객체를 생성하기 위한 설계도로 표현된다.

 

객체가 가질 수 있는 속성(필드, 멤버변수)과 동작(메서드)들로 이루어져 있다.

 

클래스는 프로그램 실행 시 JVM의 클래스 영역에 로드된다

// 클래스 정의
public class Car {
    // 멤버 변수 (속성)
    private boolean driveStatus;
    private int wheelCount;
	
    // ... (생성자 / Getter,Setter 생략)

    // 메서드    
    public void start(){
    	System.out.println("차가 움직입니다.");
        drivingStatus = true;
    }
    
    public void stop(){
    	System.out.println("차가 멈춥니다.");
        drivingStatus = false;
    }

}

 

객체

객체란 클래스를 통해 생성된 실체로, 고유의 상태를 가지고 특정 동작을 수행할 수 있다.

 

클래스가 제품 설계도라면 객체(인스턴스)는 설계도를 기반으로 생성된 제품이라고 생각하면 된다.

// 자동차 클래스 정의
public class Car {
    // 멤버 변수 (속성)
    private String brand;
    private String model;
    private String color;

    // 생성자
    public Car(String brand, String model, String color) {
        this.brand = brand;
        this.model = model;
        this.color = color;
    }

    // 메서드
    public void start() {
        System.out.println("The " + color + " " + brand + " " + model + " is starting.");
    }

    public void drive() {
        System.out.println("The " + color + " " + brand + " " + model + " is driving.");
    }

    public void stop() {
        System.out.println("The " + color + " " + brand + " " + model + " has stopped.");
    }
}

// 메인 클래스
public class Main {
    public static void main(String[] args) {
        // Car 클래스의 인스턴스(객체) 생성
        Car myCar = new Car("Toyota", "Camry", "Blue");

        // 객체의 메서드 호출
        myCar.start();
        myCar.drive();
        myCar.stop();
    }
}

 

참고로, 객체와 인스턴스는 비슷하지만 아래와 같은 차이를 보인다.

더보기

객체와 인스턴스 차이

객체 / 인스턴스는 의미가 비슷한 용어로 클래스를 실체화 한 대상을 지칭할 때 사용한다. 

두 용어를 명확하게 구분해서 사용하기는 어렵지만 굳이 나누자면 아래와 같은 차이가 있다.

 

📌 객체

- 클래스의 모든 인스턴스들을 대표해서 지칭할 때 사용된다. 

- 소프트웨어 내에 실제 실체화되지 않은 (메모리에 할당되지 않은) 추상적인 개념이다.

 

📌 인스턴스

- 클래스를 소프트웨어 내에 실체화 한 구체적인 대상으로, 메모리에 할당 되어 있는 객체를 말한다. 

- 객체보다 클래스와의 관계에 집중한 용어이다 (~의 인스턴스)

 

OOP의 4가지 특징

추상화

추상화란 데이터나 프로세스등을 공통적인 특징을 가지는 것 또는 본질이 같은 것들 끼리 추출해서 모아 놓은 것을 말한다. 

 

 

추상화 예시

1. 사용자의 아이디, 이름, 이메일 등의 데이터  -> DB의 User 테이블로 추상화 

2. 주문 ID, 주문자명, 주문 상품, 주문 가격 데이터들 -> Order 클래스로 추상화 

3. 삼성 프린터, LG 프린터 -> 프린터로 추상화 

 

 

OOP에서의 객체의 공통적인 부분들을 추상화해서 추상클래스나 인터페이스로 추출한다.

 

📌  장점

1. 코드가 간결해지고 재사용성이 높아진다. 

2. 추상화한 객체들과 다형성을 활용해서 개발하면 유지보수성이 높아진다.

 

📌  주의사항

너무 이른 시점에 추상화를 하게 되면, 추상화에 드는 비용과 추상 타입 증가에 따른 복잡도가 증가하게 된다. 

그렇기 때문에 실제 변경 및 확장이 발생하는 시점에 추상화 하는 것이 좋을 것 같다.

 

📌  예시

자동차 객체와 오토바이 객체가 있다고 가정해보자 

 

자동차와 오토바이 모두 시동을 걸고, 앞으로 또는 뒤로 이동할 수 있는 공통적인 기능이 존재한다. 

이 공통적인 부분을 Vehicle이라는 인터페이스 또는 추상클래스로 추상화할 수 있다. 

public interface Vehicle {

    void start();
    void moveForward();
    void moveBackward();
}

public class Car implements Vehicle {

    @Override
    public void start() {
        System.out.println("Car 출발");
    }

    @Override
    public void moveForward() {
        System.out.println("앞으로 1 이동");
    }

    @Override
    public void moveBackward() {
        System.out.println("뒤로 1 이동");
    }
}

public class Motorbike implements Vehicle {

    @Override
    public void start() {
        System.out.println("Motorbike 출발");
    }

    @Override
    public void moveForward() {
        System.out.println("앞으로 3 이동");
    }

    @Override
    public void moveBackward() {
        System.out.println("뒤로 3 이동");
    }
}

 

상속

상속이란, 기존 클래스를 재활용 해서 새로운 클래스를 구현하는 기능이다.

 

추상화의 연장선으로 공통적인 속성과 기능은 상위 클래스에 추상화해놓고 하위 클래스들에서 공유하며 사용하게 된다. 

 

📌 장점

클래스들의 반복적인 코드를 상위 클래스에 한번만 정의하고 사용할 수 있기 때문에, 코드의 중복을 최소화 하고 재사용성을 높일 수 있다.

 

📌 예시

아래의 코드에서 Car, Motorbike 클래스는 Vehicle 상위 클래스를 상속 받고 있다.

 

start() , stop() , displayInfo() 메서드는 상위 클래스인 Vehicle에만 구현되어 있고 Car,Motibike 클래스 내부에는 구현되어 있지 않지만 객체를 생성한 뒤 해당 메서드를 호출해서 사용할 수 있다.

// 부모 클래스: Vehicle
class Vehicle {
    public void start() {
        System.out.println("The vehicle is starting.");
    }

    public void stop() {
        System.out.println("The vehicle is stopping.");
    }
}

// 자식 클래스: Car (Vehicle을 상속)
class Car extends Vehicle {
    // Car에만 해당되는 메서드
    public void honk() {
        System.out.println("Honk! Honk!");
    }
}

// 자식 클래스: Motorbike (Vehicle을 상속)
class Motorbike extends Vehicle {
    // Motorbike에만 해당되는 메서드
    public void wheelie() {
        System.out.println("Doing a wheelie!");
    }
}

public class InheritanceExample {
    public static void main(String[] args) {
        // Car 인스턴스 생성
        Car myCar = new Car();
        myCar.start();       // Vehicle 클래스의 메서드 호출
        myCar.honk();        // Car 클래스의 메서드 호출

        // Motorbike 인스턴스 생성
        Motorbike myMotorbike = new Motorbike();
        myMotorbike.start();       // Vehicle 클래스의 메서드 호출
        myMotorbike.wheelie();     // Motorbike 클래스의 메서드 호출
    }
}

 

그리고 만약, 상위 클래스의 기능을 하위 클래스에서 일부 변경하고자 한다면 메서드 오버라이딩을 통해 재정의할 수도 있다. 

// 부모 클래스: Vehicle
class Vehicle {

    public void start() {
        System.out.println("The vehicle is starting.");
    }

    public void stop() {
        System.out.println("The vehicle is stopping.");
    }
}

// 자식 클래스: Car (Vehicle을 상속)
class Car extends Vehicle {

    @Override
    public void start() {
        System.out.println("The Car is starting.");
    }

    // Car에만 해당되는 메서드
    public void honk() {
        System.out.println("Honk! Honk!");
    }
}

 

다형성

다형성이란 한개의 참조 변수를 통해 여러 타입의 객체를 참조할 수 있는 것으로,

 

상위 클래스의 참조변수를 통해 하위 클래스들의 객체를 참조 할 수 있는 것을 말한다 

 

다형성은 주로 인터페이스와 상속을 통해 구현한다.

 

📌 장점

프로그램을 역할과 구현으로 나눠서 객체 간의 결합도를 낮추고 변경에 용이한 프로그램을 만들 수 있다

위의 예시에서 운전자는 K3 / 아반떼 / 테슬라가 아닌 자동차의 역할에 의존하고 있기 때문에,

만약 다른 자동차로 구현체가 바뀌어도 운전을 하는데 아무런 영향이 가지 않는다. (반대 입장도 마찬가지) 

 

이렇게 클라이언트 코드가 구현체가 아닌 인터페이스 또는 상위 클래스를 참조하게 함으로써, 

클라이언트 코드는 변경하지 않고 서버의 코드를 유연하게 변경할 수 있다는 장점이 있다.

 

📌 예시

Animal 인터페이스로 추상화한 Dog, Cat 클래스가 있을 때, 다형성을 사용하면 아래 예시와 같이 Animal 타입의 참조 변수로 Dog, Cat 타입의 객체를 참조 할 수 있다. 

 

만약 DI를 활용해서 Animal 인터페이스가 참조할 객체를 외부에서 주입 받는다면, 내부 코드는 변경하지 않고 구현체를 갈아 끼울수 있게 되어서 유지보수성이 높아질 수 있다.

interface Animal {
    void makeSound();
}

class Dog implements Animal {
    @Override
    void makeSound() {
        System.out.println("멍멍");
    }
}

class Cat implements Animal {
    @Override
    void makeSound() {
        System.out.println("야옹");
    }
}

public class PolymorphismExample {
    public static void main(String[] args) {
        Animal myDog = new Dog();
        Animal myCat = new Cat();

        myDog.makeSound();  // Dog 클래스의 makeSound 호출
        myCat.makeSound();  // Cat 클래스의 makeSound 호출
    }
}

 

캡슐화

캡슐화란 기능의 구현을 외부에 감추는 것을 의미한다.

 

캡슐화는 객체는 주어진 메소드를 통해서만 상호작용할 수 있게 하는 정보 은닉을 포함하는 객체지향 특징이다. 

 

📌 장점 

1. 기능에 대한 이해를 높일 수 있다.

- 기능이 외부에 구현된다면, 기능에 대한 코드를 이해하는 비용이 발생하게 된다. 

- 캡슐화 적용 시, 구현 코드가 아닌 메서드명을 통해 쉽게 기능을 이해할 수 있다.

 

2. 기능 변경에 따른 외부 코드의 유지보수 비용이 최소화된다. 

- 기능이 외부에 구현된다면, 기능 변경 시 구현된 모든 곳을 수정해야 하는 비용이 발생한다.

- 캡슐화 적용 시,  기능이 변경되어도 실제 구현부 수정하면 해당 기능을 사용하는 외부 코드는 변경이 일어나지 않는다. (최소화된다)

 

📌 예시

비즈니스 로직 중 아래와 같은 배송 상품에 대한 반품 가능 여부를 확인하는 로직이 있다고 가정해보자

public void refund(Delivery delivery, OrderItem orderItem) throws Invalid{

    if(delivery.getDeliveryState() == DELIVERED 
    	&& delivery.getDeliveredTime().isBefore(purchaseDate.plusDays(7))) {
     
		// ... 반품 비즈니스 로직 ...
    
    }
    else {
    	throw new IllegalStateException("현재 해당 상품은 반품 불가합니다");
    }
    
}

 

위의 정책에서는 배송 완료 상태이면서 배송 완료 이후 7일이 지나지 않았다면 반품이 가능했다.

 

그런데 정책이 변경되어서 배송 완료 이후 14일 이후까지 반품이 가능하게 변경되었고,

추가로 상품에 하자가 있는 경우에는 무조건 반품이 가능하게 변경되었다. 

public void refund(Delivery delivery, OrderItem orderItem) throws Invalid{

    if(orderItem.isItemDamaged()  
        || (delivery.getDeliveryState() == DELIVERED 
            && delivery.getDeliveredTime().isBefore(purchaseDate.plusDays(7)))
            ) 
       ) {
		// ... 반품 비즈니스 로직 ...
    
    }
    else {
    	throw new IllegalStateException("현재 해당 상품은 반품 불가합니다");
    }
    
}

 

만약 반품 가능 여부를 확인하는 코드가 여러 군데에 있다면, 위와 같이 모두 변경해줘야 하는 비용이 발생하기 때문에 기능 구현부를 캡슐화 하는 것이 좋다.

 

아래와 같이 Delivery 클래스 내에 isPossibleRefund() 메서드로 반품 가능 여부를 확인하는 기능을 구현하고,

 

외부에서는 해당 메서드를 사용하게끔 하면 정책이 변경되더라도 Delivery의 isPossibleRefund() 메서드만 수정하면 되기 때문에 유지보수 비용을 최소화 할 수 있다.

public void refund(Delivery delivery, OrderItem orderItem) throws Invalid{

    if(delivery.isPossibleRefund(orderItem)) {
		// ... 반품 비즈니스 로직 ...
    
    }
    else {
    	throw new IllegalStateException("현재 해당 상품은 반품 불가합니다");
    }
    
}


public class Delivery () {
	// ...
    private boolean refundPossibleYn = false;
    private DeliveryState deliveryState;
    private LocalDateTime purchaseDate;
    private LocalDateTime deliveredTime; 
    
    public boolean isPossibleRefund(OrderItem orderItem) {
    	if(orderItem.isItemDamaged()) {
        	this.returnPossibleYn = true;
        }
       	
        
	 	if(this.deliveryState == DELIVERED 
            && delivery.deliveredTime.isBefore(purchaseDate.plusDays(7)))
           ) {
           this.returnPossibleYn = true;
       }
       
       return this.returnPossibleYn;
    }
    
    // ...

}

 

📌 캡슐화 2가지 규칙 

막상 캡슐화를 하려고 하면 어떤 코드를 캡슐화해야 하는지 헷갈릴 경우가 있다. 

이럴 때는 아래의 2가지 캡슐화 규칙을 기억해서 캡슐화 진행 여부를 결정하면 된다

 

1. Tell, Don't Ask

 

"객체에게 데이터를 달라고 하지말고 해달라고 하기"

 

코드 중에 getter 등을 통해 객체에게 어떤 값을 받아온 뒤 처리하는 부분이 있다면, 캡슐화해서 해당 처리를 위임하도록 캡슐화 하는 것이 좋다. 

 

아래의 예시에서 첫번째 if문은 정회원인지 파악하기 위해 memberShip 값을 달라고 member 객체에게 요청하고 있다. 

 

이 경우에는 Tell, Don't Ask 규칙에 어긋나는 상황이기 때문에, 2번째 if문과 같이 캡슐화된 메서드를 사용하게 변경해야 한다

// ASK : 객체에게 데이터를 달라고 해서 정회원 여부를 판단하고 있다.
if(member.getMemberShip() == REGULAR) {
   // ..정회원
}

// TELL : 객체에게 정회원인지 판단해달라고 요청하고 있다.
if(member.hasRegularPermission()) {
   // ..정회원
}

 

2. Demeter's Law

 

객체 간의 최소지식 원칙으로, "각 객체는 조작하고 있는 타 객체의 내부 사정을 몰라야 한다" 라는 법칙이다.

 

객체 메서드를 연속으로 호출해서기능을 구현하지 말라는 의미이다. 

 

예시

 

아래의 코드에서 구매일로 7일이 지났는지 확인하는 코드를 구현하기 위해, delivery 객체의 구매일자를 조회하고 있다. 

다른 객체의 내부 사정(구매일자)을 알게 되기 때문에 디미터 법칙을 위반했다고 할 수 있다.

if(nowDate.isBefore(delivery.getPurchaseDate().plusDays(7))) {
	// 구매일로부터 7일이 지나지 않았음
}

 

아래와 같이 delivery 객체 내부로 캡슐화하고, 객체의 상태를 모른채로 코드를 구현할 수 있게 변경하는 것이 좋다.

if(delivery.isRefundPeriod(nowDate)){
	// 구매일로부터 7일이 지나지 않았음
}

 

 

 

참고 

https://www.inflearn.com/course/스프링-핵심-원리-기본편
https://www.codestates.com/blog/content/%EA%B0%9D%EC%B2%B4-%EC%A7%80%ED%96%A5-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-%ED%8A%B9%EC%A7%95