Java

5주차 - 클래스, 생성자, 객체, this, 메소드

voider 2020. 12. 19. 02:46

Class

  • class
  public class {
      //클래스의 기본 형태
  }

클래스에서 사용할 수 있는 접근 제어자

일반적으로 사용하는 클래스의 접근제어자는 public이고 원한다면 default제어자를 사용할 수 있다. 그외 protectedprivate은 클래스 앞에 선언할 수 없다.

default 는 가능한데 protected는 사용할 수 없게 제한하는 이유가 무엇인지는 잘 모르겠다. 반면에 private을 클래스의 접근제어자로 사용할 수 없는 이유는 분명해 보인다. 객체 지향 세계에서는 "어떤 객체도 섬이 아니다" 객체지향이란 결국 객체들끼리 협업을 하는 것이다. private을 붙여서 클래스를 만든다는 것은 객체를 지향하지 않고 객체를 섬으로 만들겠다는 거나 다름 없다.

  • final class
  public final class 클래스명 {
     // ...
  }

자바에서 final은 불변을 뜻한다. 그렇다고 final class라는 것은 이 클래스는 변경되지 않는다는 뜻으로 이해하기 보다는 확장(extends)되지 않는 클래스라고 이해하는 게 정확하다. final class는 상속할 수 없다.

  • abstact class
  public abstract class 클래스명 {
      // ...
  }

abstract는 추상 클래스다. 아직 덜 구현된 abstract 메서드를 가지고 있을 때 사용하기도 하지만 반드시 그래야만 하는 것은 아니다. abstract class 자체는 인스턴스화 할 수 없다. 따라서 그 자체만으로는 의미가 없고 자신을 필요로 하는 클래스에서 확장되어 질 때만 의미가 있기 때문에 abstract class로 만드는 경우가 많다.

  • 내부 클래스

    클래스 안에 클래스를 선언할 수도 있다. 이것을 내부 클래스라고 한다. 내부 클래스라고 어려울 것은 없고 for문 안에 for문 중첩해서 넣는 것과 문법은 다르지 않다. 내부 클래스는 밖에서 접근할 일은 별로 없고 내부적으로 사용할 때 많이 이용하기 때문에 private 접근 제어자를 사용할 수 있다.

  public class LinkedList<E>
      extends AbstractSequentialList<E>
      implements List<E>, Deque<E>, Cloneable,             java.io.Serializable
  {
      private static class Node<E> {
          E item;
          Node<E> next;
          Node<E> prev;

          Node(Node<E> prev, E element, Node<E> next) {
              this.item = element;
              this.next = next;
              this.prev = prev;
          }
      }
  }

위는 실제로 LinkedList 내부에 구현되어 있는 내부 클래스다. 내부 클래스 인 Node를 이용해서 데이터를 저장하고 자신의 이전 데이터의 정보와 다음 데이터의 정보를 저장해둔다. 이 Node라는 내부 클래스는 LinkedList밖에서는 사용할 일은 없지만 LinkedList에서는 하나로 묶어서 사용해야 하는 데이터이므로 이렇게 내부 클래스로 묶어서 사용하는 것이다.

  • static class
  public static class 클래스명 {
      // ...
  }

static class는 new연산자를 이용해 인스턴스를 생성하지 않고도 해당 클래스의 멤버나 메서드에 접근할 수 있다. 이렇게 사용할 수 있는 이유는 최초에 JVM이 구동될 때 클래스를 읽어오면서 static키워드가 붙은 클래스, 메서드, 변수를 읽어서 Static영역에 올리기 때문이다. 그러니까 static class는 JVM이 실행되어 클래스가 메모리에 올라가는 그때 딱 한 번 초기화된다. 그렇기 때문에 static class에 상태는 전역에서 공유된다. A가 Cup이라는 스태틱 클래스를 사용하는 도중에 B가 Cup이라는 스태틱 클래스를 사용하면 A와 B의 작업이 섞일 수 있다. 다른 제약들이 있긴 하지만 전역에서 공유되어야 하는 클래스라면 static클래스로 만들고 아니라면 그냥 class로 만들면 된다.

