728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

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

캡슐화나 상속은 직관적으로 이해하기 쉽다. 그러나 다형성은 제대로 이해하기도 어렵고, 잘 활용하기는 더 어렵다. 하지만 좋은 개발자가 되기 위해선 다형성에 대한 이해가 필수다.

 

다형성(Polymorphism)

이름 그대로 "다양한 형태"를 뜻한다. 자바에서 다형성은 한 객체가 여러 타입의 객체로 취급될 수 있는 능력을 뜻한다. 보통은 하나의 객체는 하나의 타입으로 고정되어 있는데, 다형성을 이용하면 하나의 객체가 다른 타입으로 사용될 수 있다는 뜻이다. 

 

다형성을 이해하기 위해서는 크게 2가지 핵심 이론을 알아야 한다.

  • 다형적 참조
  • 메서드 오버라이딩

 

다형적 참조

다음과 같은 관계를 가지는 두 클래스를 생각해보자. 

 

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

Parent

public class Parent {
    public void parentMethod() {
        System.out.println("Parent.parentMethod");
    }
}

 

Child

public class Child extends Parent {
    public void childMethod() {
        System.out.println("Child.childMethod");
    }
}

 

Main

public class Main {
    public static void main(String[] args) {
        System.out.println("Parent -> Parent");
        Parent parent = new Parent();
        parent.parentMethod();

        System.out.println("Child -> Child");
        Child child = new Child();
        child.parentMethod();
        child.childMethod();

        // 부모 변수가 자식 인스턴스를 참조 (다형적 참조)
        System.out.println("Parent -> Child");
        Parent poly = new Child();
        poly.parentMethod();
        // poly.childMethod(); 자식의 메서드는 호출할 수 없다.

        // Child child1 = new Parent(); // 자식은 부모를 담을 수 없다.
    }
}

 

Main에서 보면 부모 타입 변수가 부모 인스턴스를 참조하거나, 자식 타입 변수가 자식 인스턴스를 참조하는건 참 많이 봐왔다. 그런데 그 아래 부모 타입 변수가 자식 인스턴스를 참조하고 있다. 이것을 다형적 참조라고 한다. 이게 어떻게 가능한지는 객체와 메모리 구조를 잘 떠올려보면 납득이 된다.

 

부모 타입의 변수가 부모 인스턴스 참조 Parent parent = new Parent();

1. 우선 부모 인스턴스를 새로 생성하고

2. 그 참조값(x001)을 부모 타입의 변수에 넣는다.

3. 부모 인스턴스를 생성하면 해당 인스턴스가 힙 영역에 다음과 같이 생긴다.

4. 더 이상 설명할 내용이 없을 정도로 간결하다. 당연히 자식 인스턴스는 같은 참조에 속하지 않는다. 부모 입장에서는 자식이 누군지도 모르기 때문이다.

 

그리고 이런 이유 때문에 Child child = new Parent(); 는 불가한 것이다. 왜냐? Parent 인스턴스를 생성하면 Child는 만들어지지 않는다. Parent는 어떤 클래스가 나를 상속받는지 정보가 아무것도 없기 때문에 이런 구조에서 Child 타입의 변수는 해당 참조값을 가지고 있을 수 없는것.

 

자식 타입의 변수가 자식 인스턴스 참조 Child child = new Child();

1. 자식 인스턴스를 생성한다.

2. 그 참조값(x001)을 자식 타입의 변수에 넣는다.

3. 자식 인스턴스를 생성하면 자식 인스턴스와 함께 상속관계에 있는 부모 인스턴스도 같은 참조(x001)에 만들어진다. 

이 구조가 만들어지기 때문에 자식 타입의 변수에서 부모의 메서드를 호출하더라도 참조값(x001)이 가르키는 참조에 찾아가서 첫번째로 본인의 타입인 Child부터 확인해서 없으면 부모로 계속 거슬러 올라가서 확인할 수 있으니 부모의 메서드 호출이 가능해진다.

 

다형적 참조: 부모 타입의 변수가 자식 인스턴스를 참조: Parent poly = new Child();

여기가 중요하다. 우선 메모리에 어떻게 올라갈지 먼저 생각해보자. 자식 인스턴스를 생성했다. 그럼 자식 인스턴스가 메모리에 올라갈텐데 자식 인스턴스는 상속받는 부모도 가지고 있다. 그럼 자식 인스턴스의 참조값(x001)에 가보면 메모리에는 다음과 같이 부모와 자식 인스턴스 모두가 생성된 상태일것이다. 그래서 타입을 부모 타입으로 선언해도 문제가 없는것이다. 해당 참조값이 가르키는 메모리 상엔 부모 객체도 존재하니까.

1. 부모 타입의 변수가 자식 인스턴스를 참조한다. Parent poly = new Child();

2. Child 인스턴스를 만들었다. 이 경우 자식 타입인 Child를 생성했기 때문에 메모리 상에 Child와 Parent가 모두 생성된다.

3. 생성된 참조값을 Parent 타입의 변수인 poly에 담아둔다.

 

Parent 타입의 변수는 다음과 같이 자신인 Parent는 물론이고, 자식 타입까지 참조할 수 있다. 만약 그 하위에 또 다른 자식이 있다면 그것도 가능하다. 

  • Parent poly = new Parent()
  • Parent poly = new Child()
  • Parent poly = new Grandson()

Grandson -> Child -> Parent 이런 구조로 상속받는 형태라면 new Grandson()을 실행하면 메모리에는 총 3개의 객체가 한 참조를 만들것이다. 그렇기 때문에 이 경우 Child, Parent 타입 변수 new Grandson()을 받아들일 수 있다.

 

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

 

다형적 참조의 한계

Parent poly = new Child() 이렇게 자식 인스턴스를 참조한 상황에서 poly가 자식 타입인 Child에 있는 childMethod()를 호출하면 어떻게 될까? 호출할 수 없다. 아래는 그 흐름인데 한번 보자.

 

1. poly.childMethod()를 실행하면 먼저 참조값을 통해 인스턴스를 찾는다.

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

 

 

아니 그럼, childMethod()를 못쓰는데 이거 어쩐담?

다형성과 캐스팅

Parent poly = new Child();

이 코드 한 줄은 poly라는 Parent 타입의 변수에 Child 인스턴스를 참조하게 한다. Child 인스턴스를 만들 때 부모인 Parent 인스턴스 역시 하나의 참조값에 포함되어 만들어지는데 이때 poly라는 변수의 타입이 Parent이므로 Child가 가지는 메서드는 실행할 수 없다. 상속 관계는 부모로만 찾아서 올라갈 수 있다.

 

그러면 childMethod()를 정말 너무 쓰고 싶은데 어떻게 하면 좋을까? 다운캐스팅을 하면 된다. 다음 코드를 보자.

public class Main {
    public static void main(String[] args) {
        Parent parent = new Child();
        // parent.childrenMethod(); 컴파일 에러

        Child child = (Child) parent;
        child.childMethod();
    }
}

Parent 타입의 변수 parent에 들어있는 참조값을 가지고 Child 타입의 변수 child에 대입을 한다. 근데 대입할 때 앞부분에 (Child)를 추가해주면 이게 바로 다운 캐스팅이다. 다운 캐스팅은 자식으로 형변환을 하는것이다. 이러면 childMethod()를 호출할 수 있다.

 

1. Child 인스턴스를 생성한다. 생성할 때 부모인 Parent도 같이 생성된 하나의 참조가 만들어진다.

2. 만들어진 참조의 참조값을 Parent 타입의 변수에 넣는다.

3. 해당 변수는 타입이 Parent이므로 자식의 메서드를 사용하지 못한다.

4. 자식의 메서드를 기어코 사용하기 위해 자식 타입으로 다운캐스팅을 한다.

5. 자식 메서드를 사용한다.

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

 

캐스팅시 주의

근데 위 코드를 파헤쳐보면 많은 것들이 나온다. 일단 첫번째, 다운캐스팅이란 단어가 있다는 것은 업캐스팅도 있다는 것을 내포한다. 맞다. 업캐스팅도 있고 업캐스팅은 부모로 캐스팅하는 것이다. 그리고 우리 이미 이건 해봤다. 

Parent parent = new Child();

이 코드가 바로 업캐스팅이다. 왜냐고? 자식 인스턴스를 부모 타입의 변수에 참조하니까. 저 코드는 사실 이런 모양이다. 

Parent parent = (Parent) new Child();

근데 자바에서 생략이 가능하게 해준다. 실제로 IDE에서 작성해보면 다음과 같이 회색 불빛으로 굳이 쓸 필요없다고 말해준다.


그럼 업캐스팅은 생략을 해주는데 왜 다운캐스팅은 생략을 안해줄까? 다운 캐스팅은 런타임 에러가 발생할 가능성이 있기 때문이다.

