공부/객체 지향 프로그래밍

OOP(객체 지향 프로그래밍) 4대 특성

dev_jiwonpark 2025. 7. 3. 22:51

최근에 디자인 패턴에 대해서 공부를 하면서 SOLID 원칙, OOP의 4대 특성에 대해서 잘 모르고 있다는 생각이 들었다.

그래서 디자인 패턴 공부를 잠시 멈추고 기본적인 공부부터 진행해보려고 한다.

 

우선 객체 지향 프로그래밍 ( object-oriented programming, OOP ) 는 무엇일까?

검색창에 검색해보면 다음과 같은 정의를 찾아볼 수 있다.

1. 객체 개념을 기반으로 하는 컴퓨터 프로그래밍의 패러다임.
2. 컴퓨터 프로그램을 명령어의 목록으로 보는 시각에서 벗어나 여러 개의 독립된 단위, 즉 '객체들의 모임'으로 파악하고자 하는 것
3. 유연하고 변경이 쉬운 프로그램을 만들기 때문에 대규모 소프트웨어 개발에 많이 사용된다.

한마디로 객체 지향 프로그래밍은 단순한 프로그래밍 스타일 중 하나 가 아니라 복잡한 프로그램을 유지보수 가능하고 확장성 있게 만들기 위한 사고방식 중 하나라고도 할 수 있을 것 같다.

 

그럼 객체지향은 왜 등장했을까?

객체 지향 이전에는 절차 지향 프로그래밍으로 코드를 짰다.

간단한 계산기나 콘솔 출력 같은 경우 위 방식으로도 충분했다.

하지만 현실의 프로그램은 점점 복잡해지면서 

(1) 중복된 코드가 넘쳐나고

(2) 수정할 때 어디가 바뀔지 몰라 불안하고 

(3) 기능이 많아질수록 버그도 많아진다.

 

이런 상황에 놓인 개발자들은 죽어나는 거다 ㅠㅠ 그때 등장한 것이 객체지향 프로그래밍이다.

 

그럼 이 전에 객체지향 프로그래밍이라는 단어를 쪼개서 살펴보면 이 방식이 어떤 문제를 해결하고자 하는지를 유추할 수 있다.

 

(1) 객체 (Object)

" 데이터와 기능을 하나로 묶은 단위 "

나는 객체는 현실의 사물을 닮은 코드 덩어리라고 생각한다.

예를 들어 ' 자동차 ' 라는 객체를 떠올려 볼때

- 속성(데이터) : 브랜드, 속도, 연료량

- 동작(기능) : 달리기, 멈추기, 기름 넣기 

이렇게 현실의 개념을 코드로 묶어 표현한것이 객체인 것이다.

class Car {
  brand: string;
  fuel: number;

  constructor(brand: string) {
    this.brand = brand;
    this.fuel = 100;
  }

  drive() {
    this.fuel -= 10;
    console.log(`${this.brand}가 달립니다.`);
  }
}

위 예제에서 보면 자동차 라는 하나의 '주체'가 상태와 행동을 모두 갖고 있는것을 알 수 있다.

 

(2) 지향 (Oriented)

" 그것을 중심으로, 향하다. "

OOP에서는 모든것을 객체 단위로 생각하고, 객체들 간의 관계와 역할로 문제를 해결한다.

절차 지향은 '무엇을 할까?'에 집중하고

객체 지향은 '누가 할까?'에 집중한다.

 

(3) 프로그래밍(Programming)

" 문제를 해결하기 위한 코드 작성 방식 "

객체를 중심에 두고 생각하며 코드를 구성하는 방법론이다.

OOP를 잘하기 위해선 객체를 어떻게 나누고, 책임을 어디에 줄것이며, 협력시키느냐가 핵심이라고 생각한다.

 

따라서 위 단어들을 조합하면!

현실의 사물처럼 동작하는 코드 구조를 만들자! 라고 정리할 수도 있을 것 같다.

내가 이해하는 객체지향은 " 객체를 중심으로 세상을 코드로 재구성하는것 " 이다.

 

여기까지 객체 지향 프로그래밍이라는 단어를 하나하나 뜯어봤으니 다시 절차지향으로 돌아가서

왜 절차지향 프로그래밍에서 객체지향 프로그래밍이 필요하게 되었는지에 대해서 알아보면 좋을 것 같다!

 

아까도 말했듯이 객체지향은 "현실 세계의 사물(객체)을 코드로 옮겨온 것" 이다.

예를 들어 내가 ' 카페 주문 시스템 ' 을 만들고 싶다면?

절차 지향 프로그래밍으로 위 시스템을 만들경우 모든 메뉴, 가격, 계산 로직을 한 곳에 몰아넣고 if 문으로 처리할 것이다.

if (menu === '아메리카노') price = 3000;
else if (menu === '카페라떼') price = 3500;

