자유/대외 활동

[혼공학습단 9기] #3. 2주차 객체 지향 프로그래밍을 왜 쓸까?

Chipmunks 2023. 1. 10.
728x90

 

안녕하세요! 여러분들의 궁금증을 해결해 줄 다람쥐입니다.

 

한빛미디어 혼공학습단 9기 혼공자바 2주차 포스팅입니다.

 

혼자 공부하는 자바 진도표

 

2주차는 혼공자바 Chapter 6 ~ 9 ( 클래스, 상속, 인터페이스, 중첩 클래스와 중첩 인터페이스 ) 를 공부합니다.

 

혼자 공부하는 자바를 공부하며 궁금한 점 위주로 조사하고 정리했습니다.

 

혼공 학습단 9기 2주차 기본 미션으로 330 페이지 문제 5번 실행 결과 인증 화면 캡쳐입니다.

 

혼공 학습단 9기 2주차 선택 미션으로 객체 지향 프로그래밍의 개념을 정리합니다.

 

다람쥐의 실습 환경은 아래와 같습니다.

  • 기종 : MacBook Pro(16형, 2021년 모델), Apple M1 Pro 칩
  • 메모리 : 32 GB
  • 운영체제 : macOS Monterey 12.0.1
  • 터미널 (명령 프롬프트) : zsh (기본)

 

1. 객체지향 프로그래밍을 왜 쓸까?

객체는 물리적으로 존재하거나 개념적인 것 중에서 다른 것과 식별 가능한 것을 말합니다.

사람, 자동차 등 눈에 보이는 걸 객체로 만들 수도 있고, 주문, 학과, 강의 등도 모두 객체가 될 수 있습니다.

 

언젠가 객체와 객체의 상호작용을 설명할 때, 현실 세계를 빗대어 설명하는 건 바람직하지 않다고 들은 적이 있습니다.

현실에서 일어나는 것과 객체지향에서 일어나는 게 다르기 때문인데요.

예를 들어 책을 객체로 만든다고 가정해 봅시다.

현실 세계의 책은 스스로 가격을 말할 수 없지만, 객체 세계에서의 책은 스스로 가격을 말할 수 있습니다.

현실 세계의 책은 사람의 행위로 정보를 알아내야하지만, 객체 세계에서의 책은 스스로 정보를 전달할 수 있습니다.

현실 세계에서 주문은 사람만이 할 수 있지만, 객체 세계에서의 주문은 스스로 주문할 수가 있습니다.

 

현실 세계에서의 객체는 수동적으로 행동할 수 밖에 없지만

객체 세계에서는 스스로 능동적으로 행동할 수 있고 다른 객체와 상호작용할 수가 있다는 게 큰 특징입니다.

어떻게 보면 객체가 스스로 판단하고 움직일 수 있도록 마법처럼 생명을 불어 넣는 게 객체지향 프로그래밍이 아닐까 생각됩니다.

 

해리포터 마법으로 움직이는 체스

 

객체 지향 프로그래밍의 특징은 캡슐화, 상속, 다형성이다.

캡슐화는 데이터(필드), 동작(메소드)을 하나로 묶고 실제 구현 내용을 외부에 감추는 것을 말한다.

내부 객체의 구조를 알지 못하며 객체가 노출해서 제공하는 필드와 메소드만 이용할 수 있다.

필드와 메소드를 캡슐화하여 보호하는 이유는 외부의 잘못된 사용으로 인해 객체가 손상되지 않도록 하는 데 있다.

public, private, protected 등의 접근 제한자로 외부 객체의 접근을 제어할 수 있다.

 

상속은 부모 클래스의 필드와 메소드를 몰려 받아 사용할 수 있다.

코드의 재사용성을 높여줘 중복 코딩을 하지 않아도 된다.

유지 보수 시간을 최소화시켜 준다. 한 군데만 수정하면 다른 곳 까지 수정하지 않아도 된다.

 

다형성은 사용 방법은 동일하지만 실행 결과가 다양하게 나오는 성질을 말한다.

상속과 인터페이스를 이용해 사용할 객체를 런타임 도중에 변경할 수 있다.

기존 코드를 수정하지 않고 사용 방법이 동일한 새로운 객체를 만드는 것 만으로 문제를 해결할 수 있다.

 

기존 코드를 수정하면은 반드시 그에 따른 버그의 위험이 따른다.