자 다음 코드를 보자. Parent 타입의 parentChild로 다운캐스팅한다. 그리고 다운캐스팅을 했으니 자식 타입의 메서드 cMethod()를 호출할 수 있다.

 

아, 그리고 이 과정이 귀찮다. 그래서 일시적 캐스팅이란게 있다. 다음 코드를 보자. 이렇게 하면 변수를 굳이 만들지 않아도 된다.

 

자 위 코드는 문제없이 잘 동작한다. 근데 다음 코드를 보자. 중요! 

위 코드가 동작할까? 아니다. 런타임 에러가 발생한다.

 

에러 내용은 ClassCastException이다. 왜 이런 에러가 발생할까? 근데 우리는 알고 있다. 객체와 메모리 구조를 다시 한번 떠올려보자.

new Parent()Parent 인스턴스를 메모리 상에 만들어낸다. 그러나 본인을 상속하는 자식은 알 길이 없기 때문에 참조에 어떠한 자식도 같이 만들어지지 않는다. 그러니까 그 참조에서 Child는 없기 때문에 Child로 다운캐스팅은 할 수가 없는것이다. 다음이 그 그림이다.

 

 

 

그러나 Parent parent = new Child();는 문제가 없다. 이것도 메모리 구조를 잘 떠올려보면 Child 인스턴스를 만들 때 부모 클래스에 대한 인스턴스 역시 같이 만들어진다. 그렇기 때문에 부모 타입의 parent라는 변수가 자식으로 다운캐스팅이 되어도 아무런 문제가 없이 잘 동작할 수 있게된다. 그 그림은 다음과 같다.

 

이러한 이유 때문에 다운 캐스팅은 위험하다고 하는 것이다. 그럼 왜 업캐스팅은 안전할까?

업캐스팅은 에러가 날 수가 없다. 왜냐면 더 상위 타입으로 형변환을 하는것인데 그 말은 자식 인스턴스를 만들어낸다는 것이고 자식 인스턴스를 만들 때 당연히 부모 인스턴스도 하나의 참조에 만들어지기 때문에 무조건 캐스팅이 잘된다.

 

다음 코드가 대표적인 업캐스팅 코드이다.

Parent parent = new Child();

Child 인스턴스를 만들 때 메모리 상에 참조 내에는 Child, Parent 인스턴스 둘 다 만들어진다. 부모를 같이 만드니까.

그러니까 당연히 부모타입으로 업캐스팅이 가능한 것. 절대 문제가 발생할 수가 없는 것.

 

그러면, 다운 캐스팅을 하고 싶은데 너무 하고 싶은데 이게 문제를 발생시킬까? 아닐까?를 IDE에서 조차 알려주지 않기 때문에(런타임 에러) 아예 사용하지 말아야할까? 내가 다운캐스팅하려고 하는 이 녀석이 참조하는 참조 인스턴스가 다운 캐스팅할 녀석보다 상위 클래스인지 알 방법은 없을까? 그러니까 아래 코드처럼 parentChild로 다운캐스팅하고 싶을 때 이 parent가 참조하는 인스턴스가 Child보다 상위 클래스인지 아닌지 알 수 있는 방법 말이다.

Parent parent = new Child();

 

 

instanceof

어떤 인스턴스를 참조하고 있는지 알 수 있는 방법이다. 다음 코드를 보자.

callParent() 메서드는 파라미터로 Parent parent를 받는다. 이 때 변수 parentParent 인스턴스를 참조하고 있을수도 있고 Child 인스턴스를 참조할 수도 있다. ChildParent를 상속받기 때문에 가능한 일인데, 원하는 것은 Parent 타입의 변수가 자식 클래스의 메서드를 사용하고 싶은 것이다. 그러려면 다운캐스팅을 해줘야한다. 그러나 위에서 말했지만 다운캐스팅은 위험하다. 에러가 발생할 소지가 있기 때문인데 그래서 에러를 방지하기 위해 instanceofChild 인스턴스를 참조하는 변수인지 확인을 하는 코드다.

 

 

참고로, instanceof 키워드는 왼쪽에서 참조하는 인스턴스가 오른쪽 타입에 대입될 수 있는지를 확인해보면 된다. 

new Parent() instanceof Parent // true
new Parent() instanceof Child // false
new Child() instanceof Parent // true
new Child() instanceof Child // true

 

그러니까 이것도 객체와 메모리 구조를 떠올려보자. 왼쪽에 인스턴스가 메모리 상에 어떻게 올라가는지 확인해보자. 

new Parent()를 하면 메모리에는 Parent 인스턴스만 참조 공간에 만들어진다. 이 상태에서 Parent 타입에는 대입이 가능하고 Child 타입에는 당연히 불가능하다. new Child()를 하면 메모리에는 Parent, Child 두 개의 인스턴스 모두 참조 공간에 만들어진다. 그래서 Parent 타입으로 대입도 가능하고 Child 타입으로 대입도 가능하다.

 

그리고 자바 16부터 가능한 기능인데 뭐 그냥 알아두면 나쁠건 없는 기능이다. 다음 코드를 보자.

여기서 parent가 참조하는 참조값이 Child 인스턴스라면 다운 캐스팅을 하는데 이걸 이렇게 한번에 쓸 수 있다. 변수를 선언해버리는 것이다.

 

 

다형성의 메서드 오버라이딩

다형성은 하나의 변수 타입으로 여러 자식 인스턴스를 참조할 수 있는 것을 말한다. 이것을 다형적 참조라고 하는데 다형성의 또 다른 핵심 주제 하나는 메서드 오버라이딩이다. 다음 코드를 보자.

 

Parent

public class Parent {
    public String value = "parent";

    public void method() {
        System.out.println("Parent method");
    }
}

 

Child

public class Child extends Parent {
    public String value = "child";

    @Override
    public void method() {
        System.out.println("Child method");
    }
}

 

ParentChild 클래스 모두 value라는 필드를 가진다. 필드는 오버라이딩의 개념이 없다. 둘 다 각자 가지고 있는 필드인거고 method()라는 메서드를 Child에서 오버라이딩했다. 코드 자체는 굉장히 간단하다.

 

Main

public class Main {
    public static void main(String[] args) {
        Parent parent = new Parent();
        System.out.println(parent.value);
        parent.method();

        Child child = new Child();
        System.out.println(child.value);
        child.method();

        Parent poly = new Child();
        System.out.println(poly.value);
        poly.method();
    }
}

 

실행결과:

parent
Parent method
child
Child method
parent
Child method

 

신기한 일이 벌어졌다. 당연히 위에 두 실행결과는 납득이 된다. Parent 타입 변수에 Parent 인스턴스를 참조했으니 Parent가 가지고 있는 value와 method()가 호출될테고 Child도 마찬가지다. 그림을 통해서 좀 더 명확히 확인해보자.

child 변수는 Child 타입이다. 따라서 child.value, child.method()를 실행하면 인스턴스의 Child 타입에서 기능을 찾아서 수행한다.

 

parent 변수는 Parent 타입이다. 따라서 parent.value, parent.method()를 호출하면 인스턴스의 Parent 타입에서 기능을 찾아 수행한다. 사실 new Parent()로 인스턴스를 만들면 인스턴스 공간에 Child 자체가 없기 때문에 Child의 뭐든 가져올수도 없다.

 

 

여기서가 중요하다. Parent 타입 변수에 Child 인스턴스를 참조했더니 필드는 그대로 Parent의 필드를 반환했지만 method()는 Child의 method()를 반환했다. 

무슨 일일까? 객체와 메모리 구조를 상기해보면 Child라는 인스턴스를 생성할 때 부모인 Parent도 같은 참조 공간에 만들어지고 변수 타입이 Parent니까 분명 참조 공간에서 Parent를 바라보면 Parent의 method()가 실행됐어야 했는데 Child method()가 실행됐다. 

메서드는 오버라이딩 한 메서드가 무조건 우선순위를 가진다.

 

 

이 다형성과 메서드 오버라이딩이 강력하고 어마어마한 코드 작성을 도와준다. 이제 이 개념을 사용해볼 차례다.

 

사실, 이 내용이 스프링의 핵심 패턴인 전략 패턴과 매우 밀접한 관계가 있다. 생각해보자. 인터페이스를 만들고 그 인터페이스에서 추상 메서드를 정의했다. 그리고 그 인터페이스를 구현한 구현클래스를 만들었다. 그 구현 클래스에 "@Service" 애노테이션을 붙이면 스프링이 자동으로 해당 클래스를 인터페이스 타입의 참조값으로 으로 등록하잖아? 그리고 우리가 특정 서비스나 컨트롤러에서 DI를 하면 그 DI되는 인스턴스는 빈으로 등록한 그 녀석이 된다. 그래서 인터페이스의 메서드를 호출하면 빈으로 등록한 클래스의 메서드가 실행되는게 이 원리이다. 인터페이스는 하나, 그것을 구현한 구현클래스는 여러개일지언정 이 코드를 사용하는 클라이언트 소스에서는 전혀 변경할 필요없이 빈 등록만 다른걸로 바꿔주면 되는것 이게 바로 OCP이고. 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

