Ayden's journal

클래스와 인터페이스

타입스크립트를 오랫동안 사용해오면서 클래스 기반 프로그래밍에 익숙해졌다고 생각했지만, 최근 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

활동하기