JAVA (4) 상속과 오버라이딩

통밀·2026년 7월 21일

JAVA

목록 보기
4/10

상속과 오버라이딩: 클래스 관계부터 접근 제한자까지

들어가며

앞선 글에서는 객체와 클래스, 필드, 메서드, 생성자를 이용해 하나의 객체를 설계하는 방법을 살펴보았다.

하지만 실제 프로그램에서는 여러 클래스가 완전히 독립적으로 존재하지 않는다. 자동차와 전기 자동차처럼 공통된 속성과 기능을 가지는 클래스도 있고, 자동차와 엔진처럼 하나의 객체가 다른 객체를 부품으로 포함하는 경우도 있다.

예를 들어 Dog와 Cat 클래스가 모두 이름과 나이를 가지고 있으며 먹는 기능을 제공한다고 생각해보자.

public class Dog {

    String name;
    int age;

    void eat() {
        System.out.println("먹이를 먹습니다.");
    }

    void bark() {
        System.out.println("멍멍");
    }
}
public class Cat {

    String name;
    int age;

    void eat() {
        System.out.println("먹이를 먹습니다.");
    }

    void meow() {
        System.out.println("야옹");
    }
}

두 클래스에는 name, age, eat()이라는 코드가 중복되어 있다. 이런 공통 기능을 상위 클래스에 정의하고 하위 클래스가 물려받도록 만드는 기능이 상속이다.

이번 글에서는 Java의 상속과 포함 관계, 메서드 오버라이딩, super, Object, 패키지와 import, 접근 제한자와 final, 그리고 상속 범위를 제한하는 봉인 클래스까지 정리해보려고 한다.


상속으로 클래스의 공통 기능 재사용하기

상속이란?

상속은 기존 클래스가 가지고 있는 필드와 메서드를 새로운 클래스가 물려받아 재사용하는 기능이다.

기존 클래스를 부모 클래스 또는 상위 클래스라고 하며, 부모 클래스를 상속받는 클래스를 자식 클래스 또는 하위 클래스라고 한다.

부모 클래스
- 공통 속성
- 공통 기능

       ↓ 상속

자식 클래스
- 부모의 속성과 기능 사용
- 자신만의 속성과 기능 추가

Java에서는 extends 키워드를 이용해 상속 관계를 만든다.

public class Animal {

    String name;
    int age;

    void eat() {
        System.out.println(name + "이(가) 먹이를 먹습니다.");
    }
}
public class Dog extends Animal {

    void bark() {
        System.out.println(name + "이(가) 멍멍 짖습니다.");
    }
}

Dog 클래스에는 name, age, eat()이 직접 선언되어 있지 않지만 Animal 클래스로부터 상속받았기 때문에 사용할 수 있다.

public class AnimalExample {

    public static void main(String[] args) {
        Dog dog = new Dog();

        dog.name = "초코";
        dog.age = 3;

        dog.eat();
        dog.bark();
    }
}

실행 결과는 다음과 같다.

초코이(가) 먹이를 먹습니다.
초코이(가) 멍멍 짖습니다.

상속을 사용하면 여러 클래스에 반복되는 코드를 공통 부모 클래스로 옮길 수 있다.

Animal
- name
- age
- eat()

Dog extends Animal
- bark()

Cat extends Animal
- meow()

공통 기능을 부모 클래스에서 관리하기 때문에 부모 클래스의 코드를 수정하면 이를 상속받는 자식 클래스에도 변경 사항이 반영된다.


상속되지 않는 것

자식 클래스가 부모 클래스의 모든 코드를 그대로 물려받는 것은 아니다.

부모 클래스의 다음 요소는 자식 클래스에 직접 상속되지 않는다.

  • 부모 클래스의 생성자
  • 부모 클래스의 초기화 블록
  • 접근할 수 없는 private 멤버

생성자는 객체를 초기화하기 위해 해당 클래스가 직접 실행하는 코드이므로 상속되지 않는다. 다만 자식 객체가 만들어질 때 부모 생성자는 반드시 호출된다.

public class Parent {

    Parent() {
        System.out.println("Parent 생성자 실행");
    }
}
public class Child extends Parent {

    Child() {
        System.out.println("Child 생성자 실행");
    }
}
public class ConstructorExample {

    public static void main(String[] args) {
        Child child = new Child();
    }
}

실행 결과는 다음과 같다.

Parent 생성자 실행
Child 생성자 실행

자식 객체를 만들 때 부모 부분이 먼저 초기화되고, 이후 자식 부분이 초기화된다.


상속은 is-a 관계다

상속 관계는 흔히 is-a 관계라고 표현한다.

Dog is an Animal.
강아지는 동물이다.

ElectricCar is a Car.
전기 자동차는 자동차다.

자식 객체를 부모 타입의 한 종류라고 자연스럽게 말할 수 있다면 상속 관계를 고려할 수 있다.

public class ElectricCar extends Car {
}

반대로 다음 관계는 자연스럽지 않다.

Engine is a Car.
엔진은 자동차다.

엔진은 자동차의 한 종류가 아니라 자동차가 가지고 있는 부품이다. 이런 관계는 상속이 아니라 포함 관계로 설계해야 한다.


Java가 단일 상속만 지원하는 이유

Java 클래스는 하나의 부모 클래스만 직접 상속할 수 있다.