OCP (Open-Closed Principle) 원칙  (0) 2024.03.30
다형성 (Part.2) 사용하기 ✨  (0) 2024.03.29
상속 (Part.2)  (0) 2024.03.28
상속 (Part.1)  (0) 2024.03.28
final  (0) 2024.03.27
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

super

상속과 메모리 구조를 통해 봤다시피 어떤 타입으로 인스턴스를 생성했냐에 따라 참조값으로 들어가서 먼저 확인하는 인스턴스가 달라졌다. 그런데 만약 부모와 자식이 이름이 같은 필드를 가지고 있거나 오버라이딩 메서드가 있을 때 내가 자식 타입으로 인스턴스를 생성했는데 부모의 필드나 메서드에 접근하고 싶으면 어떻게 할까? 이때 사용하는 키워드가 super이다.

 

Car

public class Car {
    public int wheelCount = 4;
    public void move() {
        System.out.println("이동");
    }
}

 

ElectricCar

public class ElectricCar extends Car {
    public int wheelCount = 10;
    public void charge() {
        System.out.println("충전");
    }

    @Override
    public void move() {
        System.out.println("전기차 이동");
    }

    public void getWheelCount() {
        System.out.println(this.wheelCount);
        System.out.println(super.wheelCount);
    }

    public void showMove() {
        this.move();
        super.move();
    }
}

 

부모와 자식 클래스모두 wheelCount 라는 필드를 가지고 있고 move()라는 메서드가 있을 때 자식 클래스에서 본인의 필드나 메서드말고 부모의 필드나 메서드를 가져다가 사용하고 싶으면 super를 사용하면 된다. (getWheelCount(), showMove() 확인)아주 간단하다.

 

 

super 생성자

이전에 특정 클래스에서 생성자를 다른것을 호출하고 싶을 때 this()로 호출했던것이 기억날 것이다. this니까 본인의 생성자 중 특정 무언가를 호출하는거고 super()라면? 부모의 생성자 중 특정 무엇을 호출한다고 보면 된다. 근데 상속받았다면 생성자에 이 super()는 필수다.

 

어? 나는 super() 썼던 기억이 없는데? => 생략가능할 뿐이다.

 

부모 클래스의 기본 생성자를 호출하는 super()는 생략이 가능하다. 그러나 부모 클래스에 기본 생성자가 없다면 반드시 명시적으로 작성해줘야 한다. 다음 코드를 보자.

 

A

public class A {
    public A() {
        System.out.println("A 생성자 호출");
    }
}

 

B

public class B extends A {

    public B() {
        super(); // 생략 가능
        System.out.println("B 생성자 호출");
    }
}

 

Main

public class Main {
    public static void main(String[] args) {
        B b = new B();
    }
}

 

실행결과

A 생성자 호출
B 생성자 호출

종료 순서는 당연히 A가 먼저다. 스택으로 생각하자. 마지막에 호출된 게 가장 먼저 끝난다.

 

위 코드를 보면 BA를 상속받는다. 그리고 B 클래스의 인스턴스를 생성할 때 반드시 무조건 A 클래스의 생성자도 호출되어야 한다. 이건 규칙이다. 그러나 그 생성자가 기본 생성자라면 생략이 가능하다. 자바가 도와주기 때문에.

왜 그래야하냐면 메모리 구조를 떠올려보자. 부모 자식관계를 가진 클래스를 인스턴스로 생성하면 그 참조하는 곳엔 부모 객체와 자식 객체가 두개가 한곳에 생기는 것을 기억하는가? 

이 그림 그대로 자식을 만들 때 부모를 같이 만들면 당연히 부모도 생성자를 호출해야 부모 객체가 만들어질테니 super()는 필수다. 근데 이 필수 작업을 매번 해주기 귀찮으니 자바가 도와줄뿐이다. 근데 만약에 부모 생성자 중 기본 생성자가 없으면 반드시 명시적으로 작성해야한다. 다음 코드를 보자.

 

A

public class A {
    public A(int a) {
        System.out.println("A 생성자 호출: params = " + a);
    }
}

 

B

public class B extends A {

    public B() {
        System.out.println("B 생성자 호출");
    }
}

이런 부모 자식 관계를 가지는 코드가 있을 때 이는 컴파일 에러가 발생한다. 왜냐하면 A에는 기본 생성자가 없다. 개발자가 직접 생성자를 하나라도 만들면 자바는 기본적으로 생성자를 제공해주지 않는다. 그래서 기본 생성자가 없는데 B 클래스의 생성자에서 super()라는 기본 생성자를 호출하는 코드가 따로 없으면 기본으로 부모 클래스의 기본 생성자를 호출하는 'super()'를 호출하는데 부모에는 기본 생성자가 없으니 에러가 발생한다. 다음이 그 에러다.

 

그래서 이럴땐 반드시 명시적으로 작성해줘야한다.

 

그리고, 위에서 잠깐 언급한 this()를 사용해서 생성자에서 다른 생성자를 부를때 어떤 생성자가 됐던 반드시 하나의 super()는 호출이 되어야하고 된다. 아래처럼 this()를 사용한다고해도 반드시 어디서든 한번은 super()를 불러야한다.

그리고 super()this() 둘 중 하난 무조건 생성자에서 첫번째 줄에 와야한다. 둘 다 없으면 super()가 생략된 것.

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

다형성 (Part.2) 사용하기 ✨  (0) 2024.03.29
다형성 (Part.1) 매우중요 ✨  (0) 2024.03.28
상속 (Part.1)  (0) 2024.03.28
final  (0) 2024.03.27
자바 메모리 구조 ✨  (0) 2024.03.27
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

덜 중요한 부분: 규칙

클래스는 다중 상속이 불가하다 

다음 그림이 예시인데 만약, 다음 그림처럼 다중 상속을 했을 때 자식 입장에서 move()를 실행하면 어떤 부모의 move()를 실행해야 하는지 자바는 알 수 없기 때문이다. 

 

 

인터페이스는 다중 상속이 가능하다

인터페이스는 여러 인터페이스를 상속받아도 결국 구현하는 구현체에서 그 쓰임새를 만들기 때문에 위와 같은 문제가 발생하지 않아서 그렇다. 

 

 

상속 관계에서 화살표의 방향은 '내가 얘를 알고 있다'라고 해석하면 이해가 빠르다

이 그림에서 자식에서 부모 방향으로 화살표 방향이 정해지는데, 가끔 이게 헷갈렸다. 근데 확실하게 이해했다. 내가 얘를 알고 있다로 보면 바로 이해된다. 자식은 부모를 상속받는다. 그래서 자식은 부모가 누군지 명확히 알고 있다. 근데 부모는 누가 나를 상속받는지 코드 상에서 전혀 알 수 없다. 그래서 화살표 방향을 저렇게 표현한다.

 

 

매우 중요한 부분: 상속과 메모리 구조

이 부분을 제대로 이해해야 한다. 다음 코드를 보자.

ElectricCar electricCar = new ElectricCar();

 

위와 같이 ElectricCar 클래스의 객체를 생성했다. 이 클래스는 Car를 상속받는다. 이럴 때 메모리에서 어떤 구조로 만들어질까?

그림과 같이 메모리에서는 ElectricCar 뿐만 아니라 상속 관계에 있는 Car까지 함께 포함된 인스턴스를 생성한다. 참조값은 x001 하나이지만 실제로 그 안에서는 Car, ElectricCar라는 두가지 클래스 정보가 공존하는 것이다. 

 

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

 

이 상태에서 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() 메서드를 호출한다. 

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

 

 

정리를 하자면 딱 3가지 핵심을 기억하자.

  • 상속 관계의 객체를 생성하면 그 내부에는 부모와 자식이 모두 생성된다.
  • 상속 관계의 객체를 호출할 때, 대상 타입을 정해야한다. 이때 호출자의 타입을 통해 대상 타입을 찾는다. (electricCar.move()를 호출할 때 대상 타입은 electricCar라는 변수의 타입(위 예시에선 ElectricCar)을 찾아서 그 타입으로 먼저 찾는다는 의미)
  • 현재 타입에서 기능을 찾지 못하면 상위 부모 타입으로 기능을 찾아서 실행한다. 기능을 찾지 못하면 컴파일 오류가 발생한다.

 

 

오버라이딩과 메모리 구조

다를건 없다. 위에서 설명한 그대로를 따라가는데 한번 보자.

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

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

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

 

메서드 오버라이딩 조건

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

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

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

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

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