Constructor

클래스를 만들 때 필수적으로 알아야하는 것이 생성자(Constructor)다. 생성자의 종류로는 기본 생성자(default constructor)와 매개변수를 가진 생성자(parameterized constructor)가 있다. 모든 클래스는 반드시 하나 이상의 생성자를 가지고 있어야 한다. 정의된 생성자가 없다면 컴파일러가 자동으로 기본 생성자를 만든다. 정의된 생성자가 하나라도 있다면 컴파일러는 생성자를 만들지 않으니 기본 생성자가 필요하다면 직접 정의해야 한다.

생성자란 간단히 말해서 이 클래스를 사용하기 위한 필수 조건이다. 자동차 보험이라는 클래스의 인스턴스를 만든다고 해보자. 자동차 보험에 들기 위해서는 운전면허는 필수 조건이다. 운전면허 없이는 이 자동차 보험을 생성할 수 없도록 제한하는 것이 생성자다.

  public class 운전자보험 {
      long 번호판;
      String 운전면허번호;

      운전자보험(운전면허증 운전면허번호) { //매개변수를 가진 생성자
          this.운전면허번호 = 운전면허번호;
      }

      운전자보험(){ //기본 생성자
          this.운전면허번호 = "운전면허 없음";
      }
  }

메서드와 선언 방식은 비슷하지만 리턴이 없다는 게 차이점이다. 생성자에도 접근을 제한할 수 있는데 final 생성자라면 외부에서 인스턴스를 생성할 수 없다.

객체 생성(new)

  • 문법
Object object = new Object();

위 예제처럼 클래스를 객체로 생성할 때는 new연산자를 사용한다. static클래스를 제외한 모든 클래스의 인스턴스는 new로 생성해야한다. 클래스를 인스턴스화 한다는 것은 곧 객체를 생성한다는 뜻이다.

생성자와 객체 생성은 연관이 깊다. 우리는 생성자가 요구하는 방식으로만 객체를 생성할 수 있다. 말하자면 기본 생성자는 없고 이름과 나이를 받는 생성자 Person(String name, int age){..}가 있다고 하자. 이 Person클래스로 객체를 생성하려면 반드시 이름과 나이가 필요하다. 반대로 생성자가 기본 생성자 뿐이라면, 어떤 인자를 넣어서 데이터를 초기화해주고 싶어도 그럴 수 없다. 객체는 오직 생성자가 요구하는 방식대로만 생성할 수 있다.

this

우선 thisthis()를 구분해야 할 것 같다. this는 클래스 내부에서 자기 자신을 가리키는 용도로 사용한다. this()는 생성자 안에서 자신이 가진 다른 생성자를 호출할 때 사용한다. 간단한 예제를 만들어봤다.

public class TonyStark {
    boolean suit;
    String status;

    public TonyStark() {
        this.suit = false;
        this.status = "인간 토니스타크";
    }

    public TonyStark(boolean suit, String status) {
        this.suit = suit;
        this.status = status;
    }
}

토니스타크는 기본 생성자로 객체를 생성new TonyStark() 하면 수트는 입지 않은 상태 false의 평범한 인간 토니스타크가 생성된다.

그러다 어느 시점에 갑옷을 입은 아이언맨이 되어야 한다면 매개변수가 있는 생성자를 이용해 new TonyStark(true, "아이언맨") 아이언맨이 된 토니스타크 객체를 생성할 수 있다. 여기서 this를 사용했다. 보면 멤버변수의 이름과 파라미터에 선언된 변수 이름이 같다. 이럴 때 자기 자신에 대한 참조인 this를 사용해서 구분할 수 있다. this는 현재 TonyStark에 대한 참조를 가지고 있다. 따라서 this.suit라고 하면 멤버변수 suit를 가리킨다. 반면 this가 붙지 않은 suit는 자신으로부터 가장 가까이에 있는 변수를 사용하기 때문에 파라미터에 선언된 suit가 된다. 이렇게 this를 이용해서 이름이 같은 두 개의 변수를 구분할 수 있다.