public class Child extends Parent {
}

다음처럼 두 클래스를 동시에 상속할 수는 없다.

// 사용할 수 없는 문법
// public class Child extends ParentA, ParentB {
// }

만약 다중 상속을 허용했는데 두 부모 클래스에 동일한 이름과 형태의 메서드가 존재한다면 어떤 메서드를 사용해야 하는지 모호해질 수 있다.

ParentA
└─ print()

ParentB
└─ print()

Child
└─ 어느 print()를 상속해야 하는가?

Java는 이러한 복잡성을 줄이기 위해 클래스의 단일 상속만 허용한다.

다만 하나의 클래스가 여러 인터페이스를 구현하는 것은 가능하다.

public class SmartPhone
        implements Camera, MusicPlayer, Phone {
}

여러 객체의 기능이 필요하다면 인터페이스와 포함 관계를 함께 활용할 수 있다.


모든 클래스의 부모인 Object

Java의 모든 클래스는 최종적으로 Object 클래스를 상속한다.

다음 클래스에 extends가 작성되어 있지 않더라도

public class Person {
}

개념적으로는 다음과 같은 구조를 가진다.

public class Person extends Object {
}

따라서 모든 객체는 Object 클래스가 제공하는 메서드를 사용할 수 있다.

대표적인 메서드는 다음과 같다.

메서드역할
toString()객체를 문자열로 표현
equals()객체의 동등성 비교
hashCode()객체의 해시 코드 반환
getClass()객체의 클래스 정보 반환
public class Person {

    String name;
}
public class ObjectExample {

    public static void main(String[] args) {
        Person person = new Person();

        System.out.println(person.toString());
        System.out.println(person.getClass());
        System.out.println(person.hashCode());
    }
}

이 메서드들은 Person에 직접 선언하지 않았지만 Object로부터 상속받았기 때문에 사용할 수 있다.


상속과 포함 관계를 구분하기

포함 관계란?

클래스를 재사용하는 방법에는 상속만 있는 것이 아니다.

하나의 클래스가 다른 클래스의 객체를 필드로 가지도록 설계할 수도 있다. 이를 포함 관계 또는 구성 관계라고 한다.

포함 관계는 보통 has-a 관계라고 표현한다.

Car has an Engine.
자동차는 엔진을 가지고 있다.

Computer has a CPU.
컴퓨터는 CPU를 가지고 있다.
public class Engine {

    void start() {
        System.out.println("엔진이 작동합니다.");
    }
}
public class Car {

    private final Engine engine;

    public Car() {
        this.engine = new Engine();
    }

    void start() {
        engine.start();
        System.out.println("자동차가 출발할 준비를 마쳤습니다.");
    }
}
public class CarExample {

    public static void main(String[] args) {
        Car car = new Car();

        car.start();
    }
}

Car는 Engine을 상속하지 않고 필드로 포함한다.


상속과 포함 중 무엇을 선택할까?

상속과 포함은 단순히 문법의 문제가 아니라 클래스 설계의 문제다.

관계판단 기준예시
상속A는 B의 한 종류인가?강아지는 동물이다
포함A는 B를 가지고 있는가?자동차는 엔진을 가지고 있다
Dog is an Animal
→ 상속

Car has an Engine
→ 포함

상속을 잘못 사용하면 부모 클래스와 자식 클래스가 지나치게 강하게 결합될 수 있다. 부모 클래스의 변경이 모든 자식 클래스에 영향을 주기 때문이다.

따라서 단순히 코드를 재사용하고 싶다는 이유만으로 상속을 사용하기보다, 실제로 자연스러운 is-a 관계가 성립하는지 먼저 생각해야 한다.

💡 상속보다 포함을 우선해서 고려하라는 설계 원칙을 “상속보다는 컴포지션을 선호하라”라고 표현하기도 한다.


메서드 오버라이딩과 super

메서드 오버라이딩이란?

부모 클래스에서 정의한 메서드가 자식 클래스의 상황에 맞지 않을 수 있다.

예를 들어 모든 동물이 소리를 낸다는 공통 기능은 있지만, 강아지와 고양이는 서로 다른 소리를 낸다.

public class Animal {

    void sound() {
        System.out.println("동물이 소리를 냅니다.");
    }
}

자식 클래스에서 부모 메서드를 자신의 동작에 맞게 다시 정의하는 것을 메서드 오버라이딩이라고 한다.

public class Dog extends Animal {

    @Override
    void sound() {
        System.out.println("멍멍");
    }
}
public class Cat extends Animal {

    @Override
    void sound() {
        System.out.println("야옹");
    }
}
public class OverrideExample {

    public static void main(String[] args) {
        Dog dog = new Dog();
        Cat cat = new Cat();

        dog.sound();
        cat.sound();
    }
}

실행 결과는 다음과 같다.

멍멍
야옹

오버라이딩의 조건

부모 메서드를 오버라이딩하려면 기본적으로 다음 조건을 만족해야 한다.

  • 메서드 이름이 같아야 한다.
  • 매개변수의 타입, 개수, 순서가 같아야 한다.
  • 반환 타입이 같거나 부모 반환 타입의 하위 타입이어야 한다.
  • 접근 제한 범위는 부모 메서드보다 좁아질 수 없다.
  • 부모 메서드보다 더 넓은 범위의 검사 예외를 선언할 수 없다.
  • final, private, static 메서드는 일반적인 인스턴스 메서드 오버라이딩 대상이 아니다.