- static, final, private 키워드가 붙은 메서드는 오버라이딩 될 수 없다: static은 클래스 레벨에서 작동하므로 인스턴스 레벨에서 사용하는 오버라이딩이 의미가 없고, final은 더이상 변경이 불가능함을 의미하고, private은 아예 자식 클래스에서는 보이지도 않는다.

- 생성자 오버라이딩은 불가하다.

 

 

상속과 접근제어

당연히 상속받아도 접근 제어자에 의해 접근이 가능하고 불가능하다. 단순하게 접근 제어자 그대로 따라가면 된다.

- public: 상속받은 클래스에서 접근할 수 있다.

- private: 상속받은 클래스에서 접근 불가하다.

- default: 같은 패키지의 상속받은 클래스면 접근이 가능하다.

- protected: 상속받은 클래스에서 접근이 가능하다.

 

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

다형성 (Part.1) 매우중요 ✨  (0) 2024.03.28
상속 (Part.2)  (0) 2024.03.28
final  (0) 2024.03.27
자바 메모리 구조 ✨  (0) 2024.03.27
캡슐화  (0) 2024.03.26
728x90
반응형
SMALL

final 키워드는 이름 그대로 끝! 이라는 뜻이다. 변수에 final이 붙으면 더이상 값을 변경할 수 없다. 정의를 모르는게 아니고 약간 헷갈릴 수 있는 부분들이나 알아두면 코드에 더 도움이 되는것들을 적어봤다.

 

다음 코드를 보자.

Final

public class Final {
    private final int value = 10;

    public Final(int value) {
        this.value = value;
    }
}

이 코드는 에러를 발생시킨다. 왜냐하면 'value'라는 변수에 final 키워드가 붙었고 선언과 동시에 초기화를 해줬다. 그럼 그 이후에 어디서도 이 값을 변경할 수 없는데 생성자에서 받은 값을 대입하려고 하고 있기 때문이다. 그래서 저 코드는 다음과 같이 수정할 수 있다.

public class Final {
    private final int value;

    public Final(int value) {
        this.value = value;
    }
}

value라는 변수를 final로 선언했지만 초기화하지 않은 경우 생성자를 통해서 초기화하든 어디서든 딱 한번은 초기화를 할 수 있다.

그럼 여기서 다음 코드를 보자.

 

Final

public class Final {
    private final int value = 10;

    public int getValue() {
        return value;
    }
}

 

FinalMain

public class FinalMain {
    public static void main(String[] args) {
        Final f = new Final();
        Final f2 = new Final();
        Final f3 = new Final();

        System.out.println("f = " + f.getValue());
        System.out.println("f2 = " + f2.getValue());
        System.out.println("f3 = " + f3.getValue());
    }
}

 

지금 이렇게 Final 이라는 클래스가 있고 그 클래스의 인스턴스 변수 valuefinal로 선언된 변수이다.

이 클래스의 인스턴스를 3개 만들고 해당 인스턴스가 가지는 value값을 출력해보면 다음 결과가 나온다.

실행결과:

f = 10
f2 = 10
f3 = 10

 

그리고 이 세개의 인스턴스도 마찬가지고 앞으로 생성될 모든 인스턴스 역시 value라는 값은 무조건 10일것이다.

그럼 인스턴스가 새로 생성될 때마다 힙 영역에 그 인스턴스가 올라가는데 메모리 낭비가 되지 않을까? 맞다. 그래서 이럴 때 사용하면 좋은 것이 바로 static이다. 

 

static final

왜냐하면 static으로 선언된 변수를 생각해보면 static 영역에 들어가고 오로지 한 개만 존재한다. 그리고 값은 변하지 않는다 왜냐? final로 선언했으니까.

이 말은 필드에 final + 필드 초기화를 사용하는 경우 static을 붙여서 사용하는 것이 효과적이다.

그리고 이렇게 static final로 선언하면 언제라도 변경되지 않는 유일한 수가 되는데 이를 Constant(상수)라고 한다.

 

자바에서는 상수를 선언할 때 관례가 있는데 모두 대문자를 사용하고 구분은 _로 한다. 다음 코드가 그 예시가 되겠다.

public class Final {
    public static final double PI = 3.14;
    public static final int MAX_USER = 10000;
}

 

 

참조형에 대한 final

그리고 또 한가지 헷갈리는 것은 참조형 변수에 final 키워드가 붙는 경우에는 해당 변수의 참조값을 더 이상 바꿀 수 없다는 것이지 참조하고 있는 인스턴스, 배열, 리스트 등등의 내부값은 변경이 가능하다.

 

난 이게 항상 헷갈렸는데 다음 코드를 보자.

 

Final

public class Final {
    private int value;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}

 

FinalMain

public class FinalMain {
    public static void main(String[] args) {
        final Final f = new Final();

        System.out.println(f.getValue());
        f.setValue(20);
        System.out.println(f.getValue());
    }
}

 

Final 클래스의 인스턴스 변수 valuefinal이 아니다. 그리고 main() 메서드에서 Final 인스턴스를 생성할 때 변수에 final이 붙었다.

그러면 당연히 초기화 한 이후로 f라는 변수는 다른 참조값을 가질 수 없고 딱 저 인스턴스가 가지는 참조값만 가질 수 있다.

그러나, f가 가지는 참조값을 쭉 따라가면 인스턴스가 나올것인데 그 인스턴스의 변수를 못바꾸는게 아니다. 그래서 실제로 실행결과는 다음과 같다.

 

실행결과:

0
20

 

다른 예시도 보자. 다음은 ArrayList()에 대한 참조값을 final로 받는 변수다. 이 변수에 당연히 다른 ArrayList() 참조값을 넣으려면 에러가난다. 다음이 그 화면인데 그렇다고 한들 이 list가 참조하는 참조값을 따라가서 List에 원소를 추가하고 빼는것에는 아무런 문제가 없다. (new로 선언하는 것은 참조형 = 힙 영역에 메모리를 차지 = 참조값을 참조 변수에 알려줌)

그저 참조값만 더 이상 변경이 불가능한 것이다. 다음을 보자.

public class FinalMain {
    public static void main(String[] args) {

        final List<Integer> list = new ArrayList<>();

        System.out.println(list.size());

        list.add(1);
        list.add(2);
        list.add(3);

        System.out.println(list.size());
    }
}

실제로 잘 추가가 되고 사이즈를 찍어보면 실행결과는 다음과 같다.

실행 결과:

0
3

 

 

클래스와 메서드에 final

클래스에 final이 붙으면 상속이 끝!

메서드에 final이 붙으면 오버라이딩 끝!

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

상속 (Part.2)  (0) 2024.03.28
상속 (Part.1)  (0) 2024.03.28
자바 메모리 구조 ✨  (0) 2024.03.27
캡슐화  (0) 2024.03.26
Class 레벨의 접근제어자  (0) 2024.03.26
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

 

메서드 영역

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

  • 클래스 정보: 클래스의 실행 코드(바이트 코드), 필드, 메서드와 생성자 코드등 모든 실행 코드가 존재한다.
  • static 영역: static 변수들을 보관한다.
  • 런타임 상수 풀: 프로그램을 실행하는데 필요한 공통 리터럴 상수를 보관한다. 예를 들어서 프로그램에 "hello"라는 리터럴 문자가 있으면 이런 문자를 공통으로 묶어서 관리한다. 이 외에도 프로그램을 효율적으로 관리하기 위한 상수들을 관리한다. (참고로 문자열을 다루는 문자열 풀은 자바 7부터 힙 영역으로 이동했다)

스택 영역

자바 실행 시, 하나의 실행 스택이 생성된다. 각 스택 프레임은 지역 변수, 중간 연산 결과, 메서드 호출 정보등을 포함한다. 

  • 스택 프레임: 스택 영역에 쌓이는 네모 박스가 하나의 스택 프레임이다. 메서드를 호출할 때 마다 하나의 스택 프레임이 쌓이고, 메서드가 종료되면 해당 스택 프레임이 제거된다.
참고: 스택 영역은 더 정확히는 각 쓰레드 별로 하나의 실행 스택이 생성된다. 따라서 쓰레드 수 만큼 스택 영역이 생성된다. 쓰레드를 한 개만 사용하면 스택 영역도 하나이다. 쓰레드가 2개면 스택 영역도 두개이다.

힙 영역

객체(인스턴스)와 배열이 생성되는 영역이다. 가비지 컬렉션(GC)이 이루어지는 주요 영역이며, 더 이상 참조되지 않는 객체는 GC에 의해 제거된다.

 

 

메서드 코드는 메서드 영역에 존재한다.

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

 

스택과 큐

스택(Stack)

스택영역을 자바에서 가지고 있으면 스택이 어떤 구조로 이루어졌는지 이해해야 한다. 스택(Stack)은 쉽게 생각해서 위에만 구멍이 있는 통이라고 생각하면 된다.

 