메뉴 하나만 추가되고 조건문이 늘어나고 계산 방식이 바뀌면 함수 전체를 뒤엎어야한다.

 

하지만 객체 지향 방식으로 만든다면?

'음료'라는 개념을 추상화한 클래스 Drink를 만들고 각각의 메뉴를 이 클래스를 상속받아 구현한다.

class Drink {
  constructor(public name: string, public price: number) {}
  getPrice() {
    return this.price;
  }
}

class Americano extends Drink {
  constructor() {
    super("아메리카노", 3000);
  }
}

class Latte extends Drink {
  constructor() {
    super("카페라떼", 3500);
  }
}

따라서 새 메뉴인 '바닐라라떼' 를 추가하고싶다면 기존 코드를 수정할 필요 없이 그냥 확장만 하면 된다.

이게 OOP의 장점인 것이다! 

 

그럼 OOP는 어떤 특징을 갖고 있을까? 객체 지향 프로그래밍을 사용하거나 공부할 경우 많이 듣는 OOP 4대 특성이 있다.

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

 

우선 추상화 부터 공부해보자!

 

1. 추상화 (Abstraction)

객체들의 공통적인 특징(기능, 속성)을 추출해서 정의하는 것 = 클래스를 정의하는 것!

한마디로 객체에서 꼭 필요한 정보만을 추출해내는 것이다.

예를 들어 자동차라는 객체를 모델링한다고 할때 아래와 같이 정보들을 나눌 수 있을 것 이다.

- 필요한 정보 : 모델명, 속도, 연료

- 불필요한 정보 : 엔진 내부 부품수, 제조 공장 주소 

 

만약 이런 추상화를 하지 않는다면 어떨까

(1) 너무 많은 정보와 기능이 한 객체에 다 들어가 있고

(2) 코드가 길어지고 복잡해져서 가독성이 어렵고 유지보수가 어렵다.

(3) 핵심 로직이 잡다한 정보에 묻혀버리게 된다. 

class Car {
  constructor(public model: string, public speed: number) {}

  drive() {
    this.speed += 10;
    console.log(`${this.model}가 ${this.speed}km/h로 달립니다.`);
  }
}

예를 들어 자동차라는 위 같이 추상화로 만들었다고 가정했을 때 벤츠’는 기존 Car 클래스를 그대로 활용하면서, 브랜드나 기능만 덧붙여 정의하면 된다.
이런 구조 덕분에 코드의 재사용성과 유지보수성을 동시에 잡을 수 있는 것이다!

class Benz extends Car {
  constructor() {
    super("Benz", 0); // 브랜드명과 초기 속도
  }

  // Benz만의 추가 기능
  autoPark() {
    console.log("Benz가 자동 주차를 시작합니다.");
  }
}

 

const myCar = new Benz();
myCar.drive();       // Benz가 10km/h로 달린다.
myCar.autoPark();    // Benz가 자동 주차를 시작한다.

 

Car는 공통된 자동차의 개념만 담고 있고

Benz는 그걸 기반으로 확장한 클래스이다. 

즉, 추상화를 통해 핵심 로직은 건드리지 않고도 새로운 객체를 만들수 있는것이다.

 

2. 캡슐화 (Encapsulation)

객체의 내부 상태를 숨기고, 외부는 공개된 인터페이스만 사용하게 하는 것

class BankAccount {
  private balance: number;

  constructor(initialAmount: number) {
    this.balance = initialAmount;
  }

  deposit(amount: number) {
    if (amount <= 0) throw new Error("Invalid amount");
    this.balance += amount;
  }

  getBalance() {
    return this.balance;
  }
}

외부에선 직접 balance를 변경할 수 없지만 deposit() 같은 메서드로만 balance를 변경할 수있는 것이다.

만약 위 같은 캡슐화를 하지 않는다면 어떤일이 발생할까?

class BankAccount {
  public balance: number = 0;
}
const myAccount = new BankAccount();
myAccount.balance = -1000000;

데이터가 마구잡이로 변경되어 무제한으로 돈이 입출금이 가능하게 되는것이다..!

이렇게 보안과 안정성이 무너지게 되고 객체의 내부 상태를 직접 조작하게 되면서 버그가 생길수있다. 

 

3. 상속 (Inheritance)

이미 정의가 된 부모 클래스가 자식 클래스에게 모든 속성 & 기능(메서드)을 하위 클래스에게 물려주는것을 의미한다.

코드 재사용이 가능해 지며 공통된 구조를 확장할 수 있게 한다.

 

그럼 상속은 무조건 재사용할때 쓰는 것일까?

상속은 단순한 재사용이 아니라 객체 간의 명확한 관계가 있을 때만 사용하는것이 원칙이다.

 

예를 들어 "호랑이는 동물이다." 라는 문장을 볼 때

