앞선 글에서는 객체와 클래스, 필드, 메서드, 생성자를 이용해 하나의 객체를 설계하는 방법을 살펴보았다.
하지만 실제 프로그램에서는 여러 클래스가 완전히 독립적으로 존재하지 않는다. 자동차와 전기 자동차처럼 공통된 속성과 기능을 가지는 클래스도 있고, 자동차와 엔진처럼 하나의 객체가 다른 객체를 부품으로 포함하는 경우도 있다.
예를 들어 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 관계라고 표현한다.
Dog is an Animal.
강아지는 동물이다.
ElectricCar is a Car.
전기 자동차는 자동차다.
자식 객체를 부모 타입의 한 종류라고 자연스럽게 말할 수 있다면 상속 관계를 고려할 수 있다.
public class ElectricCar extends Car {
}
반대로 다음 관계는 자연스럽지 않다.
Engine is a Car.
엔진은 자동차다.
엔진은 자동차의 한 종류가 아니라 자동차가 가지고 있는 부품이다. 이런 관계는 상속이 아니라 포함 관계로 설계해야 한다.
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 {
}
여러 객체의 기능이 필요하다면 인터페이스와 포함 관계를 함께 활용할 수 있다.
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 관계가 성립하는지 먼저 생각해야 한다.
💡 상속보다 포함을 우선해서 고려하라는 설계 원칙을 “상속보다는 컴포지션을 선호하라”라고 표현하기도 한다.
부모 클래스에서 정의한 메서드가 자식 클래스의 상황에 맞지 않을 수 있다.
예를 들어 모든 동물이 소리를 낸다는 공통 기능은 있지만, 강아지와 고양이는 서로 다른 소리를 낸다.
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
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("멍멍");
}
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.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);
}
}
다만 부모와 자식 클래스에 같은 이름의 필드를 중복해서 선언하는 것은 혼란을 만들 수 있으므로 특별한 이유가 없다면 피하는 것이 좋다.
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()는 자식 생성자의 첫 번째 실행문으로만 작성할 수 있다.
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 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 메서드는 자식 클래스에서 오버라이딩할 수 없다.
public class Account {
final void printSecurityMessage() {
System.out.println("보안 기능을 실행합니다.");
}
}
public class SavingsAccount extends Account {
// 오버라이딩할 수 없음
// @Override
// void printSecurityMessage() {
// }
}
핵심 동작을 자식 클래스가 변경하지 못하도록 막을 때 사용할 수 있다.
final 클래스는 다른 클래스가 상속할 수 없다.
public final class Utility {
}
// 상속할 수 없음
// public class ChildUtility extends Utility {
// }
대표적인 final 클래스에는 String과 여러 불변 클래스가 있다.
다만 Math 클래스는 final 클래스이기도 하지만 생성자도 private으로 선언되어 객체 생성을 막고 정적 기능만 제공한다.
final 클래스는 모든 상속을 금지한다.
하지만 특정 클래스에게만 상속을 허용하고 그 외 클래스의 상속은 막고 싶을 수 있다. 이때 봉인 클래스인 sealed class를 사용할 수 있다.
public sealed class Payment
permits CardPayment, CashPayment {
}
Payment는 CardPayment와 CashPayment만 직접 상속할 수 있다.
허용된 자식 클래스는 반드시 다음 중 하나로 선언해야 한다.
finalsealednon-sealed더 이상의 상속을 허용하지 않는다.
public final class CashPayment extends Payment {
}
자신의 자식 클래스도 제한한다.
public sealed class CardPayment extends Payment
permits CreditCardPayment {
}
public final class CreditCardPayment
extends CardPayment {
}
부모 클래스에서 적용받은 봉인을 해제한다.
public non-sealed class CardPayment extends Payment {
}
이제 다른 클래스가 CardPayment를 자유롭게 상속할 수 있다.
public class CreditCardPayment extends CardPayment {
}
봉인 클래스는 상속 계층을 명확하게 제한할 수 있다.
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를 작성한다.
package com.example.order;
import com.example.member.Member;
public class Order {
Member member;
}
import를 사용하지 않으면 완전한 클래스 이름을 작성해야 한다.
public class Order {
com.example.member.Member member;
}
import는 패키지 선언과 클래스 선언 사이에 작성한다.
package com.example.order;
import com.example.member.Member;
import java.util.ArrayList;
import java.util.List;
public class Order {
}
패키지 선언은 하나만 가능하지만 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;
}
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 class Member {
public void introduce() {
System.out.println("회원입니다.");
}
}
다른 패키지에서도 import한 뒤 사용할 수 있다.
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;
// 접근 불가
이처럼 데이터를 외부에 직접 노출하지 않고 메서드를 통해 제어하는 것을 캡슐화라고 한다.
접근 제한자를 생략하면 같은 패키지 내부에서만 접근할 수 있다.
class PackageMember {
void print() {
System.out.println("같은 패키지에서 사용 가능");
}
}
패키지 내부에서만 사용하는 클래스나 기능에 적합하다.
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 | 함수형 인터페이스임을 검증 |
더 이상 사용을 권장하지 않는 클래스나 메서드에 사용한다.
public class LegacyService {
@Deprecated
public void oldMethod() {
System.out.println("이전 방식");
}
public void newMethod() {
System.out.println("새로운 방식");
}
}
기존 코드와의 호환성을 위해 메서드는 남겨두되 새로운 코드에서는 사용하지 않도록 안내할 수 있다.
특정 경고를 의도적으로 무시할 때 사용한다.
@SuppressWarnings("deprecation")
public void useLegacyMethod() {
LegacyService service = new LegacyService();
service.oldMethod();
}
경고가 불편하다는 이유로 무조건 사용하는 것이 아니라, 경고가 발생하는 원인을 이해하고 의도적으로 무시해도 안전할 때만 사용해야 한다.
지금까지 배운 내용을 이용해 회원 등급을 표현해보자.
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으로 보호하고 필요한 기능만 메서드로 제공한다.
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 필드와 클래스자식 객체를 생성한다고 해서 부모 객체와 자식 객체가 완전히 별개로 두 개 생성되는 것은 아니다.
Dog dog = new Dog();
하나의 Dog 객체 안에 Animal로부터 물려받은 부분과 Dog가 직접 선언한 부분이 함께 존재한다.
다만 초기화 순서는 부모 클래스부터 자식 클래스 순서로 진행된다.
상속받았다고 해서 부모 클래스의 모든 멤버에 직접 접근할 수 있는 것은 아니다.
private String name;
private 멤버는 부모 클래스 내부에서만 접근할 수 있다.
자식 클래스가 사용해야 한다면 부모 클래스가 protected 또는 public 메서드를 제공해야 한다.
this(...)
같은 클래스의 다른 생성자를 호출한다.
super(...)
부모 클래스의 생성자를 호출한다.
둘 다 생성자의 첫 번째 실행문에만 작성할 수 있으므로 동시에 직접 호출할 수 없다.
super는 별도로 생성된 부모 객체를 가리키는 변수가 아니다.
현재 자식 객체 안에 포함된 부모 클래스 부분의 멤버에 접근하기 위한 키워드다.
필드에 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 상속을 정리했다.
extends 키워드로 클래스 상속 관계를 만든다.is-a 관계가 성립할 때 사용한다.has-a 관계다.Object 클래스를 상속한다.@Override를 작성하는 것이 좋다.super는 부모 클래스의 멤버에 접근할 때 사용한다.super()는 부모 생성자를 호출하며 자식 생성자의 첫 번째 줄에 작성한다.super()를 삽입한다.final 변수는 값을 다시 변경할 수 없다.final 메서드는 오버라이딩할 수 없다.final 클래스는 상속할 수 없다.static final은 모든 객체가 공유하는 상수를 표현할 때 사용한다.final, sealed, non-sealed 중 하나로 선언해야 한다.import를 사용할 수 있다.java.lang 패키지는 자동으로 import된다.private은 클래스 내부, default는 같은 패키지, protected는 같은 패키지와 상속 관계, public은 모든 위치에서 접근할 수 있다.private으로 보호하고 메서드를 통해 사용하도록 만드는 것이 캡슐화의 기본이다.다음 글에서는 상속 관계를 활용하는 핵심 개념인 다형성을 다룬다.
Java 객체지향 ⑤ 다형성
- 자동 타입 변환
- 부모 타입과 자식 객체
- 메서드 오버라이딩과 동적 바인딩
- 강제 타입 변환
- instanceof
- instanceof 패턴 매칭
- 매개변수의 다형성
- 배열과 다형성
상속이 클래스 사이의 관계를 만드는 기능이라면, 다형성은 그 관계를 실제 프로그램에서 유연하게 사용하는 방법이라고 볼 수 있다.
이번 글은 사용자가 지정한 기준에 따라 Java 시리즈의 네 번째 글로 구성했다.
Java 객체지향 ① 변수와 타입 변환
Java 객체지향 ② 제어문과 배열
Java 객체지향 ③ 객체와 클래스
Java 객체지향 ④ 상속과 오버라이딩
다만 앞선 글 제목에 이미 다른 번호가 붙어 있다면 실제 게시 시리즈 이름에 맞춰 숫자만 조정하면 된다.
메모의 “별도의 extends 선언이 없는 클래스는 extends Object가 생략된다”는 설명은 학습 관점에서 맞다. 다만 인터페이스는 클래스를 상속하지 않으며 Object를 직접 상속한다고 표현하지 않는다.
메모에는 “리턴 타입이 같아야 한다”고 적혀 있었지만, 정확히는 다음과 같다.
메모에는 sealed가 선언된 클래스는 permits가 반드시 필요하다고 되어 있지만 예외가 있다.
허용된 자식 클래스가 같은 컴파일 단위, 즉 같은 .java 파일에 함께 선언되어 있으면 permits를 생략할 수 있다.
메모의 seald는 sealed로 수정했다.
String은 final 클래스가 맞다. Math도 final 클래스지만 생성자까지 private이라 객체 생성을 막고 정적 유틸리티 기능을 제공한다.
부모 클래스마다 독립적인 객체가 여러 개 생성되는 것은 아니다.
하나의 자식 객체 내부에서 부모 클래스가 담당하는 상태가 포함되고, 생성자를 통해 부모 부분부터 자식 부분까지 순서대로 초기화된다.
패키지 선언이 없는 클래스도 이름 없는 기본 패키지에 속한다고 볼 수 있다. 다만 실제 프로젝트에서는 기본 패키지를 권장하지 않는다.
오버로딩
- 같은 이름
- 다른 매개변수
- 컴파일 시점에 선택
오버라이딩
- 상속 관계
- 같은 메서드 선언
- 자식 클래스에서 재정의
- 실행 시 실제 객체 기준으로 선택
동일한 메서드를 가진 여러 부모 클래스를 동시에 상속할 때 어떤 구현을 사용할지 모호해질 수 있기 때문이다. Java는 클래스 단일 상속과 인터페이스 다중 구현을 채택했다.
아니다. private 메서드는 자식 클래스에서 보이지 않으므로 상속된 메서드를 재정의하는 오버라이딩이 성립하지 않는다. 자식 클래스에 같은 형태의 메서드를 선언해도 서로 별개의 메서드다.
인스턴스 메서드처럼 오버라이딩되지 않는다. 자식 클래스가 같은 형태의 정적 메서드를 선언하면 메서드 숨김이 발생한다.
상속되지 않는다. 다만 자식 객체 생성 시 super()를 통해 부모 생성자가 호출된다.
아니다. 참조 변수가 가리키는 대상을 바꿀 수 없다는 의미일 뿐, 참조 중인 객체의 내부 상태가 변경되지 않는다는 뜻은 아니다.