이제 this()에 대해 알아보자. this()는 생성자 내에서 생성자를 호출할 때 쓴다. 위의 예제 코드에는 중복이 존재한다.

 this.suit = false;
 this.status = "인간 토니스타크";

this.suit = suit;
this.status = status;

이 코드는 값만 다를 뿐이지 동일한 코드다. 기본 생성자 TonyStark()에서 매개변수를 가진 생성자인 TonyStark(boolean suit, String status)를 호출하면 중복을 줄일 수 있다. 하지만 그렇다고 해서 이런 식으로 호출하면 컴파일 에러가 발생한다.

    public TonyStark() {
        TonyStark(false, "인간 토니스타크");    //컴파일 에러
    }

    public TonyStark(boolean suit, String status) {
        this.suit = suit;
        this.status = status;
    }

이유는 잘 모르겠지만 이런 방식은 사용할 수 없다. 생성자 내부에서 다른 생성자를 호출하려면 반드시 this()를 사용해야 한다.

    public TonyStark() {
        this(false, "인간 토니스타크") 
    }

    public TonyStark(boolean suit, String status) {
        this(); //여기서 이렇게 할 이유는 없지만 이렇게도 가능하다는 뜻.
        this.suit = suit;
        this.status = status;
    }

이렇게 this()를 이용해서 생성자 내부에서 다른 생성자를 호출할 수 있다. 제약이 하나 있는데 this()는 반드시 생성자 첫 번째 줄에서 호출되어야 한다. 중간에 this()가 호출되면 초기화 도중에 다른 생성자에서 다시 초기화하게 되므로 충돌이 발생할 수 있기 때문이다.

method

  • 문법
  접근제어자 반환타입 메서드명(매개변수) {
      //처리할 코드
      return 반환타입;
  }

  public int sum(int i, int j) {
      return i+j;
  }

  public void print() { //void는 이 메소드가 아무것도 반환하지 않는다는 뜻
      System.out.println("Hello");
      // return; 컴파일러가 자동으로 return함.
  }

메소드는 중복되는 코드를 메소드 안으로 모아서 재사용성을 높이고 유지보수를 용이하게 만든다. 중복을 제거한다는 단순한 이유도 있지만 책임을 분리하기 위해 사용하기도 한다. 핸드드립 커피를 내리는 메서드를 정의한다고 해보자.

  public Coffee makeDripCoffee() {
      //드립을 내릴 원두를 생성한다.
      CoffeeBean coffeeBean = new CoffeeBean();

      //원두를 간다.
      CoffeeBean grindedBean = coffeeBean.grindBeans();

      //물을 준비한다.
      Water water = new Water();

      //물을 끓인다.
      Water boiledWater = water.boil()

      //뜨거운 물을 원두에 부어서 커피를 추출한다.
      Coffee dripCoffee = grindedBean.coffeeBrew(boiledWater);

      //완성된 커피를 리턴한다.
      return dripCoffee;
  }

왜 굳이 메서드를 정의할까? 이 메서드는 일종의 핸드드립 머신이다. '핸드드립 머신'이라는 말은 현실세계에서 모순이지만 프로그래밍 세계에서는 이것이 가능하다. 아침마다 원두를 꺼내고, 갈고, 물을 끓이고, 커피를 추출하는 이 번거로운 과정을 makeDripCoffee()메소드가 대신 해줄 것이기 때문에 더 이상 비몽사몽한 정신으로 수고를 들일 필요가 없다. 매일 아침 반복되는 일을 없애는 것. 이것이 메소드의 특징 중 하나다.

유지보수가 용이하다는 것은 무슨 말일까? makeDripCoffee()메소드를 온 가족이 사용한다고 해보자. 여름이 되어서 이제 뜨거운 커피 말고 콜드브루를 내려 먹기로 한다면 어떻게 될까?(물론 누군가는 여전히 뜨거운 커피를 마시고 싶을 수도 있지만 상황을 단순화하자.) 만약 메소드가 없는 상황이라면 makeDripCoffee()에 정의된 코드를 가족 구성원마다 가지고 있을 것이다. 그러면 일일이 아침마다 커피를 내리는 구성원을 찾아서 변경된 내용을 적용해주어야 한다. 하지만 makeDripCoffee()를 만들어두고 가족 구성원이 이 메소드를 사용한다면 makeDripCoffee()메소드만 수정하고 다른 곳은 건드리지 않아도 된다.

