'김영한의 실전 자바 - 기본편' 강의를 들으면서 복습할만한 내용을 정리하였다.

1. 클래스와 데이터

1.1 클래스 도입

사람이 관리하기 좋은 방식으로 어떠한 개념을 하나로 묶는 것이다.

  • 예) Student 클래스
public class Student {
	String name;
    int age;
 	int grade;
}

클래스에 정의된 변수들(name, age, grede)을 변수, 또는 필드라 한다.

  • 멤버 변수(Member Variable) : 이 변수들은 특정 클래스에 소속된 멤버이기 때문에 이렇게 부른다.

  • 필드(Field) : 데이터 항목을 가리키는 전통적인 용어이다.

  • 자바에서 멤버 변수, 필드는 같은 뜻이다. 클래스에 소속된 변수를 뜻한다.

클래스란 설계도이다.

클래스를 사용하면 int, String 과 같은 타입을 직접 만들 수 있다.(Student)

사용자가 직접 정의하는 사용자 정의 타입을 만들려면 설계도가 필요하다.

설계도가 바로 클래스 이다.

설계도인 클래스를 사용해서 실제 메모리에 만들어진 실체를 객체 또는 인스턴스라 한다.

1.2 클래스 생성

public class ClassStart3 {
	public static void main(String[] args) {
		Student student1;
      	student1 = new Student();
     	student1.name = "학생1";
     	student1.age = 15;
     	student1.grade = 90;
     
     	Student student2 = new Student();
     	student2.name = "학생2";
     	student2.age = 16;
     	student2.grade = 80;
     
     	System.out.println("이름:" + student1.name + " 나이:" + student1.age + " 성적:" + student1.grade);
     	System.out.println("이름:" + student2.name + " 나이:" + student2.age + " 성적:" + student2.grade);
    }
}

1.2.1 변수 선언

  • Student student1
    • Student 타입을 받을 수 있는 변수를 선언한다.
    • int는 정수를, String은 문자를 담을 수 있듯이 StudentStudent 타입의 객체(인스턴스)를 받을 수 있다.

1.2.2 객체 생성

  • student = new Student()
    • 객체를 사용하려면 먼저 설계도인 클래스를 기반으로 객체(인스턴스)를 생성해야 한다.
    • new Student() : new 는 새로 생성한다는 뜻이다. new Student()Student 클래스 정보를 기반으로 새로운 객체를 생성하라는 뜻이다. 이렇게 하면 메모리에 실제 Student 객체(인스턴스)를 생성한다.
    • 객체를 생성할 때는 new 클래스명() 을 사용하면 된다. 마지막에 () 도 추가해야 한다.
    • Sudent 클래스는 String name, int age, int grade 멤버 변수를 가지고 있다. 이 변수를 사용하는데 필요한 메모리 공간도 함께 화보한다.

1.2.3 참조값 보관

  • 객체를 생성하면 자바는 메모리에 어딘가에 있는 이 객체에 접근할 수 있는 참조값(주소)(x001)을 반환한다.
  • new 키워드를 통해 객체를 생성되고 나면 참조값을 반환한다. 앞서 선언한 변수인 Student student1에 생성된 객체의 참조값(x001)을 보관한다.
  • Student student1 변수는 이제 메모리에 존재하는 실제 Student 객체(인스턴스)의 참조값을 가지고 있다.
    • student1 변수는 방금 만든 객체에 접근할 수 있는 참조값을 가지고 있다. 따라서 이 변수를 통해 객체를 접근(참조)할 수 있다. 쉽게 이야기해서 student1 변수를 통해 메모리에 있는 실제 객체를 접근하고 사용할 수 있다.

참조값을 변수에 보관해야 하는 이유

객체를 생성하는 new Student() 코드 자체에는 아무런 이름이 없다. 이 코드는 단순히 Student 클래스(설계도)를 기반으로 메모리에 실제 객체를 만드는 것이다. 따라서 생성한 객체에 접근할 수 있는 방법이 필요하다. 이런 이유로 객체를 생성할 때 반환되는 참조값을 어딘가에 보관해두어야 한다. 앞서 Student student1 변수에 참조값(x001)을 저장해두었으므로 저장한 참조값을 통해서 실제 메모리에 존재하는 객체에 접근할 수 있다.

1.3 클래스, 객체, 인스턴스 정리

1.3.1 클래스 - Class

클래스는 객체를 생성하기 위한 또는 설계도 이다. 클래스는 객체가 가져야 할 속성(변수)과 기능(메서드)를 정의한다. 예를 들어 학생이라는 클래스는 속성으로 name, age, grade 를 가진다.

1.3.2 객체 - Object

객체는 클래스에서 정의한 속성과 기능을 가진 실체이다. 객체는 서로 독립적인 상태를 가진다.

예를 들어 위 코드에서 student1 은 학생1의 속성을 가지는 객체이고, student2 는 학생2의 속성을 가지는 객체이다. student1student2 는 같은 클래스에서 만들어졌지만, 서로 다른 객체이다.

1.3.3 인스턴스 - Instance

인스턴스는 특정 클래스로부터 생성된 객체를 의미한다. 그래서 객체와 인스턴스라는 용어는 자주 혼용된다. 인스턴스는 주로 객체가 어떤 클래스에 속해 있는지 강조할 때 사용한다. 예를 들어서 student1 객체는 Student 클래스의 인스턴스다. 라고 표현한다.

객체 vs 인스턴스

둘다 클래스에서 나온 실체라는 의미에서 비슷하게 사용되지만, 용어상 인스턴스는 객체보다 좀 더 관계에 초점을 맞춘 단어이다. 보통 studentStudent 의 객체이다. 라고 말하는 대신 studentStudent 의 인스턴스이다. 라고 특정 클래스와의 관계를 명확히 할 때 인스턴스라는 용어를 주로 사용한다.

1.4 자바에서 대입은 항상 변수에 들어 있는 값을 복사해서 전달한다.

students[0] = student1;
students[1] = student2;
//자바에서 대입은 항상 변수에 들어 있는 값을 복사한다.
students[0] = x001;
students[1] = x002;

자바에서 변수의 대입(=)은 모두 변수에 들어있는 값을 복사해서 전달하는 것이다. 이 경우 오른쪽 변수인 student1, student2 에는 참조값이 들어있다. 그래서 이 값을 복사해서 왼쪽에 있는 배열에 전달한다. 따라서 기존 student1, student2 에 들어있던 참조값은 당연히 그대로 유지된다.

주의!

변수에는 인스턴스 자체가 들어있는 것이 아니다! 인스턴스의 위치를 가리키는 참조값이 들어있을 뿐이다! 따라서 대입(=)시에 인스턴스가 복사되는 것이 아니라 참조값만 복사된다.

2. 기본형과 참조형

2.1 기본형 vs 참조형

자바에서 참조형을 제대로 이해하는 것은 정말 중요하다.

사용하는 값을 변수에 직접 넣을 수 있는 기본형, 객체가 지정된 메모리의 위치를 가리키는 참조값을 넣을 수 있는 참조형으로 분류할 수 있다.

  • 기본형(Primitive Type) : int, long, double, boolean 처럼 변수에 사용할 값을 직접 넣을 수 있는 데이터 타입을 기본형이라 한다.

  • 참조형(Reference Type) : Student student, int[] students 와 같이 데이터에 접근하기 위한 참조(주소)를 저장하는 데이터 타입을 참조형이라 한다. 참조형은 객체 또는 배열에 사용된다.

쉽게 이야기해서 기본형 변수에는 직접 사용할 수 있는 값이 들어있지만 참조형 변수에는 위치(참조값)가 들어가 있다. 참조형 변수를 통해서 뭔가 하려면 결국 참조값을 통해 해당 위치로 이동해야 한다.

쉽게 이해하는 팁

기본형을 제외한 나머지는 모두 참조형이다.

  • 기본형은 소문자로 시작한다. int, long, double, boolean 모두 소문자로 시작한다.
    • 기본형은 자바가 기본으로 제공하는 데이터 타입이다. 이러한 기본형은 개발자가 새로 정의할 수 없다. 개발자는 참조형인 클래스만 직접 정의할 수 있다.
  • 클래스는 대문자로 시작한다. Student
    • 클래스는 모두 참조형이다.

참고 - String

자바에서 String 은 특별하다. String 은 사실은 클래스다. 따라서 참조형이다. 그런데 기본형처럼 문자 값을 바로 대입할 수 있다. 문자는 매우 자주 다루기 때문에 자바에서 특별하게 편의 기능을 제공한다.

대원칙 : 자바는 항상 변수의 값을 복사해서 대입한다.

자바에서 변수에 값을 대입하는 것은 변수에 들어있는 값을 복사해서 대입하는 것이다.

기본형, 참조형 모두 항상 변수에 있는 값을 복사해서 대입한다. 기본형이면 변수에 들어있는 실제 사용하는 값을 복사해서 대입하고, 참조형이면 변수에 들어있는 참조값을 복사해서 대입한다.

참조형의 경우 실제 사용하는 객체가 아니라 객체의 위치를 가리키는 참조값만 복사된다. 쉽게 이야기해서 실제 건물이 복사가 되는 것이 아니라 건물의 위치인 주소만 복사되는 것이다. 따라서 같은 건물을 찾아갈 수 있는 방법이 하나 늘어날 뿐이다.

2.2 참조형과 메서드 호출

package ref;
public class Method2 {
 		public static void main(String[] args) {
 		Student student1 = createStudent("학생1", 15, 90);
 		Student student2 = createStudent("학생2", 16, 80);
 		printStudent(student1);
 		printStudent(student2);
 	}
    
 	static Student createStudent(String name, int age, int grade) {
 		Student student = new Student();
 		student.name = name;
 		student.age = age;
 		student.grade = grade;
 		return student;
 	}
    
 	static void printStudent(Student student1) {
 		System.out.println("이름:" + student1.name + " 나이:" + student1.age + " 성적:" + student1.grade);
 	}
}

메서드 내부에서 인스턴스를 생성한 후에 참조값을 메서드 외부로 반환했다. 이 참조값만 있으면 해당 인스턴스에 접근할 수 있다. 여기서는 student1 에 참조값을 보관하고 사용한다.

참조형은 메서드를 호출할 때 참조값을 전달한다. 따라서 메서드 내부에서 전달된 참조값을 통해 객체의 값을 변경하거나, 값을 읽어서 사용할 수 있다.

2.3 null

참조형 변수에는 항상 객체가 있는 위치를 가리키는 참조값이 들어간다. 그런데 아직 가리키는 대상이 없거나, 가리키는 대상을 나중에 입력하고 싶다면 어떻게 해야할까?

참조형 변수에는 아직 가리키는 대상이 없다면 null 이라는 특별한 값을 넣어둘 수 있다. null은 값이 존재하지 않는, 없다는 뜻이다.

멤버 변수에서의 참조형의 값 초기화는 참조형 = null 로 자동 초기화해준다.

GC - 아무도 참조하지 않는 인스턴스의 최후

datanull을 할당했다. 따라서 앞서 생성한 x001 Data 인스턴스를 더는 아무도 참조하지 않는다. 이렇게 아무도 참조하지 않게 되면 x001이라는 참조값을 다시 구할 방법이 없다. 따라서 해당 인스턴스에 다시 접근할 방법이 없다.

이렇게 아무도 참조하지 않는 인스턴스는 사용되지 않고 메모리 용량만 차지할 뿐이다.

C와 같은 과거 프로그래밍 언어는 개발자가 직접 명령어를 사용해서 인스턴스를 메모리에서 제거해야 했다. 만약 실수로 인스턴스 삭제를 누락하면 메모리에 사용하지 않는 객체가 가득해져서 메모리 부족 오류가 발생하게 된다.

자바는 이런 과정을 자동으로 처리해준다. 아무도 참조하지 않는 인스턴스가 있으면 JVMGC (가비지 컬렉션)가 더 이상 사용하지 않는 인스턴스라 판단하고 해당 인스턴스를 자동으로 메모리에서 제거해준다.

객체는 해당 객체를 참조하는 곳이 있으면, JVM 이 종료할 때 까지 계속 생존한다. 그런데 중간에 해당 객체를 참조하는 곳이 모두 사라지면 그때 JVM은 필요 없는 객체로 판단하고 GC를 사용해서 제거한다.

2.4 NullPointerException

만약 참조값이 없이 객체를 찾아가면 어떤 문제가 발생할까?

이 경우 NullPointerException 이라는 예외가 발생하는데, 개발자를 가장 많이 괴롭히는 예외이다.

NullPointerException은 이름 그래도 null 을 가리키나(Pointer)인데, 이때 발생하는 예외(Exception)다.

null은 없다는 뜻이므로 결국 주소가 없는 곳을 찾아갈 때 발생하는 예외이다.

객체를 참조할떄는.(dot)을 사용한다. 이렇게 하면 참조값을 사용해서 해당 객체를 찾아갈 수 있다. 그런데 참조값이 null 이라면 값이 없다는 뜻이므로, 찾아갈 수 있는 객체(인스턴스)가 없다. NullPointerException 은 이처럼 null.(dot)을 찍었을 때 발생한다.

NullPointerException이 발생하면 null 값에 . (dot)을 찍었따고 생각하면 문제를 쉽게 찾을 수 있다.

2.5 기본형과 참조형 정리

대원칙 : 자바는 항상 변수의 값을 복사해서 대입한다.

자바에서 변수에 값을 대입하는 것은 변수에 들어있는 값을 복사해서 대입하는 것이다.

기본형, 참조형 모두 항상 변수에 있는 값을 복사해서 대입한다. 기본형이면 변수에 들어있는 실제 사용하는 값을 복사해서 대입하고, 참조형이면 변수에 들어있는 참조값을 복사해서 대입한다.