반환 타입은 반드시 완전히 같아야 하는 것은 아니다. 참조 타입이라면 부모 반환 타입의 하위 타입으로 변경할 수 있으며, 이를 공변 반환 타입이라고 한다.

public class Animal {

    Animal create() {
        return new Animal();
    }
}
public class Dog extends Animal {

    @Override
    Dog create() {
        return new Dog();
    }
}

Dog는 Animal의 하위 타입이기 때문에 허용된다.


@Override 어노테이션

오버라이딩한 메서드 위에는 @Override를 작성하는 것이 좋다.

@Override
void sound() {
    System.out.println("멍멍");
}

@Override는 컴파일러에게 해당 메서드가 부모 클래스나 인터페이스의 메서드를 재정의한 것임을 알려준다.

메서드 이름이나 매개변수를 잘못 작성하면 컴파일러가 오류를 알려준다.

public class Dog extends Animal {

    @Override
    void sounds() {
        System.out.println("멍멍");
    }
}

부모 클래스에 sounds()라는 메서드가 없다면 컴파일 오류가 발생한다.

@Override가 없다면 새로운 메서드를 선언한 것으로 처리될 수 있기 때문에 오버라이딩할 때는 항상 작성하는 것이 안전하다.


오버로딩과 오버라이딩

이름이 비슷하지만 완전히 다른 개념이다.

구분오버로딩오버라이딩
위치주로 같은 클래스부모와 자식 클래스
메서드 이름같음같음
매개변수달라야 함같아야 함
목적같은 기능을 다양한 인수로 제공부모 기능을 자식에게 맞게 재정의
// 오버로딩
int add(int left, int right) {
    return left + right;
}

double add(double left, double right) {
    return left + right;
}
// 오버라이딩
@Override
void sound() {
    System.out.println("멍멍");
}

super로 부모 멤버 사용하기

this가 현재 객체를 가리킨다면 super는 현재 객체의 부모 클래스 부분을 가리킨다.

부모 메서드를 오버라이딩한 뒤 기존 기능도 함께 사용하고 싶다면 super.메서드()를 호출할 수 있다.

public class Animal {

    void introduce() {
        System.out.println("동물입니다.");
    }
}
public class Dog extends Animal {

    @Override
    void introduce() {
        super.introduce();
        System.out.println("그중에서도 강아지입니다.");
    }
}
public class SuperExample {

    public static void main(String[] args) {
        Dog dog = new Dog();

        dog.introduce();
    }
}

실행 결과는 다음과 같다.

동물입니다.
그중에서도 강아지입니다.

부모 메서드의 기존 기능을 재사용하면서 자식 기능을 추가할 수 있다.


this와 super

this.name

현재 객체의 멤버에 접근한다.

super.name

부모 클래스에 선언된 멤버에 접근한다.

부모와 자식 클래스에 같은 이름의 필드가 있다면 this와 super를 통해 구분할 수 있다.

public class Parent {

    String name = "부모 필드";
}
public class Child extends Parent {

    String name = "자식 필드";

    void printNames() {
        System.out.println(this.name);
        System.out.println(super.name);
    }
}

다만 부모와 자식 클래스에 같은 이름의 필드를 중복해서 선언하는 것은 혼란을 만들 수 있으므로 특별한 이유가 없다면 피하는 것이 좋다.


super()와 부모 생성자

this()가 같은 클래스의 다른 생성자를 호출한다면 super()는 부모 클래스의 생성자를 호출한다.

public class Animal {

    String name;

    Animal(String name) {
        this.name = name;
    }
}
public class Dog extends Animal {

    String breed;

    Dog(String name, String breed) {
        super(name);
        this.breed = breed;
    }
}
public class ConstructorExample {

    public static void main(String[] args) {
        Dog dog = new Dog("초코", "푸들");

        System.out.println(dog.name);
        System.out.println(dog.breed);
    }
}

부모 클래스에 선언된 필드는 부모 생성자에서 초기화하고, 자식 클래스에 추가된 필드는 자식 생성자에서 초기화하는 구조다.


super() 사용 규칙

super()는 자식 생성자의 첫 번째 실행문으로만 작성할 수 있다.

Dog(String name, String breed) {
    super(name);
    this.breed = breed;
}

생성자의 첫 번째 줄에는 this() 또는 super() 중 하나만 올 수 있다.

this()
→ 같은 클래스의 다른 생성자 호출

super()
→ 부모 클래스의 생성자 호출

명시적으로 작성하지 않으면 컴파일러가 매개변수가 없는 super()를 자동으로 삽입한다.

Dog() {
    super();
}

따라서 부모 클래스에 매개변수가 없는 생성자가 없으면 자식 클래스에서 부모 생성자를 직접 호출해야 한다.

public class Animal {

    Animal(String name) {
    }
}
public class Dog extends Animal {

    Dog() {
        super("이름 없음");
    }
}

자식 객체를 생성하면 부모 클래스부터 Object까지 생성자가 위에서부터 순서대로 실행된다.

Object 생성자
→ 부모 생성자
→ 자식 생성자

정확히는 별도의 부모 객체와 자식 객체가 각각 따로 생성되는 것이 아니라, 하나의 자식 객체 내부에서 부모 클래스가 담당하는 부분부터 순서대로 초기화된다고 이해하는 것이 좋다.