스택에 블럭을 넣는다.

 

스택에 블럭을 뺀다.

 

어떤 구조인가? 가장 마지막에 넣은것이 가장 먼저 나오게 되는 후입선출(LIFO: Last In First Out) 구조를 가지고 있다.

 

큐(Queue)

큐는 위와 아래에 모두 구멍이 있는 통이라고 생각하면 된다.

 

어떤 구조인가? 가장 먼저 넣은 블럭이 가장 먼저 빠져나오는 선입선출(First In First Out) 구조이다. 이것을 큐라고 한다.

왜 이런 자료 구조를 알아야 하냐면 각자 필요한 영역이 있기 때문이다. 예를 들어 선착순 이벤트를 진행하는 프로그램을 만들 때 고객이 대기해야 한다면 큐 자료 구조를 사용해야 한다. 먼저 온 손님이 먼저 실행되어야 하니까. 그럼 프로그램 실행과 메서드 호출에는 스택 구조가 적합하다. 생각해보면 A라는 메서드가 있고 그 A라는 메서드는 내부에서 B라는 메서드를 호출한 결과를 받아 어떤 결과를 내는 로직이 있을 때 A를 호출하면 A가 B를 호출한다. 그럼 호출 순서는 A -> B 인데 뭐가 먼저 실행되어야 하나? B다. B가 실행되어야 A가 그 결과를 가지고 자기의 결과를 낼 수 있으니까. 그러니까 프로그램 실행 및 메서드 호출은 스택 구조가 적합하다. 그래서 자료구조에 대해서 이해하고 있는것은 중요하다. 적어도 스택이랑 큐 정도는.

 

위 내용을 코드로 예로 들어보자. 

public class Memory {
    public static void main(String[] args) {
        System.out.println("main start");
        method1(10);
        System.out.println("main end");
    }

    public static void method1(int m1) {
        System.out.println("method1 start");
        int cal = m1 * 2;
        method2(cal);
        System.out.println("method1 end");
    }

    public static void method2(int m2) {
        System.out.println("method2 start");
        System.out.println("method2 end");
    }
}

 

실행 결과:

main start
method1 start
method2 start
method2 end
method1 end
main end

 

명백하게 스택 구조를 이루고 있다는 게 보인다. 이 코드가 실행되는 동안 스택 영역은 어떤 변화가 일어날까?

  • 처음 자바 프로그램을 실행하면 main()을 실행한다. 이 때 main()을 위한 스택 프레임이 하나 생성된다.
  • main()method1()을 호출한다. method1() 스택 프레임이 생성된다.
    • method1()m1, cal 지역변수(매개변수 포함)를 가지므로 해당 지역 변수들이 스택 프레임에 포함된다.
  • method1()method2()를 호출한다. method2() 스택 프레임이 생성된다. method2()m2 지역변수(매개변수 포함)를 가지므로 해당 지역 변수가 스택 프레임에 포함된다.

 

  • method2()가 종료된다. 이때 method2() 스택 프레임이 제거되고, 매개변수 m2도 제거된다. method2() 스택 프레임이 제거되었으므로 프로그램은 method1()로 돌아간다. 물론 method1()을 처음부터 시작하는 것이 아니라 method1()에서 method2()를 호출한 지점으로 돌아간다.
  • method1()이 종료된다. 이때 method1() 스택 프레임이 제거되고 지역변수(매개변수 포함) m1, cal도 제거된다. 프로그램은 main()으로 돌아간다.
  • main()이 종료된다. 더 이상 호출할 메서드가 없고 스택 프레임도 완전히 비워졌다. 자바는 프로그램을 정리하고 종료한다.

 

스택 영역과 힙 영역이 같이 사용되는 경우

힙 영역에는 객체(인스턴스)가 사용된다고 했다. 그 점을 기억하면서 다음 코드를 보자.

Data

public class Data {
    private int value;

    public Data(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}

DataMain

public class DataMain {
    public static void main(String[] args) {
        System.out.println("main start");
        method1();
        System.out.println("main end");
    }

    private static void method1() {
        System.out.println("method1 start");
        Data data = new Data(10);
        method2(data);
        System.out.println("method1 end");
    }

    private static void method2(Data data) {
        System.out.println("method2 start");
        System.out.println("data.value = " + data.getValue());
        System.out.println("method2 end");
    }
}

 

 

실행 결과

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

 

 

실행 결과를 보면 스택 구조로 가장 마지막에 실행된 메서드가 가장 먼저 끝나는 구조로 진행됐음을 알 수 있다.

그럼 그림을 통해 좀 더 깊이 이해해보자.

 

  • 처음 main() 메서드를 실행한다. main() 스택 프레임이 생성된다.

  • main()에서 method1()을 실행한다. method1() 스택 프레임이 생성된다.
  • method1()은 지역변수로 Data data1을 가지고 있다. 이 지역변수도 스택 프레임에 포함된다.
  • method1()new Data(10)을 사용해서 힙 영역에 Data 인스턴스를 생성한다. 그리고 참조값을 data1에 보관한다.

  • method1()method2()를 호출하면서 Data data2 매개변수에 x001 참조값을 넘긴다.
  • 이제 method1()에 있는 data1method2()에 있는 data2 지역변수(매개변수 포함)는 둘 다 같은 x001 인스턴스를 참조한다.

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

  • method1()이 종료된다. method1()의 스택 프레임이 제거되면서 지역 변수 data1도 함께 제거된다.

  • method1()이 종료된 직후의 상태를 보자. method1()의 스택 프레임이 제거되고 지역변수 data1도 함께 제거되었다.
  • 이제 x001 참조값을 가진 Data 인스턴스를 참조하는 곳이 더는 없다. 참조하는 곳이 없으므로 사용되는 곳도 없다. 결과적으로 프로그램에서는 더는 사용하지 않는 객체인 것이다. 이런 객체는 메모리만 차지하게 된다. GC는 이렇게 참조가 모두 사라진 인스턴스를 찾아서 메모리에서 제거한다. (참고로, 힙 영역 외부가 아닌 힙 영역 안에서만 인스턴스끼리 서로 참조하는 경우에도 GC의 대상이 된다.)
정리: 여기서 알아낸 사실은 지역변수는 스택 영역에서 관리되고 객체(인스턴스), 인스턴스 변수는 힙 영역에서 관리되는 것을 확인했다. 이제 나머지 하나가 남았는데 메서드 영역이다. 메서드 영역에서 관리하는 변수도 있다. static 변수가 그렇다. 

 

static 변수

static 변수는 다른 말로 정적 변수, 클래스 변수라고 표현한다. 이 변수를 사용하는 목적은 특정 인스턴스가 가지는 값이 아니라 클래스가 공통으로 사용하는 변수나 메서드를 작성하려고 할 때 사용한다. 그니까 인스턴스마다 달라지는 값이 아니라 모든 인스턴스가 다 동일한 값을 가진다는 소리다. 왜 필요할까? 특정 클래스가 공유하는 값을 전역으로 관리하고 싶은 경우가 있을 수 있기 때문이다. 

 

그리고 인스턴스마다 관리하는 변수가 아니란 소리는 이 static 변수는 힙 영역에서 관리하지 않는다는 말로 유추할 수 있다. 힙 영역은 객체(인스턴스)와 그 객체가 가지는 인스턴스 변수를 관리하는 곳이니까. 그래서 static 변수는 프로그램 전역에서 공통으로 사용되는 것들을 관리하는 메서드 영역에서 관리된다.

 

 

그래서 만약 Data3 이라는 클래스가 있고 그 클래스안에 클래스 변수 count가 있으면 인스턴스를 생성하면 해당 인스턴스와 인스턴스 변수는 힙 영역에서 관리되고 클래스 변수는 메서드 영역 내 static 영역에서 딱 1개만 생성되고 관리된다. 

 

이 상태에서 아무리 Data3 클래스의 인스턴스를 여러개 만들어도 다음과 같이 모두 같은 static 변수를 바라본다.

 

 

변수와 생명주기

