객체지향 프로그래밍이란

김도현·2023년 7월 6일

객체지향을 이해하려면

객체지향 프로그래밍에 대해 설명해보라 하면 대부분 말문이 막힐 것이다. 객체지향 프로그래밍의 개념을 이해하려면, 무작정 OOP가 어떤 것이다 하고 외우기보단 등장 배경에 대해 알 필요가 있다. 따라서 객체지향 프로그래밍이 등장하기 이전의 프로그래밍 패러다임을 몇 가지 알아보자.

순차적 (비구조적) 프로그래밍

정의한 기능의 흐름에 따라 순서대로 동작을 추가하며 프로그램을 완성하는 방식이다. 간단한 프로그램의 경우, 이렇게 코드를 짜게 되면 흐름이 눈으로 보이기 때문에 매우 직관적일 것이다. 그러나, 조금이라도 프로그램의 규모가 커지게 되면 곤란해진다. 만일 A -> B -> C 라는 동작을 구현하다가, C에서 A로 돌아가야할 상황이라면 goto 를 활용해야 한다.

goto문을 무분별하게 활용하게 되면, 그야말로 스파게티 그 자체가 완성된다. 쭉 나열된 코드 속에서 위로 갔다가 아래로 갔다가 난리도 아니게 된다. 그렇게 되면 동작이 직관적이지 못하게 되고, 유일한 장점이 사라지는 셈이다.

그래서 등장한 것이 절차적, 구조적 프로그래밍이다.

절차적 (구조적) 프로그래밍

절차적 프로그래밍에서 '절차'는 함수를 의미한다. 따라서 절차적 프로그래밍이란, 반복되는 동작을 함수 및 프로시저 형태로 모듈화하여 사용하는 방식이다.

프로시저란?
리턴값이 없는 함수, 예를 들어 데이터를 출력하는 용도로 사용하는 printf와 같은 함수를 프로시저라고 한다.

반복 동작을 모듈화하여 코드를 많이 줄일 수 있다. 허자먼 프로시저라는 것 자체가 너무 추상적이라는 단점이 있다. 무슨 말인지 이해하기 위해 아래 예시를 살펴보자.

도서관의 도서 관리 프로그램을 개발한다고 하자.

  • '책' 이라는 자료형을 구현해야 함
  • 책에 대한 함수를 구현해야 함

그러나, 구조적 프로그래밍에서는 이 둘을 따로 생각할 수 밖에 없다. 책은 책이고 책에 관한 함수는 따로 있기 때문에, 같은 소스코드 파일 내에 있더라도 이 툴의 연관 여부는 단번에 알아차리기 어렵다. 즉, 논리적으로 묶여있을 수 없는 구조이기 때문에 동작이 추상적인 것이다. 따라서, 이를 묶이 위한 패러다임으로 '객체지향 프로그래밍'이 등장하게 되었다.

객체지향 프로그래밍

어떤 개념에 대한 자료형과 함수를 '객체' 형태로 함께 묶어서 관리하기 위해 객체지향 프로그래밍 패러다임이 등장하게 되었다. 핵심 포인트는 객체 내부에 자료형 필드와 함수가 함께 존재하는 것이다. 가능한 모든 물리적, 논리적 요소를 객체로 만드는 것이 객체지향 프로그래밍이다.

결론적으로 객체 간의 독립성이 뚜렷하게 생기고, 중복되는 코드의 양이 줄어든다. 따라서 유지보수에 용이해질 것이다.

객체지향 프로그래밍 자바 예시

class Car {
    // 속성 (필드)
    String color;
    String manufacturer;

    // 행동 (메서드)
    void drive() {
        System.out.println("The car is driving.");
    }

    void stop() {
        System.out.println("The car has stopped.");
    }
}

// 실제 객체 생성
Car tesla = new Car();
tesla.color = "Red";
tesla.manufacturer = "Tesla";
tesla.drive();

Car bmw = new Car();
bmw.color = "Blue";
bmw.manufacturer = "BMW";
bmw.stop();

위와 같이 Class를 선언하여 선언한 Class를 가지고 객체를 생성하여 데이터(속성)와 함수를 묶어서 관리하는 것이 객체지향 프로그래밍이다.

여기서 Class는 "객체를 생성하기 위한 템플릿" 이라고 생각하면 된다. Class는 객체의 구조와 기능을 정의하고, 실제 객체는 이 클래스를 기반으로 생성된다.

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

1. 추상화 (Abstraction)

객체들이 공통적으로 필요로 하는 속성이나 동작을 하나로 추출해내는 작업

추상적인 개념에 의존하여 설계해야 유연함을 갖출 수 있다. 즉, 세부적인 사물들의 공통적인 특징을 파악한 후, 하나의 묶음으로 만들어내는 것이 추상화이다.

