자바에서 클래스가 많아지면 비슷한 코드가 반복되는 순간이 생깁니다. 예를 들어 온도 센서와 습도 센서가 모두 장비 이름을 가지고 있고, 연결 상태를 출력해야 한다고 생각해보겠습니다.
이때 공통 기능을 매번 다시 작성하면 코드가 길어지고 수정도 불편해집니다. 자바의 상속은 이런 공통 부분을 부모 클래스에 두고, 자식 클래스가 이어받아 사용할 수 있게 해줍니다.
상속은 코드를 줄이기 위한 문법이기도 하지만, 더 중요한 목적은 공통 개념과 구체적인 개념을 나누는 것입니다.
상속이 필요한 상황
먼저 상속을 사용하지 않는 코드를 보겠습니다.
public class TemperatureSensor {
private String name;
public TemperatureSensor(String name) {
this.name = name;
}
public void connect() {
System.out.println(name + " 연결");
}
public void measureTemperature() {
System.out.println(name + " 온도 측정");
}
}
습도 센서도 비슷하게 만들 수 있습니다.
public class HumiditySensor {
private String name;
public HumiditySensor(String name) {
this.name = name;
}
public void connect() {
System.out.println(name + " 연결");
}
public void measureHumidity() {
System.out.println(name + " 습도 측정");
}
}
두 클래스는 측정 기능은 다르지만, 장비 이름과 연결 기능은 같습니다. 이런 공통 코드를 계속 복사하면 문제가 생깁니다.
나중에 연결 메시지를 바꾸고 싶다면 여러 클래스를 모두 수정해야 합니다. 공통 기능이 많아질수록 중복도 늘어납니다.
이럴 때 상속을 사용할 수 있습니다.
부모 클래스 만들기
공통 기능을 Sensor라는 부모 클래스로 옮겨보겠습니다.
public class Sensor {
private String name;
public Sensor(String name) {
this.name = name;
}
public void connect() {
System.out.println(name + " 연결");
}
public String getName() {
return name;
}
}
Sensor는 모든 센서가 공통으로 가지는 값을 담당합니다.
여기서는 이름과 연결 기능을 가지고 있습니다.
이제 온도 센서와 습도 센서는 이 클래스를 이어받을 수 있습니다.
extends로 상속하기
자바에서 상속은 extends 키워드로 표현합니다.
public class TemperatureSensor extends Sensor {
public TemperatureSensor(String name) {
super(name);
}
public void measureTemperature() {
System.out.println(getName() + " 온도 측정");
}
}
TemperatureSensor extends Sensor는 아래처럼 읽을 수 있습니다.
TemperatureSensor는 Sensor를 상속합니다.
습도 센서도 같은 방식으로 만들 수 있습니다.
public class HumiditySensor extends Sensor {
public HumiditySensor(String name) {
super(name);
}
public void measureHumidity() {
System.out.println(getName() + " 습도 측정");
}
}
이제 두 자식 클래스는 Sensor의 connect()와 getName()을 사용할 수 있습니다.
public class SensorMain {
public static void main(String[] args) {
TemperatureSensor temperatureSensor = new TemperatureSensor("온도 센서");
HumiditySensor humiditySensor = new HumiditySensor("습도 센서");
temperatureSensor.connect();
temperatureSensor.measureTemperature();
humiditySensor.connect();
humiditySensor.measureHumidity();
}
}
실행 결과입니다.
온도 센서 연결
온도 센서 온도 측정
습도 센서 연결
습도 센서 습도 측정
자식 클래스에는 connect()를 직접 작성하지 않았지만 사용할 수 있습니다.
부모 클래스에서 물려받았기 때문입니다.
상속 관계를 어떻게 이해할까?
상속은 “자식이 부모의 기능을 물려받는다”라고 생각하면 쉽습니다. 하지만 코드를 설계할 때는 더 일반적인 개념과 더 구체적인 개념으로 나누어 보는 편이 좋습니다.
Sensor
├─ TemperatureSensor
└─ HumiditySensor
Sensor는 더 일반적인 개념입니다.
온도 센서와 습도 센서는 센서의 한 종류입니다.
이 관계가 자연스러울 때 상속을 사용하기 좋습니다.
온도 센서는 센서입니다.
습도 센서는 센서입니다.
반대로 “사용한다” 관계라면 상속보다 다른 방식이 더 적절할 수 있습니다.
센서는 로그 기록기를 사용합니다.
이 문장은 “센서는 로그 기록기입니다”가 아닙니다. 이런 경우에는 상속보다 필드로 다른 객체를 가지는 구조가 더 자연스럽습니다.
자바는 단일 상속만 지원합니다
자바 클래스는 부모 클래스를 하나만 상속할 수 있습니다.
public class TemperatureSensor extends Sensor {
}
이렇게 하나의 부모 클래스만 지정할 수 있습니다.
아래처럼 두 클래스를 동시에 상속하는 것은 불가능합니다.
// public class SmartSensor extends Sensor, Logger {
// }
여러 부모 클래스를 허용하면 같은 이름의 메서드가 있을 때 어떤 것을 사용해야 할지 애매해질 수 있습니다. 그래서 자바는 클래스 다중 상속을 허용하지 않습니다.
다만 인터페이스는 여러 개 구현할 수 있습니다. 인터페이스는 이후에 따로 정리하는 편이 좋습니다.
부모 기능을 추가하면 자식도 사용할 수 있습니다
상속의 장점은 공통 기능을 부모 클래스에 추가했을 때 자식 클래스들도 함께 사용할 수 있다는 점입니다.
예를 들어 모든 센서에 상태 출력 기능을 추가해보겠습니다.
public class Sensor {
private String name;
public Sensor(String name) {
this.name = name;
}
public void connect() {
System.out.println(name + " 연결");
}
public void printStatus() {
System.out.println(name + " 정상 작동");
}
public String getName() {
return name;
}
}
printStatus()는 부모 클래스에만 작성했습니다.
하지만 자식 클래스에서도 사용할 수 있습니다.
public class SensorMain {
public static void main(String[] args) {
TemperatureSensor temperatureSensor = new TemperatureSensor("온도 센서");
temperatureSensor.connect();
temperatureSensor.printStatus();
}
}
실행 결과입니다.
온도 센서 연결
온도 센서 정상 작동
공통 기능을 한 곳에서 관리할 수 있기 때문에 수정 범위가 줄어듭니다.
메서드 오버라이딩
부모 클래스의 메서드를 자식 클래스에서 다시 정의할 수 있습니다. 이것을 메서드 오버라이딩이라고 합니다.
예를 들어 모든 센서가 연결된다는 점은 같지만, 온도 센서는 예열 과정이 필요하다고 해보겠습니다.
public class TemperatureSensor extends Sensor {
public TemperatureSensor(String name) {
super(name);
}
@Override
public void connect() {
System.out.println(getName() + " 예열 후 연결");
}
public void measureTemperature() {
System.out.println(getName() + " 온도 측정");
}
}
TemperatureSensor는 부모의 connect()를 그대로 쓰지 않고, 자기 상황에 맞게 다시 정의했습니다.
public class SensorMain {
public static void main(String[] args) {
TemperatureSensor temperatureSensor = new TemperatureSensor("온도 센서");
HumiditySensor humiditySensor = new HumiditySensor("습도 센서");
temperatureSensor.connect();
humiditySensor.connect();
}
}
실행 결과입니다.
온도 센서 예열 후 연결
습도 센서 연결
온도 센서는 오버라이딩한 메서드가 실행됩니다. 습도 센서는 부모의 메서드를 그대로 사용합니다.
@Override를 붙이는 이유
오버라이딩할 때는 @Override를 붙이는 것이 좋습니다.
@Override
public void connect() {
System.out.println(getName() + " 예열 후 연결");
}
@Override는 “이 메서드는 부모 메서드를 다시 정의한 것입니다”라는 표시입니다.
만약 부모 클래스에 같은 메서드가 없거나, 메서드 이름이나 매개변수가 틀리면 컴파일 오류로 알려줍니다.
예를 들어 아래처럼 이름을 잘못 쓰면 오버라이딩이 아닙니다.
@Override
public void conect() {
System.out.println("연결");
}
connect가 아니라 conect라고 잘못 썼기 때문에 컴파일 오류가 납니다.
이 오류는 오히려 좋습니다.
실행하기 전에 실수를 잡아주기 때문입니다.
오버로딩과 오버라이딩 차이
이름이 비슷해서 처음에는 헷갈리기 쉽습니다.
| 구분 | 의미 | 조건 |
|---|---|---|
| 오버로딩 | 같은 이름의 메서드를 여러 개 만드는 것 | 매개변수가 달라야 합니다. |
| 오버라이딩 | 부모 메서드를 자식이 다시 정의하는 것 | 상속 관계가 있어야 합니다. |
오버로딩 예시입니다.
public void print(int value) {
System.out.println(value);
}
public void print(String value) {
System.out.println(value);
}
오버라이딩 예시입니다.
public class TemperatureSensor extends Sensor {
@Override
public void connect() {
System.out.println(getName() + " 예열 후 연결");
}
}
오버로딩은 같은 클래스 안에서도 가능합니다. 오버라이딩은 부모와 자식의 상속 관계에서 일어납니다.
super로 부모 기능 호출하기
오버라이딩을 하더라도 부모의 기능을 함께 사용하고 싶을 수 있습니다.
이때 super를 사용합니다.
public class TemperatureSensor extends Sensor {
public TemperatureSensor(String name) {
super(name);
}
@Override
public void connect() {
super.connect();
System.out.println(getName() + " 예열 완료");
}
}
super.connect()는 부모 클래스의 connect()를 호출합니다.
그 다음 자식 클래스만의 동작을 추가합니다.
public class SensorMain {
public static void main(String[] args) {
TemperatureSensor sensor = new TemperatureSensor("온도 센서");
sensor.connect();
}
}
실행 결과입니다.
온도 센서 연결
온도 센서 예열 완료
super는 부모의 필드나 메서드에 접근할 때 사용합니다.
다만 부모의 private 필드에는 직접 접근할 수 없습니다.
필요하다면 부모가 제공하는 protected 또는 public 메서드를 사용해야 합니다.
상속과 접근 제어자
상속을 배울 때 접근 제어자도 함께 봐야 합니다. 부모 클래스의 모든 것이 자식에게 무조건 열린다고 생각하면 안 됩니다.
| 접근 제어자 | 자식 클래스에서 접근 |
|---|---|
private | 직접 접근할 수 없습니다. |
default | 같은 패키지라면 접근할 수 있습니다. |
protected | 상속 관계에서 접근할 수 있습니다. |
public | 어디서든 접근할 수 있습니다. |
부모 클래스의 필드가 private이면 자식도 직접 접근할 수 없습니다.
public class Sensor {
private String name;
public Sensor(String name) {
this.name = name;
}
}
아래 코드는 사용할 수 없습니다.
public class TemperatureSensor extends Sensor {
public TemperatureSensor(String name) {
super(name);
}
public void printName() {
// System.out.println(name); // 컴파일 오류
}
}
부모의 값을 자식에서 사용해야 한다면, 부모가 메서드로 열어주는 방식이 더 안전합니다.
public class Sensor {
private String name;
public Sensor(String name) {
this.name = name;
}
protected String getName() {
return name;
}
}
protected는 상속 관계에서 사용할 수 있습니다.
다만 처음에는 필드를 직접 열기보다 필요한 메서드를 제공하는 방식이 더 이해하기 쉽습니다.
접근 제어자가 헷갈린다면 자바 접근 제어자 정리 - private부터 public까지를 먼저 보는 편이 좋습니다.
생성자와 super
상속 관계에서 자식 객체를 만들면 부모 부분도 함께 만들어집니다. 그래서 자식 생성자는 부모 생성자를 호출해야 합니다.
public class Sensor {
private String name;
public Sensor(String name) {
this.name = name;
System.out.println("Sensor 생성");
}
}
public class TemperatureSensor extends Sensor {
public TemperatureSensor(String name) {
super(name);
System.out.println("TemperatureSensor 생성");
}
}
실행해보겠습니다.
public class ConstructorMain {
public static void main(String[] args) {
TemperatureSensor sensor = new TemperatureSensor("온도 센서");
}
}
실행 결과입니다.
Sensor 생성
TemperatureSensor 생성
자식 객체를 만들었지만 부모 생성자가 먼저 실행됩니다. 부모 부분이 먼저 준비되어야 자식 부분도 안전하게 만들어질 수 있기 때문입니다.
자식 생성자 첫 줄에는 super(...) 또는 this(...)가 와야 합니다.
부모의 기본 생성자가 있다면 super()는 생략할 수 있습니다.
하지만 부모 생성자에 매개변수가 필요하다면 직접 super(name)처럼 호출해야 합니다.
final과 상속
final은 상속에서도 중요한 역할을 합니다.
클래스에 final을 붙이면 더 이상 상속할 수 없습니다.
public final class SystemConfig {
}
아래 코드는 사용할 수 없습니다.
// public class CustomConfig extends SystemConfig {
// }
메서드에 final을 붙이면 자식 클래스가 오버라이딩할 수 없습니다.
public class Sensor {
public final void checkSerialNumber() {
System.out.println("시리얼 번호 확인");
}
}
public class TemperatureSensor extends Sensor {
// public void checkSerialNumber() { } // 컴파일 오류
}
바뀌면 안 되는 클래스 구조나 메서드 동작이 있다면 final로 막을 수 있습니다.
final 자체가 아직 헷갈린다면 자바 final 정리 - 한 번 정한 값을 바꾸지 않기를 이어서 보면 좋습니다.
상속을 사용할 때 조심할 점
상속은 편리하지만 항상 정답은 아닙니다. 공통 코드가 있다고 해서 무조건 상속을 쓰면 구조가 어색해질 수 있습니다.
상속을 고려할 때는 아래 문장이 자연스러운지 확인하면 좋습니다.
자식은 부모의 한 종류입니다.
아래 문장은 자연스럽습니다.
온도 센서는 센서의 한 종류입니다.
하지만 아래 문장은 어색합니다.
센서는 로그 기록기의 한 종류입니다.
이 경우에는 상속보다 센서가 로그 기록기를 필드로 가지고 사용하는 방식이 더 자연스럽습니다.
상속은 부모와 자식이 강하게 묶이는 구조입니다. 그래서 공통 기능을 재사용하려는 이유만으로 너무 쉽게 사용하면 나중에 수정이 어려워질 수 있습니다.
정리
상속은 부모 클래스의 필드와 메서드를 자식 클래스가 이어받아 사용할 수 있게 하는 문법입니다.
자바에서는 extends를 사용해 상속 관계를 만듭니다.
자식 클래스는 부모의 기능을 사용할 수 있고, 필요한 경우 부모 메서드를 오버라이딩해 자기 방식으로 다시 정의할 수 있습니다.
상속을 사용할 때는 super, 생성자 호출 순서, 접근 제어자도 함께 이해해야 합니다.
부모의 private 필드는 자식도 직접 접근할 수 없고, 부모 생성자는 자식 생성자보다 먼저 실행됩니다.
처음에는 아래 기준으로 기억하면 좋습니다.
공통 기능은 부모 클래스
구체적인 기능은 자식 클래스
부모 기능을 바꾸고 싶으면 오버라이딩
부모 기능도 함께 쓰고 싶으면 super
상속은 객체 지향의 중요한 도구입니다. 하지만 무조건 쓰는 문법이 아니라, “자식이 부모의 한 종류인가?”라는 기준이 자연스러울 때 사용하는 편이 좋습니다.