아무리 테스트 코드를 잘 작성하더라도, 테스트 코드도 결국 사람이 짜는 거기에 놓치는 부분이 있을 수 있다.

운영 중인 서비스를 유지보수할 때도 크게 수정하면은 분명 좋은 구조가 되긴 하지만 그 만큼 버그의 발생률도 높아진다.

버그 발생은 유저의 경험에 영향을 끼치고 결국 매출에도 영향이 끼치기에, 사전에 막을 수 있으면 최대한 막아야 한다.

따라서 경우에 따라 크게 바꾸지 않고 기존 구조에서 최소한으로 수정하는 게 더욱 프로페셔널한 프로그래머일 수 있다.

 

그런 관점에서 보면 객체지향 프로그래밍을 잘한다는 건 최소한으로 문제를 해결할 수 있게 설계할 수 있다는 점이다.

캡슐화와 상속, 다형성으로 나중에 기능을 수정, 추가할 때 기존 기능에 최대한 영향이 가지 않도록 사용해야 한다.

 

객체지향 프로그래밍을 공부할 때 놓치는 실수는, 클래스를 설계하여 만드는 사람과 쓰는 사람이 분리되어 있다는 사실이다.

혼자 소규모로 실습만 하다보니 왜 캡슐화가 필요한지, 상속이 어떤 장점이 있는지, 다형성이 왜 중요한지를 이해를 못할 수도 있다.

생각해보면 우리가 System.out 클래스에서 println 메서드나 printf 메서드를 사용할 때 내부 로직을 보면서 사용하지는 않지 않은가?

println 소스 코드

 

2. 330 페이지 문제 5번 실행 결과 인증화면 캡처 - 바이트 코드로 생성자 체이닝과 상속 호출 순서 따라가보기

Parent 부모 클래스가 있습니다.

생성자를 오버로딩하여 기본 생성자에서 상수 문자열을 nation 파라미터로 보내 호출합니다.

 

Parent 부모 클래스를 상속한 Child 자식 클래스를 생성합니다.

부모 클래스와 마찬가지로 생성자를 오버로딩하여 기본 생성자에서 상수 문자열을 name 파라미터로 보내 호출합니다.

 

실행 화면을 공유합니다.

또 바이트 코드가 어떻게 만들어져 실행이 되는지 차근 차근 따라가 봅시다.

 

// Parent.java

public class Parent {
    public String nation;

    public Parent() {
        this("대한민국");
        System.out.println("Parent() call");
    }

    public Parent(String nation) {
        this.nation = nation;
        System.out.println("Parent(String nation) call");
    }
}
// Child.java

public class Child extends Parent {
    public String name;

    public Child() {
        this("홍길동");
        System.out.println("Child() call");
    }

    public Child(String name) {
        this.name = name;
        System.out.println("Child(String name) call");
    }
}
// ChildExample.java

public class ChildExample {
    public static void main(String[] args) {
        Child child = new Child();
    }
}
// 실행 결과
Parent(String nation) call
Parent() call
Child(String name) call
Child() call

실행 순서가 어떻게 되는지 바이트 코드로 한 번 살펴볼까요?

아래는 javap -c Parent, javap -c Child, javap -c ChildExample 클래스 파일의 바이트 코드를 살펴봤습니다.

 

// Parent.class

Compiled from "Parent.java"
public class Parent {
  public java.lang.String nation;

  public Parent();
    Code:
       0: aload_0
       1: ldc           #1                  // String 대한민국
       3: invokespecial #2                  // Method "<init>":(Ljava/lang/String;)V
       6: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       9: ldc           #4                  // String Parent() call
      11: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      14: return

  public Parent(java.lang.String);
    Code:
       0: aload_0
       1: invokespecial #6                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: aload_1
       6: putfield      #7                  // Field nation:Ljava/lang/String;
       9: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      12: ldc           #8                  // String Parent(String nation) call
      14: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      17: return
}

// Child.class

Compiled from "Child.java"
public class Child extends Parent {
  public java.lang.String name;

  public Child();
    Code:
       0: aload_0
       1: ldc           #1                  // String 홍길동
       3: invokespecial #2                  // Method "<init>":(Ljava/lang/String;)V
       6: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       9: ldc           #4                  // String Child() call
      11: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      14: return

  public Child(java.lang.String);
    Code:
       0: aload_0
       1: invokespecial #6                  // Method Parent."<init>":()V
       4: aload_0
       5: aload_1
       6: putfield      #7                  // Field name:Ljava/lang/String;
       9: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
      12: ldc           #8                  // String Child(String name) call
      14: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      17: return
}