기본형이든 참조형이든 변수의 값을 대입하는 방식은 같다. 하지만 기본형과 참조형에 따라 동작하는 방식이 달라진다.

기본형 vs 참조형 - 기본

  • 자바의 데이터 타입을 가장 크게 보면 기본형과 참조형으로 나눌 수 있다.

  • 기본형을 제외한 나머지 변수는 모두 참조형이다. 클래스와 배열을 다루는 변수는, 참조형이다.

  • 기본형 변수는 값을 직접 저장하지만, 참조형 변수는 참조(주소)를 저장한다.

  • 기본형 변수는 산술 연산을 수행할 수 있지만, 참조형 변수는 산술 연산을 수행할 수 없다.

  • 기본형 변수는 null 을 할당할 수 없지만, 참조형 변수는 null을 할당할 수 있다.

기본형 vs 참조형 - 대입

  • 기본형과 참조형 모두 대입시 변수 안에 있는 값을 읽고 복사해서 전달한다.

  • 기본형은 사용하는 값을 복사해서 전달하고, 참조형은 참조값을 복사해서 전달한다! 이것이 중요하다. 실제 인스터스가 복사되는 것이 아니다. 인스턴스를 가리키는 참조값을 복사해서 전달하는 것이다! 따라서 하나의 인스턴스를 여러곳에서 참조할 수 있다.

  • 헷갈리면 그냥 변수 안에 들어간 값을 떠올려보자. 기본형은 사용하는 값이, 참조형은 참조값이 들어있다! 변수에 어떤 값이 들어있든간에 그 값을 그대로 복사해서 전달한다.

기본형 vs 참조형 - 메서드 호출

  • 메서드 호출시 기본형은 메서드 내부에서 매개변수(파라미터)의 값을 변경해도 호출자의 변수 값에는 영향이 없다.

  • 메서드 호출시 참조형은 메서드 내부에서 매개변수(파라미터)로 전달된 객체의 멤버 변수를 번경하면, 호출자의 객체도 변경된다.

3. 객체 지향 프로그래밍

3.1 객체 지향 프로그래밍 vs 절차 지향 프로그래밍

객체 지향 프로그래밍과 절차 지향 프로그래밍은 서로 대치되는 개념이 아니다. 객체 지향이라도 프로그램의 작동 순서는 중요하다. 다만 어디에 더 초점을 맞추는가에 둘의 차이가 있다. 객체 지향의 경우 객체의 설계와 관계를 중시한다. 반면 절차 지향의 경우 데이터와 기능이 분리되어 있고, 프로그램이 어떻게 작동하는지 그 순서에 초점을 맞춘다.

절차 지향 프로그래밍

  • 절차 지향 프로그래밍은 이름 그대로 절차를 지향한다. 쉽게 이야기해서 실행 순서를 중요하게 생각하는 방식이다.

  • 절차 지향 프로그래밍은 프로그램의 흐름을 순차적으로 따르며 처리하는 방식이다. 즉, 어떻게 를 중심으로 프로그래밍 한다.

객체 지향 프로그래밍

  • 객체 지향 프로그래밍은 이름 그대로 객체를 지향한다. 쉽게 이야기해서 객체를 중요하게 생각하는 방식이다.

  • 객체 지향 프로그래밍은 실제 세계의 사물이나 사건을 객체로 보고, 이러한 객체들 간의 상호작용을 중심으로 프로그래밍하는 방식이다. 즉 무엇을 중심으로 프로그래밍 한다.

둘의 중요한 차이

  • 절차 지향은 데이터와 해당 데이터에 대한 처리 방식이 분리되어 있다. 반면 객체 지향에서는 데이터와 그 데이터에 대한 행동(메서드)이 하나의 객체 안에 함께 포함되어 있다.

3.2 객체란?

세상의 모든 사물을 단순하게 추상화해보면 속성(데이터)과 기능 딱 2가지로 설명할 수 있다.


자동차

  • 속성 : 차량 색상, 현재 속도
  • 기능 : 액셀, 브레이크, 문 열기, 문 닫기

동물

  • 속성 : 색상, 키, 온도
  • 기능 : 먹는다, 걷는다

게임 케릭터

  • 속성 : 레벨, 경험치, 소유한 아이템들
  • 기능 : 이동, 공격, 아이템 획득

객체 지향 프로그래밍은 모든 사물을 속성과 기능을 가진 객체로 생각하는 것이다. 객체에는 속성과 기능만 존재한다.

이렇게 단순화하면 세상에 있는 객체들을 컴퓨터 프로그램으로 쉽게 설계할 수 있다.

이런 장점 덕분에 지금은 객체 지향 프로그래밍이 가장 많이 사용된다. 참고로 실세계와 객체가 항상 1:1로 매칭되는 것은 아니다.

모듈화
객체의 각각의 기능을 메서드로 만들면 각각의 기능을 모듈화하였다고 한다.
쉽게 이야기해서 레고 블럭을 생각하면 된다. 필요한 블럭을 가져다 꼽아서 사용할 수 있다. 어떤 객체의 기능이 필요하면 해당 기능을 메서드 호출 만으로 손쉽게 사용할 수 있다.

3.3 객체 지향 프로그래밍

객체 지향 프로그래밍은 어떠한 개념을 객체로 온전히 만드는 것에 집중해야 한다. 그러기 위해서는 프로그램의 실행 순서보다는 객체로 표현하려는 클래스를 만드는 것 자체에 집중해야 한다. 객체로 표현하려는 개념이 어떤 속성(데이터)을 가지고 어떤 기능(메서드)을 제공하는지 이 부분에 초점을 맞추어야 한다. 쉽게 이야기 해서 어떠한 개념을 객체로 만들어서 제공하는 개발자와 그 객체를 사용하는 사용자가 분리되어 있다고 생각하면 된다.

public class MusicPlayer {
    int volume = 0;
    boolean isOn = false;
    void on() {
        isOn = true;
        System.out.println("음악 플레이어를 시작합니다");
    }
    void off() {
        isOn = false;
        System.out.println("음악 플레이어를 종료합니다");
    }
    void volumeUp() {
        volume++;
        System.out.println("음악 플레이어 볼륨:" + volume);
    }
    void volumeDown() {
        volume--;
        System.out.println("음악 플레이어 볼륨:" + volume);
    }
    void showStatus() {
        System.out.println("음악 플레이어 상태 확인");
        if (isOn) {
            System.out.println("음악 플레이어 ON, 볼륨:" + volume);
        } else {
 			System.out.println("음악 플레이어 OFF");
        }
    }
 }

MusicPlayer 클래스에 음악 플레이어에 필요한 속성과 기능을 모두 정의했다. 이제 음악 플레이어가 필요한 곳에서 이 클래스만 있으면 온전한 음악 플레이어를 생성해서 사용할 수 있다. 음악 플레이어를 사용하는데 필요한 모든 속성과 기능이 하나의 클래스에 포함되어 있다.

 public class MusicPlayerMain4 {
     public static void main(String[] args) {
     	MusicPlayer player = new MusicPlayer();
        //음악 플레이어 켜기
        player.on();
        //볼륨 증가
        player.volumeUp();
        //볼륨 증가
        player.volumeUp();
        //볼륨 감소
        player.volumeDown();
        //음악 플레이어 상태
        player.showStatus();
        //음악 플레이어 끄기
        player.off();
    }
 }

MusicPlayer 를 사용하는 코드를 보자. MusicPlayer 객체를 생성하고 필요한 기능(메서드)을 호출하기만 하면 된다. 필요한 모든 것은 MusicPlayer 안에 들어있다.

  • MusicPlayer 를 사용하는 입장에서는 MusicPlayer 의 데이터인 volume, iOn 같은 데이터는 전혀 사용하지 않는다.
  • MusicPlayer 를 사용하는 입장에서는 이제 MusicPlayer 내부에 어떤 속성(데이터)이 있는지 전혀 몰라도 된다. MusicPlayer 를 사용하는 입장에서는 단순하게 MusicPlayer 가 제공하는 기능 중에 필요한 기능을 호출해서 사용하기만 하면 된다.

캡슐화

MusicPlayer 를 보면 음악 플레이어를 구성하기 위한 속성과 기능이 마치 하나의 캡슐에 쌓여있는 것 같다. 이렇게 속성과 기능을 하나로 묶어서 필요한 기능을 메서드를 통해 외부에 제공하는 것을 캡술화라 한다.

객체 지향 프로그래밍 덕분에 음악 플레이어 객체를 사용하는 입장에서 진짜 음악 플레이어를 만들고 사용하는 것 처럼 친숙하게 느껴진다. 그래서 코드가 더 읽기 쉬운 것은 물론이고, 속성과 기능이 한 곳에 있기 때문에 변경도 더 쉬워진다. 예를 들어서 MusicPlayer 내부 코드가 변하는 경우에 다른 코드는 변경하지 않아도 된다. MusicPlayervolume 이라는 필드 이름이 다른 이름으로 변한다고 할 때 MusicPlayer 내부만 변경하면 된다. 또 음악 플레이어가 내부에서 출력하는 메시지를 변경할 때도 MusicPlayer 내부만 변경하면 된다. 이 경우 MusicPlayer 를 사용하는 사용자 입장에서는 코드를 전혀 변경하지 않아도 된다. 물론 외부에서 호출하는 MusicPlayer 의 메서드 이름을 변경한다면 MusicPlayer 를 사용하는 곳의 코드도 변경해야 한다.

4. 생성자

생성자는 객체 생성 직후 객체를 초기화 하기 위한 특별한 메서드로 생각할 수 있다.

4.1 생성자 - 필요한 이유

객체를 생성하는 시점에 어떤 작업을 하고 싶다면 생성자(Constructor)를 이용하면 된다. 어떠한 객체를 생성하고 나면 초기값을 설정해야 하는 경우가 있다. 객체 지향 프로그래밍에서는 속성과 기능을 한 곳에 두는 것이 더 나은 방법이다. 그러므로 객체 안에 객체의 초기값을 설정하는 생성자를 제공하는 것이 좋다.

4.2 this

4.2.1 this

this는 인스턴스 자신의 참조값을 가리킨다. 멤버 변수에 접근하려면 앞에 this. 붙여주면 된다.

4.2.2 this의 생략

this는 생략할 수 있다. 이 경우 변수를 찾을 때 가까운 지역변수(매개변수도 지역변수다)를 먼저 찾고 없으면 그 다음으로 멤버 변수를 찾는다. 멤버 변수도 없으면 오류가 발생한다.

4.3 생성자 - 도입

생성자와 메서드의 차이점

  • 생성자의 이름은 클래스 이름과 같아야 한다. 따라서 첫 글자도 대문자로 시작한다.
  • 생성자는 반환 타입이 없다. 비워두어야 한다.
  • 나머지는 메서드와 같다

4.3.1 생성자 호출

생성자는 인스턴스를 생성하고 나서 즉시 호출된다. 생성자를 호출하는 방법은 다음 코드와 같이 new 명령어 다음에 생성자 이름과 매개변수에 맞추어 인수를 전달하면 된다.

new 생성자이름(생성자에 맞는 인수 목록)
new 클래스이름(생성자에 맞는 인수 목록)

참고로 new 키워드를 사용해서 객체를 생성할 때 마지막에 괄호 () 도 포함해야 하는 이유가 바로 생성자 때문이다. 객체를 생성하면서 동시에 생성자를 호출한다는 의미를 포함한다.

4.3.2 생성자 장점

중복 호출 제거

생성자가 없던 시절에는 생성 직후에 어떤 작업을 수행하기 위해 메서드를 직접 한번 더 호출해야 했다. 생성자 덕분에 객체를 생성하면서 동시에 생성 직후에 필요한 작업을 한번에 처리할 수 있게 되었다.

제약 - 생성자 호출 필수

객체를 생성할 때 직접 정의한 생성자가 있다면 직접 정의한 생성자를 반드시 호출해야 한다는 점이다. 참고로 생성자를 메서드 오버로딩처럼 여러개 정의할 수 있는데, 이 경우에는 하나만 호출하면 된다.

생성자를 사용하면 필수값 입력을 보장할 수 있다.

좋은 프로그램은 무한한 자유도가 주어지는 프로그램이 아니라 적절한 제약이 있는 프로그램이다.

4.4 기본 생성자

4.4.1 기본 생성자

  • 매개변수가 없는 생성자를 기본 생성자라 한다.
  • 클래스에 생성자가 하나도 없으면 자바 컴파일러는 매개변수가 없고, 작동하는 코드가 없는 기본 생성자를 자동으로 만들어준다.
  • 생성자가 하나라도 있으면 자바는 기본 생성자를 만들지 않는다.

기본 생성자를 왜 자동으로 만들어줄까?

만약 자바에서 기본 생성자를 만들어주지 않는다면 생성자 기능이 필요하지 않는 경우에도 모든 클래스에 개발자가 직접 기본 생성자를 정의해야 한다. 생성자 기능을 사용하지 않는 경우도 많기 때문에 이런 편의 기능을 제공한다.

  • 생성자는 반드시 호출되어야 한다.
  • 생성자가 없으면 기본 생성자가 제공된다.
  • 생성자가 하나라도 있으면 자바는 기본 생성자가 제공되지 않는다. 이 경우 개발자가 정의한 생성자를 직접 호출해야 한다.

4.5 생성자 - 오버로딩과 this()

생성자도 메서드 오버로딩처럼 매개변수만 다르게 해서 여러 생성자를 제공할 수 있다.

