[OOP] 객체 지향 프로그래밍의 4가지 특징

koline·2024년 4월 30일

Spring

목록 보기
7/15

객체 지향 프로그래밍


객체 지향 프로그래밍(Object-Oriented Programming, OOP)이란 프로그램을 어떤 데이터의 순차적 처리 및 결과 도출과정으로 보는 것이 아닌, 현실의 사물과 대칭되는 객체(Object)들의 조합하여 소프트웨어를 개발하는 방식을 말한다.

예를 들어 기존의 개발 방식으로 피자를 만든다면 도우를 만든다 > 소스를 바른다 > 토핑을 올린다 와 같은 순서로 함수를 호출하는 방식이라면, 객체 지향 프로그래밍에서는 도우, 소스, 토핑 등 각각의 객체를 먼저 정의하고 이 객체들을 조합해 나가며 하나의 완성품을 만든다.




사용하는 이유


객체 지향적 설계를 통한 개발은 프로그램을 보다 유연하고 변경이 용이하게 만들 수 있다. 마치 컴퓨터 부품처럼 각각의 부품들이 각자의 독립적인 역할을 가지기 때문에 사용하는 부분만 쉽게 교체하고 나머지 부품들을 건드리지 않아도 된다.

뿐만 아니라, 각 부품은 언제든지 교체될 수 있기 때문에 코드의 재사용을 통해 반복적인 코드를 최소화하고, 코드를 최대한 간결하게 표현할 수 있다.

게다가 객체 지향 프로그래밍은 실제 우리가 보고 경험하는 세계를 최대한 프로그램 설계에 반영하기 위한 지속적인 노력을 통해 발전해왔기 때문에, 보다 인간 친화적이고 직관적인 코드를 작성하기에 용이허다.




객체 지향 프로그래밍의 4가지 특징


1. 추상화(Abstration)

추상화란 구체화의 반대되는 말로 객체를 더 추상적이게 만든다, 즉, 공통되는 성질로 객체를 묶는 성질을 나타낸다.

예를 들어 자동차 클래스와 오토바이 클래스가 아래와 같이 있다고 생각해보자.

자동차와 오토바이는 생김세와 용도 등이 다르지만 이동수단이라는 점에서 공통된 성질로 묶을 수 있다.

이 때, 이동수단 클래스를 슈퍼 클래스(부모 클래스, 상위 클래스)로 부르고 자동차와 오토바이를 자식 클래스, 하위 클래스로 부른다.

이것을 java코드로 작성하면 아래와 같다.

public interface Vehicle {
    public abstract void moveForward();
    void moveBackward();		// public abstract 키워드 생략 가능
}

public class Car implements Vehicle {
	@OVerride
    public void moveForward() {
    	System.out.println("자동차 직진");
    }
    
    @Override
    public void moveBackward() {
    	System.out.println("자동차 후진");
    }
}

public class MotorBike implements Vehicle {
	@OVerride
    public void moveForward() {
    	System.out.println("오토바이 직진");
    }
    
    @Override
    public void moveBackward() {
    	System.out.println("오토바이 후진");
    }
}

java에서 추상화는 추상클래스 (abstract class) 또는 인터페이스 (interface)로 구현한다. 차이는 다음과 같다.

추상클래스

  • extends 키워드를 통해 상속
  • 다중상속 불가능
  • 추상 메소드 외에 일반클래스와 같이 일반적인 필드, 메서드, 생성자를 가질수 있음
  • 클래스간의 연관 관계를 구축하는 것에 초점

인터페이스

  • implements 키워드를 통해 상속
  • 다중상속 가능
  • 내부의 모든 메서드는 public abstract 로, 모든 필드는 public static final 로 정의
  • 클래스와 별도로 구현 객체가 같은 동작을 한다는 것을 보장하기 위해 사용하는 것에 초점

위 예시에서는 인터페이스를 사용하였는데, 인터페이스에는 추상 메서드나 상수를 통해서 어떤 객체가 수행해야 하는 핵심적인 역할만을 규정해두고, 실제적인 구현은 해당 인터페이스를 상속받는 각각의 객체들에서 자신에게 맞는 방식으로 하도록 프로그램을 설계한다.

