객체 지향 프로그래밍(Object Oriented Programming)

Siyoon's dev blog·2024년 3월 18일
post-thumbnail

Java, C++, C#, Python 중 하나의 프로그래밍 언어로 프로그램을 개발해본 분들이라면 객체 지향 프로그래밍에 대해서 한번쯤은 들어봤을것이라고 생각합니다. 저는 객체 지향 프로그래밍에 대한 개념을 Java를 공부하며 처음 접했었는데요. 이번 포스팅에서는 오늘날까지도 소프트웨어 개발에서 널리 사용되는 프로그래밍 패러다임 중 하나인 객체 지향 프로그래밍(Object Oriented Programming)의 개념과 설계 방법론에 대해 이야기 해보겠습니다.

프로그램을 어떻게 설계할 것인가?

OOP에 대한 개념을 바로 알아보기보다는 현재 본인이 개발중인 프로그램이 어떤 식으로 설계되었는지 고민해보는 시간을 갖는 것을 추천드립니다.
"프로그램을 설계한다"는 것은 단순히 코드를 작성하는 것 이상의 의미를 갖습니다. 우리는 문제를 해결하기 위한 최적의 방법을 찾아야 하며, 문제의 규모가 커질수록 이는 더 복잡한 과제가 되곤 합니다. 프로그래밍 세계에는 이런 문제들을 해결하기 위한 다양한 패러다임이 존재합니다. OOP, 함수형 프로그래밍 등 각 설계 방법의 특징을 제대로 파악하고 있다면 본인이 만드는 프로젝트에 적합한 패러다임을 선택할 수 있을겁니다.
객체 지향 프로그래밍(Object-Oriented Programming, OOP)은 그 중에서도 강력하고 널리 사용되는 방법론으로 현실 세계를 프로그램에 반영한다는 개념을 기반으로 접근하는 방법입니다. 전 세계의 많은 개발자들이 왜 현재까지도 OOP를 선택하는지 알기 위해서 OOP가 추구하는 것이 무엇인지, 또 OOP를 이루고 있는 개념들은 무엇이 있는지 살펴보겠습니다.

객체 지향이란 무엇인가?

위 문단에서 저는 OOP를 "현실 세계"를 "프로그램 설계"에 반영한다는 개념을 기반으로 접근하는 프로그래밍 방법론이라고 설명했습니다. OOP를 처음 접하는 분들이라면 이 문장이 와닿지 않을 수도 있을겁니다. 이 문장을 완벽하게 이해하기 위해 "객체"와 "클래스"에 대한 직관적인 개념을 알아야합니다. 이 두 가지 개념을 처음 접하는 분들도 쉽게 이해할 수 있도록, 예시를 통해 설명해 보겠습니다.

클래스(Class)와 객체(Object)

오늘날 대부분의 사람들이 사용중인 스마트폰을 예로 들어 클래스와 객체의 개념 그리고 현실 세계가 프로그램에 어떻게 반영되는지 알아보겠습니다.

저는 현재 아이폰14를 2년째 사용중인데요, 어느날 아이폰14를 프로그램으로 구현해달라는 요구를 받았다고 가정해봅시다.

이를 위해 먼저 아이폰14가 무엇인지부터 정의해보겠습니다.

아이폰14는 6.1인치 디스플레이 화면과 후면에 2개의 카메라 렌즈가 있고 하단에는 충전 단자가 있는 "아이폰 시리즈" 중 14번째 버전입니다.

즉, 아이폰14는 상위 개념인 아이폰이라는 개념을 기반으로 확장된 개념이라는 것을 알 수 있습니다.

그럼 상위 개념 아이폰 대해서도 정의해보겠습니다.

아이폰은 애플에서 제조한 iOS를 사용하고 있는 스마트폰입니다.

친구에게 "핸드폰 뭐 써?"라는 질문을 받았을 때 우리는 보통 아이폰 또는 갤럭시라고 대답합니다. 이때 우린 무의식적으로 아이폰이나 갤럭시라는 포괄적인 개념안에 아이폰 시리즈(아이폰13,14 등) 또는 갤럭시 시리즈(S23,S24 등)와 같은 하위 개념들을 그룹핑 하여 대답하게 됩니다.

여기에서 중요한 점은 하위개념인 아이폰 시리즈들은 모두 애플에서 제조한 스마트폰이고 iOS를 사용한다는 상위 개념인 아이폰의 특징을 가지고 있다는 점입니다.