public MemberConstruct(String name, int age) {
	this.name = name;
 	this.age = age;
 	this.grade = 50;
}
public MemberConstruct(String name, int age, int grade) {
   	this.name = name;
   	this.age = age;
   	this.grade = grade;
}

4.5.1 this()

위의 두 생성자를 비교해 보면 코드가 중복 되는 부분이 있다.

이때 this() 라는 기능을 사용하면 생성자 내부에서 자신의 생성자를 호출할 수 있다. 참고로 this 는 인스턴스 자신의 참조값을 가리킨다. 그래서 자신의 생성자를 호출한다고 생각하면 된다.

MemberConstruct(String name, int age) {
 	this(name, age, 50); //변경
 }
 MemberConstruct(String name, int age, int grade) {
 	System.out.println("생성자 호출 name=" + name + ",age=" + age + ",grade=" +
grade);
 	this.name = name;
 	this.age = age;
 	this.grade = grade;
 }

이 코드는 첫번째 생성자 내부에서 두번쨰 생성자를 호출한다.

this() 규칙

this() 는 생성자 코드의 첫줄에만 작성할 수 있다.

5. 패키지

패키지(package)는 이름 그대로 물건을 운송하기 위한 포장 용기나 그 포장 묶음을 뜻한다. 해당 패키지 안에 관련된 자바 클래스들을 넣으면 된다.

5.1 패키지와 계층 구조

패키지는 보통 다음과 같이 계층 구조를 이룬다.

  • a
    • b
    • c

이렇게 하면 다음과 같이 총 3개의 패키지가 존재한다.

a, a.b, a.c

계층 구조상 a 패키지 하위에 a.b 패키지와 a.c 패키지가 있다.

그런데 이것은 우리 눈에 보기에 계층 구졸르 이룰 뿐이다. a 패키지와 a.b, a.c 패키지는 서로 완전회 다른 패키지이다.

따라서 a 패키지의 클래스에서 a.b 패키지의 클래스가 필요하면 import 해서 사용해야 한다. 반대도 물론 마찬가지이다.

정리하면 패키지가 계층 구조를 이루더라도 모든 패키지는 서로 다른 패키지이다.
물론 사람이 이해하기 쉽게 계층 구조를 잘 활용해서 패키지를 분류하는 것은 좋다. 참고로 카테고리는 보통 큰 분류에서 세세한 분류로 점점 나누어진다. 패키지도 마찬가지이다.

6. 접근 제어자

6.1 접근 제어자 이해

자바는 public, private 같은 접근 제어자(access modifier)를 제공한다. 접근 제어자를 사용하면 해당 클래스 외부에서 특정 필드나 메서드에 접근하는 것을 허용하거나 제한할 수 있다.

이런 접근 제어자가 왜 필요할까?

스피커에 들어가는 소프트웨어를 개발하는 개발자라고 생각해보자. 스피커의 음량은 절대로 100을 넘으면 안된다는 요구사항이 있다.(100을 넘어가면 스피커가 고장난다고 가정하자)

  • Speaker 객체
package access;

public class Speaker {
	int volume;
    
    Speaker(int volume) {
 		this.volume = volume;
    }
    
	void volumeUp() {
 		if (volume >= 100) {
 			System.out.println("음량을 증가할 수 없습니다. 최대 음량입니다.");
        } 
		else {
            volume += 10;
 			System.out.println("음량을 10 증가합니다.");
		}
    }
    
 	void volumeDown() {
    	volume -= 10;
 		System.out.println("volumeDown 호출");
    }
    
 	void showVolume() {
 		System.out.println("현재 음량:" + volume);
    }
 }

위의 Speaker 객체에서 volumeUp을 통해 볼륨을 올린다. 이 메서드를 통해 볼륨이 100이상 넘어가지 않도록 제어하고 있다.

새로운 개발자가 이 Speaker 객체를 사용한다고 해보자. 이때 새로운 개발자는 스피커가 100이상 넘어가면 스피커가 망가지는 것을 모른다. 새로운 개발자는 볼륨이 100이상 올라가지 않는 것을 보고 Speaker 클래스를 살펴본다. 이때 volume 필드를 직접 사용할 수 있기 때문에 새로운 개발자는 volume 필드에 직접 접근해 값을 200으로 설정하고 이 코드를 실행한 순간 스피커는 고장난다.

volume 필드

Speaker 객체를 사용하는 사용자는 Speakervolume 필드와 메서드에 모두 접근할 수 있다.

앞서 volumeUp() 과 같은 메서드를 만들어서 음량이 100을 넘지 못하도록 제약을 걸어놨지만 소용이 없다. 왜냐하면 Speaker 를 사용하는 입장에서는 volume 필드에 직접 접근해서 원하는 값을 설정할 수 있기 때문이다.

이런 문제를 근본적으로 해결하기 위해서는 volume 필드의 외부 접근을 막을 수 있는 방법이 필요하다.

만약 Speaker 클래스를 개발하는 개발자가 처음부터 private을 사용해서 volume 필드의 외부 접근을 막아두었다면 어떠했을까? 새로운 개발자도 volume 필드에 직접 접근하지 않고, volumeUp() 과 같은 메서드를 통해서 접근했을 것이다. 결과적으로 Speaker 가 망가지는 문제는 발생하지 않았을 것이다.

참고 : 좋은 프로그램은 무한한 자유도가 주어지는 프로그램이 아니라 적절한 제약을 제공하는 프로그램이다.

6.2 접근 제어자 종류

6.2.1 접근 제어자 종류

  • private : 모든 외부 호출을 막는다.

  • default(pacakage-private) : 같은 패키지안에서 호출은 허용한다.

  • protected : 같은 패키지안에서 호출을 허용한다. 패키지가 달라도 상속 관계의 호출은 허용한다.

  • public : 모든 외부 호출을 허용한다.

6.2.2 package-private

접근 제어자를 명시하지 않으면 같은 패키지 안에서 호출을 허용하는 default 접근 제어자가 적용된다.

default 라는 용어는 해당 접근 제어자가 기본값으로 사용되기 때문에 붙여진 이름이지만, 실제로는 package-private 이 더 정확환 표현이다. 왜냐하면 해당 접근 제어자를 사용하는 멤버는 동일한 패키지 내의 다른 클래스에서만 접근이 가능하기 때문이다.

6.2.3 접근 제어자 사용 위치

접근 제어자는 필드와 메서드, 생성자에 사용된다.
추가로 클래스 레벨에도 일부 접근 제어자를 사용할 수 있다.

6.2.4 접근 제어자 핵심은 속성과 기능을 외부로부터 숨기는 것이다.

  • private 은 나의 클래스 안으로 속성과 기능을 숨길 때 사용, 외부 클래스에서 해당 기능을 호출할 수 없다.

  • default 는 나의 패키지 안으로 속성과 기능을 숨길 때 사용, 외부 패키지에서 해당 기능을 호출할 수 없다.

  • protected 는 상속 관계로 속성과 기능을 숨길 때 사용, 상속 관계가 아닌 곳에서 해당 기능을 호출할 수 없다.

  • public 은 기능을 숨기지 않고 어디서든 호출할 수 있게 공개한다.

6.3 접근 제어자 사용 - 클래스 레벨

클래스 레벨의 접근 제어자 규칙

  • 클래스 레벨의 접근 제어자는 public, default 만 사용할 수 있따.
    • private, protected 는 사용할 수 없다.
  • public 클래스는 반드시 파일명과 이름이 같아야 한다.
    • 하나의 자바 파일에 public 클래스는 하나만 등장할 수 있다.
    • 하나의 자바 파일에 default 접근 제어자를 사용하는 클래스는 무한정 만들 수 있다.

7. 캡슐화

캡슐화(Encapsulation)는 객체 지향 프로그래밍의 중요한 개념 중 하나다. 캡슐화는 데이터와 해당 데이터를 처리하는 메서드를 하나로 묶어서 외부에서의 접근을 제한하는 것을 말한다. 캡슐화를 통해 데이터의 직접적인 변경을 방지하거나 제한할 수 있다.

캡슐화는 쉽게 이야기해서 속성과 기능을 하나로 묶고, 외부에 꼭 필요한 기능만 노출하고 나머지는 모두 내부로 숨기는 것이다.

7.1 데이터를 숨겨라

객체에는 속성(데이터)과 기능(메서드)이 있다. 캡슐화에서 가장 필수로 숨겨야 하는 것은 속성(데이터)이다. Speakervolume을 떠올려 보자. 객체 내부의 데이터를 외부에서 함부로 접근하게 두면, 클래스 안에서 데이터를 다루는 모든 로직을 무시하고 데이터를 변경할 수 있다. 결국 모든 안전망을 다 빠져나가게 된다. 따라서 캡슐화가 깨진다.

우리가 자동차를 운전할 때 자동차 부품을 다 열어서 그 안에 있는 속도계를 직접 조절하지 않는다. 단지 자동차가 제공하는 액셀 기능을 사용해서 액셀을 밟으면 자동차가 나머지는 다 알아서 하는 것이다.

우리가 일상에서 생각할 수 있는 음악 플레이어를 떠올려보자. 음악 플레이어를 사용할 때 그 내부에 들어있는 전원부나, 볼륨 상태의 데이털르 직접 수정할 일이 있을까? 우리는 그냥 음악 플레이어의 켜고, 끄고, 볼륨을 조절하는 버튼을 누룰 뿐이다. 그 내부에 있는 전원부나, 볼륨의 상태 데이터를 직접 수정하지 않는다. 전원 버튼을 눌렀을 때 실제 전원을 받아서 전원을 켜는 것은 음악 플레이어의 일이다. 불륨을 높였을 때 내부에 있는 볼륨 장치들을 움직이고 불륨 수치를 조절하는 것도 음악 플레이어가 스스로 해야하는 일이다. 쉽게 이야기해서 우리는 음악 플레이어가 제공하는 기능을 통해서 음악 플레이어를 사용하는 것이다. 복잡하게 음악 플레이어의 내부를 까서 그 내부 데이터까지 우리가 직접 사용하는 것은 아다.

객체의 데이터는 객체가 제공하는 기능인 메서드를 통해서 접근해야 한다.

7.2 기능을 숨겨라

객체의 기능 중에서 외부에서 사용하지 않고 내부에서만 사용하는 기능들이 있다. 이런 기능도 모두 감추는 것이 좋다. 우리가 자동차를 운전하기 위해 자동차가 제공하는 복잡한 엔진 조절 기능, 배기 기능까지 우리가 알 필요는 없다. 우리는 단지 액셀과 핸들 정도의 기능만 알면 된다.

만약 사용자에게 이런 기능까지 모두 알려준다면, 사용자가 자동차에 대해 너무 많은 것을 알아야 한다.

사용자 입장에서 꼭 필요한 기능만 외부에 노출하자. 나머지 기능은 모두 내부로 숨기자

정리하면 데이터는 모두 숨기고, 기능은 꼭 필요한 기능만 노출하는 것이 좋은 캡슐화이다.

8. 자바 메모리 구조와 static

8.1 자바 메모리 구조

자바의 메모리 구조는 크게 메서드 영역, 스택 영역, 힙 영역 3개로 나눌 수 있다.

  • 메서드 영역 : 클래스 정보를 보관한다. 이 클래스 정보가 붕어빵 틀이다.

  • 스택 영역 : 실제 프로그램이 실행되는 영역이다. 메서드를 실행할 때 마다 하나씩 쌓인다.

  • 힙 영역 : 객체(인스턴스)가 생성되는 영역이다. new 명령어를 사용하면 이 영역을 사용한다. 쉽게 이야기해서 붕어빵 틀로부터 생성된 붕어빵이 존재하는 공간이다. 참고로 배열도 이 영역에 생성된다.

  • 메서드 영역(Method Area) : 메서드 영역은 프로그램을 실행하는데 필요한 공통 데이터를 관리한다. 이 영역은 프로그램의 모든 영역에서 공유한다.

    • 클래스 정보 : 클래스의 실행 코드(바이트 코드), 필드, 메서드와 생성자 코드 등 모든 실행 코드가 존재한다.
    • static 영역 : static 변수들을 보관한다.
    • 런타임 상수 풀 : 프로그램을 실행하는데 필요한 공통 리터널 상수를 보관한다. 예를 들어서 프로그램에 "hello" 라는 리터널 문자가 있으면 이런 문자를 공통으로 묶어서 관리한다. 이 외에도 프로그램을 효율적으로 관리하기 위한 상수들을 관리한다.
  • 스택 영역(Stack Areat) : 자바 실행 시 , 하나의 실행 스택이 생성된다. 각 스택 프레임은 지역 변수, 중간 연산 결과, 메서드 호출 정보 등을 포함한다.

    • 스택 프레임 : 스택 영역에 쌓이는 네모 박스가 하나의 스택 프레임이다. 메서드를 호출할 때 마다 하나의 스택 프레임이 쌓이고, 메서드가 종료되면 해당 스택 프레임이 제거된다.
  • 힙 영역(Heap Area) : 객체(인스턴스)와 배열이 생성되는 영역이다. 가바지 컬렉션(GC)이 이루어지는 주요 영역이며, 더 이상 참조되지 않는 객체는 GC에 의해 제거된다.

메서드 코드는 메서드 영역에

자바에서 특정 클래스로 100개의 인스턴스를 생성하면, 힙 메모리에 100개의 인스턴스가 생긴다. 각각의 인스턴스는 내부에 변수와 메서드를 가진다. 같은 클래스로부터 생성된 객체라도, 인스턴스 내부의 변수 값은 서로 다를 수 있지만, 메서드는 공통된 코드를 공유한다. 따라서 객체가 생성될 때, 인스턴스 변수에는 메모리가 할당되지만, 메서드에 대한 새로운 메모리 할당은 없다. 메서드는 메서드 영역에서 공통으로 관리되고 실행된다.