클래스의 상속 범위와 구조 제한하기

final

final은 대상에 따라 “더 이상 변경하거나 확장할 수 없다”는 의미를 부여한다.


final 변수

final 변수는 한 번 값을 저장한 뒤 다시 변경할 수 없다.

final int maxCount = 100;

// maxCount = 200;
// 컴파일 오류

상수는 일반적으로 static final과 함께 선언한다.

public class MathConstants {

    public static final double PI = 3.141592;
}
System.out.println(MathConstants.PI);

final 필드가 객체마다 다른 값을 가져야 한다면 생성자에서 초기화할 수 있다. 이를 빈 final 필드 또는 blank final field라고 한다.

public class Member {

    private final String id;

    public Member(String id) {
        this.id = id;
    }
}

각 객체는 생성될 때 서로 다른 id를 한 번만 저장할 수 있다.


final 메서드

final 메서드는 자식 클래스에서 오버라이딩할 수 없다.

public class Account {

    final void printSecurityMessage() {
        System.out.println("보안 기능을 실행합니다.");
    }
}
public class SavingsAccount extends Account {

    // 오버라이딩할 수 없음
    // @Override
    // void printSecurityMessage() {
    // }
}

핵심 동작을 자식 클래스가 변경하지 못하도록 막을 때 사용할 수 있다.


final 클래스

final 클래스는 다른 클래스가 상속할 수 없다.

public final class Utility {
}
// 상속할 수 없음
// public class ChildUtility extends Utility {
// }

대표적인 final 클래스에는 String과 여러 불변 클래스가 있다.

다만 Math 클래스는 final 클래스이기도 하지만 생성자도 private으로 선언되어 객체 생성을 막고 정적 기능만 제공한다.


sealed class

final 클래스는 모든 상속을 금지한다.

하지만 특정 클래스에게만 상속을 허용하고 그 외 클래스의 상속은 막고 싶을 수 있다. 이때 봉인 클래스인 sealed class를 사용할 수 있다.

public sealed class Payment
        permits CardPayment, CashPayment {
}

Payment는 CardPayment와 CashPayment만 직접 상속할 수 있다.

허용된 자식 클래스는 반드시 다음 중 하나로 선언해야 한다.

  • final
  • sealed
  • non-sealed

final 자식 클래스

더 이상의 상속을 허용하지 않는다.

public final class CashPayment extends Payment {
}

sealed 자식 클래스

자신의 자식 클래스도 제한한다.

public sealed class CardPayment extends Payment
        permits CreditCardPayment {
}
public final class CreditCardPayment
        extends CardPayment {
}

non-sealed 자식 클래스

부모 클래스에서 적용받은 봉인을 해제한다.

public non-sealed class CardPayment extends Payment {
}

이제 다른 클래스가 CardPayment를 자유롭게 상속할 수 있다.

public class CreditCardPayment extends CardPayment {
}

sealed class를 사용하는 이유

봉인 클래스는 상속 계층을 명확하게 제한할 수 있다.

Payment
├─ CardPayment
└─ CashPayment

결제 방식, 사용자 권한, 명령 종류처럼 가능한 하위 타입이 명확하게 제한되어야 하는 모델에 적합하다.

public sealed interface UserRole
        permits Admin, Member, Guest {
}

의도하지 않은 클래스가 상속 구조에 들어오는 것을 막을 수 있으며, 클래스 계층을 분석하기도 쉬워진다.

💡 sealed class는 Java 17에서 정식 기능으로 도입되었다.

permits는 항상 반드시 직접 작성해야 하는 것은 아니다. 허용할 자식 클래스가 같은 소스 파일에 선언되어 있다면 컴파일러가 이를 추론할 수 있어 생략할 수 있다.

public sealed class Shape {
}

final class Circle extends Shape {
}

final class Rectangle extends Shape {
}

패키지와 접근 범위 관리하기

패키지란?

프로그램의 클래스가 많아지면 관련된 클래스를 목적에 따라 분류할 필요가 있다.

컴퓨터에서 파일을 폴더로 나누어 관리하는 것처럼 Java에서는 클래스를 패키지로 나누어 관리한다.

com.example
├─ member
│  ├─ Member.java
│  └─ MemberService.java
│
├─ order
│  ├─ Order.java
│  └─ OrderService.java
│
└─ product
   ├─ Product.java
   └─ ProductService.java

패키지는 클래스를 분류하는 역할뿐만 아니라 클래스의 전체 이름을 구성한다.

com.example.member.Member

이처럼 패키지 이름과 클래스 이름을 모두 포함한 이름을 Fully Qualified Name, 즉 완전한 클래스 이름이라고 한다.


패키지 선언

패키지는 소스 파일의 주석과 공백을 제외한 첫 번째 문장에 선언한다.

package com.example.member;

public class Member {
}

하나의 소스 파일에는 하나의 패키지만 선언할 수 있다.

패키지를 선언하지 않은 클래스는 이름 없는 기본 패키지에 속하지만, 실제 프로젝트에서는 기본 패키지를 사용하지 않는 것이 좋다.


패키지 이름 작성 규칙

패키지 이름은 일반적으로 모두 소문자로 작성한다.

회사나 조직의 도메인을 역순으로 사용하고 프로젝트와 기능을 이어 붙이는 방식이 널리 사용된다.

