Skip to Content
독학사독학사 3단계프로그래밍언어론18. 객체지향 언어 지원: 클래스·상속·다형성·동적 바인딩

이번 문서의 목표: 이 문서를 다 읽으면 클래스·객체·상속·다형성·동적 바인딩을 Java 코드의 실행 결과로 추적할 수 있고, “정적 바인딩과 동적 바인딩 중 어느 것이 적용되는가”를 코드만 보고 판단할 수 있으며, C++과 Java의 OOP 지원 차이를 설명할 수 있다.

왜 이 개념이 필요한가

17편에서 캡슐화와 정보 은닉을 언어가 어떻게 지원하는지 봤다. 이 편은 그 위에 세 가지를 더 얹는다. 상속(inheritance)으로 기존 클래스를 재사용·확장하고, 다형성(polymorphism)으로 같은 이름의 연산이 객체의 실제 타입에 따라 다르게 동작하게 하고, 그 다형성을 실제로 실현하는 메커니즘인 동적 바인딩(dynamic binding)을 이해하는 것이다. 이 세 가지는 09편(이름·바인딩·스코프·수명 심화)에서 다룬 “바인딩 시기”라는 개념이 객체지향 언어에서 어떻게 구체화되는지 보여 주는 대표 사례다.

쉽게 말하면: 상속은 “이미 있는 것을 물려받아 다르게 만든다”이고, 다형성은 “같은 명령을 내려도 받는 대상에 따라 다르게 반응한다”이며, 동적 바인딩은 “그 반응을 결정하는 시점을 실행 중(런타임)으로 미루는 기술”이다.

클래스와 객체, 그리고 상속

클래스(class)는 객체(object)를 찍어내는 틀이다. 객체는 클래스가 정의한 필드(상태)와 메서드(행동)를 실제로 가진 메모리상의 실체다. 상속(inheritance)은 기존 클래스(부모 클래스, superclass)의 필드와 메서드를 새 클래스(자식 클래스, subclass)가 물려받고, 필요하면 오버라이딩(overriding, 재정의)으로 동작을 바꾸는 메커니즘이다.

이 클래스 다이어그램에서 DogCatAnimal을 상속받아 name 필드와 introduce() 메서드를 그대로 물려받지만, makeSound()는 각자 다르게 오버라이딩한다. 이제 이 구조를 Java 코드로 확인해 보자.

class Animal { private String name; public Animal(String name) { this.name = name; } public String makeSound() { return "..."; } public String introduce() { return name + "이(가) 소리를 낸다: " + makeSound(); } } class Dog extends Animal { public Dog(String name) { super(name); } @Override public String makeSound() { return "멍멍"; } } class Cat extends Animal { public Cat(String name) { super(name); } @Override public String makeSound() { return "야옹"; } } public class Main { public static void main(String[] args) { Animal[] animals = { new Dog("바둑이"), new Cat("나비") }; for (Animal a : animals) { System.out.println(a.introduce()); } } }
바둑이이(가) 소리를 낸다: 멍멍 나비이(가) 소리를 낸다: 야옹

여기서 눈여겨볼 점은 Animal 타입의 배열에 DogCat 객체를 담아도, introduce() 안에서 호출한 makeSound()실제 객체의 타입(Dog인지 Cat인지)에 따라 다른 메서드로 실행됐다는 것이다. introduce()Animal 클래스에 정의되어 있고 makeSound()를 호출할 뿐인데, 실행 결과는 객체마다 다르다. 이것이 다형성이 눈에 보이는 방식이다.

다형성과 동적 바인딩의 관계

다형성(polymorphism)은 그리스어로 “여러(poly) 형태(morph)“라는 뜻으로, 같은 이름의 연산이 호출되는 객체의 실제 타입에 따라 서로 다르게 동작하는 성질을 말한다. 이 성질이 실제로 가능하려면 언어가 어느 makeSound()를 실행할지 결정하는 시점을 미뤄야 한다. 이 결정을 컴파일 시점에 하면 정적 바인딩(static binding), 실행 시점에 객체의 실제 타입을 보고 하면 동적 바인딩(dynamic binding)이라 한다.