인스턴스의 메서드를 호출하면 실제로는 메서드 영역에 있는 코드를 불러와서 수행한다.

8.2 스택 영역과 힙 영역

  • Data
package memory;
public class Data {
 	private int value;
    
 	public Data(int value) {
 		this.value = value;
    }
    
 	public int getValue() {
 		return value;
    }
 }
  • JavaMemoryMain2
package memory;

public class JavaMemoryMain2 {

	public static void main(String[] args) {
 		System.out.println("main start");
 		method1();
 		System.out.println("main end");
    }
    
 	static void method1() {
 		System.out.println("method1 start");
 		Data data1 = new Data(10);
 		method2(data1);
 		System.out.println("method1 end");
    }
    
 	static void method2(Data data2) {
 		System.out.println("method2 start");
 		System.out.println("data.value=" + data2.getValue());
		System.out.println("method2 end");
    }
}
  • main() -> method1() -> method2() 순서로 호출되는 단순한 코드이다.

  • method1() 에서 Data 클래스의 인스턴스를 생성한다.

  • method1() 에서 method2() 를 호출할 때 매개변수에 Data 인스턴스의 참조값을 전달한다.

  • 실행 결과

    main start
    method1 start
    method2 start
    data.value=10
    method2 end
    method1 end
    main end

  • method2() 종료
    • method2()가 종료된다. method2()의 스택 프레임이 제거되면서 매개변수 data2도 함께 제거된다.

  • method1 종료 직후

  • method()의 스택 프레임이 제거되고 지역 변수 data1 도 함께 제거되었다.
  • 이제 x001 참조값(data1)을 가진 Data 인스턴스를 참조하는 곳이 더는 없다.
  • 참조하는 곳이 없으므로 사용되는 곳도 없다. 결과적으로 프로그램에서 더는 사용하지 않는 객체인 것이다. 이런 객체는 메모리만 차지하게 된다.
  • GC(가비지 컬렉션)은 이렇게 참조가 모두 사라진 인스턴스를 찾아서 메모리에서 제거한다.

지역 변수는 스택 영역에, 객체(인스턴스)는 힙 영역에 관리되는 것을 확인했다. 메서드 영역이 관리하는 변수도 있다.

8.3 static 변수

특정 클래스에서 공용으로 함께 사용할 수 있는 변수를 만들 수 있다면 편리할 것이다.

static 키워드를 사용하면 공용으로 함께 사용하는 변수를 만들 수 있다.

  • Data3
package static1;
public class Data3 {
	public String name;
 	public static int count; //static
 
 	public Data3(String name) {
 		this.name = name;
        count++;
    }
}
  • static int count 부분을 보면 변수 타입(int) 앞에 static 키워드가 붙어있다.
  • 이렇게 멤버 변수에 static을 붙이게 되면 static 변수, 정적 변수 또는 클래스 변수라 한다.
  • 객체가 생성된면 생성자에서 정적 변수 count 의 값을 하나 증가 시킨다.

  • DataCountMain3
package static1;

public class DataCountMain3 {
	
    public static void main(String[] args) {
 		Data3 data1 = new Data3("A");
 		System.out.println("A count=" + Data3.count);
        
 		Data3 data2 = new Data3("B");
 		System.out.println("B count=" + Data3.count);
        
 		Data3 data3 = new Data3("C");
 		System.out.println("C count=" + Data3.count);
    }
}

코드를 보면 count 정적 변수에 접근하는 방법이 조금 특이하다. Data3.count 와 같이 클래스명에 .(dot)을 사용한다. 마치 클래스에 직접 접근하는 것 처럼 느껴진다.

  • static 이 붙은 멤버 변수는 메서드 영역에서 관리한다.
    • static이 붙은 멤버 변수 count 는 인스턴스 영역에 생성되지 않는다. 대신에 메서드 영역에서 이 변수를 관리한다.
  • Data3("A") 인스턴스를 생성하면 생성자가 호출된다.
  • 생성자에서 count++ 코드가 있다. countstatic 이 붙은 정적 변수다. 정적 변수는 인스턴스 영역이 아니라 메서드 영역에서 관리한다. 따라서 이 경우 메서드 영역에 있는 count 의 값이 하나 증가된다.

최종적으로 메서드 영역에 있는 count 변수의 값은 3이 된다.

static 이 붙은 정적 변수에 접근하려면 Data3.count 와 같이 클래스명 + . (dot) + 변수명으로 접근하면 된다.

static 변수를 사용한 덕분에 공용 변수를 사용해서 편리하게 문제를 해결할 수 있었다.

정리

static 변수는 쉽게 이야기 해서 클래스인 붕어빵 틀이 특별히 관리하는 변수이다. 붕어빵 틀은 1개이므로 클래스 변수도 하나만 존재한다. 반면에 인스턴스 변수는 붕어빵인 인스턴스의 수만큼 존재한다.

용어 정리

public class Data3 {
    public String name;
    public static int count; //static
}

위의 예제 코드에서 name, count 는 둘다 멤버 변수이다.

멤버 변수(필드)는 static 이 붙은 것과 아닌 것에 따라 다음과 같이 분류할 수 있다.

멤버 변수(필드)의 조류

  • 인스턴스 변수 : static 이 붙지 않은 변수, 예) name
    • static 이 붙지 않은 멤버 변수는 인스턴스를 생성해야 사용할 수 있고, 인스턴스에 소속되어 있다. 따라서 인스턴스 변수라 한다.
    • 인스턴스 변수는 인스턴스를 만들때마다 새로 만들어진다.
  • 클래스 변수 : static이 붙은 변수, 예) count
    • 클래스 변수, 정적 변수, static 변수 등으로 부른다.
    • static 이 붙은 멤버 변수는 인스턴스와 무관하게 클래스에 바로 접근해서 사용할 수 있고, 클래스 자체에 소속되어 있다. 따라서 클래스 변수라 한다.
    • 클래스 변수는 자바 프로그램을 시작할 때 딱 1개만 만들어진다. 인스턴스와는 다르게 보통 여려곳에서 공유하는 목적으로 사용된다.

변수와 생명주기

  • 지역 변수(매겨변수 포함) : 지역 변수는 스택 영역에 있는 스택 프레임 안에 보관된다. 메서드가 종료되면 스택 프레임도 제거 되는데 이때 해당 스택 프레임에 포함된 변수도 함께 제거된다. 따라서 지역 변수는 생존 주기가 짧다.

  • 인스턴스 변수 : 인스턴스에 있는 멤버 변수를 인스턴스 변수라 한다. 인스턴스 변수는 힙 영역을 사용한다. 힙 영역은 GC(가비지 컬렉션)가 발생하기 전까지는 생존하기 때문에 보통 지역 변수보다 생존 주기가 길다.

  • 클래스 변수 : 클래스 변수는 메서드 영역의 static 영역에 보관되는 변수이다. 메서드 영역은 프로그램 전체에서 사용하는 공용 공간이다. 클래스 변수는 해당 클래스가 JVM에 로딩 되는 순간 생성된다. 그리고JVM이 종료될 때까지 생명주기가 이어진다. 따라서 가장 긴 생명주기를 가진다.

static 이 정적이라는 이유는 바로 여기에 있다. 힙 영역에 생성되는 인스턴스 변수는 동적으로 생성되고, 제거된다. 반면에 static 인 정적 변수는 거의 프로그램 실행 시점에 딱 만들어지고, 프로그램 종료 시점에 제거된다. 정적 변수는 이름 그래도 정적이다.

8.4 static 메서드

정적 메서드는 객체 생성 없이 클래스에 있는 메서드를 바로 호출할 수 있다는 장점이 있다.

하지만 정적 메서드는 언제나 사용할 수 있는 것이 아니다.

정적 메서드 사용법

  • static 메서드는 static 만 사용할 수 있다.

    • 클래스 내부의 기능을 사용할 때, 정적 메서드는 static 이 붙은 정적 메서드나 정적 변수만 사용할 수 있다.
    • 클래스 내부의 기능을 사용할 때, 정적 메서드는 인스턴스 변수나, 인스턴스 메서드를 사용할 수 없다.
  • 반대로 모든 곳에서 static을 호출할 수 있다.

    • 정적 메서드는 공용 기능이다. 따라서 접근 제어자만 허락한다면 클래스를 통해 모든 곳에서 static 을 호출할 수 있다.

정적 메서드가 인스턴스의 기능을 사용할 수 없는 이유

정적 메서드는 클래스의 이름을 통해 바로 호출할 수 있다. 그래서 인스턴스처럼 참조값의 개념이 없다.

특정 인스턴스의 기능을 사용하려면 참조값을 알아야 하는데, 정적 메서드는 참조값 없이 호출한다. 따라서 정적 메서드 내부에서 인스턴스 변수나 인스턴스 메서드를 사용할 수 없다.

멤버 메서드의 종류

  • 인스턴스 메서드 : static 이 붙지 않은 멤버 메서드
  • 클래스 메서드 : static 이 붙은 멤버 메서드
    • 클래스 메서드, 정적 메서드, static 메서드등으로 부른다.

static 이 붙지 않은 멤버 메서드는 인스턴스를 생성해야 사용할 수 있고, 인스턴스에 소속되어 있다. 따라서 인스턴스 메서드라 한다. static 이 분은 멤버 메서드는 인스턴스와 무관하게 클래스에 바로 접근해서 사용할 수 있고, 클래스 자체에 소속되어 있다. 따라서 클래스 메서드라 한다.

정적 메서드 활용

정적 메서드는 객체 생성이 필요 없이 메서드의 호출만으로 필요한 기능을 수행할 때 주로 사용한다.

예를 들어서 간단한 메서드 하나로 끝나는 유틸리티성 메서드에 자주 사용한다. 수학의 여러가지 기능을 담은 클래스를 만들 수 있는데, 이 경우 인스턴스 변수 없이 입력한 값을 계산하고 반환하는 것이 대부분이다. 이럴 때 정적 메서드를 사용해서 유틸리티성 메서드를 만들면 좋다.

main() 메서드는 정적 메서드

인스턴스 생성 없이 실행하는 가장 대표적인 메서드가 바로 main() 메서드이다.

main() 메서드는 프로그램을 시작하는 시작점이 되는데, 생각해보면 객체를 생성하지 않아도 main() 메서드가 작동했따. 이것은 main() 메서드가 static 이기 때문이다.

정적 메서드는 정적 메서드만 호출할 수 있다. 따라서 정적 메서드인 main()이 호출하는 메서드에는 정적 메서드를 사용했다.

물론 더 정확히 말하자면 정적 메서드는 같은 클래스 내부에서 정적 메서드만 호출할 수 있다. 따라서 정적 메서드인 main() 메서드가 같은 클래스에서 호출하는 메서드도 정적 메서드로 선언해서 사용했다.

9. final

9.1 final 변수와 상수

final 키워드는 이름 그대로 끝! 이라는 뜻이다.

변수에 final 키워드가 붙으면 더는 값을 변경할 수 없다.

  • final 을 지역 변수에 설정할 경우 최초 한번만 할당할 수 있다. 이후에 값을 변경하려면 컴파일 오류가 발생한다.

  • final 을 지역 변수 선언시 바로 초기화 한 경우 이미 값이 할당되었기 때문에 값을 할당할 수 없다.

  • 매개변수에 final 이 붙으면 메서드 내부에서 매개변수의 값을 변경할 수 없다. 따라서 메서드 호출 시점에 사용된 값이 끝까지 사용된다.

final - 필드(멤버 변수)

 // final 필드 - 필드 초기화
public class FieldInit {
	static final int CONST_VALUE = 10;
    final int value = 10;
}
  • static 변수에도 final 을 선언할 수 있다.
    • 이러한 경우를 상수라고 한다.

  • FieldInit 과 같이 final 필드를 초기화 하는 경우, 모든 인스턴스가 다음 오른쪽 그림과 같이 같은 값을 가진다.
  • 여기서는 FieldInit 인스턴스의 모든 value 값은 10 이 된다.
  • 모든 인스턴스가 같은 값을 사용하기 때문에 결과적으로 메모리를 낭비하게 된다. 또 메모리 낭비를 떠나서 같은 값이 계속 생성되는 것은 개발자가 보기에 명확한 중복이다. 이럴 때 사용하면 좋은 것이 static 영역이다.

static final

  • FieldInit.MY_VALUEstatic 영역에 존재한다. 그리고 final 키워드를 사용해서 초기화 값이 변하지 않는다.

  • static 영역은 단 하나만 존재하는 영역이다. MY_VALUE 변수는 JVM 상에서 하나만 존재하므로 앞서 설명한 중보고가 메모리 비효율 문제를 모두 해결할 수 있다.

이런 이유로 필드에 final + 필드 초기화를 사용하는 경우 static 을 붙여서 사용하는 것이 효율적이다.

상수(Constant)

상수는 변하지 않고, 항상 일정한 값을 갖는 수를 말한다. 자바에서는 보통 단 하나만 존재하는 변하지 않는 고정된 값을 상수라 한다.

이런 이유로 상수는 static final 키워들 사용한다.

자바 상수 특징

  • static final 키워드를 사용한다.
  • 대문자를 사용하고 구분은 _(언더스코어)로 한다.
  • 필드를 직접 접근해서 사용한다.
    • 상수는 기능이 아니라 고정된 값 자체를 사용하는 것이 목적이다.
    • 상수는 값을 변경할 수 없다. 따라서 필드에 직접 접근해도 데이터가 변하는 문제가 발생하지 않는다.
  • 보통 이런 상수들은 애플리케이션 전반에서 사용되기 떄문에 public 을 자주 사용한다. 물론 특정 위치에서만 사용된다면 다른 접근 제어자를 사용하면 된다.
  • 상수는 중앙에서 값을 하나로 관리할 수 있다는 장점이 있다.
  • 상수는 런타임에 변경할 수 없다. 상수를 변경하려면 프로그램을 종료하고, 코드를 변경한 다음에 프로그램을 다시 실행해야 한다.