com.company.project.member
com.company.project.order
org.example.application.product

예를 들어 다음과 같이 구성할 수 있다.

com.ssafy.project.member
com.ssafy.project.order
com.ssafy.project.common

import

현재 클래스와 다른 패키지에 있는 클래스를 짧은 이름으로 사용하려면 import를 작성한다.

package com.example.order;

import com.example.member.Member;

public class Order {

    Member member;
}

import를 사용하지 않으면 완전한 클래스 이름을 작성해야 한다.

public class Order {

    com.example.member.Member member;
}

import 작성 위치

import는 패키지 선언과 클래스 선언 사이에 작성한다.

package com.example.order;

import com.example.member.Member;
import java.util.ArrayList;
import java.util.List;

public class Order {
}

패키지 선언은 하나만 가능하지만 import는 여러 개 작성할 수 있다.


와일드카드 import

특정 패키지의 여러 클래스를 가져오려면 *를 사용할 수 있다.

import java.util.*;

다만 *는 해당 패키지에 직접 포함된 클래스만 가져온다. 하위 패키지까지 가져오지는 않는다.

import java.util.*;

위 코드는 다음 하위 패키지의 클래스까지 가져오지 않는다.

java.util.concurrent
java.util.function
java.util.stream

또한 와일드카드 import는 어떤 클래스를 사용하는지 한눈에 파악하기 어려울 수 있어 IDE가 생성하는 개별 import를 유지하는 경우가 많다.


이름이 같은 클래스 사용하기

서로 다른 패키지에 이름이 같은 클래스가 존재할 수 있다.

java.util.Date
java.sql.Date

두 클래스를 동시에 사용해야 한다면 하나는 완전한 클래스 이름으로 작성해야 한다.

import java.util.Date;

public class DateExample {

    Date utilDate;
    java.sql.Date sqlDate;
}

기본으로 import되는 패키지

java.lang 패키지는 별도로 import하지 않아도 자동으로 사용할 수 있다.

String
System
Math
Object
Integer

다음 import는 명시적으로 작성하지 않아도 된다.

import java.lang.*;

Eclipse 계열 IDE에서는 일반적으로 Ctrl + Shift + O를 이용해 import를 자동으로 정리할 수 있다.


접근 제한자로 객체 보호하기

제한자란?

제한자는 클래스, 필드, 생성자, 메서드 선언부에 추가적인 의미를 부여하는 키워드다.

제한자는 크게 접근 제한자와 그 외 제한자로 나눌 수 있다.

접근 제한자
- public
- protected
- default
- private

그 외 제한자
- static
- final
- abstract
- synchronized
- ...

하나의 선언에 여러 제한자를 함께 사용할 수 있다.

public static final int MAX_COUNT = 100;

순서는 문법적으로 크게 중요하지 않지만 일반적으로 접근 제한자를 가장 앞에 작성한다.


접근 제한자 비교

접근 제한자는 해당 클래스나 멤버를 어느 범위까지 사용할 수 있는지 결정한다.

접근 제한자같은 클래스같은 패키지다른 패키지의 자식 클래스그 외
public가능가능가능가능
protected가능가능조건부 가능불가능
default가능가능불가능불가능
private가능불가능불가능불가능

default는 실제 키워드를 작성하는 것이 아니라 접근 제한자를 생략한 상태다.

class Member {
}
void print() {
}

public

public은 어디에서든 접근할 수 있다.

public class Member {

    public void introduce() {
        System.out.println("회원입니다.");
    }
}

다른 패키지에서도 import한 뒤 사용할 수 있다.


private

private은 해당 클래스 내부에서만 접근할 수 있다.

public class BankAccount {

    private long balance;

    public void deposit(long amount) {
        if (amount > 0) {
            balance += amount;
        }
    }

    public long getBalance() {
        return balance;
    }
}

외부 코드가 balance를 직접 음수로 변경하지 못하도록 보호할 수 있다.

BankAccount account = new BankAccount();

// account.balance = -100_000;
// 접근 불가

이처럼 데이터를 외부에 직접 노출하지 않고 메서드를 통해 제어하는 것을 캡슐화라고 한다.


default

접근 제한자를 생략하면 같은 패키지 내부에서만 접근할 수 있다.

class PackageMember {

    void print() {
        System.out.println("같은 패키지에서 사용 가능");
    }
}

패키지 내부에서만 사용하는 클래스나 기능에 적합하다.


protected

protected는 같은 패키지에서는 default와 비슷하게 접근할 수 있다.

다른 패키지에서는 해당 클래스를 상속한 자식 클래스가 상속 관계를 통해 접근할 수 있다.

package parent;

public class Parent {

    protected int value = 10;
}
package child;

import parent.Parent;

public class Child extends Parent {

    void printValue() {
        System.out.println(value);
    }
}

다른 패키지의 일반 클래스에서는 사용할 수 없다.

protected는 “어디서나 자식 객체를 통해 접근 가능하다”는 단순한 의미가 아니며, 다른 패키지에서는 상속받은 자식 클래스 내부에서 상속 관계를 통해 사용해야 한다.


오버라이딩과 접근 제한자

자식 클래스에서 메서드를 오버라이딩할 때 접근 범위를 부모보다 좁게 만들 수 없다.

public class Parent {

