Java 클래스와 객체 | OOP, 상속, 인터페이스

이 글의 핵심

상속과 인터페이스, 추상 클래스는 문법은 쉬운데 언제 무엇을 써야 할지 판단이 어렵습니다. 이 글은 공통 구현을 물려줄 때와 계약만 정할 때를 구분하는 기준, @Override를 빠뜨리면 오버로딩으로 바뀌는 함정을 짚고, 도서 관리 예제로 필드를 감추고 역할을 나누는 객체 설계를 직접 따라가게 합니다.

들어가며

Java는 객체 지향 프로그래밍(OOP)(데이터와 동작을 객체·클래스로 묶어 설계하는 방식)을 널리 쓰는 언어입니다. 클래스는 객체를 찍어내는 설계도이고, new로 만든 인스턴스는 그 설계도를 바탕으로 만든 실물입니다. 같은 설계도로 여러 객체를 만들 수 있으며, 필드·메서드는 설계도에 적힌 규격대로 동작합니다.


클래스 정의와 생성자 오버로딩

클래스 정의

클래스는 객체의 설계도입니다:

public class Person {
    // 1. 필드 (Field) - 인스턴스 변수
    // private: 클래스 외부에서 직접 접근 불가 (캡슐화)
    private String name;
    private int age;
    
    // 2. 생성자 (Constructor)
    // 객체 생성 시 자동 호출되는 특수 메서드
    public Person(String name, int age) {
        // this: 현재 객체를 가리키는 참조
        // this.name: 필드 name
        // name: 매개변수 name
        this.name = name;
        this.age = age;
    }
    
    // 3. 메서드 (Method)
    public void introduce() {
        // 필드에 직접 접근 가능
        System.out.println("안녕하세요, " + name + "입니다.");
    }
    
    // 4. Getter (필드 값 읽기)
    public String getName() {
        // 외부에서 private 필드를 읽을 수 있게 함
        return name;
    }
    
    // 5. Setter (필드 값 쓰기)
    public void setName(String name) {
        // 외부에서 private 필드를 수정할 수 있게 함
        // 유효성 검사 추가 가능
        this.name = name;
    }
    
    public int getAge() {
        return age;
    }
    
    public void setAge(int age) {
        // 유효성 검사: 음수 나이 방지
        if (age > 0) {
            this.age = age;
        } else {
            System.out.println("나이는 양수여야 합니다");
        }
    }
}
// 사용 예제
public class Main {
    public static void main(String[] args) {
        // 객체 생성 (인스턴스화)
        // new Person(): 생성자 호출
        // person: 객체 참조 변수
        Person person = new Person("홍길동", 25);
        
        // 메서드 호출
        person.introduce();  // 안녕하세요, 홍길동입니다.
        
        // Getter로 필드 읽기
        System.out.println("이름: " + person.getName());  // 홍길동
        
        // Setter로 필드 수정
        person.setAge(26);
        System.out.println("나이: " + person.getAge());  // 26
        
        // person.age = 30;  // ❌ 컴파일 에러
        // private 필드는 외부에서 직접 접근 불가
        // 반드시 Getter/Setter 사용
    }
}

Person person = new Person("홍길동", 25);에서 person 변수에는 객체 자체가 아니라 힙에 만들어진 객체를 가리키는 참조가 들어갑니다. 그래서 Person other = person;으로 대입하면 객체가 복사되지 않고 같은 객체를 두 변수가 가리키며, other.setAge(30)은 person.getAge()에도 반영됩니다. 메서드에 객체를 넘길 때도 참조 값이 복사되어 전달되므로, 메서드 안에서 필드를 바꾸면 호출한 쪽 객체가 바뀝니다. 기본 타입과 참조 타입의 차이는 변수와 타입에서 다뤘습니다.

생성자를 하나라도 직접 정의하면 컴파일러가 자동으로 만들어 주던 매개변수 없는 기본 생성자는 사라집니다. 위 Person에는 (String, int) 생성자만 있으므로 new Person()은 “constructor Person in class Person cannot be applied to given types” 오류가 납니다. JPA 엔티티나 JSON 역직렬화 라이브러리처럼 기본 생성자가 필요한 도구를 쓸 때 자주 만나는 오류입니다.