매직 넘버

public class ConstantMain1 {
	public static void main(String[] args) {
 		System.out.println("프로그램 최대 참여자 수 " + 1000);
 		int currentUserCount = 999;
 		process(currentUserCount++);
 		process(currentUserCount++);
    	process(currentUserCount++);
 	}
 
 	private static void process(int currentUserCount) {
 		System.out.println("참여자 수:" + currentUserCount);
 		if (currentUserCount > 1000) {
 			System.out.println("대기자로 등록합니다.");
		} 
		else {
 			System.out.println("게임에 참가합니다.");
		}
	}
}

이 코드에는 다음고 같은 문제가 있다.

  • 만약 프로그램 최대 참여자 수를 현재 1000명에서 2000명으로 변경해야 하면 2곳의 변경 포인트가 발생한다. 만약 애플리케이션의 100곳에서 이 숫자를 사용했다면 100곳 모두 변경해야 한다.
  • 매직 넘버 문제가 발생했다. 숫자 1000 이라는 것이 무슨 뜻일까? 이 값만 보고 이해하기 어렵다.
public class ConstantMain2 {
 	public static void main(String[] args) {
 		System.out.println("프로그램 최대 참여자 수 " + Constant.MAX_USERS);
 		int currentUserCount = 999;
 		process(currentUserCount++);
 		process(currentUserCount++);
 		process(currentUserCount++);
    }
    
   private static void process(int currentUserCount) {
   		System.out.println("참여자 수:" + currentUserCount);
 		if (currentUserCount > Constant.MAX_USERS) {
 			System.out.println("대기자로 등록합니다.");
        } 
		else {
 			System.out.println("게임에 참가합니다.");
        }
    }
}
  • Constant.MAX_USERS 상수를 사용했다. 만약 프로그램 최대 참여자 수를 변경해야 하면 Constant.MAX_USERS 의 상수 값만 변경하면 된다.
  • 매직 넘버 문제를 해결했다. 숫자 1000 이 아니라 사람이 인지할 수 있게 MAX_USERS 라는 변수명으로 코드를 이해할 수 있다.

9.2 final 변수와 참조

변수는 크게 기본형 변수와 참조형 변수가 있따.

public class Data {
	public int value;
}
public class FinalRefMain {

	public static void main(String[] args) {
 		final Data data = new Data();
		//data = new Data(); //final 변경 불가 컴파일 오류
		//참조 대상의 값은 변경 가능
        data.value = 10;
 		System.out.println(data.value);
        data.value = 20;
        System.out.println(data.value);
    }
}

참조형 변수 datafinal 이 붙었다. 변수 선언 시점에 참조값을 할당했으므로 더는 참조값을 변경할 수 없다.

그런데 참조 대상의 객체의 멤버 변수인 value 는 값을 변경할 수 있다.

  • 참조형 변수 datafinal 이 붙었다. 이 경우 참조형 변수에 들어있는 참조값을 다른 값으로 변경하지 못한다. 쉽게 이야기해서 이제 다른 객체를 참조할 수 없다. 그런데 이것의 정확한 뜻을 잘 이해해야 한다. 참조형 변수에 들어있는 참조값만 변경하지 못한다는 뜻이다. 이 변수 이외에 다른 곳에 영향을 주는 것이 아니다.
  • Data.vlauefinal 이 아니다. 따라서 값을 변경할 수 있다.

final 은 매우 유용한 제약이다. 만약 특정 변수의 값을 할당한 이후에 변경하지 않아야 한다면 final을 사용하자.


10. 상속

10.1 상속 관계

상속은 객체 지향 프로그래밍의 핵심 요소 중 하나로, 기존 클래스의 필드와 메서드를 새로운 클래스에서 재사용하게 해준다. 이름 그대로 기존 클래스의 속성과 기능을 그대로 물려받는 것이다. 상속을 사용하려면 extends 키워드를 사용하면 된다. 그리고 extends 대상은 하나만 선택할 수 있다.

용어 정리

  • 부모 클래스 (슈퍼 클래스) : 상속을 통해 자신의 필드와 메서드를 다른 클래스에 제공하는 클래스

  • 자식 클래스 (서브 클래스) : 부모 클래스로부터 필드와 메서드를 상속받는 클래스

상속 예)

  • Car 부모 클래스
public class Car {
	public void move() {
 		System.out.println("차를 이동합니다.");
    }
}
  • ElectricCar 자식 클래스
public class ElectricCar extends Car {
	public void charge() {
    	System.out.println("충전합니다.");
    }
}
  • GasCar 자식 클래스
public class GasCar extends Car {
	public void fillUp() {
 		System.out.println("기름을 주유합니다.");
    }
}

상속 구조도

전기차와 가솔린차가 Car를 상속 받은 덕분에 전기차와 가솔린차는 move()를 사용할 수 있다.

상속은 부모의 기능을 자식이 물려 받는 것이다. 따라서 자식이 부모의 기능을 물려 받아서 사용할 수 있다. 반대로 부모 클래스는 자식 클래스에 접근할 수 없다. 자식 클래스는 부모 클래스의 기능을 물려 받기 때문에 접근할 수 있지만, 그 반대는 아니다. 부모 코드를 보면 자식에 대한 정보가 하나도 없다. 반면에 자식 코드는 extends Car 를 통해서 부모를 알고 있다.

단일 상속

자바는 다중 상속을 지원하지 않는다. 그래서 extends 대상은 하나만 선택할 수 있다. 부모를 하나만 선택할 수 있다는 뜻이다. 물론 부모가 또 다른 부모를 하나 가지는 것은 괜찮다.

만약 다중 상속을 사용하게 되면 어떤 부모의 속성과 메서드를 사용해야 할지 애매한 문제가 발생한다. 이것을 다이아몬드 문제라 한다. 그리고 다중 상속을 사용하면 계층 구조가 매우 복잡해질 수 있다. 이런 문제점 때문에 자바는 클래스의 다중 상속을 허용하지 않는다. 대신에 인터페이스의 다중 구현을 허용해서 이러한 문제를 피한다.


10.2 상속과 메모리 구조

상속 관계를 객체로 생성할 때 메모리 구조를 확인해보자.

ElectricCar electricCar = new ElectricCar();

new ElectricCar() 를 호출하면 ElectricCar 뿐만 아니라 상속 관계에 있는 Car 까지 함께 포함해서 인스턴스를 생성한다. 참조값은 x001로 하나지만 실제로 그 안에서는 Car, ElectricCar 라는 두가지 클래스 정보가 공존하는 것이다.

상속이라고 해서 단순하게 부모의 필드와 메서드만 물려 받는게 아니다. 상속 관계를 사용하면 부모 클래스도 함께 포함해서 생성된다. 외부에서 볼때는 하나의 인스턴스를 생성하는 것 같지만 내부에서는 부모와 자식이 모두 생성되고 공간도 구분된다.

  • electricCar.charge() 호출

electricCar.charge() 를 호출하면 참조값을 확인해서 x001.charge() 를 호출한다. 따라서 x001 을 찾아서 charge() 를 호출하면 되는 것이다. 그런데 상속 관계의 경우에는 내부에 부모와 자식이 모두 존재한다. 이때 부모인 Car 를 통해서 charge()를 찾을 지 아니면 ElectricCar 를 통해서 charge()를 찾을지 선택해야 한다.

이때는 호출하는 변수의 타입(클래스)을 기준으로 선택한다. electricCar 변수의 타입이 ElectricCar 이므로 인스턴스 내부에 같은 타입인 ElectricCar 를 통해서 charge() 를 호출한다.

  • electricCar.move() 호출

electricCar.move() 를 호출하면 먼저 x001 참조로 이동한다. 내부에는 Car, ElectricCar 두가지 타입이 있다. 이때 호출하는 변수인 electricCar 의 타입이 ElectricCar 이므로 이 타입을 선택한다.

그런데 ElectricCar 에는 move() 메서드가 없다. 상속 관계에서는 자식 타입에 해당 기능이 없으면 부모 타입으로 올라가서 찾는다. 이 경우 ElectricCar 의 부모인 Car 로 올라가서 move()를 찾는다. 부모인 Carmove()가 있으므로 부모에 있는 move() 메서드를 호출한다.

만약 부모에서도 해당 기능을 찾지 못하면 더 상위 부모에서 필요한 기능을 찾아본다. 부모에 부모로 계속 올라가면서 필드나 메서드를 찾는 것이다. 물론 계속 찾아도 없으면 컴파일 오류가 발생한다.

정리

1. 상속 관계의 객체를 생성하면 그 내부에는 부모와 자식이 모두 생성된다.

2. 상속 관계의 객체를 호출할 때, 대상 타입을 정해야 한다. 이때 호출자의 타입을 통해 대상 타입을 찾는다.

3. 현재 타입에서 기능을 찾지 못하면 상위 부모 타입으로 기능을 찾아서 실행한다. 기능을 찾지 못하면 컴파일 오류가 발생한다.


10.3 상속과 메서드 오버라이딩

부모 타입의 기능을 자식에서는 다르게 재정의 하고 싶을 수 있다. 예를 들어서 자동차의 경우 Car.move() 라는 기능이 있다. 이 기능을 사용하면 단순히 "차를 이동합니다."라고 출력한다. 전기차의 경우 보통 더 빠르기 때문에 전기차가 move() 를 호출한 경우에는 "전기차를 빠르게 이동합니다."라고 출력을 변경하고 싶다.

이렇게 부모에게서 상속 받은 기능을 자식이 재정의 하는 것을 메서드 오버라이딩(Overriding)이라 한다.

Car

public class Car {
	
    public void move() {
 		System.out.println("차를 이동합니다.");
    }
    
    public void openDoor() {
 		System.out.println("문을 엽니다.");
    }
}

ElectricCar

public class ElectricCar extends Car {

    @Override
 	public void move() {
 		System.out.println("전기차를 빠르게 이동합니다.");
    }
    
 	public void charge() {
 		System.out.println("충전합니다.");
    }
}

ElectricCar 는 부모인 Carmove() 기능을 그대로 사용하고 싶지 않다. 메서드 이름은 같지만 새로운 기능을 사용하고 싶다. 그래서 ElectricCarmove() 메서드를 새로 만들었다. 이렇게 부모의 기능을 자식이 새로 재정의하는 것을 메서드 오버라이딩이라 한다. 이제 ElectricCarmove()를 호출하면 Carmove() 가 아니라 ElectricCarmove() 가 호출된다.

@Override

@ 이 붙은 부분을 애노테이션이라 한다. 애노테이션은 주석과 비슷한데, 프로그램이 읽을 수 있는 특별한 주석이라 생각하면 된다.

이 애노테이션은 상위 클래스의 메서드를 오버라이드하는 것임을 나타낸다. 이름 그대로 오버라이딩한 메서드 위에 이 애노테이션을 붙여야 한다.

컴파일러는 이 애노테이션을 보고 메서드가 정확히 오버라이드 되었는지 확인한다. 오버라이딩 조건을 만족시키지 않으면 컴파일 에러를 발생시킨다. 따라서 실수로 오버라이딩을 못하는 경우를 방지해준다.

오버라이딩과 클래스

Carmove() 메서드를 ElectricCar에서 오버라이딩 했다.

오버라이딩과 메모리 구조

  1. electricCar.move()를 호출한다.

  2. 호출한 electricCar 의 타입은 ElectricCar 이다. 따라서 인스턴스 내부의 ElectricCar 타입에서 시작한다.

  3. ElectricCar 타입에 move() 메서드가 있다. 해당 메서드를 실행한다. 이때 실행할 메서드를 이미 찾았으므로 부모 타입을 찾지 않는다.

오버로딩(Overloading)과 오버라이딩(Overriding)

  • 메서드 오버로딩 : 메서드 이름이 같고 매개변수(파라미터)가 다른 메서드를 여러개 정의하는 것을 메서드 오버로딩(Overloading)이라 한다. 같은 이름의 메서드를 여러개 정의했다고 이해하면 된다.

  • 메서드 오버라이딩 : 메서드 오버라이딩은 하위 클래스에서 상위 클래스의 메서들 재정의하는 과정을 의미한다. 따라서 상속 관계에서 사용한다. 부모의 기능을 자식이 다시 정의하는 것이다. 기존 기능을 새로운 기능으로 덮어버린다고 이해하면 된다. 오버라이딩을 우리말로 번역하면 무언가를 다시 정의한다고 해서 재정의라 한다. 상속 관계에서는 기존 기능을 다시 정의한다고 이해하면 된다.

메서드 오버라이딩 조건