그렇다면 아이폰의 상위 개념은 무엇일까요?
위 문단에서 아이폰에 대해 정의한 문장을 다시 읽어보겠습니다.

아이폰은 애플에서 제조한 iOS를 사용하고 있는 스마트폰입니다.

여기서 스마트폰이라는 개념은 아이폰 뿐만 아니라 갤럭시, 샤오미와 같은 다른 스마트폰들까지 모두 포괄하는 개념이며, 반대로 이 하위 개념들은 모두 스마트폰의 특징을 그대로 가지며 자신들만의 고유한 특징을 추가적으로 가지게 되는 것입니다.

정리해보자면 우리는 아이폰 14라는 개념에서 출발하여 계속하여 상위 개념을 정의해나갈 수 있는겁니다.

아이폰 14 ➡️ 아이폰 ➡️ 스마트폰 ➡️ 휴대 전화 ➡️ 무선 전화기 ➡️ 전화기 ➡️ 통신 기기

이때 아이폰 14, 아이폰과 같은 하나의 개념클래스(Class)라고 부릅니다.
"아이폰 14"라는 개념은 고유한 물건의 이름이 아니라 어떠한 제품 라인의 이름입니다. (여기에서 고유하다라는 의미는 전 세계에서 단 한개만 존재하는 수준의 고유함입니다.)

즉, 아이폰 14라는 클래스(개념)는 실체가 있는 것이 아니며 이 개념을 기반으로 공장에서 조립을 하고 일련번호를 부여한 후 출고를 해야 우리 손에 잡을 수 있는 실체가 되는 것입니다. 이렇게 생산된 아이폰 14에는 고유한 ID인 일련번호가 부여되있기 때문에 고유하다고 말할 수 있습니다.

여기에서 일련번호가 부여된 아이폰14들을 "객체"라고 할 수 있습니다.

즉, 클래스란 일종의 설계도이며 우리는 이 설계도를 사용하여 실제 물건을 만들어내는데, 이때 실제 물건을 객체라고 부르는 것입니다.

  • 책 ➡️ 문학작품 ➡️ 예술작품 ➡️ 예술
  • 커피 ➡️ 음료 ➡️ 식품 ➡️ 물질
  • 셔츠 ➡️ 옷 ➡️ 의류 ➡️ 패션

우리는 일상속에 존재하는 대부분의 물건들의 특징을 모아 상위 개념을 정의해나갈 수 있습니다. 이와 같이 상위 개념을 만들어나가는 행위 자체를 추상화(Abstraction)라고 합니다.

추상화에 대해서 조금 더 자세히 알아보겠습니다.

추상화(Abstraction)

복잡한 것을 단순하게 표현하자

저는 "추상"이라는 개념을 미술에서 처음 접했던 것 같습니다.
추상파의 대표적인 화가 피카소의 작품을 통해 추상이라는 개념을 이해해보겠습니다.

factorio thumbnail

위 그림은 피카소가 그린 "소"라는 작품입니다. 다리가 4개 있고 머리에 뿔이 있으며 엉덩이에 꼬리가 달려있는 모습을 볼 수 있습니다. 위 그림은 소가 가지고 있는 최소한의 특징을 표현한 것이며 이 특징들은 젖소든, 황소든 소라는 본질적인 형태에서는 크게 벗어나지 않습니다.

마찬가지로 사람들은 개인마다 생김새, 목소리, 성격이 모두 다르지만 기본적으로 팔 2개, 다리 2개, 머리가 1개라는 핵심적인 특징을 가지고 있으며 바로 이것이 본질입니다.

이와 같이 어떤 복잡한 대상의 핵심적인 특징만 남기고 나머지 요소를 배제하여 단순화 시킨 것을 추상화라고합니다.

그렇다면 프로그래밍에서 추상화가 필요한 이유는 뭘까요?
그에 대한 답을 간단히 말하자면 더 복잡하고 어려운 것을 만들기 위해서입니다.

웹사이트에 접속하기 위해 필요한 "로그인 시스템"을 구현한다고 가정해봅시다.
겉으로 보기에 로그인 시스템은 단일 기능같지만, 사실 여러 세부 기능들이 모아 만들어진 기능입니다.

  • 사용자 인증
  • 세션 관리
  • 사용자 정보 검증
  • 그 외 복잡한 과정들..