  • 지역변수(매개변수 포함): 지역 변수는 스택 영역에 있는 스택 프레임 안에 보관된다. 메서드가 종료되면 스택 프레임도 제거되는데 이때 해당 스택 프레임에 포함된 지역 변수도 함께 제거된다. 따라서 지역 변수는 생존 주기가 짧다.
  • 인스턴스 변수: 인스턴스에 있는 멤버 변수 중 static이 붙지 않은 변수를 인스턴스 변수라고 한다. 인스턴스 변수는 힙 영역을 사용한다. 힙 영역은 GC(가비지 컬렉션)가 발생하기 전까지는 생존하기 때문에 보통은 지역변수보다 생명주기가 길다.
  • 클래스 변수: 클래스 변수는 메서드 영역의 static 영역에 보관되는 변수이다. 메서드 영역은 프로그램 전체에서 사용하는 공용 공간이다. 클래스 변수는 해당 클래스가 JVM에 로딩되는 순간  생성된다. 그리고 JVM이 종료될 때까지 생명주기가 이어진다. 따라서 가장 긴 생명주기를 가진다.
static이 정적이라는 이유는 바로 여기에 있다. 힙 영역에 생성되는 인스턴스 변수는 동적으로 생성되고 제거된다. 반면에 static인 정적 변수는 거의 프로그램 실행 시점에 딱 한번 만들어지고 프로그램 종료 시점에 제거된다. 정적 변수는 이름 그대로 정적이다.

 

 

정적 변수 접근법

정적 변수는 클래스로부터 바로 접근도 가능하고 클래스의 인스턴스로도 접근이 가능하다.

Data.count // 가능 (클래스로부터 접근)
data.count // 가능 (인스턴스로부터 접근)

 

근데 인스턴스를 통한 접근은 관례상 추천하지 않는다. 이유가 뭘까? 코드를 읽는 사람은 이것을 보고 인스턴스 변수라고 생각하지 클래스 변수라고 생각하지 않기 때문이다. 관례는 따라야 좋기 때문에 관례이다. 

 

그리고 인스턴스로 접근을 한다고해도 결국 힙 영역에 해당 인스턴스로 가서 count를 찾는데 힙 영역에 없는 메서드 영역에 있는 변수구나!라고 판단해서 결국은 메서드 영역에 static 영역으로 가서 해당 변수에 접근한다.

 

물론, 해당 클래스 변수가 만들어지는 클래스 안에서는 그냥 접근하면 된다. 그냥.

 

 

static 메서드

static 메서드도 있는데 얘는 왜 있을까? 이유를 알아야 그 용도도 올바르게 사용할 수 있다. static 메서드는 해당 클래스의 인스턴스를 만들 필요가 없을 때 static 메서드를 만든다. 아래 코드를 보자.

Utils

public class Utils {

    public String decoration(String str) {
        return "*" + str + "*";
    }
}

UtilsMain

public class UtilsMain {
    public static void main(String[] args) {
        Utils utils = new Utils();
        String str = "Hello";
        String decoration = utils.decoration(str);

        System.out.println(str);
        System.out.println(decoration);
    }
}

 

Utils 클래스에 있는 메서드 decoration()은 문자열을 받아서 해당 문자열 앞뒤로 "*"을 붙여주는 메서드이다. 이것을 사용하는 코드가 UtilsMain이고 여기서 보면 Utils 클래스의 인스턴스를 생성한 후 해당 인스턴스로 decoration()에 접근한다. 근데, 인스턴스를 만들 필요가 있을까? 없다. 인스턴스를 사용하는 부분이 단 한개도 없다. 이럴 때 다음 코드를 아래처럼 변경해보자.

변경한 Utils

public class Utils {
    public static String decoration(String str) {
        return "*" + str + "*";
    }
}

변경한 UtilsMain

public class UtilsMain {
    public static void main(String[] args) {
        String str = "Hello";
        String decoration = Utils.decoration(str);

        System.out.println(str);
        System.out.println(decoration);
    }
}

 

기존 decoration() 메서드를 static 메서드로 변경하고 나니 인스턴스를 만들 필요없이 클래스 레벨로 바로 접근해서 사용했다. 코드 양도 줄고 불필요한 시간 낭비도 줄이고 가독성도 높아진 느낌이다. 이럴 때 static 메서드를 사용한다.

 

그러나, static 메서드는 언제나 사용할 수 있는 것이 아니다. 

정적 메서드 사용법

  • static 메서드는 static만 사용할 수 있다.
    • static 메서드 안에서는 static 변수나 static 메서드만 사용이 가능하고 인스턴스 변수나 인스턴스 메서드는 사용할 수 없다.
  • 반대로 어디서든 static 메서드를 호출할 수는 있다.
    • 접근 제어자만 허락한다면 어디서나 호출할 수 있다. 그러라고 만든 키워드가 static이니까.

아래 그림을 보면 이해가 바로 된다.

힙 영역에 있는 인스턴스 변수를 메서드 영역 내 static 영역에 있는 메서드가 알 길이 있을까? 참조값이 없으면 해당 변수에 접근 자체를 할 수 없다. 모르니까. 인스턴스 메서드는 메서드 영역에 있지만 static 영역에 있는게 아니라 이 또한 마찬가지로 어떤 메서드를 말하는지 참조값을 모르니 알 수 없다. 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

상속 (Part.1)  (0) 2024.03.28
final  (0) 2024.03.27
캡슐화  (0) 2024.03.26
Class 레벨의 접근제어자  (0) 2024.03.26
Package에서 딱 하나 헷갈리는 한가지  (0) 2024.03.26
728x90
반응형
SMALL

참고 자료:

 

김영한의 실전 자바 - 중급 1편 | 김영한 - 인프런

김영한 | 실무에 필요한 자바의 다양한 중급 기능을 예제 코드로 깊이있게 학습합니다., [사진]국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바

www.inflearn.com

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

 

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

캡슐화는 그러니까 두가지 파트로 나뉘어지는데, 1. 속성과 기능을 하나로 묶는다. 2. 외부에 꼭 필요한 기능만을 노출시키고 나머지는 숨긴다. 그럼 여기서 1번은 클래스 내 필드를 만들고 그 필드를 가지고 어떤 행위를 할 것인가에 대한 메서드를 정의하는 것으로 생각할 수 있다. 2번은 그 필드와 메서드를 접근 제어자를 통해 제한하는 것이다.

 

그럼 어떤것을 숨길까?

 

데이터를 숨겨라

객체에는 속성(데이터)과 기능(메서드)이 있다. 캡슐화에서 가장 필수로 숨겨야하는 것은 속성(데이터)이다. 예를 들어, Account라는 클래스가 있을 때 해당 클래스의 속성으로 'balance'라는 필드가 있으면 외부에서 이 필드에 직접적으로 접근이 가능하면 어디서나 잔고에 돈을 더하거나 뺄 수 있을것이다. 최악으로 가는 길이다. 자동차를 예시로 생각해보면 우리가 자동차를 운전할 때 자동차 부품을 다 열어서 그 안에 있는 속도계를 직접 조절하지 않는다. 단지 자동차가 제공해주는 엑셀이라는 기능을 사용해서 엑셀을 밟으면 자동차가 나머지는 다 알아서 하는 것이다.

 

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

 

기능을 숨겨라

객체의 기능 중 외부에서 사용하지 않고 내부에서만 사용하는 기능들이 있다. 이런 기능도 모두 감추는 것이 좋다. 예를 들어 자동차 엑셀을 밟는것만 알면 되는데 엑셀을 밟을 때 자동차가 하는 내부적으로 하는 행위를 우리가 안다고 자동차가 앞으로 가는것에 어떤 도움을 줄 수 있는가? 오히려 알면 독이 될 수도 있다. 

 

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

 

아래 캡슐화가 잘 된 코드로써 예를 들어보자.

Account

public class Account {
    private int balance;

    public Account(int balance) {
        this.balance = balance;
    }

    public int deposit(int amount) {
        balance += amount;
        return balance;
    }

    public int withdraw(int amount) {
        balance -= amount;
        return balance;
    }

