> [!abstract] Introduction > 객체 지향 프로그래밍*Object-Oriented Programming*, OOP은 데이터와 그 데이터를 조작하는 함수를 하나의 단위인 객체*object*로 묶어 프로그램을 구성하는 패러다임입니다. [[Procedural Programming]]이 "어떤 절차로 처리할 것인가"에 집중한다면, OOP는 "어떤 것들이 존재하고, 그것들이 서로 어떻게 상호작용하는가"에 집중합니다. 이 글에서는 OOP의 핵심 개념과 네 가지 원칙을 살펴보고, C, Java, Python, Rust 네 언어가 이를 어떻게 다르게 구현하는지 비교합니다. ## 클래스와 객체 OOP에서 가장 기본적인 두 개념은 클래스*class*와 객체*object*(인스턴스*instance*)다. 클래스는 객체를 만들기 위한 설계도*blueprint*다. 어떤 데이터(속성*attribute*)를 가지고, 어떤 동작(메서드*method*)을 할 수 있는지를 정의한다. 객체는 이 설계도에 따라 메모리에 실제로 생성된 실체다. 클래스 하나로 여러 객체를 만들 수 있으며, 각 객체는 독립적인 상태를 가진다. ![[classAndObject.svg]] ```java // 클래스: 설계도 class Dog { String name; int age; void bark() { System.out.println(name + ": 멍!"); } } // 객체: 실체 Dog a = new Dog(); // a와 b는 같은 클래스에서 만들어졌지만 Dog b = new Dog(); // 각자 독립적인 name, age를 가진다 ``` 객체 간의 상호작용은 메시지 전달*message passing*로 이루어진다. `a.bark()`라는 호출은 "a에게 bark라는 메시지를 보낸다"는 뜻이다. 이 개념은 OOP의 창시자 중 한 명인 Alan Kay가 강조한 핵심 아이디어로, 그는 OOP의 본질을 "객체들 사이의 메시징"이라고 정의했다[^alan-kay-messaging]. ## 네 가지 원칙 OOP를 떠받치는 네 가지 원칙은 캡슐화, 상속, 다형성, 추상화이다. 이 원칙들은 독립적인 것이 아니라 서로를 보완하며 작동한다. ### 캡슐화 캡슐화*Encapsulation*는 객체의 내부 상태를 외부로부터 숨기고, 공개된 인터페이스(메서드)를 통해서만 접근하도록 제한하는 것이다. 이를 정보 은닉*information hiding*이라고도 한다. 캡슐화의 목적은 객체의 내부 구현을 자유롭게 바꿀 수 있도록 보장하는 것이다. 외부 코드가 내부 상태에 직접 접근하지 않으니, 내부 구조가 바뀌어도 외부에 영향이 없다. ![[encapsulation.svg]] #### Java — 접근 제어자 Java는 `private`, `protected`, `public`, 패키지 기본 접근*package-private* 네 가지 접근 제어자*access modifier*로 가시성을 세밀하게 제어한다. ```java class BankAccount { private int balance; // 외부 접근 불가 public void deposit(int amount) { if (amount > 0) balance += amount; // 유효성 검증 후 변경 } public int getBalance() { return balance; // 읽기 전용 접근 } } ``` `balance`를 `private`으로 선언했으므로, 외부 코드는 `deposit()`과 `getBalance()`를 통해서만 잔액에 접근할 수 있다. 음수 입금 같은 비정상적 조작이 원천적으로 차단된다. #### C — 구조체와 불투명 포인터 C에는 `class` 키워드가 없지만, [[구조체(C)]]와 불투명 포인터*opaque pointer*를 조합하면 캡슐화를 구현할 수 있다 (Amini, 2022). ```c /* account.h — 공개 인터페이스 */ typedef struct Account Account; /* 불투명 타입: 내부 구조를 숨긴다 */ Account *account_new(void); void account_deposit(Account *a, int amount); int account_get_balance(const Account *a); void account_free(Account *a); ``` ```c /* account.c — 비공개 구현 */ #include "account.h" #include <stdlib.h> struct Account { int balance; /* 이 필드는 account.h를 포함하는 외부 코드에서 보이지 않는다 */ }; Account *account_new(void) { Account *a = calloc(1, sizeof(*a)); return a; } void account_deposit(Account *a, int amount) { if (amount > 0) a->balance += amount; } int account_get_balance(const Account *a) { return a->balance; } void account_free(Account *a) { free(a); } ``` 헤더 파일에는 `struct Account`의 전방 선언*forward declaration*만 노출하고, 실제 필드 정의는 `.c` 파일에 숨긴다. 외부 코드는 `Account` 포인터를 통해서만 객체를 조작할 수 있으며, 내부 필드에 직접 접근할 수 없다. 이것이 C에서의 정보 은닉이다. #### Python — 관례 기반 은닉 Python은 언어 차원에서 접근 제한을 강제하지 않는다. 대신 밑줄 접두사*underscore prefix*라는 관례로 의도를 표현한다. ```python class BankAccount: def __init__(self): self._balance = 0 # 단일 밑줄: "내부용"이라는 관례 self.__secret = "key" # 이중 밑줄: 네임 맹글링 적용 def deposit(self, amount): if amount > 0: self._balance += amount ``` 단일 밑줄(`_balance`)은 "이 속성은 내부용이니 직접 접근하지 말라"는 **관례**일 뿐, 접근을 막지는 못한다. 이중 밑줄(`__secret`)은 네임 맹글링*name mangling*[^name-mangling]을 통해 `_BankAccount__secret`으로 이름이 바뀌므로 우회적으로 접근은 가능하지만 실수로 접근하는 것을 방지한다. Python의 철학은 "우리는 모두 동의한 성인이다*We're all consenting adults here*"로 요약된다 — 개발자의 자율성을 신뢰하는 것이다. #### Rust — 모듈 기반 가시성 Rust는 `pub` 키워드와 모듈*module* 시스템으로 가시성을 제어한다. 기본적으로 모든 것이 비공개이며, `pub`을 붙여야 외부에서 접근할 수 있다. ```rust mod bank { pub struct Account { balance: i32, // pub이 없으므로 모듈 외부에서 접근 불가 } impl Account { pub fn new() -> Self { Account { balance: 0 } } pub fn deposit(&mut self, amount: i32) { if amount > 0 { self.balance += amount; } } pub fn balance(&self) -> i32 { self.balance } } } ``` `Account` 구조체 자체는 `pub`이지만 `balance` 필드는 비공개다. 외부 코드는 `Account::new()`와 `deposit()`으로만 객체를 조작할 수 있다. 이 접근 방식은 C의 불투명 포인터와 Java의 접근 제어자 사이의 중간 지점이다. ### 상속 상속*Inheritance*은 기존 클래스(부모*superclass*)의 속성과 메서드를 새로운 클래스(자식*subclass*)가 물려받는 메커니즘이다. 코드 재사용과 계층적 분류를 가능하게 한다. ![[inheritanceHierarchy.svg]] #### Java — 클래스 상속 Java는 `extends` 키워드로 단일 상속*single inheritance*을 지원한다. 다중 상속은 다이아몬드 문제[^diamond-problem]를 피하기 위해 허용하지 않으며, 대신 인터페이스*interface*를 통한 다중 구현을 지원한다. ```java class Animal { String name; void speak() { System.out.println("..."); } } class Dog extends Animal { @Override void speak() { System.out.println(name + ": 멍!"); } void fetch() { System.out.println(name + "가 공을 물어온다"); } } ``` `Dog`는 `Animal`의 `name` 필드와 `speak()` 메서드를 물려받고, `speak()`를 재정의*override*하며 `fetch()`라는 고유 메서드를 추가한다. #### C — 구조체 내장으로 상속 흉내 내기 C에는 상속 문법이 없지만, 구조체의 첫 번째 멤버로 부모 구조체를 내장*embed*하면 메모리 레이아웃을 활용한 상속을 흉내 낼 수 있다 (Amini, 2022). ```c typedef struct { char name[32]; } Animal; typedef struct { Animal base; /* 첫 번째 멤버 → Dog* ↔ Animal* 캐스팅 가능 */ int fetch_count; } Dog; void animal_speak(Animal *a) { printf("%s: ...\n", a->name); } void dog_speak(Animal *a) { printf("%s: 멍!\n", a->name); } /* 사용 */ Dog d = { .base = { .name = "Rex" }, .fetch_count = 0 }; dog_speak((Animal *)&d); /* Dog*를 Animal*로 캐스팅 */ ``` C 표준은 구조체의 첫 번째 멤버와 구조체 자체가 같은 주소를 가진다고 보장한다[^c-struct-first-member]. 이 성질을 이용하면 `Dog*`를 `Animal*`로 안전하게 캐스팅할 수 있어, 부모 타입의 함수에 자식 객체를 전달하는 것이 가능해진다. GLib/GObject[^gobject]가 이 기법으로 C 위에 완전한 객체 시스템을 구축한 대표적 사례다. #### Python — 다중 상속과 MRO Python은 다중 상속*multiple inheritance*을 직접 지원한다. 다이아몬드 문제는 C3 선형화*C3 linearization* 알고리즘 기반의 MRO*Method Resolution Order*로 해결한다. ```python class Animal: def __init__(self, name): self.name = name def speak(self): return "..." class Pet: def __init__(self, name, owner): self.owner = owner class Dog(Animal, Pet): # 다중 상속 def speak(self): return f"{self.name}: 멍!" print(Dog.__mro__) # (<class 'Dog'>, <class 'Animal'>, <class 'Pet'>, <class 'object'>) ``` `Dog.__mro__`를 보면 메서드 탐색 순서가 `Dog → Animal → Pet → object`임을 알 수 있다. `speak()`를 호출하면 MRO 순서대로 탐색하여 가장 먼저 발견된 정의를 사용한다. #### Rust — 상속 대신 합성 Rust는 전통적인 클래스 상속을 **지원하지 않는다**. 대신 [[Trait]]과 합성*composition*을 통해 코드 재사용과 다형성을 달성한다. ```rust struct Animal { name: String } struct Dog { animal: Animal, // 합성: Animal을 필드로 포함 fetch_count: u32, } impl Dog { fn speak(&self) -> String { format!("{}: 멍!", self.animal.name) } } ``` Rust가 상속을 배제한 이유는 명확하다. 상속은 부모와 자식 사이에 강한 결합*tight coupling*을 만들고, 깊은 상속 계층은 코드의 예측 가능성을 떨어뜨린다. Rust는 "상속보다 합성을 선호하라*Favor composition over inheritance*"라는 설계 원칙(Gamma et al., 1994)을 언어 수준에서 강제한 것이다. ### 다형성 다형성*Polymorphism*은 같은 인터페이스로 서로 다른 타입의 객체를 다룰 수 있게 하는 원칙이다. "하나의 이름, 여러 형태"로 요약할 수 있다. 다형성은 크게 두 가지로 나뉜다. 컴파일 시점에 결정되는 정적 다형성*static polymorphism*(메서드 오버로딩*overloading*, 제네릭*generics*)과 런타임에 결정되는 동적 다형성*dynamic polymorphism*(메서드 오버라이딩*overriding*, 가상 함수*virtual function*)이다. #### Java — 가상 메서드 테이블 Java에서 인스턴스 메서드는 기본적으로 가상 메서드*virtual method*다[^java-virtual]. 객체의 실제 타입에 따라 런타임에 호출될 메서드가 결정된다. ```java Animal[] animals = { new Dog("Rex"), new Cat("Nabi") }; for (Animal a : animals) { a.speak(); // 런타임에 Dog.speak() 또는 Cat.speak()가 호출됨 } ``` 내부적으로 JVM은 각 클래스마다 가상 메서드 테이블*vtable*을 유지한다. `a.speak()` 호출 시 `a`가 실제로 가리키는 객체의 vtable에서 `speak()`의 주소를 찾아 호출한다. 이것이 동적 디스패치*dynamic dispatch*다. #### C — 함수 포인터 테이블 C에서 다형성을 구현하려면 함수 포인터*function pointer*를 직접 관리해야 한다. 이 방식이 바로 vtable의 원리다. ```c typedef struct { void (*speak)(void *self); /* 가상 함수 테이블의 원형 */ } AnimalVtable; typedef struct { const AnimalVtable *vt; char name[32]; } Animal; void dog_speak(void *self) { Animal *a = self; printf("%s: 멍!\n", a->name); } void cat_speak(void *self) { Animal *a = self; printf("%s: 야옹!\n", a->name); } static const AnimalVtable dog_vt = { .speak = dog_speak }; static const AnimalVtable cat_vt = { .speak = cat_speak }; /* 다형적 호출 */ Animal animals[] = { { .vt = &dog_vt, .name = "Rex" }, { .vt = &cat_vt, .name = "Nabi" }, }; for (int i = 0; i < 2; i++) { animals[i].vt->speak(&animals[i]); // 런타임에 적절한 함수 호출 } ``` `AnimalVtable` 구조체가 바로 수동으로 만든 vtable이다. 각 객체는 자신의 타입에 맞는 vtable 포인터를 가지고 있어, 함수 호출 시 적절한 구현이 선택된다. Java의 가상 메서드 호출이 내부적으로 이와 동일한 메커니즘을 사용하는 것이다. #### Python — 덕 타이핑 Python의 다형성은 덕 타이핑*duck typing*[^duck-typing]에 기반한다. 객체의 타입이 아니라, 필요한 메서드를 가지고 있느냐가 중요하다. ```python class Dog: def speak(self): return "멍!" class Cat: def speak(self): return "야옹!" class Robot: def speak(self): return "삐빅!" for thing in [Dog(), Cat(), Robot()]: print(thing.speak()) # 공통 부모 클래스가 없어도 동작 ``` `Dog`, `Cat`, `Robot`은 어떤 공통 부모도 상속하지 않는다. 그러나 모두 `speak()` 메서드를 가지고 있으므로 동일한 방식으로 호출할 수 있다. 이것이 "오리처럼 걷고 오리처럼 꽥꽥거리면, 그것은 오리다"라는 덕 타이핑의 원리다. #### Rust — 트레이트 객체와 제네릭 Rust는 동적 다형성과 정적 다형성을 모두 명시적으로 구분한다. ```rust trait Speakable { fn speak(&self) -> String; } struct Dog { name: String } struct Cat { name: String } impl Speakable for Dog { fn speak(&self) -> String { format!("{}: 멍!", self.name) } } impl Speakable for Cat { fn speak(&self) -> String { format!("{}: 야옹!", self.name) } } // 정적 디스패치 — 컴파일 시점에 단형화(monomorphization) fn greet_static(animal: &impl Speakable) { println!("{}", animal.speak()); } // 동적 디스패치 — 런타임에 vtable로 해소 fn greet_dynamic(animal: &dyn Speakable) { println!("{}", animal.speak()); } ``` `impl Speakable`은 컴파일 시점에 각 타입별로 함수가 복제되는 정적 디스패치이고, `dyn Speakable`은 vtable을 통한 동적 디스패치이다. Rust는 이 선택을 개발자에게 맡겨, 성능과 유연성 사이의 트레이드오프를 명시적으로 제어할 수 있게 한다. [[Trait]]에서 이 메커니즘을 더 자세히 다룬다. ### 추상화 추상화*Abstraction*는 복잡한 시스템의 핵심적인 부분만 노출하고 세부 구현을 감추는 원칙이다. 캡슐화가 "어떻게 숨길 것인가"라면, 추상화는 "무엇을 보여줄 것인가"에 관한 것이다. #### Java — 추상 클래스와 인터페이스 Java는 추상 클래스*abstract class*와 인터페이스*interface*라는 두 가지 추상화 도구를 제공한다. ```java // 추상 클래스: 일부 구현을 포함할 수 있음 abstract class Shape { abstract double area(); // 하위 클래스가 반드시 구현 void describe() { // 공통 구현 제공 System.out.println("넓이: " + area()); } } // 인터페이스: 순수한 계약 interface Drawable { void draw(); } class Circle extends Shape implements Drawable { double radius; Circle(double r) { this.radius = r; } @Override double area() { return Math.PI * radius * radius; } @Override public void draw() { /* 그리기 로직 */ } } ``` 추상 클래스는 "~이다*is-a*" 관계를, 인터페이스는 "~할 수 있다*can-do*" 관계를 표현한다. Java 8 이후 인터페이스에 `default` 메서드가 도입되면서 두 개념의 경계가 다소 흐려졌지만, 설계 의도의 차이는 여전히 유효하다. #### C — 함수 포인터 인터페이스 C에서 추상화는 불완전한 타입*incomplete type*과 함수 포인터로 구현한다. 위에서 본 `AnimalVtable`이 바로 인터페이스의 역할을 하는 것이다 — 구체적인 구현 없이 "speak라는 함수가 있어야 한다"는 계약만 정의한다. #### Python — 추상 기반 클래스 Python은 `abc` 모듈의 추상 기반 클래스*Abstract Base Class, ABC*로 추상화를 지원한다. ```python from abc import ABC, abstractmethod class Shape(ABC): @abstractmethod def area(self) -> float: ... class Circle(Shape): def __init__(self, radius: float): self.radius = radius def area(self) -> float: return 3.14159 * self.radius ** 2 # Shape() ← TypeError: 추상 클래스는 인스턴스화 불가 ``` `@abstractmethod`로 장식된 메서드를 구현하지 않으면 하위 클래스도 인스턴스화할 수 없다. 덕 타이핑의 유연성 위에 명시적인 계약을 추가하는 장치다. #### Rust — 트레이트가 곧 인터페이스 Rust에서는 [[Trait]]이 추상화의 핵심 도구다. 트레이트는 메서드 시그니처의 집합으로, Java의 인터페이스와 유사하지만 기본 구현*default implementation*도 제공할 수 있다. ```rust trait Shape { fn area(&self) -> f64; fn describe(&self) { // 기본 구현 println!("넓이: {}", self.area()); } } ``` ## 언어별 OOP 모델 비교 ![[oopPolymorphism.svg]] 네 언어의 OOP 구현을 종합하면 다음과 같다. | 특성 | C | Java | Python | Rust | | :--- | :--- | :--- | :--- | :--- | | 객체 모델 | 구조체 + 함수 포인터 | 클래스 기반 | 클래스 기반 (프로토타입 혼합) | 구조체 + 트레이트 | | 캡슐화 | 불투명 포인터 | 접근 제어자 4단계 | 관례 (밑줄 접두사) | `pub` + 모듈 시스템 | | 상속 | 구조체 내장 (수동) | 단일 상속 + 인터페이스 | 다중 상속 + MRO | 없음 (합성만) | | 다형성 | 함수 포인터 vtable (수동) | 가상 메서드 (자동) | 덕 타이핑 | `impl`/`dyn` Trait (명시적) | | 추상화 | 불완전 타입 + 헤더 | 추상 클래스 / 인터페이스 | ABC | 트레이트 | | 메모리 관리 | 수동 (`malloc`/`free`) | GC | GC (참조 카운팅) | 소유권 시스템 | 이 표에서 두 가지 흥미로운 경향이 보인다. 첫째, C와 Rust는 "명시적" 쪽에, Java와 Python은 "암묵적" 쪽에 위치한다. C는 모든 OOP 메커니즘을 개발자가 직접 구축해야 하고, Rust는 정적/동적 디스패치를 개발자가 명시적으로 선택한다. 반면 Java는 vtable을 자동으로 관리하고, Python은 덕 타이핑으로 타입 제약 자체를 느슨하게 둔다. 둘째, 현대 언어일수록 전통적 상속에서 멀어지는 추세가 있다. Go는 상속을 완전히 배제하고 인터페이스만 지원하며, Rust도 같은 방향을 택했다. 이는 깊은 상속 계층이 만드는 복잡성을 실무에서 반복적으로 경험한 결과, "상속보다 합성을 선호하라"(Gamma et al., 1994)는 원칙이 언어 설계에까지 반영된 것이다. ## 정리 OOP는 캡슐화, 상속, 다형성, 추상화라는 네 가지 원칙을 중심으로 프로그램을 객체 간의 상호작용으로 모델링하는 패러다임이다. 같은 원칙이라도 언어마다 구현 방식이 크게 다르다 — C는 구조체와 함수 포인터로 모든 것을 수동으로 구축하고, Java는 가상 메서드와 접근 제어자로 자동화하며, Python은 덕 타이핑으로 유연성을 극대화하고, Rust는 트레이트와 소유권 시스템으로 안전성과 성능을 동시에 추구한다. OOP의 진짜 가치는 특정 문법을 외우는 것이 아니라, "데이터와 행위를 하나로 묶고, 외부에는 필요한 것만 노출하며, 같은 인터페이스로 다양한 구현을 다룬다"는 설계 사고방식을 체화하는 데 있다. 이 사고방식은 [[Object-Relational Mapping]]처럼 객체와 다른 시스템을 연결하는 기술을 이해하는 데에도 기반이 된다. --- ## 출처 - Amini, K. (2022). *전문가를 위한 C* (박지윤, Trans.; 1st ed.). 한빛미디어. - Gamma, E. et al. (1994). *Design Patterns: Elements of Reusable Object-Oriented Software*. Addison-Wesley. pp. 18–20. - Kay, A. (1998). 'The Early History of Smalltalk', in *History of Programming Languages II*. ACM Press. - Stroustrup, B. (2013). *The C++ Programming Language* (4th ed.). Addison-Wesley. Chapter 20: Derived Classes. [^alan-kay-messaging]: Alan Kay는 1998년 에세이에서 "OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things"라고 정의했다. 그에게 OOP의 핵심은 클래스나 상속이 아니라 객체 간의 메시지 전달이었다. [^name-mangling]: 네임 맹글링은 Python 인터프리터가 `__`로 시작하는 속성 이름 앞에 `_클래스이름`을 붙이는 메커니즘이다. `__secret` → `_BankAccount__secret`으로 변환되어, 하위 클래스에서 같은 이름의 속성과 충돌하는 것을 방지한다. [^diamond-problem]: 다이아몬드 문제. 클래스 B와 C가 모두 A를 상속하고, D가 B와 C를 다중 상속할 때 A의 메서드를 B의 것으로 호출할지 C의 것으로 호출할지 모호해지는 문제. Java는 클래스 다중 상속을 금지하고, Python은 MRO(C3 선형화)로 해결한다. [^c-struct-first-member]: C11 표준 §6.7.2.1 ¶15: "구조체 객체 내의 첫 번째 멤버에 대한 포인터를 적절히 변환하면 구조체 객체의 포인터가 된다. 그 역도 성립한다." [^gobject]: GLib Object System. GNOME 프로젝트의 기반 라이브러리로, 순수 C 위에 참조 카운팅, 시그널 시스템, 프로퍼티, 상속을 포함한 완전한 객체 시스템을 구축한 사례. GTK 위젯 툴킷이 GObject 위에 구축되어 있다. [^java-virtual]: Java에서 `static`, `final`, `private` 메서드만 비가상*non-virtual*이다. 나머지 인스턴스 메서드는 모두 가상 메서드처럼 동작하여 동적 디스패치가 적용된다. [^duck-typing]: 덕 타이핑. 이름은 James Whitcomb Riley의 격언 "오리처럼 걷고, 오리처럼 수영하고, 오리처럼 꽥꽥거리면 — 나는 그것을 오리라고 부르겠다"에서 유래. 객체의 타입보다 행위(메서드)의 존재 여부로 적합성을 판단하는 타입 시스템.