개발자는 위 여러 기능들을 추상화하여 "로그인"이라는 단일 기능으로 간소화할 수 있습니다. 이를 통해, 다른 개발자들은 실제로 어떤 절차를 거쳐서 사용자가 인증되는지에 대한 세부적인 원리를 알지 못하더라도 '로그인 함수'를 호출만으로 사용자 인증 기능을 사용할 수 있습니다.

factorio thumbnail (cpu가 뭔지도 모르는 초등학생조차 컴퓨터를 사용할 수 있습니다.)

즉, 구체적이고 복잡한 구현을 감추어 단순한 형태로 표현함으로써, 내가 만든 모듈이나 부품, 혹은 제품을 사용하는 사용자가 근본적인 원리를 알지 못하더라도 대상의 기능을 활용할 수 있도록 만들어주는 것입니다.

결국, 추상적인 개념을 클래스로 정의하고, 이를 이용해 실체를 가진 객체를 만들어냄으로써 현실 세계의 모든 것을 프로그램으로 표현할 수 있다는 관점에서 출발하는 것이 객체 지향 프로그래밍의 기초입니다.

OOP는 클래스와 객체들을 조립하는 형태로 프로그램을 만들어나가는데 이때 상속, 캡슐화, 다형성은 조립을 더 원할하게 해줍니다. 이 3가지는 OOP를 이해하는데 반드시 필요한 개념으로 이에 대해서도 간단하게 알아보도록 하겠습니다.

상속 (Inheritance)

방금 전 추상화에 대한 설명을 진행하며 아이폰14라는 개념에서 출발하여 상위 개념을 정의해나갔던 예시를 생각해봅시다.

아이폰 14 ➡️ 아이폰 ➡️ 스마트폰 ➡️ 휴대 전화

아이폰 14, 아이폰 SE와 같은 여러 아이폰 시리즈들은 "아이폰"이라는 상위 클래스의 속성을 물려받은 것인데, 이 개념을 객체 지향 프로그래밍에서 "상속"이라 말합니다.
상속이 어떻게 이루어지는지 코드로 살펴보겠습니다.

class Iphone{
	String manufacturer = "apple"
    String os = "ios"
}

class Iphone14 extends Iphone {
	int version = 14
}

class Main {
	public static void main (String[] args){
    	Iphone14 myIphone14 = new Iphone14();
        
		System.out.println(myIPhone7.manufacturer);
        System.out.println(myIPhone7.os);
        System.out.println(myIPhone7.version);
    }
}
apple
ios
14

OOP를 제공하는 많은 프로그래밍 언어에서 클래스 간 상속을 할때 "extends(확장하다)"라는 예약어로 표현됩니다. 하위 클래스의 입장에서 생각해보면 상위 클래스의 속성을 상속(Inherit)받는 것이지만, 상위 개념의 관점에서 보면 자신의 속성들이 하위 개념으로 넘어가면서 확장되는 것이기 때문에 extends라는 예약어를 사용합니다.
Iphone14 클래스를 생성할 때 extends 예약어를 사용하여 Iphone 클래스를 상속받았습니다. Iphone14클래스에는 manufactureros속성이 명식적으로 선언되지 않았지만 부모 클래스인 Iphone클래스의 속성을 그대로 물려받은 것을 확인할 수 있습니다.

위와 같은 방법으로 Iphone클래스 재사용하여 Iphone15, IphoneSE 클래스도 새로 만들 수 있습니다.

class Iphone15{
	int version = 15;
}

class IphoneSE{
	String version = SE;
}

즉, 추상화가 잘된 클래스를 하나만 만들어놓는다면 그와 비슷한 속성이 필요한 다른 클래스를 생성할수 있습니다. 아이폰 시리즈 전체에 걸친 변경사항이 생겼을 때도 하위 클래스들은 건드릴 필요 없이 Iphone 클래스 하나만 고친다면 이 클래스를 상속받은 모든 하위 아이폰 시리즈 클래스에도 자동으로 적용되겠죠? 개발 기간도 단축시킬 수 있고 휴먼 에러가 발생할 확률도 줄일 수 있습니다.

상속은 OOP에서 클래스의 재사용성을 높여주고 확장성 및 유연성 측면에서 강력한 도구가 됩니다. 하지만 클래스의 상속 관계가 복잡해지게 되면 개발자가 구조를 파악하기 힘들다는 단점을 가지고 있으므로 개발자가 확실한 의도를 가지고 상속 관계를 설계하는 것이 중요합니다.

캡슐화 (Encapsulation)