이것을 역할과 구현의 분리라고 하며, 유연하고 변경에 열려있는 프로그램을 설계하기 위해 자주 사용한다.


2. 상속(Inheritance)

상속이란 기존의 클래스를 재활용하여 새로운 클래스를 작성하는 것을 말한다. 앞서 봤었던 추상화의 연장선에서, 상속은 클래스 간 공유될 수 있는 속성과 기능들을 상위 클래스로 추상화 시켜 해당 상위 클래스로부터 확장된 여러 개의 하위 클래스들이 모두 상위 클래스의 속성과 기능들을 간편하게 사용할 수 있도록 한다.

즉, 클래스들 간 공유하는 속성과 기능들을 반복적으로 정의할 필요 없이 딱 한 번만 정의해두고 간편하게 재사용할 수 있어 반복적인 코드를 최소화하고 공유하는 속성과 기능에 간편하게 접근할 수 있도록 한다.

자동차 오토바이 예시를 다시 보면, 공통된 속성과 기능들과 그렇지 않은 것들로 위와 같이 구분할 수 있다.

이것을 java코드로 표현하면 다음과 같다.

public class Car {
	String model;
    String color;
    int wheels;
    boolean isConvertable;
    
    public void moveForward() {
    	System.out.println("자동차 직진");
    }
    
    public void moveBackward() {
    	System.out.println("자동차 후진");
    }
    
    public void openWindow() {
    	System.out.println("자동차 창문 열기");
    }
}

public class MotorBike  {
	String model;
    String color;
    int wheels;
    boolean isRaceable;
    
    public void moveForward() {
    	System.out.println("오토바이 직진");
    }
    
    public void moveBackward() {
    	System.out.println("오토바이 후진");
    }
    
    public void stunt() {
    	System.out.println("오토바이 묘기부리기");
    }
}

한눈에 봐도 알 수 있듯이 공통되는 기능과 속성이 많다. 이럴 경우에 재사용 될 수 있는 하나의 클래스로 정의하여 즉, 추상화와 상속을 사용하여 공통된 부모 클래스를 상속하도록 하고 각각의 고유한 속성과 기능은 각각에 맞게 정의해줄 수 있다.

public class Vehicle {
	String model;
    String color;
    int wheels;
    
    void moveForward() {
    	System.out.println("이동수단 직진");
    }
    void moveBackward() {
    	System.out.println("이동수단 후진");
    }
}

public class Car extends Vehicle {
    boolean isConvertable;
    
    public void openWindow() {
    	System.out.println("자동차 창문 열기");
    }
}

public class MotorBike extends Vehicle {
    boolean isRaceable;
    
    @Override
    public void moveForward() {		// 메소드 오버라이딩 가능
    	System.out.println("오토바이 직진")
    }
    
    public void stunt() {
    	System.out.println("오토바이 묘기부리기");
    }
}

위와 같이 공통된 상위 클래스를 생성하여 공통된 속성과 메소드를 정의해줌으로써 반복되는 코드를 작성해야하는 번거로움을 줄일 수 있다.

뿐만 아니라, 상위클래스에서 정의된 메소드 일지라도 하위클래스에서 각각에 맞게 수정할 필요가 있다면, 오버라이딩하여 재정의 해줄 수도 있다.

이 부분이 앞서 추상화에서 봤었던 인터페이스를 통한 구현과 상속을 구분하는 핵심적인 차이 중에 하나라 할 수 있다.

양자 모두 상위 클래스-하위 클래스의 관계를 전제하면서 공통적인 속성과 기능을 공유할 수 있지만, 상속의 경우 상위 클래스의 속성과 기능들을 하위 클래스에서 그대로 받아 사용하거나 오버라이딩을 통해 선택적으로 재정의하여 사용할 수 있는 반면, 인터페이스를 통한 구현은 반드시 인터페이스에 정의된 추상 메서드의 내용이 하위 클래스에서 정의되어야 한다.


3. 다형성

다형성이란 어떤 객체의 속성이나 기능이 상황에 따라 여러 가지 형태를 가질 수 있는 성질을 말한다.

즉, 어떤 객체의 속성이나 기능이 그 맥락에 따라 다른 역할을 수행할수 있는 객체 지향의 특성을 의미합니다. 대표적인 예로 오버라이딩(overriding)과 오버로딩(overloading)이 있다.