getter/setter를 모든 필드에 기계적으로 붙이면 사실상 public 필드와 다를 바 없어진다는 점도 기억해 두세요. 캡슐화의 핵심은 setAge처럼 규칙을 지키는 방법으로만 상태를 바꾸게 하는 것이고, 바뀌면 안 되는 값(주민번호, 생성 시각)은 setter 없이 final 필드로 두는 편이 낫습니다. 참고로 이 setAge는 잘못된 값을 받으면 메시지만 출력하고 조용히 무시하는데, 호출한 쪽은 실패를 알 수 없습니다. 아래 GoodPerson처럼 예외를 던지는 편이 실수를 빨리 드러냅니다.

캡슐화의 장점:

// ❌ 나쁜 예: public 필드
class BadPerson {
    public int age;  // 직접 접근 가능
}
BadPerson p = new BadPerson();
p.age = -10;  // 유효하지 않은 값 설정 가능!
// ✅ 좋은 예: private 필드 + Setter
class GoodPerson {
    private int age;
    
    public void setAge(int age) {
        if (age > 0 && age < 150) {  // 유효성 검사
            this.age = age;
        } else {
            throw new IllegalArgumentException("유효하지 않은 나이");
        }
    }
}
GoodPerson p = new GoodPerson();
// p.age = -10;  // 컴파일 에러 (직접 접근 불가)
p.setAge(-10);  // 예외 발생 (유효성 검사)

BadPerson의 문제는 -10이라는 값 하나가 아니라, 잘못된 값이 어디서 들어왔는지 추적할 수 없다는 점입니다. public 필드는 프로젝트 전체 어디서든 바꿀 수 있어서 나이가 음수로 저장된 원인을 찾으려면 모든 대입문을 뒤져야 합니다. setter 하나를 통과하게 만들면 검증도, 로그도, 디버거 중단점도 한 곳에 둘 수 있습니다. IllegalArgumentException은 “인자가 잘못되었다”는 뜻의 표준 예외라 별도 클래스를 만들지 않아도 의도가 전달됩니다.

생성자 오버로딩

public class Person {
    private String name;
    private int age;
    private String email;
    
    // 기본 생성자
    public Person() {
        this("익명", 0, "");
    }
    
    // 이름만
    public Person(String name) {
        this(name, 0, "");
    }
    
    // 이름과 나이
    public Person(String name, int age) {
        this(name, age, "");
    }
    
    // 모든 필드
    public Person(String name, int age, String email) {
        this.name = name;
        this.age = age;
        this.email = email;
    }
}

생성자 오버로딩에서 this(...)로 다른 생성자를 호출하는 이유는 초기화 코드를 한 곳에 모으기 위해서입니다. 모든 생성자가 각자 필드를 대입하면, 나중에 email 형식 검사를 추가할 때 네 곳을 모두 고쳐야 하고 하나를 빠뜨리기 쉽습니다. 위처럼 모든 경로가 가장 인자가 많은 생성자로 모이게 하면 검증 로직을 그 한 곳에만 두면 됩니다. this(...) 호출은 생성자의 첫 문장이어야 합니다.

인자가 네다섯 개를 넘어가면 오버로딩 조합이 폭발하고, new Person("홍길동", 25, "[email protected]")에서 어떤 값이 무엇인지 호출부만 보고 알기 어려워집니다. 선택 인자가 많은 클래스라면 빌더 패턴(Person.builder().name("홍길동").age(25).build())이나, 불변 데이터 묶음이라면 Java 16의 record를 검토할 만합니다. 또 기본 생성자가 나이를 0으로 채우는데 앞의 setAge는 0을 거부하는 것처럼, 생성자와 setter의 규칙이 어긋나지 않게 검증을 한 곳에서 공유하는 것이 좋습니다.


상속 (Inheritance)

기본 상속

상속은 기존 클래스를 확장하여 새로운 클래스를 만드는 기법입니다:

// 부모 클래스 (Super Class, Base Class)
public class Animal {
    // protected: 자식 클래스에서 접근 가능
    // private보다 넓으며, public보다 좁은 접근 범위
    protected String name;
    
    // 생성자
    public Animal(String name) {
        this.name = name;
    }
    