위 코드에서 a.makeSound()를 호출할 때, 컴파일러는 a의 선언된 타입이 Animal이라는 것만 안다. 만약 정적 바인딩이었다면 Animal.makeSound()(즉 "..."을 반환하는 버전)가 항상 호출됐을 것이다. 하지만 실행 결과는 "멍멍", "야옹"이었다 — 이것은 Java의 인스턴스 메서드 호출이 동적 바인딩되기 때문이다. 자바 가상 머신(JVM)은 실행 중에 a가 가리키는 객체가 실제로 Dog인지 Cat인지 확인하고, 그 클래스에 오버라이딩된 메서드를 호출한다.

쉽게 말하면: 정적 바인딩은 “설계도(선언 타입)만 보고 미리 정해 버리는 것”이고, 동적 바인딩은 “실제 물건(객체)을 보고 그때 가서 정하는 것”이다.

이 관계를 표로 정리하면 다음과 같다.

구분정적 바인딩동적 바인딩
결정 시점컴파일 시점실행(런타임) 시점
기준변수의 선언된 타입객체의 실제 타입
대표 사례Java의 오버로딩(overloading) 해결, 정적 메서드 호출, C의 함수 호출Java·C++의 오버라이딩된 인스턴스 메서드 호출(가상 함수)
실행 속도빠름(호출 위치가 고정됨)상대적으로 느림(가상 함수 테이블 조회 필요)
다형성 지원지원하지 않음(오버로딩은 다형성과 다른 개념)다형성을 실질적으로 실현하는 메커니즘

자주 틀리는 점: 오버로딩(overloading)과 오버라이딩(overriding)을 혼동하는 문항이 자주 나온다. 오버로딩은 같은 이름의 메서드를 매개변수 목록(개수·타입)을 다르게 여러 개 정의하는 것으로, 정적 바인딩(컴파일 시점에 어떤 메서드인지 결정)으로 해결된다. 오버라이딩은 부모 클래스의 메서드를 자식 클래스가 같은 시그니처로 재정의하는 것으로, 동적 바인딩으로 해결된다. “다형성을 구현하는 것은 오버로딩이다”라는 보기는 오답이다.

C++의 동적 바인딩: virtual 키워드가 필요한 이유

Java는 인스턴스 메서드가 기본적으로 동적 바인딩되지만, C++은 다르다. C++에서 메서드(멤버 함수)가 동적 바인딩되려면 virtual 키워드를 명시적으로 붙여야 한다. 붙이지 않으면 정적 바인딩된다.

#include <iostream> using namespace std; class Animal { public: virtual string makeSound() { return "..."; } string introduce() { return "소리: " + makeSound(); } }; class Dog : public Animal { public: string makeSound() override { return "멍멍"; } }; int main() { Animal* a = new Dog(); cout << a->introduce() << endl; delete a; return 0; }
소리: 멍멍

이 코드에서 makeSound()virtual로 선언되어 있기 때문에 Animal* 포인터가 가리키는 실제 객체(Dog)의 메서드가 호출된다. 만약 virtual을 빼면 어떻게 될까?

class Animal { public: string makeSound() { return "..."; } // virtual 없음 string introduce() { return "소리: " + makeSound(); } };
소리: ...

virtual이 없으면 컴파일러는 포인터의 선언된 타입(Animal*)만 보고 Animal::makeSound()를 정적으로 호출해 버린다. 실제 객체가 Dog여도 소용없다. 이 차이가 C++과 Java의 대표적인 OOP 설계 차이로, “C++은 기본이 정적 바인딩이고 virtual로 동적 바인딩을 선택하는 언어, Java는 기본이 동적 바인딩인 언어”라고 정리할 수 있다.

상속·다형성을 지원하기 위한 접근 제어와 생성자 호출 순서

상속 관계에서 객체를 생성할 때, 생성자(constructor)는 부모 클래스부터 먼저 호출된다. 앞의 Java 예제에서 Dog 생성자의 super(name) 호출이 바로 이것이다. 생성자 호출 순서를 코드로 다시 확인해 보자.

class A { A() { System.out.println("A 생성자"); } } class B extends A { B() { System.out.println("B 생성자"); } } class C extends B { C() { System.out.println("C 생성자"); } } public class Main { public static void main(String[] args) { new C(); } }
A 생성자 B 생성자 C 생성자
단계호출되는 생성자설명
1A()C 객체를 만들려면 C가 상속받는 B가 필요하고, BA가 필요하므로 최상위 부모부터 초기화
2B()A 초기화가 끝난 뒤 B의 필드·초기화 코드 실행
3C()마지막으로 C 자신의 초기화 실행

자주 틀리는 점: “자식 클래스 생성자가 먼저 실행되고 나중에 부모 클래스 생성자가 호출된다”고 순서를 거꾸로 아는 오류가 흔하다. 객체는 부모의 상태가 먼저 갖춰져야 자식이 그 위에 자신의 상태를 얹을 수 있으므로, 생성자는 항상 최상위 부모 → 자식 순서로 호출된다.

접근 제어(access control)는 상속과 맞물려 세 단계로 나뉜다.

접근 지정자같은 클래스자식 클래스다른 클래스(같은 패키지 밖)
private가능불가능불가능
protected가능가능불가능(단, Java는 같은 패키지도 허용)
public가능가능가능

추상 클래스와 인터페이스: 다형성의 두 가지 뼈대

객체지향 언어는 “구현 없이 규격만 정의”하는 두 가지 문법을 제공한다.