    protected void print() {
    }
}

다음 오버라이딩은 가능하다.

public class Child extends Parent {

    @Override
    public void print() {
    }
}

protected보다 넓은 public으로 변경했기 때문이다.

다음 코드는 사용할 수 없다.

public class Child extends Parent {

    // 접근 범위를 좁힐 수 없음
    // @Override
    // private void print() {
    // }
}

부모 타입으로 사용할 수 있었던 기능이 자식 객체에서는 갑자기 사용할 수 없게 되면 다형성의 일관성이 깨질 수 있기 때문이다.


어노테이션으로 코드에 정보 추가하기

어노테이션이란?

어노테이션은 소스 코드에 추가하는 메타데이터다.

사전적으로는 주석이라는 의미지만 일반 주석과 달리 컴파일러, JVM, 프레임워크 등이 읽고 활용할 수 있다.

@Override
public String toString() {
    return "Member";
}

어노테이션은 코드 자체의 동작을 직접 작성하기보다 코드에 대한 정보나 처리 방법을 추가한다.

대표적인 기본 어노테이션은 다음과 같다.

어노테이션역할
@Override오버라이딩한 메서드임을 표시
@Deprecated더 이상 사용을 권장하지 않는 요소 표시
@SuppressWarnings특정 컴파일 경고 억제
@FunctionalInterface함수형 인터페이스임을 검증

@Deprecated

더 이상 사용을 권장하지 않는 클래스나 메서드에 사용한다.

public class LegacyService {

    @Deprecated
    public void oldMethod() {
        System.out.println("이전 방식");
    }

    public void newMethod() {
        System.out.println("새로운 방식");
    }
}

기존 코드와의 호환성을 위해 메서드는 남겨두되 새로운 코드에서는 사용하지 않도록 안내할 수 있다.


@SuppressWarnings

특정 경고를 의도적으로 무시할 때 사용한다.

@SuppressWarnings("deprecation")
public void useLegacyMethod() {
    LegacyService service = new LegacyService();
    service.oldMethod();
}

경고가 불편하다는 이유로 무조건 사용하는 것이 아니라, 경고가 발생하는 원인을 이해하고 의도적으로 무시해도 안전할 때만 사용해야 한다.


종합 예제: 회원 등급 상속 구조

지금까지 배운 내용을 이용해 회원 등급을 표현해보자.

Member 클래스

package member;

public class Member {

    private final String id;
    private String name;

    public Member(String id, String name) {
        this.id = id;
        this.name = name;
    }

    public void printInfo() {
        System.out.println("아이디: " + id);
        System.out.println("이름: " + name);
    }

    public int calculatePoint(int price) {
        return price / 100;
    }

    public String getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    protected void setName(String name) {
        this.name = name;
    }
}

id는 객체 생성 후 변경되어서는 안 되므로 final로 선언했다.

외부에서 필드에 직접 접근하지 못하도록 private으로 보호하고 필요한 기능만 메서드로 제공한다.


VipMember 클래스

package member;

public final class VipMember extends Member {

    private final double pointRate;

    public VipMember(
            String id,
            String name,
            double pointRate
    ) {
        super(id, name);
        this.pointRate = pointRate;
    }

    @Override
    public int calculatePoint(int price) {
        return (int) (price * pointRate);
    }

    @Override
    public void printInfo() {
        super.printInfo();
        System.out.println("회원 등급: VIP");
        System.out.printf(
                "적립률: %.1f%%%n",
                pointRate * 100
        );
    }
}

VipMember는 Member를 상속한다.

부모의 생성자는 super()를 이용해 호출하고, 포인트 계산 방식은 VIP 회원에게 맞게 오버라이딩한다.

printInfo()에서는 부모 메서드를 super.printInfo()로 재사용한 뒤 VIP 정보를 추가로 출력한다.


실행 클래스

package app;

import member.Member;
import member.VipMember;

public class MemberExample {

    public static void main(String[] args) {
        Member normalMember = new Member(
                "member01",
                "김자바"
        );

        VipMember vipMember = new VipMember(
                "vip01",
                "이자바",
                0.05
        );

        normalMember.printInfo();
        System.out.println(
                "적립 포인트: "
                        + normalMember.calculatePoint(100_000)
        );

        System.out.println();

        vipMember.printInfo();
        System.out.println(
                "적립 포인트: "
                        + vipMember.calculatePoint(100_000)
        );
    }
}

실행 결과는 다음과 같다.

아이디: member01
이름: 김자바
적립 포인트: 1000

아이디: vip01
이름: 이자바
회원 등급: VIP
적립률: 5.0%
적립 포인트: 5000

이 예제에는 다음 개념이 사용되었다.

  • extends를 이용한 상속
  • super()를 이용한 부모 생성자 호출
  • @Override를 이용한 메서드 재정의
  • super.메서드()를 이용한 부모 기능 재사용
  • private을 이용한 데이터 은닉
  • protected 멤버
  • final 필드와 클래스
  • 패키지와 import

헷갈리기 쉬운 개념

상속과 객체 생성

자식 객체를 생성한다고 해서 부모 객체와 자식 객체가 완전히 별개로 두 개 생성되는 것은 아니다.

Dog dog = new Dog();

하나의 Dog 객체 안에 Animal로부터 물려받은 부분과 Dog가 직접 선언한 부분이 함께 존재한다.