    // 메서드
    public void makeSound() {
        System.out.println("동물 소리");
    }
    
    public void sleep() {
        // protected 필드 name 사용
        System.out.println(name + "이(가) 잠을 잡니다.");
    }
}
// 자식 클래스 (Sub Class, Derived Class)
public class Dog extends Animal {
    // extends: 상속 키워드
    // Dog는 Animal의 모든 필드와 메서드를 물려받음
    
    // Dog만의 고유 필드
    private String breed;
    
    // 생성자
    public Dog(String name, String breed) {
        // super(name): 부모 생성자 호출 (필수)
        // 반드시 첫 줄에 위치해야 함
        super(name);
        
        // 자식 클래스의 필드 초기화
        this.breed = breed;
    }
    
    // 메서드 오버라이딩 (재정의)
    @Override  // 어노테이션: 오버라이딩임을 명시
    public void makeSound() {
        // 부모의 makeSound()를 재정의
        System.out.println("멍멍!");
    }
    
    // Dog만의 고유 메서드
    public void fetch() {
        // 부모의 protected 필드 name 사용 가능
        System.out.println(name + "이(가) 공을 가져옵니다.");
    }
}
// 사용 예제
public class Main {
    public static void main(String[] args) {
        // Dog 객체 생성
        Dog dog = new Dog("바둑이", "진돗개");
        
        // 오버라이딩된 메서드 호출
        dog.makeSound();  // 멍멍! (Dog의 메서드)
        
        // 상속받은 메서드 호출
        dog.sleep();      // 바둑이이(가) 잠을 잡니다. (Animal의 메서드)
        
        // Dog만의 메서드 호출
        dog.fetch();      // 바둑이이(가) 공을 가져옵니다.
        
        // 다형성 (Polymorphism)
        Animal animal = new Dog("멍멍이", "시바견");
        // 부모 타입 변수에 자식 객체 할당 가능
        animal.makeSound();  // 멍멍! (Dog의 메서드 호출)
        // animal.fetch();   // ❌ 컴파일 에러
        // Animal 타입이므로 Dog의 메서드는 호출 불가
    }
}

상속의 특징:

  1. 코드 재사용: 공통 기능을 부모 클래스에 정의
  2. 확장성: 새로운 기능을 자식 클래스에 추가
  3. 다형성: 부모 타입으로 자식 객체 참조 가능
  4. 단일 상속: Java는 한 클래스만 상속 가능 (다중 상속 불가)

super(name)이 “필수”인 이유는 Animal에 매개변수 없는 생성자가 없기 때문입니다. 자식 생성자에서 super(...)를 쓰지 않으면 컴파일러가 첫 줄에 super()를 넣는데, 부모에 그런 생성자가 없으면 “constructor Animal in class Animal cannot be applied to given types” 오류가 납니다. 부모가 먼저 초기화되어야 자식이 부모의 필드를 안전하게 쓸 수 있으므로 호출 순서가 강제됩니다. (Java 25부터는 super(...) 앞에 필드를 건드리지 않는 인자 검증 코드를 둘 수 있게 되었지만, 부모 생성자가 먼저 실행된다는 원칙은 같습니다.)

Animal animal = new Dog(...)에서 animal.makeSound()가 “멍멍!”을 출력하는 것이 다형성의 핵심입니다. 어떤 메서드를 호출할 수 있는지는 변수의 선언 타입(Animal)이 정하고, 실제로 어떤 구현이 실행되는지는 런타임 객체의 타입(Dog)이 정합니다(동적 바인딩). 그래서 fetch()는 컴파일 오류이고, 꼭 호출해야 한다면 if (animal instanceof Dog d) { d.fetch(); }처럼 타입을 확인한 뒤 써야 합니다. 이런 형변환이 코드 곳곳에 필요해진다면 상속 구조를 다시 볼 신호입니다.

필드는 다형성이 적용되지 않는다는 점도 알아 둘 만합니다. 자식에서 부모와 같은 이름의 필드를 선언하면 오버라이딩이 아니라 숨김(hiding)이 되어, 변수 타입에 따라 서로 다른 필드가 읽힙니다. protected 필드도 같은 패키지의 다른 클래스에서 접근할 수 있을 만큼 범위가 넓어서, 실무에서는 필드는 private으로 두고 자식에게 필요한 동작만 protected 메서드로 여는 편을 선호합니다.