추상화는 두 가지 방법으로 수행할 수 있다.

  1. 데이터 추상화: 데이터 추상화는 클래스의 인스턴스 변수와 메서드를 캡슐화하여 데이터를 숨기고 사용자에게 필요한 정보만을 제공하는 것을 의미한다.
  2. 메서드 추상화: 메서드 추상화는 메서드의 작업을 단순화하여 사용자에게 필요한 기능만 제공하는 것을 의미한다. 이는 메서드 시그니처만 보고도 메서드가 무엇을 하는지 알 수 있게 해준다.
public abstract class Animal {
    // 데이터 추상화
    private String name;

    public Animal(String name) {
        this.name = name;
    }

    // 메서드 추상화
    public abstract void sound(); // 동물이 내는 소리

    // 사용자에게 필요한 정보만 제공
    public String getName() {
        return name;
    }
}

public class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }

    // sound 메서드 구현
    @Override
    public void sound() {
        System.out.println("Bark!");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal dog = new Dog("Rover");
        System.out.println(dog.getName()); // Rover
        dog.sound(); // Bark!
    }
}

위의 코드에서 Animal은 추상 클래이다. 이 클래스는 'name'이라는 인스턴스 변수와 'sound()' 라는 추상 메서드를 정의한다. 'sound()' 메서드는 추상 메서드로 선언되어 있기 때문에, 'Animal' 클래스를 상속받는 모든 클래스는 이 메서드를 구현해야 한다. 이 방법을 통해 각 동물 클래스에서 어떤 소리를 내는지 결정할 수 있다. 이렇게 하면 각 동물 클래스의 내부 작업에 대해서 걱정할 필요 없이 'sound()' 메서드를 호출하는 것만으로 동물이 어떤 소리를 내는지 알 수 있다. 또한, 'Animal' 클래스는 'name' 이라는 private 인스턴스 변수를 가지고 있다. 이 변수에 접근하기 위해 'getName()' 이라는 public 메서드를 통해야 한다. 이것이 데이터 추상화의 예시이다.

2.캡슐화 (Encapsulation)

정보 은닉화를 통해 높은 응집도, 낮은 결합도를 유지할 수 있도록 설계하는 것

쉽게 말하면, 한 곳에서 변화가 일어나도 다른 곳에 미치는 사이드 이펙트를 최소화 시키는 것을 의미한다. 즉, 객체 내부의 어떤 동작에 대한 구현이 어떻게 되어있는지 감추는 것이다. 이를 통해 외부에서 뭔가 잘못 건드려 객체를 손상시키는 일을 방지할 수 있다.

결합도란 어떤 기능을 실행할 때 다른 클래스나 모듈에 얼마나 의존적인지를 나타내는 지표이다. 객체 간의 독립성을 강조하기 위해 객체지향 프로그래밍이 등장했다. 그런데 결합도가 높아서야 객체지향으로 설계하는 의미가 있을까?

따라서 독립적으로 만들어진 객체들 간의 의존도를 최대한 낮게 만드는 것이 중요하다. 때문에 소프트웨어 공학적으로, 객체 내의 모듈 간의 요소가 밀접한 관련이 있는 것으로 구성하여 응집도를 높이고, 결합도를 줄여야 요구사항 변경에 대처하는 좋은 설계라고 배운다.

한 줄 정리
객체 각각은 독립적으로 작용할 수 있도록 응집도가 강해야 하고 다른 모듈을 참조하는 결합도는 낮아야 함

높은 응집도와 낮은 결합도는, '은닉화'를 통해 이루어낼 수 있다. 외부에서 접근할 필요 없는 것들은 접근 지정자들 private 으로 두어접근에 제한을 두는 것이다. 외부 객체는 객체 내부의 구조를 모르게 하고, 해당 객체가 노출해서 제공하는 필드와 메서드만 이용할 수 있도록 하여 의도하지 않은 동작 오류를 방지하고 유지보수 효율을 높일 수 있다.

3. 상속 (Inheritance)

여러 객체들이 지닌 공통적 특성을 부각시켜 하나의 개념이나 법칙으로 성립하는 과정 (일반화라고도 한다.)

누구든 상속의 개념은 알고 있을 것이다. 자식 클래스가 부모 클래스의 필드나 메서드를 그대로 물려받아 사용할 수 있게, 혹은 조금 다듬어서 사용할 수 있게 해주는 것이다. 조금 더 엄밀히 따지면 상속은 자식 클래스를 외부로부터 은닉하는 캡슐화의 일종이다.

상속을 활용하면 상위 클래스의 구현을 활용함으로써, 코드 재사용이 용이해진다. 그러나, 상속을 통한 재사용을 할 때 나타나는 단점이 명백하다. 따라서 객체지향 프로그래밍에서 '코드 재사용'을 목적으로 하는 상속 행위는 엄격히 금한다.