다만 초기화 순서는 부모 클래스부터 자식 클래스 순서로 진행된다.


상속과 접근

상속받았다고 해서 부모 클래스의 모든 멤버에 직접 접근할 수 있는 것은 아니다.

private String name;

private 멤버는 부모 클래스 내부에서만 접근할 수 있다.

자식 클래스가 사용해야 한다면 부모 클래스가 protected 또는 public 메서드를 제공해야 한다.


this()와 super()

this(...)

같은 클래스의 다른 생성자를 호출한다.

super(...)

부모 클래스의 생성자를 호출한다.

둘 다 생성자의 첫 번째 실행문에만 작성할 수 있으므로 동시에 직접 호출할 수 없다.


super와 부모 객체

super는 별도로 생성된 부모 객체를 가리키는 변수가 아니다.

현재 자식 객체 안에 포함된 부모 클래스 부분의 멤버에 접근하기 위한 키워드다.


final과 불변 객체

필드에 final을 붙였다고 해서 그 객체의 내부 상태까지 모두 변경할 수 없는 것은 아니다.

final StringBuilder builder = new StringBuilder("Java");

builder.append(" Programming");

builder가 다른 객체를 참조하도록 변경할 수는 없지만, 참조 중인 StringBuilder 객체의 내용은 변경할 수 있다.

// builder = new StringBuilder("Spring");
// 참조 변경은 불가능

실무에서는 어떻게 사용할까?

상속은 강력하지만 부모와 자식 클래스 사이의 결합을 강하게 만든다.

부모 클래스의 내부 구조가 바뀌면 여러 자식 클래스가 영향을 받을 수 있기 때문에, 실제 개발에서는 단순한 코드 재사용만을 목적으로 상속을 사용하지 않는다.

다음 조건을 만족할 때 상속을 고려하는 것이 좋다.

  • 자식 클래스가 부모 클래스의 진짜 하위 종류다.
  • 부모 타입으로 자식 객체를 사용해도 의미가 자연스럽다.
  • 부모 클래스의 계약을 자식 클래스가 유지할 수 있다.
  • 오버라이딩을 통해 행위를 확장하거나 구체화할 필요가 있다.

단순히 다른 클래스의 기능을 사용하고 싶다면 포함 관계가 더 적절할 수 있다.

public class OrderService {

    private final PaymentService paymentService;

    public OrderService(PaymentService paymentService) {
        this.paymentService = paymentService;
    }
}

Spring에서도 서비스 클래스가 다른 서비스나 저장소 객체를 필드로 가지고 사용하는 포함 관계가 매우 많이 사용된다.

OrderService has a PaymentService

또한 필드는 가능한 한 private으로 보호하고, 외부에는 필요한 메서드만 공개하는 것이 일반적인 설계 방식이다.

private long balance;

public void deposit(long amount) {
}

상속 구조가 명확하게 제한되어야 하는 도메인에서는 sealed class를 활용할 수 있지만, 모든 프로젝트에서 반드시 사용할 필요는 없다. 일반적인 클래스 설계에서는 인터페이스, 조합, 의존성 주입도 함께 고려해야 한다.


오늘 배운 내용 정리

이번 글에서는 클래스의 기능을 재사용하고 확장하는 Java 상속을 정리했다.

  • 상속은 부모 클래스의 필드와 메서드를 자식 클래스가 재사용하는 기능이다.
  • Java에서는 extends 키워드로 클래스 상속 관계를 만든다.
  • 부모 클래스의 생성자와 초기화 블록은 자식 클래스에 상속되지 않는다.
  • 자식 객체를 생성할 때는 부모 클래스 부분부터 먼저 초기화된다.
  • 상속은 일반적으로 is-a 관계가 성립할 때 사용한다.
  • 포함 관계는 한 객체가 다른 객체를 필드로 가지는 has-a 관계다.
  • Java 클래스는 하나의 부모 클래스만 직접 상속할 수 있다.
  • 여러 기능이 필요할 때는 인터페이스와 포함 관계를 함께 활용할 수 있다.
  • 모든 Java 클래스는 최종적으로 Object 클래스를 상속한다.
  • 오버라이딩은 부모 메서드를 자식 클래스에 맞게 다시 정의하는 것이다.
  • 오버라이딩한 메서드에는 @Override를 작성하는 것이 좋다.
  • 오버라이딩 시 메서드 이름과 매개변수 목록이 같아야 한다.
  • 참조 타입 반환값은 부모 반환 타입의 하위 타입으로 변경할 수 있다.
  • 오버라이딩하는 메서드의 접근 범위는 부모보다 좁아질 수 없다.
  • super는 부모 클래스의 멤버에 접근할 때 사용한다.
  • super()는 부모 생성자를 호출하며 자식 생성자의 첫 번째 줄에 작성한다.
  • 부모 생성자를 명시적으로 호출하지 않으면 컴파일러가 super()를 삽입한다.
  • final 변수는 값을 다시 변경할 수 없다.
  • final 메서드는 오버라이딩할 수 없다.
  • final 클래스는 상속할 수 없다.
  • static final은 모든 객체가 공유하는 상수를 표현할 때 사용한다.
  • 봉인 클래스는 허용된 클래스만 상속하도록 제한한다.
  • 봉인 클래스의 자식은 final, sealed, non-sealed 중 하나로 선언해야 한다.
  • 패키지는 관련된 클래스를 계층적으로 분류하고 이름 충돌을 방지한다.
  • 다른 패키지의 클래스를 사용할 때는 import를 사용할 수 있다.
  • java.lang 패키지는 자동으로 import된다.
  • 접근 제한자는 클래스와 멤버를 사용할 수 있는 범위를 결정한다.
  • private은 클래스 내부, default는 같은 패키지, protected는 같은 패키지와 상속 관계, public은 모든 위치에서 접근할 수 있다.
  • 필드를 private으로 보호하고 메서드를 통해 사용하도록 만드는 것이 캡슐화의 기본이다.
  • 어노테이션은 컴파일러, JVM, 프레임워크가 활용할 수 있는 메타데이터다.