상속은 강한 결합입니다. 부모 클래스의 내부 구현을 바꾸면 모든 자식의 동작이 함께 바뀔 수 있어서, “is-a” 관계(개는 동물이다)가 분명할 때만 쓰고 기능 재사용이 목적이라면 다른 객체를 필드로 두는 조합(composition)을 먼저 고려하는 것이 일반적인 설계 지침입니다.

super 키워드

public class Employee extends Person {
    private String department;
    
    public Employee(String name, int age, String department) {
        super(name, age);  // 부모 생성자
        this.department = department;
    }
    
    @Override
    public void introduce() {
        super.introduce();  // 부모 메서드 호출
        System.out.println("부서: " + department);
    }
}

super.introduce()는 부모의 원래 구현을 먼저 실행한 뒤 자식의 동작을 덧붙이는 패턴입니다. 부모 동작을 완전히 대체하려면 super 호출을 빼면 됩니다. Person의 name 필드는 private이라 Employee에서 직접 읽을 수 없지만, super.introduce()나 getName()처럼 부모가 공개한 메서드를 통하면 됩니다. 자식이 부모의 private 필드까지 직접 만질 수 있다면 부모의 검증 규칙을 우회할 수 있기 때문입니다.

FAQ에서 다루는 @Override의 역할이 여기서 드러납니다. public void introduce(String prefix)처럼 매개변수를 바꾸거나 introduse()로 오타를 내면 오버라이딩이 아니라 새 메서드(오버로딩)가 됩니다. @Override를 붙여 두면 “method does not override or implement a method from a supertype” 컴파일 오류로 바로 알려 주지만, 없으면 조용히 부모의 introduce()가 계속 호출되어 “왜 내가 고친 코드가 실행되지 않지?”라는 상황이 됩니다. equals(Person other)처럼 Object.equals(Object)를 오버라이드한다고 착각하는 것이 대표적인 사례입니다.


인터페이스 (Interface)

기본 인터페이스

public interface Drawable {
    void draw();  // 추상 메서드
    
    default void display() {  // 기본 구현 (Java 8+)
        System.out.println("화면에 표시");
    }
    
    static void info() {  // 정적 메서드
        System.out.println("Drawable 인터페이스");
    }
}
public class Circle implements Drawable {
    private double radius;
    
    public Circle(double radius) {
        this.radius = radius;
    }
    
    @Override
    public void draw() {
        System.out.println("원 그리기: 반지름 " + radius);
    }
}
// 사용
Circle circle = new Circle(5.0);
circle.draw();
circle.display();
Drawable.info();

인터페이스는 “이 타입이면 draw()를 호출할 수 있다”는 계약입니다. 인터페이스의 추상 메서드는 자동으로 public abstract이므로 구현 클래스에서 public을 빼면 “attempting to assign weaker access privileges” 오류가 납니다. default 메서드는 Java 8에서 기존 인터페이스에 메서드를 추가해도 모든 구현 클래스가 깨지지 않게 하려고 도입된 기능입니다(List.sort, Map.forEach가 이렇게 추가되었습니다). static 메서드는 구현 객체가 아니라 Drawable.info()처럼 인터페이스 이름으로만 호출할 수 있습니다.

인터페이스를 쓰는 진짜 이점은 호출하는 쪽 코드가 구체 클래스를 몰라도 된다는 것입니다. void render(List<Drawable> items)는 원이든 사각형이든, 나중에 추가될 도형이든 draw()만 있으면 받아들입니다. 테스트에서 실제 구현 대신 가짜 구현을 끼워 넣기 쉬운 것도 같은 이유입니다.

다중 인터페이스 구현

public interface Movable {
    void move(int x, int y);
}
public interface Resizable {
    void resize(double scale);
}
public class Shape implements Drawable, Movable, Resizable {
    private int x, y;
    private double size;
    
    @Override
    public void draw() {
        System.out.println("도형 그리기");
    }
    
    @Override
    public void move(int x, int y) {
        this.x = x;
        this.y = y;
    }
    