  • 추상 클래스(abstract class): 일부 메서드는 구현하고 일부(추상 메서드)는 자식 클래스가 반드시 구현하도록 강제한다. 필드와 일반 메서드를 가질 수 있다.
  • 인터페이스(interface): (전통적으로) 메서드 시그니처만 정의하고 구현체를 갖지 않는다. 한 클래스가 여러 인터페이스를 동시에 구현할 수 있어, 단일 상속만 지원하는 Java에서 다중 상속과 비슷한 유연성을 준다.
interface Flyable { String fly(); } abstract class Bird { abstract String makeSound(); String describe() { return "새가 운다: " + makeSound(); } } class Sparrow extends Bird implements Flyable { String makeSound() { return "짹짹"; } public String fly() { return "포르르 난다"; } }

SparrowBird를 상속(단일 상속)하면서 동시에 Flyable을 구현(다중 구현 가능)한다. 이것이 “Java는 클래스의 다중 상속은 금지하지만 인터페이스의 다중 구현은 허용한다”는 설계의 실제 모습이다.

핵심 정리

  • 상속은 부모 클래스의 필드·메서드를 자식 클래스가 물려받고 오버라이딩으로 동작을 바꿀 수 있게 하는 메커니즘이다.
  • 다형성은 같은 연산 호출이 객체의 실제 타입에 따라 다르게 동작하는 성질이며, 이를 실현하는 메커니즘이 동적 바인딩이다.
  • 오버로딩(매개변수 목록으로 구분, 정적 바인딩)과 오버라이딩(같은 시그니처 재정의, 동적 바인딩)은 다른 개념이다.
  • Java는 인스턴스 메서드가 기본적으로 동적 바인딩되지만, C++은 virtual 키워드를 붙여야 동적 바인딩되고 그렇지 않으면 정적 바인딩된다.
  • 생성자는 항상 최상위 부모 클래스부터 자식 클래스 순서로 호출된다.
  • 추상 클래스는 일부 구현을 포함할 수 있고 단일 상속만 가능하지만, 인터페이스는 (전통적으로) 구현이 없고 한 클래스가 여러 개를 동시에 구현할 수 있다.

마무리 복습

문제 14지선다
다형성(polymorphism)과 동적 바인딩(dynamic binding)의 관계를 가장 정확히 설명한 것은?
문제 24지선다
다음 Java 코드의 실행 결과로 옳은 것은? (Animal 클래스의 makeSound()는 오버라이딩되어 있고, Dog·Cat이 각각 재정의한다고 가정)
문제 34지선다
오버로딩(overloading)과 오버라이딩(overriding)의 차이로 옳은 것은?
문제 44지선다
C++에서 makeSound()라는 멤버 함수를 virtual로 선언하지 않았을 때 Animal* 포인터가 Dog 객체를 가리키는 상태로 makeSound()를 호출하면 어떤 일이 일어나는가?
문제 54지선다
클래스 C가 B를, B가 A를 상속하는 구조에서 new C()를 실행했을 때 생성자 호출 순서로 옳은 것은?
문제 64지선다
추상 클래스(abstract class)와 인터페이스(interface)의 전통적인 차이로 가장 적절한 것은?
문제 74지선다
다음 중 접근 지정자에 대한 설명으로 옳지 않은 것은?

참고 자료

Last updated on