개발을 하다보면 클래스에 정의되어있는 상태를 특정 메소드를 통해서만 변경할 수 있도록 만들어야 하는 경우가 있을 것입니다. 예시 코드를 통해 은행 계좌 클래스를 만들기로 가정해봅시다.

public class BankAccount {
    public int balance; // 계좌의 잔액이 외부에서 직접 접근 가능합니다.

    public BankAccount(int initialBalance) {
        this.balance = initialBalance;
    }
}

// 메인 클래스
public class Main {
    public static void main(String[] args) {
        BankAccount account = new BankAccount(1000);
        // 직접 접근을 통한 잔액 변경합니다.
        account.balance += 500; // 입금
        account.balance -= 300; // 출금

        // 잘못된 데이터로 인한 잔액 변경
        account.balance = -100; // 잔액이 음수가 됩니다.
    }
}

Main클래스에서 BankAccount 클래스를 상속받은 account라는 인스턴스를 생성합니다. (초기 balance :1000)
이후, balance 상태에 직접 접근하여 잔액을 변경하다보니 휴먼 에러가 발생했고 잔액이 음수가 되며 이 계좌를 가진 고객은 갑자기 빚이 생긴 불상사가 생긴 것입니다.

이런 경우를 방지하기 위해, 우리는 상태를 접근하거나 변경하는 메소드가 필요한 것이고 해당 외부에서는 메소드를 통해서만 상태를 접근할 수 있도록 제어해야합니다.
클래스 내부의 데이터를 감추는 것을 정보 은닉이라고 하며, 일반적으로 public, private, protected 같은 접근제한자를 사용하여 원하는 정보를 선택적으로 감추거나 노출시킬 수 있습니다.

public class BankAccount {
    private int balance; // 계좌의 잔액은 외부에서 직접 접근할 수 없습니다.

    public BankAccount(int initialBalance) {
        this.balance = initialBalance;
    }

    // 입금 메소드: 잔액에 금액을 추가합니다.
    public void deposit(int amount) {
        if (amount > 0) {
            balance += amount;
        } else {
            System.out.println("Invalid amount");
        }
    }

    // 출금 메소드: 잔액에서 금액을 차감합니다.
    public void withdraw(int amount) {
        if (amount > 0 && balance >= amount) {
            balance -= amount;
        } else {
            System.out.println("Invalid transaction");
        }
    }

    // 잔액 조회 메소드
    public int getBalance() {
        return balance;
    }
}

이 코드에서는 private 접근제한자를 사용하여 balance를 직접 변경하는 것을 막았습니다. (직접 접근을 시도하면 에러가 발생합니다)
depositwithdraw 메소드를 통해 간접적으로 변경할 수 있도록 만들어, 유효한 금액을 체크하는 로직을 포함하여 갑자기 잔액이 마이너스가 되어 빚이 생기는 불상사를 방지합니다.

protected 접근제한자는 1) 같은 패키지 안에 있는 클래스이거나 다른 패키지에 있더라도 2) 해당 클래스를 상속받은 클래스가 아니면 모두 접근을 막는 접근제한자입니다. 일반적으로는 하위 클래스가 부모 클래스의 필드나 메소드에 접근할 수 있게 하려고 사용됩니다. (private 보다는 더 넓은 접근 범위를 가지고 있으며, public보다는 제한적인 접근 범위를 가집니다.)

즉, 접근제한자를 사용하여 클래스의 변수나 메소드를 감추어 이것들이 클래스 외부로 노출되는 것을 막는 것을 캡슐화라고 합니다. 이를 통해 클래스의 정보가 잘못된 방식으로 변경되는 것을 막을 수 있습니다.

다형성 (Polymorphism)

다형성(Polymorphism)은 어떤 하나의 변수명이나 함수명이 상황에 따라서 다르게 해석될 수 있는 것을 의미합니다.
Java에서 다형성을 위한 대표적 기능은 추상 클래스(Abstract Class)인터페이스(Interface), 오버로딩(Overloading)이 있습니다. 추상 클래스를 통한 예시 코드를 통해 다형성의 개념을 이해해보겠습니다.

추상 클래스를 사용한 다형성 구현
강아지와 고양이의 특성을 고려하여 클래스로 만들어보겠습니다.

강아지와 고양이는 둘다 "동물"입니다.
강아지는 "멍멍"소리내고, 고양이는 "야옹" 소리를 냅니다.