호랑이(자식)는 동물(부모)이다. 이렇게 포함 관계로 명확하게 구분된다. 이를 IS-A 관계라고 한다.

 

그런데 만약 자동차와 엔진의 관계를 말할때 자동차는 엔진이다? 뭔가 이상하다.

자동차는 엔진을 가지고 있다. 라고 말해야 자연스러운 것! 이를 HAS-A 관계라고 한다. 

아래는 잘못된 상속 예시 & 올바른 상속 예시이다!

// 잘못된 상속 예시 - 자동차는 엔진이다.
class Engine {
  charge() {}
}

class Car extends Engine {
  drive() {}
}

// 올바른 구조
class Car {
  battery: Engine;

  constructor() {
    this.engine = new Engine(); // 자동차가 엔진을 가지고 있다.
  }

  drive() {
    this.engine.charge();
    console.log("자동차가 움직입니다.");
  }
}

이렇게 상속은 명확한 관계 (IS-A) 가 있을 때만 사용하는것이 원칙이다. 

상속은 자식 클래스가 부모의 속성과 기능을 사용할 수 있게 해주면서도 부모 클래스 내부 구현을 은닉할 수 있게 해준다.

때문에 상속은 재사용 + 캡슐화가 결합된 구조라고 할 수 있다.

 

4. 다형성 (Polymorphism)

하나의 객체(변수, 함수)가 상황에 따라 서로 다르게 동작하는 특성
즉, 동일한 메서드 호출이 어떤 객체냐에 따라 다르게 해석되는 것이 다형성이다.

예를 들어 자동차 클래스를 상속받은 일반차, 전기차, 하이브리드 차의 엔진을 한번 켜보자! 

 

그전에 만약 다형성을 지키지 않고 개발을 하게 되는 상황부터 확인해보자

일반차, 전기차, 하이브리드 차 엔진을 켜기위해서 어떻게 해야할까..?

다형성이 없다면 객체가 어떤 종류인지 판별하고 동작을 분기시키기 위해 조건문을 아래와 같이 직접 써야 한다.

type CarType = "Basic" | "Electric" | "Hybrid";

function startEngine(carType: CarType) {
  if (carType === "Basic") {
    console.log("일반 자동차 시동");
  } else if (carType === "Electric") {
    console.log("전기차 배터리 시동");
  } else if (carType === "Hybrid") {
    console.log("하이브리드 엔진 시동");
  }
}

또 새로운 차 종류가 생긴다면 조건문이 추가 될 것이고 수정해야하며 함수가 차의 종류별 로직을 모두 알아야한다.

함수에 역할이 집중되어 있는 상황인것이다..!

class Car {
  startEngine() {
    console.log("일반 자동차 엔진이 켜집니다.");
  }
}

class ElectricCar extends Car {
  startEngine() {
    console.log("전기차 배터리 시동이 켜집니다.");
  }
}

class HybridCar extends Car {
  startEngine() {
    console.log("하이브리드 엔진이 켜집니다.");
  }
}

이제 이들을 배열에 넣고 startEngine() 호출해 본다면?

const cars: Car[] = [new Car(), new ElectricCar(), new HybridCar()];

cars.forEach((car) => {
  car.startEngine();
});

다음과 같은 실행결과가 나온다.

일반 자동차 엔진이 켜집니다.
전기차 배터리 시동이 켜집니다.
하이브리드 엔진이 켜집니다.

이렇게 하나의 Car 타입으로 다뤘지만 실제로는 각각의 클래스에 따라 다르게 동작하고 있고

새로운 자동차 종류를 추가해도 Car 배열에만 넣으면 끝이다 .

 

객체가 스스로 자신의 동작을 정의하게 함으로써 책임을 분리시킬 수 있는 것이다. 

때문에 기존 코드 수정없이 확장이 가능하게된다!

 

결론적으로 다형성이 없다면 객체마다 다른 행동을 하게 만들기 위해 매번 " 넌 누구니..? " 하고 물어보고 행동을 지정해줘야하는데

다형성이 있다면 객체가 스스로 " 난 이거할게~ " 라고 행동할 수 있다. 

 

이렇게 객체지향이 무엇이고 왜 필요하고 객체지향의 특징이 무엇인지를 살펴보았다.

이 글의 제목인 객체지향의 4대 특성을 간단하게 정리하자면 이는 그냥 '이론'이 아니라 복잡한 현실문제를 코드로 표현하는데 꼭 필요한 원칙들이다. 이 네가지가 어우러질때 깨끗하고 확장 가능한 코드를 만들수 있는 것이다!

만약 지키지 않는다면,,! 유지보수 지옥이 펼쳐지는 것이다..!

'공부 > 객체 지향 프로그래밍' 카테고리의 다른 글

[OOP] SOLID 원칙  (1) 2025.07.04