그 이유에는 3가지 정도가 있는데,

  1. 부모 클래스의 변경이 불편해짐
    -> 부모 클래스에 의존하는 자식 클래스가 많을 때 부모 클래스의 변경이 필요하다면, 이를 의존하는 자식 클래스들이 영향을 받게 됨
  2. 불필요한 클래스의 증가
    -> 유사 기능 확장시, 필요 이상의 불필요한 클래스를 만들어야할 수 있음
  3. 잘못된 상속 사용
    -> 같은 종류가 아닌 클래스의 구현을 재사용하기 위해 상속을 받게 되면, 문제가 발생할 수 있음

그렇다면 이러한 문제는 어떻게 해결할까?
-> 구성(Composition) 을 통해 해결

객체 구성은 객체 내부 필드에서 다른 객체를 참조하는 방식으로 구현한다.
상속에 비해 런타임 구조가 복잡하고 구현이 어렵지만, 변경시 유연함을 확보할 수 있다는 장점이 크다.
-> 같은 종류가 아닌 클래스를 상속하고 싶을 땐, 객체 컴포지션을 먼저 적용해볼 것

상속은 반드시

  • IS-A 관계 즉, 추상화(형식이나 클래스와 같은)들 사이의 포함 관계가 성립할 때
  • 재사용 관점 X, 기능의 확장 관점 O

이러한 상황에서만 사용해야 한다. 상속을 코드 재사용의 개념으로 이해하면 안 된다. 코드를 재사용할 수 있다고 무지성으로 상속을 사용하는 경우가 있는데, 이렇게 되면 클래스간의 결합도가 너무 높아져 유지보수 효율이 떨어지게 된다. 일반적인 개념을 구체화하는 상황에서 상속을 사용하자.

IS-A
말 그대로 포함 관계를 의미한다. 한 클래스 A가 다른 클래스 B의 자식 클래임을 이야기한다./
e.g 햄스터는 동물이다. (소의 부모 클래스는 동물)

HAS-A
상속이 아는 구성(Composition) 관계를 의미한다. 한 객체가 다른 객체에 속한다는 이야기이다.
e.g 컴퓨터안에는 CPU가 있다. (컴퓨터 객체가 CPU 객체를 구성함)

4. 다형성(Polymorphism)

서로 다른 클래스의 객체가 같은 동작 수행 명령을 받았을 때, 각자의 특성에 맞는 방식으로 동작하는 것

객체지향 패러다임의 핵심이다. 한 가지 형태가 여러 형태로 나타날 수 있는 능력을 의미한ㄷ. 이는 메서드 오버로딩, 메서드 오버라이딩, 인터페이스 등을 통해 구현될 수 있다.

  1. 메서드 오버로딩: 같은 이름의 메서드를 여러 개 가지면서 매개변수의 유형과 개수가 다르도록 하는 기술이다. 이를 통해 같은 이름의 메서드를 호출하더라도 다른 동작을 수행할 수 있게 한다.
public class OverloadingExample {
    void display(String s){
        System.out.println(s);
    }
    void display(String s, int n){
        for (int i = 0; i < n; i++) {
            System.out.println(s);
        }
    }
}
  1. 메서드 오버라이딩: 서브 클래스가 슈퍼 클래스의 메서드를 재정의하는 기술이다. 이를 통해 상속받은 메서드의 동작을 서브 클래스에 맞게 변경할 수 있다.
class Animal {
   public void move() {
      System.out.println("Animals can move");
   }
}

class Dog extends Animal {
   public void move() {
      System.out.println("Dogs can walk and run");
   }
}
  1. 인터페이스: 인터페이스는 클래스가 어떤 동작을 해야하는지를 명시한 "계약"이다. 클래스가 인터페이스를 구현하면, 그 인터페이스의 모든 메서드를 반드시 구현해야 한다. 이를 통해 같은 인터페이스를 구현하는 여러 클래스가 동일한 동작을 보장하면서도 각자의 방식으로 동작을 구현할 수 있게 된다.
interface Animal {
    void makeSound();
}

class Cat implements Animal {
    public void makeSound() {
        System.out.println("Meow");
    }
}

class Dog implements Animal {
    public void makeSound() {
        System.out.println("Bark");
    }
}

이러한 다형성의 특징 덕분에 코드의 재사용성을 높이고, 확장성을 개선하며, 코드의 유지보수를 쉽게 할 수 있다.

출처: https://velog.io/@haero_kim/%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-%EC%9D%B4%ED%95%B4%ED%95%98%EA%B8%B0

profile
Just do it

0개의 댓글