Notice
Recent Posts
Recent Comments
Link
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

끈기 있는 개발 공간

자바가 확장한 객체 지향 [스프링 입문을 위한 자바 객체지향의 원리와 이해 Ch4] 본문

Java

자바가 확장한 객체 지향 [스프링 입문을 위한 자바 객체지향의 원리와 이해 Ch4]

tenacy 2022. 8. 19. 23:23

abstract, 왜 사용할까?

간단한 예로 abstract의 필요성을 알아봅시다.

public class Driver {
    public static void main(String[] args) {
        동물[] 동물들 = new 동물[5];

        동물들[0] = new 쥐();
        동물들[1] = new 고양이();
        동물들[2] = new 강아지();
        동물들[3] = new 송아지();
        동물들[4] = new 병아리();

        for (int i = 0; i < 동물들.length; i++) {
            동물들[i].울어보세요();
        }
    }
}

나는 쥐! 찍! 찍!
나는 고양이! 야옹! 야옹!
나는 강아지! 멍! 멍!
나는 송아지! 음메! 음메!
나는 병아리! 삐약! 삐약!

Process finished with exit code 0

저 동물들의 슈퍼 클래스인 동물 클래스를 살펴봅시다.

public class 동물 {

    String name;

    동물() {
        name = "동물";
    }

    public void 울어보세요() {
        System.out.printf("나는 %s! 어떻게 울어야 하나요?\n", name);
    }
}

, 고양이, 강아지, 송아지, 병아리 등은 울음이라는 공통적인 행위를 갖고 있으니 동물의 울어보세요() 메서드를 오버라이딩하는 게 맞는 거 같습니다. 하지만 몇 가지 애매한 부분들이 있습니다.

  • 동물 클래스 인스턴스는 어떻게 울어야 하는지 알 수 없다.
    • 실수로 동물 클래스의 인스턴스를 만들고, 울어보세요() 메서드를 호출하면 난감해진다.
  • 동물 클래스의 울어보세요() 메서드의 몸체를 비워두는 것도 이상하다.

바로 이럴 때 추상 클래스를 사용하게 됩니다.

동물 클래스를 추상 클래스로 바꾸어봅시다.

public abstract class 동물 {

    String name;

    public abstract void 울어보세요();
}

위 두 가지 문제점을 해결했습니다.

  • 동물 클래스는 인스턴스화될 일이 없으므로 동물 인스턴스가 어떻게 울어야 하는지 걱정할 필요가 없다.
  • 울어보세요() 메서드의 구현을 하위 클래스에게 맡겼으므로 몸체를 비워두는 것이 당연하다.
    • 그냥 맡기는 것이 아니다. 책임을 강제하는 것이다.

생성자

  • 생성자를 따로 정의하지 않아도 컴파일 시에 생성되는 생성자가 있다. 이를 기본 생성자라고 부른다.
  • 기본 생성자 외 별도의 생성자를 정의하면 컴파일 시에 기본 생성자는 생성되지 않는다.

static 블록

static 블록은 클래스가 스태틱 영역에 배치될 때 실행되는 코드 블록입니다. 다음 코드로 살펴보겠습니다.

public class 동물 {
    static {
        System.out.println("동물 클래스 레디 온!");
    }
}

public class Driver01 {
    public static void main(String[] args) {
        동물 뽀로로 = new 동물();
    }
}

동물 클래스 레디 온!

Process finished with exit code 0

동물 클래스가 스태틱 영역에 배치될 때 실행되기 때문에 위와 같은 결과가 나오게 됩니다. 그렇다면 다음 코드의 실행 결과는 어떻게 될까요?

public class Driver02 {
    public static void main(String[] args) {
        System.out.println("main 메서드 시작!");
    }
}

main 메서드 시작!

Process finished with exit code 0

이 경우에는 다음과 같은 이유로 static 블록이 실행되지 않습니다.

  • 동물 클래스를 사용하는 코드가 없기에 동물 클래스는 스태틱 영역에 배치되지 않는다.
  • 동물 클래스가 스태틱 영역에 배치되지 않았기 때문에 static 블록을 실행하지 않는다.

2장에서는 프로그램이 시작될 때 모든 패키지와 모든 클래스가 T 메모리의 스태틱 영역에 로딩된다고 배운적이 있습니다. 하지만 실제로는 해당 패키지 또는 클래스가 처음으로 사용될 때 로딩되는 것이 맞습니다.

마지막으로 클래스의 인스턴스를 만드는 작업이 아닌 클래스의 정적 속성에 접근하는 예를 살펴보겠습니다.

public class Animal {
    static int age = 0;

    static {
        System.out.println("Animal class ready on!");
    }
}