    @Override
    public void resize(double scale) {
        this.size *= scale;
    }
}

클래스 상속은 하나만 가능하지만 인터페이스는 여러 개를 구현할 수 있습니다. 다중 상속이 금지된 이유는 두 부모가 같은 이름의 메서드와 필드를 가질 때 어느 쪽을 쓸지 모호해지는 문제(다이아몬드 문제) 때문인데, 상태가 없는 인터페이스는 이 문제가 훨씬 작습니다. 다만 두 인터페이스가 같은 시그니처의 default 메서드를 가지면 컴파일러가 “inherits unrelated defaults” 오류를 내고, 구현 클래스에서 직접 오버라이드한 뒤 Drawable.super.display()처럼 어느 쪽을 쓸지 명시해야 합니다.

예제 코드에서 size 필드는 초기화하지 않아 기본값 0.0이므로 resize를 몇 번 호출해도 0에 머뭅니다. 또 이 절의 Shape와 다음 절의 추상 클래스 Shape, 앞 절과 다음 절의 Circle은 이름이 같으니 한 패키지에 함께 두면 컴파일되지 않습니다. 개념별 예제로 따로 실행하세요.


추상 클래스 (Abstract Class)

public abstract class Shape {
    protected String color;
    
    public Shape(String color) {
        this.color = color;
    }
    
    // 추상 메서드
    public abstract double area();
    
    // 일반 메서드
    public void printColor() {
        System.out.println("색상: " + color);
    }
}
public class Circle extends Shape {
    private double radius;
    
    public Circle(String color, double radius) {
        super(color);
        this.radius = radius;
    }
    
    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}
public class Rectangle extends Shape {
    private double width, height;
    
    public Rectangle(String color, double width, double height) {
        super(color);
        this.width = width;
        this.height = height;
    }
    
    @Override
    public double area() {
        return width * height;
    }
}

추상 클래스는 일부는 구현하고 일부는 자식에게 맡기는 클래스입니다. color 필드와 printColor()처럼 모든 도형이 똑같이 가지는 상태와 동작은 부모에 두고, 도형마다 계산법이 다른 area()만 abstract로 선언했습니다. abstract 메서드가 있으면 클래스도 abstract여야 하며, new Shape("red")처럼 직접 객체를 만들 수 없습니다. 자식이 area()를 구현하지 않으면 “Circle is not abstract and does not override abstract method area()” 오류가 나서 구현 누락을 컴파일 단계에서 막아 줍니다.

인터페이스와 추상 클래스 중 무엇을 쓸지는 공유할 상태와 생성자가 있는가로 판단하면 대부분 정리됩니다. 공통 필드와 초기화 로직이 있다면 추상 클래스, 서로 관련 없는 클래스들이 같은 능력(비교 가능, 그릴 수 있음)을 가진다는 것만 표현하면 된다면 인터페이스입니다. 둘을 함께 쓰는 경우도 흔해서, 표준 라이브러리의 List 인터페이스와 그 공통 구현을 담은 AbstractList가 대표적인 예입니다. 공개 API의 타입은 인터페이스로 두고, 구현을 돕는 뼈대는 추상 클래스로 제공하는 방식입니다.


접근 제어자로 캡슐화하기

접근 제어자

public class BankAccount {
    private double balance;  // private: 외부 접근 불가
    
    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }
    
    public void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }
    
    public boolean withdraw(double amount) {
        if (amount > 0 && balance >= amount) {
            balance -= amount;
            return true;
        }
        return false;
    }
    
    public double getBalance() {
        return balance;
    }
}

Java의 접근 제어자는 네 단계입니다. private(같은 클래스), 아무것도 안 쓴 package-private(같은 패키지), protected(같은 패키지 + 자식 클래스), public(어디서나)입니다. 기본값이 package-private이라는 점 때문에 제어자를 빠뜨린 필드는 같은 패키지 어디서든 바뀔 수 있습니다.

BankAccount에는 setBalance가 없습니다. 잔액은 입금과 출금이라는 의미 있는 동작으로만 바뀌어야 하므로, 임의의 값을 넣는 setter 대신 규칙을 가진 메서드만 공개했습니다. 이것이 getter/setter 나열과 진짜 캡슐화의 차이입니다. withdraw가 실패를 false로 알리는 방식은 호출한 쪽이 반환값을 무시하면 실패가 묻히므로, 잔액 부족을 예외로 알리는 설계도 흔합니다.