@Override 애노테이션을 활용하면 웬만한 조건을 다 검사해준다.

  • 메서드 이름 : 메서드 이름이 같아야 한다.

  • 메서드 매겨변수(파라미터) : 매개변수(파라미터) 타입, 순서, 개수가 같아야 한다.

  • 반환 타입 : 반환 타입이 같아야 한다. 단 반환 타입이 하위 클래스 타입일 수 있다.

  • 접근 제어자 : 오버라이딩 메서드의 접근 제어자는 상위 클래스의 메서드보다 더 제한적이어서는 안된다. 예를 들어, 상위 클래스의 메서드가 protected 로 선언되어 있으면 하위 클래스에서 이를 public 또는 protected로 오버라이드할 수 있지만, private 또는 default로 오버라이드 할 수 없다.

  • 예외 : 오버라이딩 메서드는 상위 클래스의 메서드보다 더 많은 체크 예외를 throws로 선언할 수 없다. 하지만 더 적거나 같은 수의 예외, 또는 하위 타입의 예외는 선언할 수 있다.

  • static, final, private : 키워드가 붙은 메서드는 오버라이딩 될 수 없다.

    • static은 클래스 레벨에서 작동하므로 인스턴스 레벨에서 사용하는 오버라이딩이 의미가 없다. 쉽게 이야기해서 그냥 클래스 이름을 통해 필요한 곳에 직접 접근하면 된다.
    • final 메서드는 재정의를 금지한다.
    • private 메서드는 해당 클래스에서만 접근 가능하기 때문에 하위 클래스에서 보이지 않는다. 따라서 오버라이딩 할 수 없다.
  • 생성자 오버라이딩 : 생성자는 오버라이딩 할 수 없다.


10.4 상속과 접근 제어

접근 제어자 종류

  • private : 모든 외부 호출을 막는다.

  • default(pacakage-private) : 같은 패키지안에서 호출은 허용한다.

  • protected : 같은 패키지안에서 호출을 허용한다. 패키지가 달라도 상속 관계의 호출은 허용한다.

  • public : 모든 외부 호출을 허용한다.

접근 제어와 메모리 구조

본인 타입에 없으면 부모 타입에서 기능을 찾는데, 이때 접근 제어자가 영향을 준다. 왜냐하면 객체 내부에서는 자식과 부모가 구분되어 있기 때문이다. 결국 자식 타입에서 부모 타입의 기능을 호출할 때, 부모 입장에서 보면 외부에서 호출한 것과 같다.


10.5 super - 부모 참조

부모와 자식의 필드명이 같거나 메서드가 오버라이딩 되어 있으면, 자식에서 부모의 필드나 메서드를 호출할 수 없다. 이때 super 키워드를 사용하면 부모를 참조할 수 있다. super 는 이름 그대로 부모 클래스에 대한 참조를 나타낸다.

Parent

public class Parent {

	public String value = "parent";
    
 	public void hello() {
 		System.out.println("Parent.hello");
    }
}

Chlid

public class Child extends Parent {
	
    public String value = "child";
    
    @Override
 	public void hello() {
		System.out.println("Child.hello");
    }
    
 	public void call() {
    	System.out.println("this value = " + this.value); //this 생략 가능
		System.out.println("super value = " + super.value);
 		this.hello(); //this 생략 가능
		super.hello();
    }
}

super 메모리 그림

child.call()


10.6 super - 생성자

상속 관계의 인스턴스를 생성하면 결국 메모리 내부에는 자식과 부모 클래스가 각각 만들어진다. Child 를 만들면 부모인 Parent 까지 함께 만들어지는 것이다. 따라서 각각의 생성자도 모두 호출되어야 한다.

상속 관계를 사용하면 자식 클래스의 생성자에서 부모 클래스의 생성자를 반드시 호출해야 한다. (규칙)

ClassA 최상위 부모 클래스

public class ClassA {
 	
    public ClassA() {
 		System.out.println("ClassA 생성자");
    }
}

ClassB

public class ClassB extends ClassA {
	
    public ClassB(int a) {
 		super(); //기본 생성자 생략 가능
		System.out.println("ClassB 생성자 a="+a);
    }
    
 	public ClassB(int a, int b) {
 		super(); //기본 생성자 생략 가능
		System.out.println("ClassB 생성자 a="+a + " b=" + b);
    }
}
  • ClassBClassA를 상속 받았다. 상속을 받으면 생성자의 첫줄super(...) 를 사용해서 부모 클래스의 생성자를 호출해야 한다.
    • 예외로 생성자 첫줄에 this(...) 를 사용할 수는 있다. 하지만 super(...) 는 자식의 생성자 안에서 언젠가는 반드시 호출해야 한다.
  • 부모 클래스의 생성자가 기본 생성자(파라미터가 없는 생성자)인 경우에는 super() 를 생략할 수 있다.
    • 상속 관계에서 첫줄에 super(...) 를 생략하면 자바는 부모의 기본 생성자를 호출하는 super() 를 자동으로 만들어준다.
    • 참고로 기본 생성자를 많이 사용하기 때문에 편의상 이런 기능을 제공한다.

ClassC

public class ClassC extends ClassB {

	public ClassC() {
    	super(10, 20);
 		System.out.println("ClassC 생성자");
    }
}
  • ClassCClassB를 상속 받았다. ClassB에는 다음 두 생성자가 있다.

    • ClassB(int a)
    • ClassB(int a, int b)
  • 생성자는 하나만 호출할 수 있다. 두 생성자 중에 하나를 선택하면 된다.

    • super(10, 20) 를 통해 부모 클래스의 ClassB(int a, int b) 생성자를 선택했다.
  • 참고로 ClassC의 부모인 ClassB에는 기본 생성자가 없다. 따라서 부모의 기본 생성자를 호출하는 super()를 사용하거나 생략할 수 없다.

ClassC classC = new ClassC(); 실행 과정

  • 실행 결과
ClassA 생성자
ClassB 생성자 a=10 b=20
ClassC 생성자

실행해보면 ClassA -> ClassB -> ClassC 순서로 실행된다. 생성자의 실행 순서가 결과적으로 최상위 부모부터 실행되어서 하나씩 아래로 내려오는 것이다. 따라서 초기화는 최상위 부모부터 이루어진다. 왜냐하면 자식 생성자의 첫줄에서 부모의 생성자를 호출해야 하기 때문이다.

1 ~ 3 까지의 과정

new ClassC() 를 통해 ClassC 인스턴스를 생성한다. 이때 ClassC()의 생성자가 먼저 호출되는 것이 맞다. 하지만 ClassC()의 생성자는 가장 먼저 super(...)를 통해 ClassB(...)의 생성자를 호출한다. ClassB() 의 생성자도 부모인 ClassA() 의 생성자를 가장 먼저 호출한다.

4 ~ 6 까지의 과정

ClassA()의 생성자는 최상위 부모이다. 생성자 코드를 실행하면서 "ClassA 생성자" 를 출력한다. ClassA() 생성자 호출이 끝나면 ClassA()를 호출한 ClassB(...) 생성자로 제어권이 돌아간다. ClassB(...) 생성자가 코드를 실행하면서 "ClassB 생성자 a=10 b=20"를 출력한다. 생성자 호출이 끝나면 ClassB(...) 를 호출한 ClassC()의 생성자로 제어권이 돌아간다. ClassC() 가 마지막으로 생성자 코드를 실행하면서 "ClassC 생성자" 를 출력한다.

정리

  • 상속 관계의 생성자 호출은 결과적으로 부모에서 자식 순서로 실행된다. 따라서 부모의 데이터를 먼저 초기화하고 그 다음에 자식의 데이터를 초기화한다.

  • 상속 관계에서 자식 클래스의 생성자 첫줄에 반드시 super(...) 를 호출해야 한다. 단 기본 생성자(super())인 경우 생략할 수 있다.

11. 다형성 1

11.1 다형성

객체지향 프로그래밍의 대표적인 특징으로는 캡슐화, 상속, 다형성이 있다. 그 중에서 다형성은 객체지향 프로그랭의 꽃이라 불린다.

다형성(Polymorphism)은 이름 그대로 다양한 형태, 여러 형태 를 뜻한다.

프로그래밍에서 다형성은 한 객체가 여러 타입의 객체로 취급될 수 있는 능력을 뜻한다. 보통 하나의 객체는 하나의 타입으로 고정되어 있다. 그런데 다형성을 사용하면 하나의 객체가 다른 타입으로 사용될 수 있다는 뜻이다.


11.2 다형적 참조

부모와 자식이 있고, 각각 다른 메서드를 가진다.

다형적 참조 : 부모 타입의 변수가 자식 인스턴스 참조

System.out.println("Parent -> Child");
Parent poly = new Child();
poly.parentMethod();

  • 부모 타입의 변수가 자식 인스턴스를 참조한다.
  • Parent poly = new Child()
  • Child 인스턴스를 만들었다. 이 경우 자식 타입인 Child를 생성했기 때문에 메모리 상에 ChildParent 가 모두 생성된다.
  • 생성된 참조값을 Parent 타입의 변수인 poly 에 담아둔다.

부모는 자식을 담을 수 있다.

  • 부모 타입은 자식 타입을 담을 수 있다.
  • Parent poly 는 부모 타입이다. new Child() 를 통해 생성된 결과는 Child 타입이다. 자바에서 부모 타입은 자식 타입을 담을 수 있다.
  • 반대로 자식 타입은 부모 타입을 담을 수 없다.
    • Child child = new Parent() : 컴파일 오류 발생

다형적 참조

자바에서 부모 타입은 자신은 물론이고, 자신을 기준으로 모든 자식 타입을 참조할 수 있다. 이것이 바로 다양한 형태를 참조할 수 있다고 해서 다형적 참조라 한다.

다형적 참조의 한계

poly.childMethod() 를 실행하면 먼저 참조값을 통해 인스턴스를 찾는다. 그리고 다음으로 인스턴스 안에서 실행할 타입을 찾아야 한다. 호출자인 polyParent 타입이다. 따라서 Parent 클래스부터 시작해서 필요한 기능을 찾는다. 그런데 상속 관계는 부모 방향으로 찾아 올라갈 수는 있지만 자식 방향으로 찾아 내려갈 수는 없다. Parent 는 부모 타입이고 상위에 부모가 없다. 따라서 childMethod() 를 찾을 수 없으므로 컴파일 오류가 발생한다.


11.3 다형성과 캐스팅

Parent poly = new Child() 와 같이 부모 타입의 변수를 사용하게 되면 poly.childMethod() 와 같이 자식 타입에 있는 기능은 호출할 수 없다.

다운캐스팅

호출하는 타입을 자식인 Child 타입으로 변경하면 인스턴스의 Child 에 있는 childMethod() 를 호출할 수 있다. 하지만 다음과 같은 문제에 봉착한다.

부모는 자식을 담을 수 있지만 자식은 부모를 담을 수 없다.

Child child = (Child) poly

(타입) 처럼 괄호와 그 사이에 타입을 지정하면 참조 대상을 특정 타입으로 변경할 수 있다. 이렇게 특정 타입으로 변경하는 것을 캐스팅이라 한다.

참고로 캐스팅을 한다고 해서 Parent poly의 타입이 변하는 것은 아니다. 해당 참조값을 꺼내고 꺼낸 참조값이 Child 타입이 되는 것이다. 따라서 poly의 타입은 Parent로 기존과 같이 유지된다.

캐스팅

  • 업캐스팅(upcasting) : 부모 타입으로 변경
  • 다운캐스팅(downcasting) : 자식 타입으로 변경

캐스팅 용어

캐스팅 은 영어 단어 cast 에서 유래되었다. cast 는 금속이나 다른 물질을 녹여서 특정한 형태나 모양으로 만드는 과정을 의미한다.

일시적 다운 캐스팅

Child child = (Child) poly
child.childMethod();

다운캐스팅 결과를 변수에 담아두는 과정이 번거롭다. 이런 과정 없이 일시적으로 다운캐스팅을 해서 인스턴스에 있는 하위 클래스의 기능을 바로 호출할 수 있다.

// 일시적 다운캐스팅 - 해당 메서드를 호출하는 순간만 다운캐스팅
((Child) poly).childMethod();

polyParent 타입이다. 그런데 위의 코드를 실행하면 Parent 타입을 임시로 Child 로 변경한다. 그리고 메서드를 호출할 때 Child 타입에서 찾아서 실행한다.

업캐스팅

현재 타입을 부모 타입으로 변경하는 것을 업캐스팅이라 한다.

Child child = new Child();
Parent parent1 = (Parent) child; //업캐스팅은 생략 가능, 생략 권장
Parent parent2 = child; //업캐스팅 생략

업캐스팅은 생략할 수 있다. 다운캐스팅은 생략할 수 없다. 참고로 업캐스팅은 매우 자주 사용하기 때문에 생략을 권장한다.


11.4 다운캐스팅과 주의점

다운캐스팅은 잘못하면 심각한 런타임 오류가 발생할 수 있다.

Parent parent2 = new Parent();
Child child2 = (Child) parent2; // 런타임 오류 - ClassCastException
child2.childMethod(); // 실행 불가

다운캐스팅이 가능한 경우

다운캐스팅이 불가능한 경우

다운캐스팅이 가능한 경우와 불가능한 경우의 차이점은 new Parent()에 있다.

Parent parent2 = new Parent()

먼저 new Parent() 로 부모 타입으로 객체를 생성한다. 따라서 메모리 상에 자식 타입은 전혀 존재하지 않는다. 생성 결과는 parent2에 담아둔다. 이 경우 같은 타입이므로 여기서는 문제가 발생하지 않는다.

Child child2 = (Child) parent2

다음으로 parent2Child 타입으로 다운캐스팅한다. 그런데 parent2Parent로 생성이 되었다. 따라서 메모리 상에 Child 자체가 존재하지 않는다. Child 자체를 사용할 수 없는 것이다.

자바에서는 이렇게 사용할 수 없는 타입으로 다운캐스팅하는 경우에 ClassCastException 이라는 예외를 발생시킨다. 예외가 발생하면 다음 동작이 실행되지 않고, 프로그램이 종료된다. 따라서 child2.childMethod() 코드 자체가 실행되지 않는다.

업캐스팅이 안전하고 다운캐스팅이 위험한 이유

