클래스와 인터페이스
타입스크립트를 오랫동안 사용해오면서 클래스 기반 프로그래밍에 익숙해졌다고 생각했지만, 최근 Java를 배우기 시작하면서 기존에 알던 개념들을 더 깊이 있게 돌아보게 되었다. Java는 타입스크립트와 비교했을 때 문법적으로는 유사한 부분이 많지만, 객체지향 프로그래밍 언어로서의 철학과 제약에서 차이를 보인다. 이 글에서는 Java의 클래스, 인터페이스, 추상 클래스에 대해 정리하고, 타입스크립트 개발자의 관점에서 차이점이나 느낀 점들을 함께 서술해보았다.
클래스(Class) – 객체의 설계도
Java에서 클래스는 현실 세계의 사물이나 개념을 프로그래밍 세계에 옮겨오기 위한 청사진 역할을 한다. 필드(field), 생성자(constructor), 메서드(method)를 통해 객체의 상태와 행동을 정의할 수 있으며, 이러한 구조는 타입스크립트의 클래스와 유사하다.
자바에서는 모든 클래스가 명시적으로 접근 제어자를 가진다. 일반적으로 하나의 public 클래스를 하나의 파일로 정의하며, 이 파일의 이름은 반드시 클래스 이름과 동일해야 한다. 예를 들어 Person이라는 클래스를 정의하려면 파일명은 Person.java가 되어야 한다.
public class Person {
// 필드
private String name;
private int age;
// 생성자
public Person(String name, int age) {
this.name = name;
this.age = age;
}
// 메서드
public void greet() {
System.out.println("Hello, my name is " + name);
}
// Getter
public String getName() {
return name;
}
// Setter
public void setName(String name) {
this.name = name;
}
}
자바의 클래스는 public, private, protected, static과 같은 접근 제어자를 통해 멤버의 가시성을 세밀하게 제어하며, 이는 캡슐화를 보다 강력하게 실현할 수 있게 한다. 또한 클래스는 단일 상속만 지원하므로, 여러 기능을 조합할 땐 인터페이스와 조합하는 것이 일반적이다.
생성자 오버로딩
Java에서는 하나의 클래스 내에 여러 개의 생성자를 정의할 수 있다. 이를 생성자 오버로딩(Constructor Overloading)이라고 하며, 서로 다른 매개변수 목록(parameter list)을 가진 생성자들을 선언함으로써 다양한 초기화 방식을 제공할 수 있다.
public class User {
private String name;
private int age;
// 기본 생성자
public User() {
this("Unknown", 0); // 다른 생성자 호출
}
// 이름만 받는 생성자
public User(String name) {
this(name, 0); // 다른 생성자 호출
}
// 나이만 받는 생성자
public User(int age) {
this("Unknown", age); // 다른 생성자 호출
}
// 이름과 나이를 받는 생성자
public User(String name, int age) {
this.name = name;
this.age = age;
}
}
이제 User 객체는 다음과 같이 다양한 방식으로 생성할 수 있다:
User u1 = new User(); // name: Unknown, age: 0
User u2 = new User("Alice"); // name: Alice, age: 0
User u2 = new User(25); // name: Unknown, age: 25
User u3 = new User("Bob", 25); // name: Bob, age: 25
타입스크립트에서는 선택적 매개변수(optional parameter), 매개변수 기본값(default value), 또는 조건문을 활용한 내부 로직 분기로 유사한 기능을 제공하기는 한다. 하지만 이름만 받거나 나이만 받는 등 매개변수 조합에 따라 완전히 다른 초기화 방식을 제공하려면, 타입스크립트에서는 매개변수 타입을 유니언으로 정의하고 런타임에서 타입을 분기하는 수고가 필요하다. 예를 들어 constructor(name: string | number, age?: number)와 같이 선언한 뒤, 생성자 내부에서 typeof name === 'string' 같은 조건문으로 분기하여 각각의 초기화 로직을 작성해야 한다.
반면, Java는 시그니처가 다른 여러 생성자를 별도로 선언할 수 있어 이런 분기를 정적 타입 시스템 차원에서 자연스럽게 해결할 수 있다는 점이 큰 차이다.
추상 클래스(Abstract Class) – 공통 로직과 구조의 틀
추상 클래스는 공통 로직을 구현하면서도 하위 클래스에서 반드시 구현해야 하는 메서드를 정의할 수 있는 클래스다. abstract 키워드를 사용하며, 인스턴스로 생성할 수는 없다.
public abstract class Animal {
private String name;
public Animal(String name) {
this.name = name;
}
public void breathe() {
System.out.println(name + " is breathing");
}
public abstract void makeSound();
}
public class Dog extends Animal {
public Dog(String name) {
super(name);
}
public void makeSound() {
System.out.println("Bark!");
}
}
인터페이스와 달리 필드와 생성자를 가질 수 있고, 일부 메서드는 구현할 수 있다는 점이 특징이다. 완전히 추상적인 인터페이스와 구체적인 클래스 사이에서 공통된 기능을 제공하면서 유연한 구조 설계를 도울 수 있다.
인터페이스(Interface) – 역할을 정의하는 계약
Java에서 인터페이스는 클래스가 "무엇을 할 수 있어야 하는가"를 정의하는 일종의 계약(contract)이다. 타입스크립트의 인터페이스처럼 특정 메서드 시그니처를 강제할 수 있으며, 자바에서는 다중 구현이 가능하다는 점에서 중요한 구조적 장치로 사용된다.
public interface Drivable {
void drive();
}
이 인터페이스를 구현하는 클래스는 drive() 메서드를 반드시 구현해야 한다:
public class Car implements Drivable {
@Override
public void drive() {
System.out.println("The car is driving");
}
}
이처럼 자바의 인터페이스는 객체가 가져야 할 행위의 형태를 정의하며, 클래스는 이를 implements 키워드로 구현한다. 자바는 클래스는 하나만 상속할 수 있지만, 인터페이스는 여러 개 구현할 수 있으므로 역할 기반 설계에 적합하다.
default 메서드
Java 8부터 인터페이스에 default 키워드를 사용해 기본 메서드 구현을 포함할 수 있게 되었다. 이는 기존 인터페이스에 기능을 추가하면서도 기존 구현 클래스에 영향을 주지 않기 위한 장치다.
public interface Greeter {
void greet(String name);
default void sayHello() {
System.out.println("Hello!");
}
}
위 인터페이스를 구현하는 클래스는 greet()는 필수로 구현해야 하지만, sayHello()는 생략 가능하다. 기본 구현이 있기 때문이다.
public class KoreanGreeter implements Greeter {
@Override
public void greet(String name) {
System.out.println("안녕하세요, " + name + "님!");
}
}
Greeter greeter = new KoreanGreeter();
greeter.greet("철수"); // 출력: 안녕하세요, 철수님!
greeter.sayHello(); // 출력: Hello! (기본 구현)
필요하다면 sayHello()도 오버라이드할 수 있다:
public class FriendlyGreeter implements Greeter {
@Override
public void greet(String name) {
System.out.println("Hi, " + name + "!");
}
@Override
public void sayHello() {
System.out.println("Hey there!");
}
}
static 메서드
인터페이스에 static 메서드를 정의하면, 인스턴스 없이도 인터페이스 이름으로 직접 호출할 수 있는 도우미 메서드를 만들 수 있다:
public interface MathUtils {
static int square(int x) {
return x * x;
}
}
int result = MathUtils.square(4); // 16
다중 인터페이스 충돌 시 해결 방법
여러 인터페이스에서 동일한 시그니처의 default 메서드가 정의되어 충돌할 경우, 구현 클래스에서 명시적으로 어떤 인터페이스의 메서드를 사용할지 지정해줘야 한다.
public interface A {
default void greet() {
System.out.println("Hello from A");
}
}
public interface B {
default void greet() {
System.out.println("Hello from B");
}
}
public class C implements A, B {
@Override
public void greet() {
A.super.greet(); // 또는 B.super.greet();
}
}
인터페이스 vs 추상클래스
추상 클래스와 인터페이스는 모두 설계의 틀을 제공한다는 공통점이 있지만, 중요한 차이점이 존재한다. 추상 클래스는 필드(멤버 변수)와 일부 구현된 메서드를 포함할 수 있으며, 생성자도 가질 수 있다. 반면, 인터페이스는 기본적으로 상수(static final 변수)만 가질 수 있고, 메서드는 구현하지 않는 추상 메서드로만 구성되었다.
하지만 Java 8부터 인터페이스에도 default 메서드와 static 메서드가 추가되면서, 기본 구현을 포함할 수 있게 되었다. 이는 기존 인터페이스에 새로운 메서드를 추가할 때, 기존 구현 클래스를 깨뜨리지 않으면서도 기능을 확장할 수 있도록 설계된 변화다.
따라서, 추상 클래스는 공통 필드와 구현을 공유하는 데 초점을 두고, 인터페이스는 다중 상속이 가능하도록 역할(behavior) 중심의 계약을 정의하는 데 더욱 적합하다.
// 추상 클래스 예시
public abstract class Vehicle {
protected String brand;
public Vehicle(String brand) {
this.brand = brand;
}
// 일반 메서드 (구현 있음)
public void showBrand() {
System.out.println("브랜드: " + brand);
}
// 추상 메서드 (구현 없음, 하위 클래스가 반드시 구현)
public abstract void drive();
}
// 인터페이스 예시
public interface Flyable {
// 상수 (public static final 생략 가능)
int MAX_ALTITUDE = 10000;
// 추상 메서드 (구현 없음)
void fly();
// default 메서드 (기본 구현 포함)
default void checkAltitude() {
System.out.println("최대 비행 고도는 " + MAX_ALTITUDE + "미터입니다.");
}
// static 메서드 (인스턴스 없이 호출 가능)
static void info() {
System.out.println("Flyable 인터페이스는 비행 가능한 객체를 위한 계약입니다.");
}
}
// 구현 클래스 예시
public class Airplane extends Vehicle implements Flyable {
public Airplane(String brand) {
super(brand);
}
@Override
public void drive() {
System.out.println("비행기를 조종합니다.");
}
@Override
public void fly() {
System.out.println("비행 중입니다.");
}
}
// 사용 예시
public class Main {
public static void main(String[] args) {
Airplane airplane = new Airplane("Boeing");
airplane.showBrand(); // 추상 클래스의 일반 메서드
airplane.drive(); // 추상 클래스의 추상 메서드 구현
airplane.fly(); // 인터페이스 추상 메서드 구현
airplane.checkAltitude(); // 인터페이스 default 메서드 호출
Flyable.info(); // 인터페이스 static 메서드 호출
}
}
위 예시는 추상 클래스와 인터페이스의 주요 차이점을 명확하게 보여준다. 먼저, 추상 클래스는 필드와 생성자를 가질 수 있으며, 구현된 메서드와 추상 메서드를 함께 포함할 수 있다. 이를 통해 공통된 상태와 행동을 하위 클래스에 물려주면서, 반드시 구현해야 하는 메서드를 강제할 수 있다.
반면, 인터페이스는 기본적으로 상수와 추상 메서드만을 정의했으나, Java 8부터는 default 메서드와 static 메서드를 포함할 수 있게 되었다. default 메서드는 인터페이스 내에서 기본 구현을 제공하여, 구현 클래스에서 선택적으로 오버라이드할 수 있다. 그리고 static 메서드는 인터페이스 이름으로 직접 호출할 수 있는 메서드로, 유틸리티 성격의 기능을 제공하는 데 사용된다.
이처럼 추상 클래스와 인터페이스는 각각의 목적과 사용법에 차이가 있으며, 상황에 맞게 적절히 선택하여 활용하는 것이 중요하다.
익명 클래스(Anonymous Class) – 일회성 구현체의 강력한 도구
자바의 익명 클래스는 이름이 없는 일회성 클래스로, 주로 인터페이스나 추상 클래스의 구현이 간단할 때 사용된다. 타입스크립트에서도 비슷하게 익명 객체나 함수로 일회성 동작을 구현할 수 있지만, 자바의 익명 클래스는 클래스 기반 언어의 특성을 살려, 즉석에서 새로운 하위 클래스를 정의하고 인스턴스를 생성할 수 있다는 점이 특징이다.
Runnable task = new Runnable() {
@Override
public void run() {
System.out.println("작업 실행 중...");
}
};
new Thread(task).start();
여기서 new Runnable() { ... } 부분이 익명 클래스다. 이름 없이 Runnable 인터페이스를 구현한 클래스를 즉석에서 정의하고 인스턴스를 만든 것이다.
블로그의 정보
Ayden's journal
Beard Weard Ayden