자바에서 추상 클래스, 인터페이스, 다형성은 객체지향 프로그래밍의 핵심 개념 세 가지입니다. 처음 배울 때는 셋 다 비슷해 보여서 "그냥 다 추상 클래스 쓰면 안 되나?" 싶었는데, 직접 코드를 짜보고 나서야 각각의 역할이 왜 나뉘어 있는지 체감이 됐습니다.

추상 클래스, 언제 쓰고 언제 안 쓰나
추상 클래스(Abstract Class)는 구현되지 않은 메소드가 하나 이상 포함된 클래스를 말합니다. 여기서 추상 메소드(Abstract Method)란, 메소드 이름과 반환 타입만 선언해두고 실제 동작 코드는 비워둔 것입니다. "이런 기능이 있어야 한다"고 명세만 남겨두고, 실제 구현은 상속받는 자식 클래스에게 맡기는 구조입니다.
제가 처음 이 개념을 접했을 때는 "그냥 일반 클래스에 메소드 만들면 되지, 왜 굳이 비워두냐"는 생각이었습니다. 그런데 Animal이라는 추상 클래스를 만들고, Dog과 Cat이 각각 makeSound()를 따로 구현하는 걸 직접 짜보니까 이해가 됐습니다. 부모가 "소리를 내는 기능은 반드시 있어야 해"라고 강제하면서도, 각자 어떻게 소리를 낼지는 자식에게 맡기는 겁니다.
추상 클래스는 abstract 키워드로 선언하고, 자식 클래스에서는 extends로 상속받습니다. 단, 추상 클래스는 직접 객체를 생성할 수 없고, 반드시 상속을 통해서만 사용할 수 있습니다. 또한 자바에서 클래스 상속은 단일 상속만 허용합니다. 즉, 하나의 클래스가 두 개의 부모 클래스를 동시에 extends 하는 건 불가능합니다.
추상 클래스의 특징을 정리하면 다음과 같습니다.
- 일반 필드와 생성자를 포함할 수 있다
- 일반 메소드와 추상 메소드를 함께 가질 수 있다
- 단일 상속만 가능하다 (extends 1개)
- new 키워드로 직접 인스턴스화 불가
추상 클래스가 유용한 상황은 공통된 필드나 일반 메소드를 자식 클래스들과 공유하고 싶을 때입니다. 예를 들어 Animal 클래스에 name 필드나 eat() 메소드처럼 모든 동물에게 공통으로 적용되는 속성은 부모에서 구현해두고, makeSound()처럼 종류마다 달라지는 건 추상 메소드로 비워두는 식으로 활용합니다.
인터페이스, 추상 클래스와 뭐가 다른가
인터페이스(Interface)는 추상 클래스보다 한 발 더 나아간 완전한 추상화 구조입니다. 기본적으로 모든 메소드가 추상 메소드이고, 필드는 public static final 상수만 허용됩니다. 생성자도 없습니다. interface 키워드로 선언하고, 가져다 쓸 때는 implements를 사용합니다.
추상 클래스와 인터페이스의 가장 큰 차이는 다중 구현 가능 여부입니다. 자바에서 클래스는 하나의 클래스만 상속할 수 있지만, 인터페이스는 여러 개를 동시에 implements 할 수 있습니다. 제가 직접 써봤는데, 이 차이가 생각보다 훨씬 크게 느껴졌습니다. 예를 들어 Gugu라는 클래스가 Animal 인터페이스와 FlyAnimal 인터페이스를 동시에 구현하면, sound()와 fly() 두 가지 기능을 모두 갖게 됩니다.
자바 공식 문서에 따르면, 인터페이스는 서로 관련 없는 클래스들이 공통된 동작 규약을 공유할 때 특히 유용하다고 명시되어 있습니다(출처: Oracle Java Documentation). 실제로 코드를 짜다 보면, 상속 관계가 없는 두 클래스에 같은 기능을 넣어야 할 때 인터페이스가 훨씬 자연스럽게 맞아 들어가는 걸 느끼게 됩니다.
인터페이스에는 Java 8부터 default 메소드와 static 메소드도 정의할 수 있게 됐습니다. 여기서 default 메소드란, 인터페이스 안에서 기본 구현을 제공하는 메소드로, 이를 implements한 클래스가 별도로 오버라이딩하지 않아도 그대로 사용할 수 있는 메소드입니다. 이 기능이 추가되면서 추상 클래스와의 경계가 조금 흐려졌다는 의견도 있는데, 저는 여전히 "공통 필드가 필요하면 추상 클래스, 다중 구현이 필요하면 인터페이스"라는 기준이 가장 실용적이라고 봅니다.
다형성, 코드가 유연해지는 순간
다형성(Polymorphism)은 하나의 타입 변수에 여러 종류의 객체를 담을 수 있는 성질입니다. 여기서 다형성이란, 부모 타입의 변수에 자식 객체를 대입해 사용함으로써 코드를 유연하고 확장 가능하게 만드는 객체지향의 핵심 원리입니다.
Animal dog = new Dog(); 이 한 줄이 처음에는 별로 대단해 보이지 않았습니다. 그런데 매개변수로 Animal 타입 하나만 받아서 Dog이든 Cat이든 Gugu든 전부 처리할 수 있게 되는 구조를 짜보고 나서, 왜 다형성이 객체지향의 핵심인지 실감이 됐습니다.
다만 여기서 주의할 점이 있습니다. Animal 타입 변수에 Dog 객체를 담으면, 컴파일러는 그 변수를 Animal 타입으로만 인식합니다. 즉, Animal 인터페이스에 선언된 메소드만 호출할 수 있고, Dog이 추가로 구현한 메소드는 바로 사용할 수 없습니다. 이때 필요한 것이 다운캐스팅(Downcasting)입니다. 다운캐스팅이란, 부모 타입으로 담겨 있는 객체를 원래의 자식 타입으로 명시적으로 형변환하는 것을 말합니다. ((Dog) dog).fly() 처럼 괄호 안에 원하는 타입을 적어 강제 변환합니다.
반대로 자식 타입을 부모 타입 변수에 담는 것은 업캐스팅(Upcasting)이라 부르며, 이건 자동으로 처리됩니다. 정리하면, 자식에서 부모 방향은 자동, 부모에서 자식 방향은 명시적 형변환이 필요합니다.
이런 객체지향 설계 원칙은 SOLID 원칙과도 연결됩니다. SOLID란 좋은 객체지향 설계를 위한 다섯 가지 원칙의 앞 글자를 딴 것으로, 다형성은 그 중 OCP(개방 폐쇄 원칙), 즉 "기존 코드를 수정하지 않고 기능을 확장할 수 있어야 한다"는 원칙을 구현하는 핵심 도구입니다. 마틴 파울러를 비롯한 여러 소프트웨어 설계 전문가들이 이 원칙의 중요성을 강조해왔으며(출처: Refactoring Guru), 제가 공부하면서도 "왜 이렇게 설계하는가"의 답이 결국 여기로 귀결되는 경우가 많았습니다.
다형성을 활용할 때 실수하기 쉬운 포인트를 정리하면 다음과 같습니다.
- Animal 타입으로 선언된 변수는 Animal에 없는 메소드를 직접 호출할 수 없다
- Dog 고유의 메소드를 쓰려면 반드시 (Dog)으로 다운캐스팅해야 한다
- 잘못된 다운캐스팅은 런타임에 ClassCastException을 발생시키므로 instanceof로 타입 체크 후 사용하는 것이 안전하다
추상 클래스, 인터페이스, 다형성 이 세 가지는 각각 따로 공부하면 "그래서 언제 써?"라는 의문이 남습니다. 제 경험상 이 개념들은 직접 구조를 설계하고 코드를 짜봐야 연결이 됩니다. Animal 인터페이스 하나 만들고, Dog, Cat, Gugu 클래스를 각각 implements 해보고, 메인에서 Animal 타입 배열에 전부 담아서 반복문으로 sound()를 호출해보는 실습 하나가 이론 열 번보다 효과적이었습니다. 지금 개념이 머릿속에서 따로따로 노는 느낌이라면, 작은 코드라도 직접 연결해보는 게 가장 빠른 방법입니다.