오버라이딩은 앞선 예시들에서 봤던 것처럼 메서드를 각각의 클래스의 맥락에 맞게 재정의하여 사용하는 것을 말한다.

오버로딩이란 하나의 클래스 내에서 같은 이름의 메서드를 여러 개 중복하여 정의하는 것을 말한다.

객체 지향 프로그래밍에서 다형성이은 한 타입의 참조변수를 통해 여러 타입의 객체를 참조할 수 있도록 만든 것을 의미합니다. 좀 더 구체적으로, 상위 클래스 타입의 참조변수로 하위 클래스의 객체를 참조할 수 있도록 하는 것이다.

public class Main {
	public static void main(String[] args) {
		// 다형성 적용 전
		Car car = new Car();
        MotorBike mb = new MotorBike();
        
        // 다형성 적용 후
        Vehicle vehicles[] = new Vehicle[2];
        vehicle[0] = new Car();
        vehicles[1] = new MotorBike();
    }
}

상위 클래스 Vehicle 타입의 객체를 생성해주면, Vehicle 클래스와 상속 관계에 있는 모든 하위 클래스들을 그 안에 담아줄 수 있다. 이렇게 다형성을 활용하면 하나의 타입만으로 여러 가지 타입의 객체를 참조할 수 있어 보다 간편하고 유연하게 코드를 작성하는 것이 가능해진다.

예를 들어, 위의 이동수단 예시에 운전자 클래스를 추가해보자.

public class Driver {
	void drive(Car car) {
    	car.moveForward();
        car.moveBackward();
    }
    
    void drive(MotorBike motorBike) {
    	motorBike.moveForward();
        motorBike.moveBackward();
    }
}

public class Main {
	public static void main(String[] args) {
		Car car = new Car();
        MotorBike mb = new MotorBike();
        Driver driver = new Driver();
        
        driver.drive(car);
        driver.drive(mb);
    }
}

위와 같이 Driver 객체가 매개변수로 Car 또는 MotorBike 객체를 전달받아 운전할 때, 즉, 하나의 객체가 다른 객체의 속성과 기능에 접근하여 어떤 기능을 사용할 때, "A클래스가 B클래스에 의존한다"라고 표현한다.

위 예시에서는 Driver 클래스가 Car 클래스와 MotorBike 클래스에 의존하고 있다. 즉 Driver 클래스와 다른 두 개의 클래스가 서로 직접적인 관계를 가지고 있는데, 이러한 상황을 조금 어려운 말로 객체들 간의 결합도가 높다고 할 수 있다.

만약 CarMotorBike 말고도 버스, 택시 더 나아가 소형차, 중형차 등 객체의 종류가 수도 없이 많아진다면 개발자는 Driver 클래스를 작성하다 은퇴해야할 것이다. 뿐만 아니라, 만약 MotorBike 객체의 이름 혹은 메서드명 등이 바뀐다면 해당 객체가 사용된 모든 부분을 찾아가서 수정해야할 것이다.

그런 이유로 다형성을 OOP의 꽃이라고 하는 것이다. 다형성을 적용하면 위 코드는 아래와 같이 간결해질 수 있다.

public class Driver {
	void drive(Vehicle vehicle) {
    	vehicle.moveForward();
        vehicle.moveBackward();
    }
}

public class Main {
	public static void main(String[] args) {
		Vehicle car = new Car();
        Vehicle mb = new MotorBike();
        Driver driver = new Driver();
        
        driver.drive(car);
        driver.drive(mb);
    }
}

위와 같이 작성해주면 원래 Driver 객체가 Car 객체와 MotorBike 객체에 각각 의존하며 결합도가 높았던 반면, Driver 객체가 Vehicle이라는 인터페이스에 의존하게 되어 결합도가 훨씬 낮아지게 된다.


4. 캡슐화(Encapsulation)

캡슐화란 클래스 안에 서로 연관있는 속성과 기능들을 하나의 캡슐(capsule)로 만들어 데이터를 외부로부터 보호하는 것을 말한다.

캡슐화를 통해 얻을 수 있는 장점은 크게 두가지가 있다.

데이터 보호(data protection): 외부로부터 클래스에 정의된 속성과 기능들을 보호
데이터 은닉(data hiding): 내부의 동작을 감추고 외부에는 필요한 부분만 노출