업캐스팅의 경우 이런 문제가 절대로 발생하지 않는다. 왜냐하면 객체를 생성하면 해당 타입의 상위 부모 타입은 모두 함께 생성되기 때문이다. 따라서 위로만 타입을 변경하는 업캐스팅은 메모리 상에 인스턴스가 모두 존재하기 때문에 항상 안전하다. 따라서 캐스팅을 생략할 수 있다.

반면에 다운캐스팅의 경우 인스턴스에 존재하지 않는 하위 타입으로 캐스팅하는 문제가 발생할 수 있다. 왜냐하면 객체를 생성하면 부모 타입은 모두 함께 생성되지만 자식 타입은 생성되지 않는다. 따라서 개발자가 이런 문제를 인지하고 사용해야 한다는 의미로 명시적으로 캐스팅을 해주어야 한다.

new B() 로 인스턴스를 생성하면 인스턴스 내부에 자신과 부모인 A, B 가 생성된다. 따라서 B 의 부모 타입인 A, B 모두 B 인스턴스를 참조할 수 있다. 상위로 올라가는 업캐스팅은 인스턴스 내부에 부모가 모두 생성되기 때문에 문제가 발생하지 않는다. 하지만 객체를 생성할 때 하위 자식은 생성되지 않기 때문에 하위로 내려가는 다운캐스팅은 인스턴스 내부에 없는 부분을 선택하는 문제가 발생할 수 있다.

C c = (C) new B() : 하위 타입으로 강제 다운캐스팅, 하지만 B 인스턴스에 C 와 관련되 부분이 없으므로 잘못된 캐스팅, ClassCastException 런타임 오류 발생

컴파일 오류 vs 런타임 오류

컴파일 오류는 변수명 오타, 잘못된 클래스 이름 사용 등 자바 프로그램을 실행하기 전에 발생하는 오류이다. 이런 오류는 IDE에서 즉시 확인할 수 있기 때문에 안전하고 좋은 오류이다.

반면에 런타임 오류는 이름 그대로 프로그램이 실행되고 있는 시점에 발생하는 오류이다. 런타임 오류는 매우 안좋은 오류이다. 왜냐하면 보통 고객이 해당 프로그램을 실행하는 도중에 발생하기 때문이다.


11.5 instanceof

다형성에서 참조형 변수는 이름 그대로 다양한 자식을 대상으로 참조할 수 있다. 그런데 참조하는 대상이 다향하기 때문에 어떤 인스턴스를 참조하고 있는지 확인하려면 어떻게 해야할까?

Parent parent1 = new Parent()
Parent parent2 = new Child()

여기서는 Parent 는 자신과 같은 Parent의 인스턴스도 참조할 수 있고, 자식 타입인 Child 의 인스턴스도 참조할 수 있다. 이때 parent1, parent2 변수가 참조하는 인스턴스의 타입을 확인하고 싶다면 instanceof 키워드를 사용하면 된다.

private static void call(Parent parent) {
	parent.parentMethod();
 	if (parent instanceof Child) {
 		System.out.println("Child 인스턴스 맞음");
 		Child child = (Child) parent;
        child.childMethod();
	}
}

이 메서드는 매겨변수로 넘어온 parent 가 참조하는 타입에 따라서 다른 명령을 수행한다. 참고로 지금처럼 다운캐스팅을 수행하기 전에 먼저 instanceof 를 사용해서 원하는 타입으로 변경이 가능한지 확인한 다음에 다운캐스팅을 수행하는 것이 안전하다.

instanceof 키워드의 값은 왼쪽에 오는 값이 오른쪽에 있는 값에 대입이 되는지 보면 된다. 즉, 자식 instanceof 부모 이면 true를 반환한다. 또한, A instanceof Atrue를 반환한다. parent instanceof Childfalse다.

자바 16 - Pattern Matching for instanceof

자바 16부터는 instanceof 를 사용하면서 동시에 변수를 선언할 수 있다.

private static void call(Parent parent) {
	parent.parentMethod();
 	//Child 인스턴스인 경우 childMethod() 실행
	if (parent instanceof Child child) {
 		System.out.println("Child 인스턴스 맞음");
        child.childMethod();
	}
}

parent instanceof Childtrue 인 경우 바로 child 변수에 대입을 해줘서 인스턴스가 맞는 경우 직접 다운캐스팅 하는 코드를 생략할 수 있다.

11.6 다형성과 메서드 오버라이딩

오버라이딩 된 메서드가 항상 우선권을 가진다.

위의 상속 구조를 가지고 ChildParentmethod() 를 오버라이딩 한 상황이다. 멤버 변수인 value는 오버라이딩 되지 않는다. 메서드는 오버라이딩 된다.

Parent poly = new Child();
System.out.println("Parent -> Child");
System.out.println("value = " + poly.value); //변수는 오버라이딩X
poly.method(); //메서드 오버라이딩!
  • 실행 결과
Parent -> Child
value = parent
Child.method

poly 변수는 Parent 타입이다. 따라서 Poly.value, poly.method()를 호출하면 인스턴스의 Parent 타입에서 기능을 먼저 찾아서 실행한다.

  • poly.value : Parent 타입에 있는 value 값을 읽는다.
  • poly.method() : Parent 타입에 있는 method()를 실행하려고 한다. 그런데 하위 타입인 Child.method() 가 오버라이딩 되어 있다. 오버라이딩 된 메서드는 항상 우선권을 가진다. 따라서 Parent.method() 가 아니라 Child.method()가 실행된다.

오버라이딩 된 메서드가 항상 우선권을 가진다. 오버라이딩은 부모 타입에서 정의한 기능을 자식 타입에서 재정의하는 것이다. 만약 자식에서도 오버라이딩 하고 손자에서도 같은 메서드를 오버라이딩을 하면 손자의 오버라이딩 메서드가 우선권을 가진다. 더 하위 자식의 오버라이딩 된 메서드가 우선권을 가지는 것이다.

정리

다형적 참조 : 하나의 변수 타입으로 다양한 자식 인스턴스를 참조할 수 있는 기능

메서드 오버라이딩 : 기존 기능을 하위 타입에서 새로운 기능으로 재정의


12. 다형성 2

12.1 다형성 활용

다형성을 사용하기 위해 여기서는 상속 관계를 사용한다. Animal(동물) 이라는 부모 클래스를 만들고 sound() 메서드를 정의한다. 이 메서드는 자식 클래스에서 오버라이딩 할 목적으로 만들었다.

Dog, Cat, CawAnimal 클래스를 상속받았다. 그리고 각각 부모의 sound() 메서드를 오버라이딩 한다.

public class AnimalPolyMain1 {
 
	public static void main(String[] args) {
 		Dog dog = new Dog();
 		Cat cat = new Cat();
 		Caw caw = new Caw();
 
 		soundAnimal(dog);
 		soundAnimal(cat);
 		soundAnimal(caw);
    }
    
	//동물이 추가 되어도 변하지 않는 코드
	private static void soundAnimal(Animal animal) {
 		System.out.println("동물 소리 테스트 시작");
        animal.sound();
 		System.out.println("동물 소리 테스트 종료");
    }
}
  • soundAnimal(dog) 를 호출하면
  • soundAniaml(Animal animal 메서드에 Dog 인스턴스가 전달된다.
    • Animal animal = dog 로 이해하면 된다. 부모는 자식을 담을 수 있다. AnimalDog 의 부모다.
  • 메서드 안에서 animal.sound() 메서드를 호출한다.

  • animal 변수의 타입은 Animal 이므로 Dog 인스턴스에 있는 Animal 클래스 부분을 찾아서 sound() 메서드를 실행한다. 그런데 하위 클래스인 Dog 에서 sound() 메서드를 오버라이딩 했다. 따라서 오버라이딩한 메서드가 우선권을 가진다.

  • Dog 클래스에 있는 sound() 메서드가 호출된다.

이 코드이 핵심은 Animal animal 이다.

  • 다형적 참조 덕분에 animal 변수는 자식인 Dog, Cat, Caw의 인스턴스를 담을 수 있고, 참조할 수 있다.

  • 메서드 오버라이딩 덕분에 animal.sound() 를 호출해도 Dog.sound(), Cat.sound(), Caw.sound() 와 같이 각 인스턴스의 메서드를 호출할 수 있다. 만약 자바에 메서드 오버라이딩이 없었다면 모두 Animalsound() 가 호출되었을 것이다.

다형성 덕분에 이후에 새로운 동물을 추가해도 코드를 그대로 재사용할 수 있다.

새로운 기능이 추가되었을 때 변하는 부분을 최소화 하는 것이 잘 작성된 코드이다. 이렇게 하기 위해서는 코드에서 변하는 부분과 변하지 않는 부분을 명확하게 구분하는 것이 좋다.

Animal 클래스를 생성할 수 있는 문제

Animal 클래스는 동물이라는 클래스이다. 이 클래스를 직접 생성해서 사용할 일이 있을까? 예) Animal animal = new Animal();

동물이라는 추상적인 개념이 실제로 존재하는 것은 이상하다. 사실 이 클래스는 다형성을 위해서 필요한 것이지 직접 인스턴스를 생성해서 사용할 일은 없다.

하지만 Animal 도 클래스이기 때문에 인스턴스를 생성하고 사용하는데 아무런 제약이 없다. 누군가 실수로 new Animal() 을 사용해서 Animal 의 인스턴스를 생성할 수 있다는 것이다. 이렇게 생성된 인스턴스는 작동은 하지만 제대로된 기능을 수행하지는 않는다.

Animal 클래스를 상속 받는 곳에서 sound() 메서드 오버라이딩을 하지 않을 가능성

개발자가 실수로 sound() 메서드를 오버라이딩 하는 것을 빠트릴 수 있다. 이렇게 되면 부모의 기능을 상속 받는다. 따라서 코드상 아무런 문제가 발생하지 않는다. 물론 프로그램을 실행하면 기대와 다르게 Animal.sound() 가 호출될 것이다.

좋은 프로그램은 제약이 있는 프로그램이다. 추상 클래스와 추상 메서드르 사용하면 이런 문제를 한번에 해결할 수 있다.


12.2 추상 클래스

추상 클래스

동물(Animal)과 같이 부모 클래스는 제공하지만, 실제 생성되면 안되는 클래스를 추상 클래스라 한다.

추상 클래스는 이름 그대로 추상적인 개념을 제공하는 클래스이다. 따라서 실제적인 인스턴스가 존재하지 앟는다. 대신에 상속을 목적으로 사용되고, 부모 클래스 역할을 담당한다.

abstract class AbstractAnimal {...}
  • 추상 클래스는 클래스를 선언할 때 앞에 추상이라는 의미의 abstract 키워드를 붙여주면 된다.

  • 추상 클래스는 기존 클래스와 완전히 같다. 다만 new AbstractAnimal() 와 같이 직접 인스턴스를 생성하지 못하는 제약이 추가된 것이다.

추상 메서드

부모 클래스를 상속 받는 자식 클래스가 반드시 오버라이딩 해야 하는 메서드를 부모 클래스에 정의할 수 있다. 이것을 추상 메서드라 한다. 추상 메서드는 이름 그대로 추상적이 개념을 제공하는 메서드이다. 따라서 실체가 존재하지 않고, 메서드 바디가 없다.

public abstract void sound();
  • 추상 메서드는 선언할 때 메서드 앞에 추상이라는 의미의 abstract 키워드를 붙여주면 된다.

  • 추상 메서드가 하나라도 있는 클래스는 추상 클래스로 선언해야 한다.

    • 그렇지 않으면 컴파일 오류가 발생한다.

    • 추상 메서드는 메서드 바디가 없다. 따라서 작동하지 앟는 메서드를 가진 불완전한 클래스로 볼 수 있다. 따라서 직접 생성하지 못하도록 추상 클래스로 선언해야 한다.

  • 추상 메서드는 상속 받는 자식 클래스가 반드시 오버라이딩 해서 사용해야 한다.

    • 그렇지 않으면 컴파일 오류가 발생한다.

    • 추상 메서드는 자식 클래스가 반드시 오버라이딩 해야 하기 때문에 메서드 바디 부분이 없다. 바디 부분을 만들면 컴파일 오류가 발생한다.

    • 오버라아딩 하지 않으면 자식도 추상 클래스가 되어야 한다.

  • 추상 메서드는 기존 메서드와 완전히 같다. 다만 메서드 바디가 없고, 자식 클래스가 해당 메서드를 반드리 오버라이딩 해야 한다는 제약이 추가된 것이다.

AbstractAnimal

public abstract class AbstractAnimal {
	
    public abstract void sound();
    
 	public void move() {
 		System.out.println("동물이 움직입니다.");
    }
}
  • AbstractAnimalabstract 가 붙은 추상 클래스이다. 이 클래스는 직접 인스턴스를 생성할 수 없다.

  • sound()abstract 가 붙은 추상 메서드이다. 이 메서드는 자식이 반드시 오버라이딩 해야 한다.

이 클래스는 move() 라는 메서드를 가지고 있는데, 이 메서드는 추상 메서드가 아니다. 상속을 목적으로 만들어진 메서드이다. 따라서 자식 클래스가 오버라이딩 하지 않아도 된다.

Dog

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

지금까지 설명한 제약 (추상 클래스는 직접 인스턴스 생성 불가, 추상 메서드는 자식이 오버라이딩 해야함)을 제외하고 나머지는 모두 일반적이 클래스와 동일하다. 추상 클래스는 제약이 추가된 클래스일뿐이다. 메모리 구조, 실행 결과 모두 동일하다.

  • 추상 클래스 덕분에 Animal 인스턴스를 생성할 문제를 근본적으로 방지해준다.
  • 추상 메서드 덕분에 새로운 동물의 자식 클래스를 만들때 실수로 sound() 를 오버라이딩 하지 않을 문제를 근본적으로 방지해준다.