다음 글 예고

다음 글에서는 상속 관계를 활용하는 핵심 개념인 다형성을 다룬다.

Java 객체지향 ⑤ 다형성

- 자동 타입 변환
- 부모 타입과 자식 객체
- 메서드 오버라이딩과 동적 바인딩
- 강제 타입 변환
- instanceof
- instanceof 패턴 매칭
- 매개변수의 다형성
- 배열과 다형성

상속이 클래스 사이의 관계를 만드는 기능이라면, 다형성은 그 관계를 실제 프로그램에서 유연하게 사용하는 방법이라고 볼 수 있다.


📌 작성자 참고 메모 — 블로그에 넣지 않아도 되는 내용

이번 글의 시리즈 위치

이번 글은 사용자가 지정한 기준에 따라 Java 시리즈의 네 번째 글로 구성했다.

Java 객체지향 ① 변수와 타입 변환
Java 객체지향 ② 제어문과 배열
Java 객체지향 ③ 객체와 클래스
Java 객체지향 ④ 상속과 오버라이딩

다만 앞선 글 제목에 이미 다른 번호가 붙어 있다면 실제 게시 시리즈 이름에 맞춰 숫자만 조정하면 된다.


메모에서 수정한 내용

Object의 자식 클래스

메모의 “별도의 extends 선언이 없는 클래스는 extends Object가 생략된다”는 설명은 학습 관점에서 맞다. 다만 인터페이스는 클래스를 상속하지 않으며 Object를 직접 상속한다고 표현하지 않는다.

오버라이딩 반환 타입

메모에는 “리턴 타입이 같아야 한다”고 적혀 있었지만, 정확히는 다음과 같다.

  • 기본 타입 반환값은 같아야 한다.
  • 참조 타입 반환값은 부모 반환 타입과 같거나 그 하위 타입일 수 있다.

sealed 클래스의 permits

메모에는 sealed가 선언된 클래스는 permits가 반드시 필요하다고 되어 있지만 예외가 있다.

허용된 자식 클래스가 같은 컴파일 단위, 즉 같은 .java 파일에 함께 선언되어 있으면 permits를 생략할 수 있다.

sealed 철자

메모의 seald는 sealed로 수정했다.

final 클래스 예시

String은 final 클래스가 맞다. Math도 final 클래스지만 생성자까지 private이라 객체 생성을 막고 정적 유틸리티 기능을 제공한다.

Object까지 객체가 모두 만들어진다는 표현

부모 클래스마다 독립적인 객체가 여러 개 생성되는 것은 아니다.

하나의 자식 객체 내부에서 부모 클래스가 담당하는 상태가 포함되고, 생성자를 통해 부모 부분부터 자식 부분까지 순서대로 초기화된다.

모든 클래스는 패키지에 속한다는 표현

패키지 선언이 없는 클래스도 이름 없는 기본 패키지에 속한다고 볼 수 있다. 다만 실제 프로젝트에서는 기본 패키지를 권장하지 않는다.


면접이나 평가에서 자주 나오는 질문

오버로딩과 오버라이딩의 차이

오버로딩
- 같은 이름
- 다른 매개변수
- 컴파일 시점에 선택

오버라이딩
- 상속 관계
- 같은 메서드 선언
- 자식 클래스에서 재정의
- 실행 시 실제 객체 기준으로 선택

Java가 다중 상속을 지원하지 않는 이유

동일한 메서드를 가진 여러 부모 클래스를 동시에 상속할 때 어떤 구현을 사용할지 모호해질 수 있기 때문이다. Java는 클래스 단일 상속과 인터페이스 다중 구현을 채택했다.

private 메서드는 오버라이딩되는가?

아니다. private 메서드는 자식 클래스에서 보이지 않으므로 상속된 메서드를 재정의하는 오버라이딩이 성립하지 않는다. 자식 클래스에 같은 형태의 메서드를 선언해도 서로 별개의 메서드다.

static 메서드는 오버라이딩되는가?

인스턴스 메서드처럼 오버라이딩되지 않는다. 자식 클래스가 같은 형태의 정적 메서드를 선언하면 메서드 숨김이 발생한다.

생성자는 상속되는가?

상속되지 않는다. 다만 자식 객체 생성 시 super()를 통해 부모 생성자가 호출된다.

final 참조 변수는 불변 객체인가?

아니다. 참조 변수가 가리키는 대상을 바꿀 수 없다는 의미일 뿐, 참조 중인 객체의 내부 상태가 변경되지 않는다는 뜻은 아니다.

profile
넘어가 울타리 green green

0개의 댓글