실제 금융 계산이라면 double을 쓰면 안 됩니다. 이진 부동소수점은 0.1을 정확히 표현하지 못해 0.1 + 0.2가 0.30000000000000004가 되고, 반복된 입출금에서 오차가 쌓입니다. 금액은 BigDecimal이나 최소 단위(원, 센트)의 long으로 다룹니다. 여러 스레드가 같은 계좌에 동시에 접근한다면 잔액 확인과 차감 사이에 경쟁 상태가 생기므로 멀티스레드에서 다루는 동기화도 필요합니다.


도서 관리 시스템 예제

예제: 도서 관리 시스템

public class Book {
    private String title;
    private String author;
    private int year;
    private boolean available;
    
    public Book(String title, String author, int year) {
        this.title = title;
        this.author = author;
        this.year = year;
        this.available = true;
    }
    
    public boolean borrow() {
        if (available) {
            available = false;
            return true;
        }
        return false;
    }
    
    public void returnBook() {
        available = true;
    }
    
    public String getTitle() {
        return title;
    }
    
    public void printInfo() {
        System.out.println("제목: " + title);
        System.out.println("저자: " + author);
        System.out.println("출판년도: " + year);
        System.out.println("대출 가능: " + (available ? "예" : "아니오"));
    }
}
public class Library {
    private List<Book> books;
    
    public Library() {
        books = new ArrayList<>();
    }
    
    public void addBook(Book book) {
        books.add(book);
    }
    
    public Book findBook(String title) {
        for (Book book : books) {
            if (book.getTitle().equals(title)) {
                return book;
            }
        }
        return null;
    }
}

Book은 자기 상태(available)를 스스로 관리합니다. 외부에서 book.setAvailable(false)를 부르는 대신 borrow()를 호출하면, 이미 대출 중인 책을 다시 빌리는 경우를 Book이 알아서 거부합니다. 대출 규칙이 바뀌어도(예: 예약자가 있으면 대출 불가) borrow() 한 곳만 고치면 됩니다. Library는 책 목록을 관리하는 역할만 맡고, 책의 대출 상태를 직접 건드리지 않습니다. 이렇게 상태를 가진 객체가 그 상태를 바꾸는 규칙도 함께 가지는 것이 앞에서 본 캡슐화를 설계로 옮긴 모습입니다.

코드를 그대로 컴파일하려면 Library 파일에 import java.util.List;와 import java.util.ArrayList;가 필요합니다. findBook이 못 찾으면 null을 반환하는데, 호출한 쪽이 검사를 빠뜨리면 library.findBook("없는 책").borrow()에서 NullPointerException이 납니다. Java 8 이후라면 Optional<Book>을 반환해 “없을 수 있음”을 타입으로 드러내는 편이 안전합니다. 또 books 필드는 final로 선언해 다른 리스트로 바꿔치기되지 않게 하고, 목록을 외부에 돌려줄 때는 List.copyOf(books)처럼 복사본이나 읽기 전용 뷰를 주어야 외부 코드가 목록을 직접 수정하지 못합니다. 컬렉션 활용은 Java 컬렉션에서 이어집니다.


클래스와 상속 요약

핵심 요약

  1. 클래스: 객체의 설계도, 필드 + 메서드
  2. 생성자: 객체 초기화, 오버로딩 가능
  3. 상속: extends, super, @Override
  4. 인터페이스: implements, 다중 구현 가능
  5. 추상 클래스: abstract, 일부 구현 가능
  6. 캡슐화: private, public, protected

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 메서드를 오버라이드할 때 @Override를 꼭 붙여야 하나요?

A. 붙이지 않아도 동작은 하지만 붙이는 것이 좋습니다. @Override가 있으면 컴파일러가 부모 클래스나 인터페이스에 같은 시그니처의 메서드가 실제로 있는지 검사해 줍니다. 메서드 이름 오타나 매개변수 타입 차이 때문에 오버라이드가 아니라 새 메서드를 만들어 버리는 실수를 컴파일 단계에서 잡을 수 있습니다.