Java 객체 지향 프로그래밍에서 캡슐화를 구현하기 위한 방법은 크게 두 가지가 있다. 우선 접근제어자를 활용하는 것이다. 접근제어자는 클래스 또는 클래스의 내부의 멤버들에 사용되어 해당 클래스나 멤버들을 외부에서 접근하지 못하도록 접근을 제한하는 역할을 한다.

두번째로는 gettersetter 메서드가 있다.

public class Car {
	private String model;
    private String color;
    private int wheels;
    
    public String getModel() {
    	return model;
    }
    
    public void setModel(String model) {
    	this.model = model;
    }
    
    public String getColor() {
    	return color;
    }
    
    public void setColor(String color) {
    	this.color = color;
    }
    
    public int getWheels() {
    	return wheels;
    }
    
    public void setWheels() {
    	this.wheels = wheels;
    }
}

위는 가장 일반적인 getter, setter 메서드를 적용한 클래스의 예시인데 모든 속성값들은 private 접근 제어자로 선언되어 있고, getter, setter 메서드의 접근제어자만이 public 으로 열려있다.

캡슐화를 사용해야하는 이유는 뭘까? 아래의 예시를 보자.

public class Car {
	public void moveForward() {
    	System.out.println("직진합니다.");
    }
    
	public void moveBackward() {
    	System.out.println("후진합니다.");
    }
}

public class Driver {
	private Car car;
    
    public Driver(Car car) {
    	this.car = car;
    }
    
    public void drive() {
    	car.moveForward();
    	car.moveBackward();
    }
}

public class Main {
	public static void main(String[] args) {
    	Car car = new Car();
        Driver driver = new Driver(car);
        
        driver.drive();
    }
}

위의 코드는 에러 없이 작동하는 코드지만, 결합도가 매우 높은 코드이다. 만약 Car 클래스의 수정이 있어서 moveForward() 함수 또는 moveBackward() 함수가 Deprecated 되고 새로운 함수가 생긴다면? Driver 클래스로 찾아가서 수정해야할 것이다.

지금은 함수가 두개밖에 없지만 만약 함수가 매우 많고 여러 클래스가 얽혀있다면 스파게티 코드 그 자체가 될 것이다.

그렇다면 어떻게 위 코드의 결합도를 낮출 수 있을까?

public class Car {
	private String model;
    private String color;
    private int wheels;
    
    public String getModel() {
    	return model;
    }
    
    public void setModel(String model) {
    	this.model = model;
    }
    
    public String getColor() {
    	return color;
    }
    
    public void setColor(String color) {
    	this.color = color;
    }
    
    public int getWheels() {
    	return wheels;
    }
    
    public void setWheels() {
    	this.wheels = wheels;
    }
}

위는 가장 일반적인 getter, setter 메서드를 적용한 클래스의 예시인데 모든 속성값들은 private 접근 제어자로 선언되어 있고, getter, setter 메서드의 접근제어자만이 public 으로 열려있다.

캡슐화를 사용해야하는 이유는 뭘까? 아래의 예시를 보자.

public class Car {
	// 접근제어자를 private으로 변경
	private void moveForward() {
    	System.out.println("직진합니다.");
    }
    
	// 접근제어자를 private으로 변경
	private void moveBackward() {
    	System.out.println("후진합니다.");
    }
    
    // 다른 클래스에서 사용할 수 있는 메서드를 모아서 접근 허용
    public void operate() {
    	moveForward();
        moveBackward();
    }
}

public class Driver {
	private Car car;
    
    public Driver(Car car) {
    	this.car = car;
    }
    
    public void drive() {
    	car.operate();
    }
}

public class Main {
	public static void main(String[] args) {
    	Car car = new Car();
        Driver driver = new Driver(car);
        
        driver.drive();
    }
}

이 코드는 이전 코드와 거의 유사하지만 operate() 함수만 열어줌으로써 결합도를 매우 낮췄다. 이 코드에서는 Car 클래스에 어떤 변경이 발생하던 상관없이 operate 함수만 그대로 열려 있다면 외부에서 변경이 일어날 일은 없다.

profile
개발공부를해보자

0개의 댓글