그럼 책임을 분리한다는 것은 뭘까? 그전에 책임은 뭘까? 책임은 자신을 사용하는 사람이 자신에게 기대하는 것이라고 할 수 있겠다. 그럼 makeDripCoffee()의 책임은 명확하다. 커피를 반환하는 것이 makeDripCoffee()의 책임이다. makeDripCoffee()는 훌륭하게 맡은 일에 책임을 지고 있지만, 문제는 자신이 책임져야 할 책임 외에도 다른 책임들을 함께 지고 있다는 것이다. makeDripCoffee()안에 있는 다른 책임들을 찾아보자. 원두를 만들고 그라인딩하는 것이 makeDripCoffee()의 책임일까? 물을 가져와서 끓이는 것이 makeDripCoffee()의 책임일까? makeDripCoffee()는 그냥 그라인딩된 원두를 가져와서 준비된 뜨거운 물을 붓기만 하면 된다. 나머지는 makeDripCoffee()의 책임이 아니다. 한 곳에 너무 많은 책임이 모여있으면 가독성도 떨어질 뿐더러 유지보수도 어려워진다. 그래서 하나의 메서드는 하나의 책임만 지는 것이 좋다. 책임을 분산시켜서 좀 더 유지보수학기 좋은 코드로 만들었다.

  public Coffee makeDripCoffee() {
      //그라인딩된 원두를 받아온다.
      CoffeeBean grindedBean = getGrindedBean(); 
      //끓는 물을 받아온다.
      Water boiledWater = getBoiledWater();
      //뜨거운 물을 원두에 부어서 커피를 추출한다.
      Coffee dripCoffee = grindedBean.coffeeBrew(boiledWater);
      //완성된 커피를 리턴한다.
      return dripCoffee;
  }

  private CoffeeBean getGrindedBean() {
      //드립을 내릴 원두를 생성한다.
      CoffeeBean coffeeBean = new CoffeeBean();

      //원둘를 갈아서 반환한다.
      return coffeeBean.grindBeans();
  }

  private Water getBoiledWater() {
      //물을 준비한다.
      Water water = new Water();

      //물을 끓여서 반환한다.
      return water.boil()
  }

이렇게 책임을 분산시키면 makeDripCoffee()는 오직 커피를 만드는 책임만을 가진다. 물을 어떻게 끓인 것인지, 원두는 어떻게 갈 것인지는 makeDripCoffee()의 책임이 아니므로 고민할 필요가 없다. 원두를 어떻게 갈 것인지는 getGrindedBean()이 고민해야 할 문제고 물을 어떻게 끓일 것인지는 getBoiledWater()가 결정한다. makeDripCoffee()는 오직 자기가 할 일, 커피를 추출하는 일에만 신경 쓰면 된다. 물이 삼다수에서 아리수로 바뀐다던지, 그라인더가 수동에서 자동으로 바뀐다던지 하는 변화에는 신경 쓰지 않아도 되는 것이다. 아리수든 전동 그라인더든 수동 그라인더든 그냥 받아온 재료와 물을 가지고 커피를 내리기만 하면 된다. 이렇게 하면 makeDripCoffee()의 코드를 건드리지 않고도 작동 방식을 바꿀 수 있다. 이처럼 책임에 따라 관심사를 나누고 분리하면 변화에 유연하게 대처할 수 있다.

'Java' 카테고리의 다른 글

다이나믹 디스패치, 더블 디스패치  (0) 2020.12.26
6주차 - 상속  (0) 2020.12.25
4주차 - 제어문  (0) 2020.12.11
자바 람다식(Java Lamda Expression)  (0) 2020.10.29
Hassing & HashFunction  (0) 2020.09.10