    public void status() {
        System.out.println("현재 잔고: " + balance + "원");
    }
}

 

위 코드를 보면 은행 계좌 클래스임을 알 수 있고 필드로 'balance'라는 속성을 가진다. 그 필드는 외부에서 접근하지 못하는 'private'이다. 그리고 이 계좌라는 클래스에서 할 수 있는 행위는 입금, 출금, 잔고 상태 확인이 있을 수 있는데 각각의 기능은 외부에서도 사용가능 하도록 'public'으로 만들었다. 

 

그럼 이 Account 클래스를 가져다가 사용하는 코드를 보자.

AccountMain

public class AccountMain {
    public static void main(String[] args) {
        Account account = new Account(10000);

        account.deposit(5000);
        account.status();
        account.withdraw(1500);
        account.status();
    }
}

외부에서는 이 클래스의 객체를 만들어서 입금, 상태 확인, 출금, 상태 확인 기능을 차례대로 사용했다. 아무런 문제가 없다. 

이제 여기서 Account 클래스에 새로운 메서드를 만들것이다. 그 메서드는 외부에서 접근하지 못하는 기능이다. 

바로 입금이나 출금할 때 금액에 대한 유효성 검사를 하는 메서드이다.

private boolean isAmountValid(int amount) {
    return amount > 0;
}

 

그리고 그 메서드를 deposit(), withdraw()에 추가했다. 이렇게 내부에서만 사용하고 외부에서는 사용할 필요가 없는 기능은 숨기는 것이다. 

public int deposit(int amount) {
    if (isAmountValid(amount)) {
        balance += amount;
    } else {
        System.out.println("입금할 금액에 문제가 있습니다.");
    }
    return balance;
}

public int withdraw(int amount) {
    if (isAmountValid(amount) && balance > amount) {
        balance -= amount;
    } else {
        System.out.println("출금할 금액에 문제가 있습니다.");
    }
    return balance;
}

 

그럼 만약 이 메서드가 외부에서 접근이 가능하게 만들었다면 어떤 문제가 발생할까? 만약 Account를 만드는 개발자와 Account를 가져다 사용하는 개발자가 나뉘어져 있다면 사용하는 개발자는 다음과 같은 화면을 볼 것이다.

Account 객체를 만들었고 그 객체에 .을 찍어보니 다음과 같이 isAmountValid()라는 메서드가 있다. 그럼 가져다 사용하는 개발자는 생각할 것이다.

"음.. 내가 유효성검사를 한 후 입금이나 출금을 해야하나?"

너무나 합리적인 생각이다. 왜냐하면 public으로 접근 제어자를 설정했다는 의미는 외부에서 가져다가 사용하라는 뜻이다. 그래서 사용하는 개발자는 다음과 같은 끔찍한 코드를 짠다. 이미 내부에서 검사를 하는데 검사를 또 하고 있는것이다. 

 

또는 balance라는 필드를 public으로 변경해보자. 다음과 같이 사용하는 개발자는 balance라는 필드가 public으로 되어 있는것을 보고 생각한다. 

"아 내가 직접 잔고에 접근해서 잔고를 수정할 수 있겠구나?"

이것 또한 합리적이다. 마찬가지로 public으로 선언한 의미는 곧 외부에서 사용하란 소리이기 때문이다. 그럼 다음과 같이 끔찍한 코드가 작성이 가능해진다. 갑자기 사용자의 통장이 마이너스 통장이 되버린다.

 

이런것을 보고 캡슐화가 깨졌다고 표현한다. 그래서 접근 제어자와 캡슐화를 통해 데이터를 안전하게 보호하는 것은 물론이고, 사용하는 개발자 입장에서도 해당 기능을 사용하는 복잡도도 낮출 수 있다.

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

final  (0) 2024.03.27
자바 메모리 구조 ✨  (0) 2024.03.27
Class 레벨의 접근제어자  (0) 2024.03.26
Package에서 딱 하나 헷갈리는 한가지  (0) 2024.03.26
생성자 - this()와 오버로딩  (0) 2024.03.26
728x90
반응형
SMALL

기본적으로 접근제어자 개념은 이해하고 있지만 클래스 레벨에 붙는 접근제어자 개념이 빈약한것 같아 기록한다.

우선 접근제어자는 총 4개가 있다. public, private, default, protected.

  • public: 모든 외부의 접근을 허용
  • private: 같은 클래스말고는 모두 허용하지 X
  • default: 같은 패키지 안에서만 접근 허용
  • protected: 같은 패키지 안에서만 접근 허용, 다른 패키지에서는 근본적으로 접근 불가하지만 상속받은 경우 접근이 가능

근데 클래스에서는 딱 2개 public과 default만 사용할 수 있다. 규칙이다. 그리고 그 외 규칙이 더 있다.

  • 한 파일당 클래스 레벨에서 public은 무조건 딱 하나만 있을 수 있다.
  • public 클래스와 파일명은 반드시 같아야 한다.
  • default 클래스는 무한정 만들 수 있다.

 

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

자바 메모리 구조 ✨  (0) 2024.03.27
캡슐화  (0) 2024.03.26
Package에서 딱 하나 헷갈리는 한가지  (0) 2024.03.26
생성자 - this()와 오버로딩  (0) 2024.03.26
객체 지향 프로그래밍  (0) 2024.03.26
728x90
반응형
SMALL

진짜 어려울 게 하나도 없는 Package 개념에서 헷갈리는 딱 한가지만을 정리하고자 한다.

보통 패키지 구조는 다음과 같다.

  • a
    • b
    • c
      • d
      • e
      • f

그리고 이 구조는 파일시스템에서 디렉토리의 구조와 백퍼센트 일치해야 한다. 

근데 이게 중요한게 아니고 이 구조라면 패키지는 몇개일까? 6개다.

a/a.b/a.c/a.c.d/a.c.e/a.c.f

 

즉, a안에 b가 있으면 a 패키지에 포함 아닌가요? 절대 아니다. a랑 a.b랑은 완전히 다른 패키지이다. 사람이 볼때야 저게 a안에 b처럼 보이지만 자바는 둘을 완전히 다른 패키지로 판단한다. 이것만 좀 주의. 그래서 실제로 만약 특정 클래스에서 a.c와 같은 구조를 가진 패키지 전체를 임포트해도 a.c.d 패키지안에 있는 클래스 사용하려면 따로 임포트해야 한다. 다음이 그 대답이다.

다음과 같은 패키지 구조를 가질 때 Order 클래스에서 User와 UserService를 임포트 할 땐 이렇게 한다. 

즉, user와 user.service는 완전히 다른 패키지라는 이야기이다. 이것만 헷갈리지말자!

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

캡슐화  (0) 2024.03.26
Class 레벨의 접근제어자  (0) 2024.03.26
생성자 - this()와 오버로딩  (0) 2024.03.26
객체 지향 프로그래밍  (0) 2024.03.26
NullPointerException  (0) 2024.03.25
728x90
반응형
SMALL

생성자 개념은 익숙하다고 해도 오버로딩 시 this()를 사용하는 경우가 어떤 경우가 있는지 좀 명확하게 하기 위해 작성을 해본다.

예를 들어, 다음과 같은 코드가 있다고 해보자.

 

Student

public class Student {
    private String name;
    private int age;
    private int grade;

    public Student(String name, int age) {
        this.name = name;
        this.age = age;
        this.grade = 0;
    }

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

    public String getName() {
        return name;
    }

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

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    public int getGrade() {
        return grade;
    }

    public void setGrade(int grade) {
        this.grade = grade;
    }
}

 

이 Student 클래스는 두 개의 생성자를 가지고 있다. 하나는 'name'과 'age'를 받는것과 나머지 하나는 'name', 'age', 'grade'를 받는것. 그리고 'grade'를 받지 않는 생성자를 사용할 땐 일단 점수는 0이라고 가정하고 'grade'에 0을 대입한다.

 

그런데 자세히 보면, 두 생성자가 거의 같은 코드이다. 이 중복을 어떻게 없앨 순 없을까? 그때 사용하는 것이 this()이다.

그래서 this()를 사용한 리팩토링 코드는 다음과 같다.

public Student(String name, int age) {
    this(name, age, 0);
}

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

이전 코드와 비교해보면 중복이 확실히 제거됐음을 알 수 있다. 이게 바로 this() 키워드이다.

 

주의

this()를 쓸 때 주의할 점이 있다. this()는 반드시 첫 줄에 있어야 한다. 예를 들어 다음과 같이 첫 줄이 아니면 에러가 발생한다.

public Student(String name, int age) {
    System.out.println("생성자 호출");
    this(name, age, 0);
}

 

그 에러 내용은 다음과 같다. 첫 줄에 적으라고 알려준다.

728x90
반응형
LIST
728x90
반응형
SMALL

나는 이게 항상 헷갈렸다. 그래서 객체 지향 프로그램은 절차 지향이랑 뭐가 다른데? 비슷한거 같은데 왜 분리 해놓은거야? 

이 두 단어의 의미는 알고 있다. 두 단어의 의미는 다음과 같다.

  • 절차 지향 프로그래밍: 순차적으로 프로그래밍이 실행된다. 작성한 코드의 순서에 중점을 두고 진행된다.
  • 객체 지향 프로그래밍: 프로그램의 동작이나 특정 행위를 객체를 중심으로 코드를 작성하고 그에 맞춰 프로그램이 실행된다.

어떻게 다른걸까? 우선 객체 지향 프로그래밍을 이해하기에 앞서 절차 지향 프로그래밍이 뭔지 알아보자.

 

음악 플레이어 프로그램

다음과 같은 음악 플레이어 프로그램이 있다고 가정해보자.