public class Driver05 {
    public static void main(String[] args) {
        System.out.println("main 메서드 시작!");
        System.out.println(Animal.age);
    }
}

main 메서드 시작!
Animal class ready on!
0

Process finished with exit code 0

어느 정도 정리가 된 거 같습니다. 정리해보겠습니다.

  • 클래스 정보는 해당 클래스가 코드에서 맨 처음 사용될 때 T 메모리의 스태틱 영역에 로딩된다.
    • 이 때, 단 한 번 해당 클래스의 static 블록이 실행된다.
  • 클래스가 제일 처음 사용될 때는 다음 세 가지 경우 중 하나이다.
    • 클래스의 정적 속성을 사용할 때
    • 클래스의 정적 메서드를 사용할 때
    • 클래스의 인스턴스를 최초로 만들 때
  • 프로그램 실행 시 발로 클래스들의 정보를 T 메모리의 static 영역에 로딩하지 않는 이유?
    • 결국, 스태틱 영역도 메모리이기 때문
    • 메모리는 최대한 늦게 사용을 시작하고 최대한 빨리 반환하는 게 정석
    • 메모리에 최대한 늦게 로딩함으로써 메모리 사용을 최대한 늦추기 위함
  • static 블록과 유사하게 인스턴스를 위한 인스턴스 블록도({}) 존재
    • 아무런 표시없는 {}
    • 객체 생성자가 실행된기 전에 먼저 실행됨

final 키워드

final마지막, 최종이라는 의미를 가진 단어입니다. final은 객체 지향 언어의 구성 요소의 전부인 클래스, 변수, 메서드 각각에 붙을 때마다 다른 현상(?)을 보이지만, 결국 그 의미는 같습니다. 같이 한 번 확인해봅시다.

final이 클래스에 붙었을 때

public final class 고양이 {
}

public class 길고양이 extends 고양이 {
}

이 경우 다음과 같은 컴파일 에러가 표시됩니다.

  • The type 길고양이 cannot subclass the final class 고양이

즉, 상속을 허락하지 않겠다는 의미입니다.

자, 이제 final의 의미를 떠올려봅시다. 고양이는 최종적인 클래스입니다. 따라서, 상속할 필요가 없는 것이죠.

final이 변수에 붙었을 때

public class 고양이 {
    final static int 정적상수1 = 1;
    final static int 정적상수2;

    final int 객체상수1 = 1;
    final int 객체상수2;

    static {
        정적상수2 = 2;

        //상수는 한 번 초기화되면 값을 변경할 수 없다.
        //정적상수2 = 4;
    }

    고양이() {
        객체상수2 = 2;

        //상수는 한 번 초기화되면 값을 변경할 수 없다.
        //객체상수2 = 4;

        final int 지역상수1 = 1;
        final int 지역상수2;

        지역상수2 = 2;
    }
}

자바에서 상수는 선언 후에 초기화가 가능합니다. 단, 한 번만 초기화가 가능합니다. 다른 언어에서는 상수에 const 키워드를 사용하는데, 자바는 초기화가 가능한 점을 두고 다른 언어와 차별을 두어 final 키워드를 사용합니다.

자, 이제 final의 의미를 떠올려봅시다. 변수가 최종적이라는 뜻입니다. 따라서, 더 이상 값을 변경할 필요가 없다는 뜻입니다.

final이 메서드에 붙었을 때

public class 동물 {
    final void 숨쉬다() {
        System.out.println("호흡 중");
    }
}

public class 포유류 extends 동물 {
    void 숨쉬다() {
        System.out.println("호흡 중");
    }
}

이 경우 다음과 같은 컴파일 에러가 표시됩니다.

  • Cannot override the final method from 동물

즉, 오버라이딩을 허용하지 않겠다는 뜻입니다.

자, 이제 final의 의미를 떠올려봅시다. 숨쉬다() 메서드는 최종적인 메서드입니다. 따라서, 더 이상 재정의할 필요가 없겠죠.

instanceof 연산자

  • 객체가 특정 클래스의 인스턴스인지 물어보는 연산자이다.
    • 맞으면 true, 아니면 false를 반환한다.
  • 객체 참조 변수의 타입이 아닌 실제 객체의 타입에 의해 처리한다.
  • LSP를 어기는 코드에서 주로 나타나는 연산자이다.
    • 리팩터링의 대상이 아닌지 점검해볼 필요가 있다.

packge 키워드

  • 이름공간을 만든다.

interface 키워드와 implements 키워드

interface는 public 추상 메서드와 public 정적 상수만 가질 수 있습니다. 다음 코드를 확인해봅시다.

public interface Speakable {
    double PI = 3.14159;
    final double absoluteZeroPoint = -275.15;