// ChildExample.class

Compiled from "ChildExample.java"
public class ChildExample {
  public ChildExample();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class Child
       3: dup
       4: invokespecial #3                  // Method Child."<init>":()V
       7: astore_1
       8: return
}

먼저 ChildExample 부터 볼까요?

Child 인스턴스를 만들고 Child 클래스의 기본 생성자를 호출합니다.

 

Child 기본 생성자 바이트 코드를 볼까요.

"홍길동" 문자열을 만들고 하나의 문자열을 매개변수로 받는 오버로딩 생성자를 호출합니다.

오버로딩 생성자에서 Parent 부모 클래스의 기본 생성자를 호출합니다.

 

Parent 클래스의 기본 생성자로 넘어갑니다.

"대한민국" 문자열을 만들고 하나의 문자열을 매개변수로 받는 오버로딩 생성자를 호출합니다.

오버로딩 생성자에선 모든 객체의 최상위 부모 클래스인 Object 클래스의 기본 생성자를 호출합니다.

그 다음 System.out 에서 println 정적 메서드를 가져와 Parent(String nation) call 을 출력합니다.

 

호출 스택에서 Parent 클래스 오버로딩 생성자가 사라집니다.

Parent 클래스 기본 생성자에서 Parent() call 을 출력한 다음 호출 스택에서 사라집니다.

Child 클래스 오버로딩 생성자에서 Child(String name) call 을 출력한 다음 호출 스택에서 사라집니다.

Child 클래스 기본 생성자에서 Child() call 을 출력한 다음 호출 스택에서 사라집니다.

 

ChildExample 클래스의 main 메서드에서 4: invokespecial #3 // Method Child."<init>":()V

코드가 무사히 실행된 다음 호출 스택에서 사라지게 됩니다.

 

바이트 코드를 살펴보며 신기했던 점은 부모 클래스의 기본 생성자를 호출하는 게 오버로딩된 생성자에만 있다는 점인데요.

만약 기본 생성자에서 오버로딩된 생성자를 호출하지 않는다면 어떻게 변할까요?

public class Child extends Parent {
    public String name;

    public Child() {
        System.out.println("Child() call");
    }

    public Child(String name) {
        this.name = name;
        System.out.println("Child(String name) call");
    }
}
Compiled from "Child.java"
public class Child extends Parent {
  public java.lang.String name;

  public Child();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method Parent."<init>":()V
       4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
       7: ldc           #3                  // String Child() call
       9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      12: return

  public Child(java.lang.String);
    Code:
       0: aload_0
       1: invokespecial #1                  // Method Parent."<init>":()V
       4: aload_0
       5: aload_1
       6: putfield      #5                  // Field name:Ljava/lang/String;
       9: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      12: ldc           #6                  // String Child(String name) call
      14: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      17: return
}

그 결과는 기본 생성자와 오버로딩된 생성자 모두 부모 클래스의 기본 생성자를 호출하는 코드가 생겼습니다.

클래스 파일로 컴파일하는 단계에서 부모 클래스의 기본 생성자를 중복 호출하지 않도록 막아주는 것 같습니다.

 

3. 혼공학습단 2주차 후기

객체 지향 프로그래밍을 자바 언어로 학습할 수 있어서 좋았어요.

혼공자바 책 내용도 가볍게 복습할 수 있었고 미션도 재밌었어요.

참고로 조금 더 보충한 거는 신용권 저자님의 이것이 자바다 도서를 참고했습니다!

혼공자바 내용보다 더 디테일한 설명과 삽화가 있어서, 이것이 자바다도 같이 복습했네요. ☺️

 

혼공학습단 9기 다른 글 보러가기

2023.01.08 - [자유/대외 활동] - [혼공학습단 9기] #2. 자바 기본 정말 안다고 생각해?

 

[혼공학습단 9기] #2. 자바 기본 정말 안다고 생각해?

안녕하세요! 여러분들의 궁금증을 해결해 줄 다람쥐입니다. 한빛미디어 혼공학습단 9기 혼공자바 1주차 포스팅입니다. 1주차는 혼공자바 Chapter 1 ~ 5 ( 자바 설치, 이클립스 설치, 변수와 타입, 연

itchipmunk.tistory.com

2023.01.08 - [자유/대외 활동] - [혼공학습단 #1] 한빛미디어 혼공학습단 9기 선정

댓글