  • 플레이어를 실행/종료 할 수 있다.
  • 플레이어의 볼륨을 줄이고, 키울 수 있다.
  • 플레이어의 상태를 표시할 수 있다.

그래서 다음과 같이 실행 결과를 출력하는 프로그램이 있다고 생각해보자.

 

그러면 이런식으로 코드를 작성하면 될 것이다. 이 코드는 절차 지향으로 작성된 코드이다.

public class MusicPlayerMain {
    public static void main(String[] args) {
        int volume = 0;
        boolean isOn = false;

        // 플레이어 실행
        isOn = true;
        System.out.println("뮤직 플레이어를 실행합니다.");

        // 플레이어 볼륨 증가
        volume++;
        System.out.println("뮤직 플레이어의 볼륨을 증가합니다. 볼륨: " + volume);

        // 플레이어 볼륨 증가
        volume++;
        System.out.println("뮤직 플레이어의 볼륨을 증가합니다. 볼륨: " + volume);

        // 플레이어 볼륨 감소
        volume--;
        System.out.println("뮤직 플레이어의 볼륨을 감소합니다. 볼륨: " + volume);

        // 플레이어의 상태 표시
        if (isOn) {
            System.out.println("뮤직 플레이어: ON, 볼륨: " + volume);
        } else {
            System.out.println("뮤직 플레이어: OFF");
        }

        isOn = false;
        System.out.println("뮤직 플레이어를 종료합니다.");
    }
}

 

그런데 이 코드에서 사용되는 변수를 지역변수로 선언하는 게 아니라 클래스로 만들수도 있을 것이다. 그럼 다음과 같은 클래스가 하나 필요하다.

public class Player {
    boolean isOn;
    int volume;
}

 

그리고, 이 클래스의 객체를 만들어서 지역변수를 변경해보자. 이제 지역변수로 데이터를 정의하고 사용하는 게 아니고 클래스의 인스턴스로 데이터 묶음을 가지는 모양이 만들어졌다.

public class MusicPlayerMain {
    public static void main(String[] args) {
        Player player = new Player();

        // 플레이어 실행
        player.isOn = true;
        System.out.println("뮤직 플레이어를 실행합니다.");

        // 플레이어 볼륨 증가
        player.volume++;
        System.out.println("뮤직 플레이어의 볼륨을 증가합니다. 볼륨: " + player.volume);

        // 플레이어 볼륨 증가
        player.volume++;
        System.out.println("뮤직 플레이어의 볼륨을 증가합니다. 볼륨: " + player.volume);

        // 플레이어 볼륨 감소
        player.volume--;
        System.out.println("뮤직 플레이어의 볼륨을 감소합니다. 볼륨: " + player.volume);

        // 플레이어의 상태 표시
        if (player.isOn) {
            System.out.println("뮤직 플레이어: ON, 볼륨: " + player.volume);
        } else {
            System.out.println("뮤직 플레이어: OFF");
        }

        player.isOn = false;
        System.out.println("뮤직 플레이어를 종료합니다.");
    }
}

 

만들고 나니, 중복 코드가 많아보인다. 이 부분을 메서드로 추출해서 중복 코드를 최대한 없애보자. 다음과 같이 정말 깔끔한 코드가 됐다. 

public class MusicPlayerMain {
    public static void main(String[] args) {
        Player player = new Player();

        // 플레이어 실행
        on(player);

        // 플레이어 볼륨 증가
        volumeUp(player);

        // 플레이어 볼륨 증가
        volumeUp(player);

        // 플레이어 볼륨 감소
        volumeDown(player);

        // 플레이어의 상태 표시
        playerStatus(player);

        // 플레이어 종료
        off(player);        
    }

    public static void on(Player player) {
        player.isOn = true;
        System.out.println("뮤직 플레이어를 실행합니다.");
    }

    public static void off(Player player) {
        player.isOn = false;
        System.out.println("뮤직 플레이어를 종료합니다.");
    }

    public static void playerStatus(Player player) {
        if (player.isOn) {
            System.out.println("뮤직 플레이어: ON, 볼륨: " + player.volume);
        } else {
            System.out.println("뮤직 플레이어: OFF");
        }
    }
    
    public static void volumeUp(Player player) {
        player.volume++;
        System.out.println("뮤직 플레이어의 볼륨을 증가합니다. 볼륨: " + player.volume);
    }

    public static void volumeDown(Player player) {
        player.volume--;
        System.out.println("뮤직 플레이어의 볼륨을 감소합니다. 볼륨: " + player.volume);
    }
}

 

그러나, 이것은 정말 깔끔하게 잘 만들어진 절차 지향 프로그래밍이다. 무슨 말이냐면 데이터와 기능이 분리되어 있다. 정작 데이터를 관리하는 곳은 Player라는 클래스인데 그 클래스의 인스턴스(객체)의 데이터를 다루는 기능(플레이어 실행, 플레이어 종료, 볼륨 증가, 볼륨 감소)들은 모두 메인 메서드 안에 정의되어 있다. 이렇게 데이터와 기능이 분리되어 관리되는 방식이 대표적인 절차 지향 프로그래밍이다. 과거에는 모두 이런식으로 프로그램을 만들었다.

 

생각해보면 이상하다 볼륨을 높이고 줄이는 기능은 Player와 아주 아주 밀접하게 연관이 있는데 분리가 되어 있다는 사실이. 그래서 데이터를 관리하는 클래스안에 그 데이터에 대한 특정 행위를 하는 기능을 작성하는 것을 메서드라고 하고 그 메서드와 클래스를 한 곳에 관리하여 프로그램을 작성해보자. 이것이 곧 객체 지향 프로그래밍이다. 객체가 중심이 되는 것이다.

 

그럼 객체가 중심이 되도록 클래스에 데이터와 기능을 모두 같이 관리하도록 위 코드를 리팩토링해보자.

 

객체 지향 프로그래밍

Player

public class Player {
    private boolean isOn;
    private int volume;

    public void on() {
        this.isOn = true;
        System.out.println("뮤직 플레이어를 실행합니다.");
    }

    public void off() {
        this.isOn = false;
        System.out.println("뮤직 플레이어를 종료합니다.");
    }

    public void playerStatus() {
        if (this.isOn) {
            System.out.println("뮤직 플레이어: ON, 볼륨: " + this.volume);
        } else {
            System.out.println("뮤직 플레이어: OFF");
        }
    }

    public void volumeUp() {
        this.volume++;
        System.out.println("뮤직 플레이어의 볼륨을 증가합니다. 볼륨: " + this.volume);
    }

    public void volumeDown() {
        this.volume--;
        System.out.println("뮤직 플레이어의 볼륨을 감소합니다. 볼륨: " + this.volume);
    }
}

Main

public class MusicPlayerMain {
    public static void main(String[] args) {
        Player player = new Player();

        // 플레이어 실행
        player.on();

        // 플레이어 볼륨 증가
        player.volumeUp();

        // 플레이어 볼륨 증가
        player.volumeUp();

        // 플레이어 볼륨 감소
        player.volumeDown();

        // 플레이어의 상태 표시
        player.playerStatus();

        // 플레이어 종료
        player.off();
    }
}

 

위 코드를 보자. 얼마나 깔끔하고 아름다운가? 더 나아가서 이 코드는 아름답기만 할 뿐 아니라, 다른 클래스에서도 객체가 가질 수 있는 모든 기능을 사용할 수 있다. 예전 코드처럼 MusicPlayerMain 클래스에 정의한 기능들은 해당 클래스에서만 사용할 수 있다 (물론 접근제어자 개념과 MusicPlayerMain 클래스의 인스턴스를 만들어서 사용할 순 있지만). 그리고 큰 차이가 있는데, 데이터와 기능을 같은곳에서 관리하기 때문에 전달해줄 파라미터가 없다. 필요가 없다. 본인의 볼륨을 줄이고 키우면 되고 본인을 끄고 키면 되는데 무슨 파라미터가 필요하겠는가? 이렇게 객체가 중심이 되어 객체가 가지는 기능을 그 객체를 만들어내는 클래스에 정의하고 객체를 생성해서 객체가 행할 수 있는 행위들을 실행하는 방식을 객체 지향 프로그래밍이라고 한다.

 

그러니까 객체 지향 프로그래밍은 절차도 중요하지만 절차보다 객체 자체에 더 집중을 하는 방식. 다른 말로 음악 플레이어가 어떤 순서에 의해 어떤 동작을 했느냐보다 어떻게 음악 플레이어를 만들것인가?에 초점을 더 두는것을 객체 지향 프로그래밍이라고 생각하면 될 것 같다.

 

 

그리고 한 가지 더! 캡슐화라는 개념이 여기서 나오는데 플레이어를 구성하기 위한 속성과 기능이 마치 하나의 캡슐에 쌓여있는 것 같다. 이렇게 속성과 기능을 하나로 묶어서 필요한 기능을 메서드를 통해 외부에 제공하는 것을 캡슐화라고 한다. 그리고 이 결과 유지보수와 변경에도 용이해진다. 예를 들어, 플레이어의 'volume'이라는 필드명을 'playerVolume' 이라고 변경해야 할 때 이 플레이어를 가져다 쓰는 외부에 변경이 필요한가? 전혀 필요 없고 해당 클래스에서만 수정하면 된다. 서비스를 실행하는 쪽에서는 어떠한 변경도 필요가 없어진다는 뜻이다. 이게 객체 지향(좀 더 명확하게는 캡슐화)의 장점이라고 생각하면 되겠다.

 

728x90
반응형
LIST

+ Recent posts