    void sayYes();
}

public class Speaker implements Speakable {
    public void sayYes() {
        System.out.println("I say NO!!!");
    }
}

public class Driver {
    public static void main(String[] args) {
        System.out.println(Speakable.absoluteZeroPoint);
        System.out.println(Speakable.PI);

        Speaker reporter1 = new Speaker();
        reporter1.sayYes();
    }
}

SpeakablePIabsoluteZeroPoint는 정적 상수로 선언이 되어 있지 않음에도 컴파일 에러가 나지 않습니다. 어떻게 된 일일까요?

인터페이스는 추상 메서드와 정적 상수만 가질 수 있기 때문에 따로 메서드에 public과 abstract, 속성에 public과 static, final을 붙이지 않아도 자동으로 자바가 알아서 붙여줍니다. 결국, 자바가 설계가 잘 되어 있기 때문에 발생한 일이라고 보시면 됩니다.

자바에서 람다의 도입은 언어적으로 큰 변화를 이뤄냈습니다. 이 람다가 바로 인터페이스를 기초로 하고 있습니다. 이에 따라 인터페이스에도 큰 변화가 생겼는데, 자바 8 이전까지 즉, 람다의 도입 전까지 인터페이스는 정적 상수와 객체 추상 메서드만 가질 수 있었지만 자바 8부터는 디폴트 메서드라고 하는 객체 구상 메서드와 정적 추상 메서드를 지원할 수 있게 언어 스펙이 바뀌었습니다.

this 키워드

this는 객체가 자기 자신을 지칭할 때 쓰는 키워드입니다. 여기서 중요한 건 this의 대상이 객체라는 겁니다.

일단 정리해보겠습니다.

  • 지역 변수와 속성의 이름이 같은 경우 지역 변수가 우선한다.
  • 객체 변수와 이름이 같은 지역 변수가 있는 경우 객체 변수를 사용하려면 this를 접두사로 사용한다.
  • 정적 변수와 이름이 같은 지역 변수가 있는 경우 정적 변수를 사용하려면 클래스명을 접두사로 사용한다.

마지막 내용이 중요합니다. 왜 정적 변수와 이름이 같은 지역 변수가 있을 때 this가 아닌 클래스명을 접두사로 사용해야 할까요? this는 객체를 가리키기 때문입니다. 정적(static) 변수는 객체가 아니라 클래스에 즉, 힙 영역이 아니라 스태틱 영역에 위치하기 때문에 this를 사용하지 않는 겁니다.

super 키워드

  • 바로 상위 클래스의 인스턴스를 지칭하는 키워드입니다. this와 느낌이 비슷한 키워드입니다.
  • super.super 형태로 상위의 상위 클래스의 인스턴스에는 접근이 불가능합니다.

ADVANCED

public class 펭귄 {
    void test() {
        System.out.println("Test");
    }
}

public class Driver {
    public static void main(String[] args) {
        펭귄 뽀로로 = new 펭귄();

        뽀로로.test();
    }
}

펭귄 인스턴스인 뽀로로test()를 호출했으므로 스택에 쌓여진 스택 프레임은 펭귄이 아닌 뽀로로가 있어야 할 거 같지만 Debugger는 펭귄의 스택 프레임이 쌓여 있다고 알려주고 있습니다.

이는 조금만 생각해보면 왜 JVM이 그런 선택을 한 건지 이해할 수 있습니다. 펭귄 인스턴스 100개가 있다면, test() 메서드도 각 펭귄 객체에 따라 힙 영역에 100개가 만들어져야 합니다. 하지만 객체 멤버 메서드(test() 메서드)는 각 객체별로 달라지는 건 없습니다. 사용하는 객체 멤버 속성의 값만 다를 뿐이죠. 즉, 똑같은 객체 멤버 메서드인 test() 메서드를 힙 영역에 100개나 만드는 건 심각한 메모리 낭비입니다. 이것이 객체 멤버 메서드 test()를 스태틱 영역에 단 하나만 보유하는 이유입니다.

그리고 눈에 보이지는 않지만 test() 메서드를 호출할 때 객체 자신을 나타내는 this 객체 참조 변수를 넘깁니다. 그렇게 함으로써 클래스(펭귄)의 스택 프레임에서도 객체에 대해 참조를 할 수 있는 것이죠. 즉, 위의 코드는 JVM에 의해 아래와 같이 변경된다고 생각하면 됩니다.

public class 펭귄 {
    static void test(펭귄 this) {
        System.out.println("Test");
    }
}

public class Driver {
    public static void main(String[] args) {
        펭귄 뽀로로 = new 펭귄();

        펭귄.test(뽀로로);
    }
}

Comments