위 명제를 고려하여 부모 클래스 Animal을 생성하고, Animal 클래스를 상속받는 하위 클래스 DogCat을 생성하여 특성을 고려한 메소드를 추가하겠습니다.

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

class Dog extends Animal{
	Dog(){
    	super("dog")
    }
    
    public void makeSound(){
    	System.out.println("멍멍")
    }
}


class Cat extends Animal{
	Cat(){
    	super("cat")
    }
    
    public void makeSound(){
    	System.out.println("야옹")
    }
}

이제 강아지와 고양이의 소리를 내도록 코드를 만들어보겠습니다.
Animal 클래스에는 소리를 내는 메소드 makeSound가 별도로 없기 때문에 조건문을 통해 구현을 해야합니다.

class Main{
	public static void main (String[] args){
    	Dog myDog = new Dog();
        Cat myCat = new Cat();
        
        Main.makeSound(myDog);
        Main.makeSound(myCat);
    }
    
    public static void makeSound (Animal animal) {
    	if(animal instanceof Dog){
        	Dog myPet = (Dog)animal;
            myPet.makeSound();
        }
        else if (animal instanceof Cat){
        	Cat myPet = (Cat)animal;
            myPet.makeSound();
        }
    
    }
}

만약 Animal 클래스를 상속받은 동물이 더 추가된다면 어떻게 될까요?
동물의 종류만큼 분기의 개수도 늘어날 것이고 코드는 갈수록 복잡해질 것입니다.

이럴 때 다형성을 사용하여 코드를 심플하게 만들 수 있습니다.
Animal 클래스에서 동물들이 소리를 내는 메소드명을 makeSound로 통일하되, 이 메소드를 호출했을 때 실행되는 코드는 동물에 따라 달라지도록 만드는 것입니다. 이때 Animal을 상속받은 모든 인스턴스가 makeSound 메소드를 강제로 구현하도록 만들어준다면 개발자가 소리를 못내는 동물을 만들 실수도 없애줄 것입니다.

abstract class Animal {
    abstract void makeSound(); // 내부 구현체가 없는 추상 메소드를 선언합니다. 
}

// 오버라이딩(Overriding)은 부모 클래스의 멤버 변수나 메소드를 덮어 씌우는 것을 말합니다.
class Dog extends Animal {
    @Override 
    void makeSound() {
        System.out.println("멍멍");
    }
}

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

이렇게 추상 클래스인 Animal을 상속받은 동물 클래스들은 무조건 makeSound 메소드를 구현해야합니다.

class Main{
	public static void main(String[] args){
    	Dog myDog = new Dog();
        Cat myCat = new Cat();
    }
    
    Main.makeSound(myDog);
    Main.makeSound(myCat);
    
    public static void makeSound(Animal animal){
    	animal.makeSound();
    }
}

추상 메소드를 사용하여 클래스 내부의 makeSound 메소드를 강제하였습니다.
즉, Animal을 상속받는 새로운 동물들(호랑이, 말 등)이 추가되더라도 makeSound 메소드는 모든 동물들이 가지게 될 것이고 동물 별로 다르게 소리를 낼 것입니다. 이것을 다형성이라고 합니다.

마치며

객체 지향 프로그래밍은 1990년대 초반부터 아직까지도 프로그래밍 설계에 중요한 역할 하고 있는 개념입니다.
생각보다 많은 개발자들은 OOP가 범용적으로 사용되는 패러다임이라는 것을 근거로 "그냥" 사용하곤 합니다.

하지만 프로그램을 개발할때 필요한 기술을 선택할 때는 그 기술의 장점, 단점을 알고 그 기술을 선택했을 때 얻을 수 있는 것과 잃을 수 있는 것을 제대로 파악하고 있어야 올바른 선택을 할 수 있습니다. (누구나 아는 사실이지만만 꽤나 지켜지지 않습니다). 때문에 함수형 프로그래밍과 같은 다른 패러다임을 선택하더라도 비교대상이 되는 OOP가 무엇인지 알고 있어야겠지요.

최소한 어떤 패러다임이 어떤 방향을 추구하는지, 거기에서 파생된 개념은 어떤 것들이 있는지를 학습하고 각 상황에 맞는 패러다임을 도입해서 사용한다면 개발자는 자신의 설계 결정에 명확한 근거를 가지고 개발할것입니다.

제 포스팅은 전적으로 아래 링크의 블로그 참고했습니다.
evans library

profile
직접 글을 쓰는 지루함을 즐기기 위한 블로그

0개의 댓글