순수 추상 클래스 : 모든 메서드가 추상 메서드인 추상 클래스

앞서 만든 예제에서 move() 도 추상 메서드로 만들어야 한다고 가정해보자.

이 경우 AbstractAnimal 클래스의 모든 메서드가 추상 메서드가 된다. 이런 클래스를 순수 추상 클래스라 한다.

  • 순수 추상 클래스
public abstract class AbstractAnimal {
	public abstract void sound();
    public abstract void move();
}

모든 메서드가 추상 메서드인 순수 추상 클래스는 코드를 실행할 바디 부분이 전혀 없다. 단지 다형성을 위한 부모 타입으로써 껍데기 역할만 제공할 뿐이다.

순수 추상 클래스 특징

  • 인스턴스를 생성할 수 없다.

  • 상속시 자식은 모든 메서드를 오버라이딩 해야 한다. (모든 메서드가 추상 메서드이므로)

  • 주로 다형성을 위해 사용된다.

상속하는 클래스는 모든 메서드를 구현해야 한다.

"상속시 자식은 모든 메서드를 오버라이딩 해야 한다." 라는 특징은 상속 받는 클래스 입장에서 보면 부모의 모든 메서들 구현해야 하는 것이다.

이런 특징을 잘 생각해보면 순수 추상 클래스는 마치 어떤 규격을 지켜서 구현해야 하는 것 처럼 느껴진다.

이것은 우리가 일반적으로 이야기하는 인터페이스와 같이 느껴진다. 이런 순수 추상 클래스의 개념은 프로그래밍에서 매우 자주 사용된다. 자바는 순수 추상 클래스를 더 편리하게 사용할 수 있도록 인터페이스라는 개념을 제공한다.


12.3 인터페이스

자바는 순수 추상 클래스를 더 편리하게 사용할 수 있는 인터페이스라는 기능을 제공한다.

인터페이스

public interface InterfaceAnimal {
	public abstract void sound();
 	public abstract void move();
}

인터페이스는 순수 추상 클래스에 약간의 편의 기능이 추가된다.

  • 인터페이스의 메서드는 모두 public abstract 이다.

  • 메서드에 public abstratct 를 생략할 수 있다. 참고로 생략이 권장된다.

  • 인터페이스는 다중 구현(다중 상속)을 지원한다.

인터페이스와 멤버 변수

public interface InterfaceAnimal {
	public static final double MY_PI = 3.14;
}

인터페이스에서 멤버 변수는 public static final 이 모두 포함되었다고 간주된다. (생략이 권장된다.)

InterfaceAnimal

public interface InterfaceAnimal {
	void sound();
    void move();
}

Dog

public class Dog implements InterfaceAnimal {
    
	@Override
 	public void sound() {
 		System.out.println("멍멍");
    }
    
    @Override
 	public void move() {
		System.out.println("개 이동");
 	}
}

인터페이스르 상속 받을 때는 extends 대신에 implements 라는 구현이라는 키워드를 사용해야 한다. 인터페이스는 그래서 상속이라 하지 않고 구현이라 한다.

클래스, 추상 클래스, 인터페이스 모두 똑같다.

  • 클래스, 추상 클래스, 인터페이스는 프로그램 코드, 메모리 구조상 모두 똑같다. 모두 자바에서는 .class 로 다루어진다. 인터페이스를 작성할 때도 .java 에 인터페이스를 정의한다.

  • 인터페이스는 순수 추상 클래스와 비슷하다고 생각하면 된다.

상속 vs 구현

부모 클래스의 기능을 자식 클래스가 상속 받을 때, 클래스는 상속 받는다고 표현하지만, 부모 인터페이스의 기능을 자식이 상속 받을 때는 인터페이스를 구현한다고 표현한다.

상속은 이름 그대로 부모의 기능을 물려 받는 것이 목적이다. 하지만 인터페이스는 모든 메서드가 추상 메서드이다. 따라서 물려받을 수 있는 기능이 없고, 오히려 인터페이스에 정의한 모든 메서드를 자식이 오버라이딩 해서 기능을 구현해야 한다. 따라서 구현한다고 표현한다.

인터페이스는 메서드 이름만 있는 설계도이고, 이 설계도가 실제 어떻게 작동하는지는 하위 클래스에서 모두 구현해야 한다. 따라서 인터페이스의 경우 상속이 아니라 해당 인터페이스를 구현한다고 표현한다.

상속과 구현은 사림이 표현하는 단어만 다를 뿐이지 자바 입장에서는 일반 상속 구조와 동일하게 작동한다.

인터페이스를 사용해야 하는 이유

모든 메서드가 추상 메서드인 경우 순수 추상 클래스를 만들어도 되고, 인터페이스를 만들어도 된다. 그런데 왜 인터페이스르 사용해야 할까?

  • 제약 : 인터페이스를 만드는 이유는 인터페이스를 구현하는 곳에서 인터페이스의 메서드를 반드시 구현해라는 규약(제약)을 주는 것이다. 인터페이스의 규약(제약)은 반드시 구현해야 하는 것이다. 그런데 순수 추상 클래스의 경우 미래에 누군가 그곳에 실행 가능한 메서드를 끼워 넣을 수 있다. 이렇게 되면 추가된 기능을 자식 클래스에서 구현하지 않을 수도 있고, 또 더는 순수 추상 클래스가 아니게 된다. 인터페이스는 모든 메서드가 추상 메서드이다. 따라서 이런 문제를 원천 차단할 수 있다.

  • 다중 구현 : 자바에서 클래스 상속은 부모를 하나만 지정할 수 있다. 반면에 인터페이스는 부모를 여려명 두는 다중 구현(다중 상속)이 가능하다.

좋은 프로그램은 좋은 제약이 있는 프로그램이다.


12.4 인터페이스 - 다중 구현

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

위의 그림처럼 다중 AirplaneCar 가 다중 상속을 사용하고 있다고 해보자. 그러면 AirplanCar 입장에서 move() 를 호출할 때 어떤 부모의 move() 를 사용해야 할지 애매한 문제가 발생한다. 이것을 다이아몬드 문제라 한다. 그리고 다중 상속을 사용하면 클래스 계층 구조가 매우 복잡해질 수 있다.

  • 인터페이스 다중 구현 그림

InterfaceA, InterfaceB 는 둘다 같은 methodCommon() 을 가지고 있다. 그리고 Child 는 두 인터페이스를 구현했다. 상속 관계의 경우 두 부모 중에 어떤 한 부모의 methodCommon() 을 사용해야 할지 결정해야 하는 다이아몬드 문제가 발생한다.

하지만 인터페이스 자신은 구현을 가지지 않는다. 대신에 인터페이스를 구현하는 곳에서 해당 기능을 모두 구현해야 한다. 여기서 InterfaceA, InterfaceB 는 같은 이름의 methodCommon() 을 제공하지만 이것의 기능은 Child 가 구현한다. 그리고 오버라이딩에 의해 어차피 Child 에 있는 methodCommon() 이 호출된다. 결과적으로 두 부모 중에 어떤 한 부모의 methodCommon() 을 선택하는 것이 아니라 그냥 인터페이스들을 구현한 Child 에 있는 methodCommon() 이 사용된다. 이런 이유로 인터페이스는 다이아몬드 문제가 발생하지 않는다. 따라서 인터페이스의 경우 다중 구현을 허용한다.

  • InterfaceA 타입으로 참조할 경우

  • InterfaceB 타입으로 참조할 경우

13. 다형성과 설계

13.1 좋은 객체 지향 프로그래밍이란?

객체 지향 특징

  • 추상화

  • 캡슐화

  • 상속

  • 다형성

객체 지향 프로그래밍

  • 객체 지향 프로그래밍은 컴퓨터 프로그램을 명령어의 목록으로 보는 시각에서 벗어나 여러개의 독립된 단위, 즉 객체 들의 모임으로 파악하고자 하는 것이다. 각각의 객체메시지를 주고 받고, 데이터를 처리할 수 있다. (협력)

  • 객체 지향 프로그래밍은 프로그램을 유연하고 변경이 용이하게 만들기 때문에 대규모 소프트웨어 개발에 많이 사용된다.

유연하고, 변경이 용이?

  • 레고 블럭 조립하듯이

  • 키보드, 마우스 갈아 끼우듯이

  • 컴퓨터 부품 갈아 끼우듯이

컴포넌트를 쉽고 유연하게 변경하면서 개발할 수 있는 방법

13.2 다형성의 실세계 비유

  • 실세계와 객체 지향을 1:1 로 매칭 X

  • 그래도 실세계의 비유로 이해하기에는 좋음

  • 역할구현으로 세상을 구분

예시

  • 운전자 - 자동차

  • 공연 무대

  • 키보드, 마우스, 세상의 표준 인터페이스들

  • 정렬 알고리즘

  • 할인 정책 로직

역할과 구현을 분리

  • 역할구현으로 구분하면 세상이 단순해지고, 유연해지며 변경도 편리해진다.

  • 장점

    • 클라이언트는 대상의 역할(인터페이스)만 알면 된다.
    • 클라이언트는 구현 대상의 내부 구조를 몰라도 된다.
    • 클라이언트는 구현 대상의 내부 구조가 변경되어도 영향을 받지 않는다.
    • 클라이언트는 구현 대상 자체를 변경해도 영향을 받지 않는다.

자바 언어

  • 자바 언어의 다형성을 활용

    • 역할 = 인터페이스

    • 구현 = 인터페이스를 구현한 클래스, 구현 객체

  • 겍체를 설계할 때 역할구현을 명확히 분리

  • 객체 설계시 역할(인터페이스)을 먼저 부여하고, 그 역할을 수행하는 구현 객체 만들기

객체의 협력이라는 관계부터 생각

  • 혼자 있는 객체는 없다.

  • 클라이언트 : 요청, 서버 : 응답

  • 수 많은 객체 클라이언트와 객체 서버는 서로 협력 관계를 가진다.

13.3 자바 언어의 다형성

  • 오버라이딩을 떠올려보자

  • 오버라이딩은 자바 기본 문법

  • 오버라이딩 된 메서드가 실행

  • 다형성으로 인터페이스를 구현한 객체를 실행 시점에 유연하게 변경할 수 있다.

  • 물론 클래스 상속 관계도 다형성, 오버라이딩 적용가능

13.4 다형성의 본질

  • 인터페이스를 구현한 객체 인스턴스를 실행 시점유연하게 변경할 수 있다.

  • 다형성의 본질을 이해하려면 협력이라는 객체 사이의 관계에서 시작해야함

  • 클라이언트를 변경하지 않고, 서버의 구현 기능을 유연하게 변경할 수 있다.

정리

  • 다형성이 가장 중요하다!

  • 디자인 패턴 대부분은 다형성을 활용하는 것이다

  • 스프링의 핵심인 제어의 역전(IoC), 의존관계 주입(DI)도 결국 다형성을 활용하는 것이다.

  • 스프링을 사용하면 마치 레고 블럭 조립하듯이 구현을 편리하게 변경할 수 있다.

13.5 OCP(Open-Closed Principle) 원칙

좋은 객체 지향 설계 원칙 중 하나로 OCP 원칙이라는 것이 있다.

  • Open for extension : 새로운 기능의 추가나 변경 사항이 생겼을 때, 기존 코드는 확장할 수 있어야 한다.

  • Closed for modification : 기존의 코드는 수정되지 않아야 한다.

확장에는 열려있고, 변경에는 닫혀 있다는 뜻인데, 쉽게 이야기해서 기존의 코드 수정 없이 새로운 기능을 추가할 수 있다는 의미다.

새로운 차량의 추가

Car 라는 인터페이스를 구현하는 K3Car, Model3Car가 있다. 여기에 NewCar를 추가해보자.

여기에 새로운 차량을 추가해도 Driver의 코드는 전혀 변경하지 않는다. 운전할 수 있는 차량의 종류가 계속 늘어나도 Car 를 사용하는 Driver 의 코드는 전혀 변경하지 않는다. 기능을 확장해도 main() 일부를 제외한 프로그램의 핵심 부분의 코드는 전혀 수정하지 않아도 된다.

확장에는 열려있다는 의미

Car 인터페이스를 사용해서 새로운 차량을 자유롭게 추가할 수 있다. Car 인터페이스를 구현해서 기능을 추가할 수 있다는 의미이다. 그리고 Car 인터페이스를 사용하는 클라이언트 코드인 DriverCar 인터페이스를 통해 새롭게 추가된 차량을 자유롭게 호출할 수 있다. 이것이 확장에 열려있다는 의미이다.

코드 수정은 닫혀 있다는 의미

새로운 차를 추가하게 되면 기능이 추가되기 때문에 기존 코드의 수정은 불가피하다. 당연히 어딘가의 코드는 수정해야 한다.

핵심은 새로운 자동차를 추가할 때 가장 영향을 많이 받는 중요한 클라이언트인 Driver 의 코드를 수정하지 않아도 된다는 뜻이다.

전략 패턴(Starategy Pattern)

디자인 패턴 중에 가장 중요한 패턴을 하나 뽑으라고 하면 전략 패턴을 뽑을 수 있다. 전략 패턴은 알고리즘을 클라이언트 코드의 변경 없이 쉽게 교체할 수 있다. Car 인터페이스가 바로 전략을 정의하는 인터페이스가 되고, 각각의 차량이 전략의 구체적인 구현이 된다. 그리고 전략을 클라이언트 코드(Driver)의 변경 없이 손쉽게 교체할 수 있다.

profile
가오리의 개발 이야기

0개의 댓글