자바와 객체 지향

객체 지향


객체 지향의 시작

기존의 기계 종속적인 개발이 아닌 생활하는 현실 세계처럼 프로그래밍할 수는 없을까?
객체 지향이 현실 세계를 반영한다는 말은 이미 오래 전에 객체 지향 언어의 틀을 만든 누군가가 한 말이다.
그 증거는 바로 객체다.

우리가 주변에서 사물을 인지하는 방식대로 프로그래밍 하고자 하는 것이 바로 객체 지향의 출발이다.
0과 1로 대변되는 컴퓨터에 맞춰 사고하던 방식을 버리고 현실세계를 인지하는 방식으로 프로그램을 만드는 것이다. 그래서 객체 지향은 직관적이라고 할 수 있다.

원숭이, 코끼리, 강아지, 고양이, 앵무새, 알파카, 낙타, 타조, 호랑이 등은 동물이라는 분류에 속한다. 그리고 우리는 이 동물들을 각각의 객체(Object)라고 하고 객체들은 생김새, 사는 곳, 손과 발의 유무, 발가락 유무 등의 속성(Property)를 갖고 있고 날다, 뛰다, 구르다, 짖다 등의 행위(Method)를 갖는다.

객체 지향 이전에는 속성과 메소드를 객체라는 단위로 묶지 않고 각각의 속성과 행위가 따로 따로 분리된 형태로 작성을 했었다. 그러나 객체 지향에서는 우리가 인지하는대로 작성하기 때문에 객체 지향은 직관적이라고 할 수 있다.


객체 지향의 4대 특성

이 책의 저자는 객체 지향의 4대 특성을 쉽게 외우기 위해 외치는 단어가 있다.

캡! 상추다~!

음... 음... 그렇다고 한다.
캡상추다의 각 글자들은 4대 특성의 앞글자를 따서 만든 단어로

캡 - 캡슐화(Encapsulation) : 정보 은닉(information hiding)
상 - 상속(Inheritance) : 재사용
추 - 추상화(Abstraction) : 모델링
다 - 다형성(Polymorphism) : 사용 편의

를 뜻한다.
저자가 말하기로 상속의 Inheritance에 줄이 그어져 있는 것은 영단어 그대로 받아들이게되면 상속의 잘못된 표현을 접하게되기 때문이라고 한다.
저자는 상속보다는 확장이라는 표현을 사용하고있고 실제로 상속을 위해 클래스를 확장하는 extends 라는 명령어를 써야하기 때문에 확장이라는 의미가 더 정확하다고 할 수 있다.

객체 지향의 각 특성들을 이해하기 위해서는 클래스와 객체의 관계에 대해서 이해해야 하는데 클래스는 분류에 대한 개념이고 객체는 실체라고 할 수 있다.
예를들어 앞에서 보았던 원숭이, 코끼리, 강아지 등은 객체라고 했었다.
이때 클래스는 이 객체들을 분류하기 위한 동물이라는 개념이었다. 다시말해 객체는 클래스의 한 분류라고 할 수 있다.
원숭이는 동물의 분류중 하나이고, 코끼리도 강아지도 동물이라는 개념의 분류중 하나이다.
그 안에서 세부적으로 포유류, 조류 등으로 나누는 것은 상속의 개념으로 뒤에서 배울 예정이다.

추상화 (모델링)

클래스는 객체들의 같은 특성을 지닌 여러 객체를 총칭하는 집합의 개념으로 추상화는 이 구체적인 것들을 분해해서 특성을 가지고 재조합 하는 것이다. 그래서 모델링이라고 부를 수 있는 것이다.
실제 사물을 명확하게 복제하는 것이 아닌 목적에 맞게 관심 있는 특성만을 추출해서 표현하는 것이다.
다시말해서, 공통되는 특성들을 모아 단순하게 묘사하는 것이다.
이는 데이터베이스의 테이블을 설계하거나 클래스 설계에 추상화가 사용되기도 한다.

상속 (재사용)

클래스의 특성을 상속한다는 것은 상위 클래스에의 특성들을 하위 클래스에서 상속하고 거기에 더해 특성들을 추가하거나 재사용하는 등의 확장을 의미한다.
조직도와 같은 상하 관계나 계층도가 아닌 큰 분류에서 작은 분류로 공통적인 것들을 상속하고 거기에 더 세분화하여 확장하는 분류도의 개념으로 이해하면 된다.

  • 상위 클래스 - 하위 클래스
  • 슈퍼 클래스 - 서브 클래스

이때 상위 클래스로 갈수록 추상화, 일반화 됐다고 할 수 있고 하위 클래스로 갈수록 구체화, 특수화 됐다고 할 수 있다.

"하위 클래스는 상위 클래스다" - 리스코프 치환 원칙(LSP)

상속 관계에서 반드시 만족해야 할 문장이다.
하위 클래스는 상속 클래스의 확장이므로 결국 크게보면 상위 클래스의 파생인셈이니 '고래는 동물이다' 와 같은 문장이 성립해야 한다는 것이다.

다음의 예시를 보자.

public class 동물 {
  String myClass;
  
  동물() {
     myClass = "동물";
   }
  
  void showMe() {
     System.out.println(myClass);
   }
 }
public class 포유류 extends 동물 {
 포유류() {
  myClass = "포유류";
   }
 }
public class 조류 extends 동물 {
 조류() {
  myClass = "조류";
 }
}
public class 고래 extends 포유류 {
 고래() {
  myClass = "고래";
 }
}
public  class Driver01 {
 public static void main(String[] args) {
  동물 animal = new 동물();
  포유류 mammalia = new 포유류();
  조류 bird = new 조류();
  고래 whale = new 고래();
  
  animal.showMe();
  mammalia.showMe();
  bird.showMe();
  whale.showMe();
 }
}
- 실행 결과 -

동물
포유류
조류
고래

위의 코드를 통해 알 수 있는 상속의 강력함은 상위 클래스인 동물 에서만 showMe()라는 메소드를 구현 했지만 이를 상속하는 모든 하위 클래스에서 showMe() 메소드를 사용할 수 있다. 상속이라는 것이 상위 클래스의 특성을 상속한다는 것이지 부모-자식 같은 관계가 아니다.
이를 절차적/구조적 프로그래밍에서는 각각의 showMe()메소드를 모두 구현했어야 했을 것이다.

public  class Driver02 {
 public static void main(String[] args) {
  동물 animal = new 동물();
  동물 mammalia = new 포유류();
  동물 bird = new 조류();
  동물 whale = new 고래();
  
  animal.showMe();
  mammalia.showMe();
  bird.showMe();
  whale.showMe();
 }
}

만일 위와 같이 각각의 클래스명을 모두 상위 클래스인 '동물'로 생성하였을 때에도 결과는 위의 Driver01의 결과와 같다.
이때 보여주는 특징이 바로 '하위 클래스는 상위 클래스다' 즉, 하위 분류는 상위분류다 라는 말이다. 포유류 한 마리를 동물이라 하는데에 이견이 있을까? 고래 한 마리를 포유류 또는 동물이라고 하는 데 이견이 있을까? 이처럼 객체 지향은 현실 세계를, 인간의 논리를 그대로 코드로 옮길 수 있는 힘이 있다.

객체 지향의 상속은 상위 클래스의 특성을 재사용하는 것이다.
객체 지향의 상속은 상위 클래스의 특성을 확장하는 것이다.
객체 지향의 상속은 is a kind of 관계를 만족해야 한다.

상속과 인터페이스

상속 관계가 is a kind of 라고 할 때 다중 상속을 포기하고 인터페이스를 도입한 자바에서 인터페이스는 어떤 관계를 나타내는 것일까?
우선 자바에서 예를 들어 다중 상속은 사람과 물고기를 함께 상속하는 인어공주에게 "수영해!" 라고 한다면 사람처럼 팔과 다리를 이용해서 수영을 해야할까? 아니면 물고기처럼 지느러미를 통해 헤엄쳐야 할까? 이와 같은 문제를 다중 상속의 다이아몬드 문제라고 하는데 다중 상속은 득보다 실이 많다고 판단했기 때문에 결국 다중 상속을 포기하고 자바에서는 인터페이스를 도입했다.
그렇다면 다중 상속을 포기하고 도입한 인터페이스이기 때문에 상속과 같이 is a kind of 일까?

사실 인터페이스는 be able to와 같은 형태로 만드는 것이 좋다.
예를 들어 상위 클래스는 하위 클래스에게 특성(속성과 메소드)을 상속해 주고, 인터페이스는 클래스가 '무엇을 할 수 있다' 라고 기능을 구현하도록 강제하게 된다.

따라서, 상위 클래스는 하위 클래스에게 물려줄 특성이 많을수록 상위 클래스가 풍성할수록 좋으며(LSP, 리스코프 치환 원칙), 인터페이스에 메서드가 적을수록 좋다(ISP, 인터페이스 분할 원칙).

다형성 : 사용편의성

객체 지향에서 다형성이라고 한다면 오버라이딩(overriding)과 오버로딩(overloading)이라고 할 수 있는데, 물론 상위 클래스와 하위 클래스 사이에서도 다형성을 이야기 할 수 있고 인터페이스와 그것의 구현 클래스 사이에서도 다형성을 이야기할 수 있지만 가장 기본은 오버라이딩과 오버로딩이라고 할 수 있다.
(* 오버로딩이 다형성인지 아닌지에 대해서는 이견이 있다고 한다.)

아래의 예제 코드를 살펴보자

public class Animal {
 public String name;
 
 public void showName() {
   System.out.printf("안녕 나는 %s야. 반가워\n", name);
   }
 }
 

public class Penguin extends Animal {
 public String habitat;
 
 public void showHabitat() {
  System.out.printf("%s는 %s에 살아\n", name, habitat);
 }
 
//오버라이딩 - 재정의 : 상위 클래스의 메소드와 같은 메소드 이름, 같은 인자 리스트
 public void showName() {
  System.out.printf("어머 내 이름은 알아서 뭐하게요?");
  }
  
//오버로딩 - 중복정의 : 같은 메소드 이름, 다른 인자 리스트
 public void showName(String yourName) {
  System.out.printf("%s 안녕, 나는 %s라고 해\n", yourName, name);
 }
}
public class Driver {
 public static void main(String[] args) {
  Penguin pororo = new Penguin();
  
  pororo.name = "뽀로로";
  pororo.habitat = "남극";
  
  pororo.showName();
  pororo.showName("초보람보");
  pororo.showHabitat();
  
  Animal pingu = new Penguin();
  
  pingu.name = "핑구";
  pingu.showName();
 }
}

- 실행결과 -
1. 어머 내 이름은 알아서 뭐하게요? (오버라이딩)
2. 초보람보 안녕, 나는 뽀로로라고 해 (오버로딩)
3. 뽀로로는 남극에 살아
4. 어머 내 이름은 알아서 뭐하게요? (오버라이딩)

1번의 경우 Animal을 확장한 Penguin에서 오버라이딩을 통해 showName() 메소드를 재정의 했기 때문에 "어머 내 이름은 알아서 뭐하게요?" 라는 새침한 대답이 돌아온다,
2번의 경우 오버로딩을 통해 다른 인자가 들어왔을 경우 같은 메소드 이름이지만 다른 내용이 출력된다. 따라서 입력받은 다른 인자 'yourName'인 '초보람보'와 기존 입력값인 'name'이 함께 입력된 문구가 출력된다.
3. 따로 오버라이딩이나 오버로딩 되지 않았기 때문에 기존의 메소드 문구가 출력된다.
4. Animal 클래스 타입의 pingu를 생성했지만 생성한 인스턴스가 하위 클래스인 Penguin이기 때문에 Animal을 확장하여 오버라이딩한 내용의 "어머 내 이름은 알아서 뭐하게요?" 라는 새침한 대답이 1번과 같이 돌아오게 된다.

4번으로 미루어보아 알 수 있듯이 상위 클래스 타입의 객체 참조 변수를 사용하더라도 하위 클래스에서 오버라이딩(재정의)한 메소드가 호출된다는 사실을 잊지 말자.

오버로딩은 함수명 하나를 통해 인자 목록만 달리하면 다른 내용을 구현할 수 있고, 오버라이딩은 하위 클래스가 재정의한 메소드를 알아서 호출해 줌으로써 형변환이나 다른 연산자를 통해 하위 클래스가 무엇인지 신경쓰지 않아도 된다.

캡슐화 : 정보 은닉

자바에서 정보 은닉(information hiding)이라고 하면 접근 제어자인 private, default, protected, public이 생각날텐데 접근 제어자가 객체 멤버(인스턴스 멤버)와 쓰일 때와 정적 멤버(클래스 멤버)와 함께 쓰일 때를 비교해보자.

package main.packageOne;

public class A {
    private int pri;
    int def;
    protected int pro;
    public int pub;

    void runSomething() {
        pri = 10;
        def = 10;
        pro = 10;
        pub = 10;
    }

    static void runStaticThing() {
        pri = 10; (x)
        def = 10; (x)
        pro = 10; (x)
        pub = 10; (x)
    }
}

만약 위와 같은 코드가 있다고 할 때 객체 멤버들에 runSomething() 메소드에서는 모든 객체 멤버에 접근할 수 있지만 static인 runStaticThing() 메소드에서는 어떠한 객체 멤버에도 접근할 수 없다.

package main.packageOne;

public class A {
    static private int pri;
    static int def;
    static protected int pro;
    static public int pub;

    void runSomething() {
        pri = 10;
        def = 10;
        pro = 10;
        pub = 10;
    }

    static void runStaticThing() {
        pri = 10;
        def = 10;
        pro = 10;
        pub = 10;
    }
}

그러나 객체 멤버들이 모두 static으로 정의되어 있다고 한다면 이야기는 달라진다. 정적 메소드와 마찬가지로 해당 클래스가 로드 될 때 static으로 정의된 객체 멤버들과 static으로 정의된 runSomething() 메소드가 같이 로드 되면서 사용할 수 있게 된다.
또한 동일 패키지, 동일 클래스 안에서 사용하므로 문제 없이 모두 사용할 수 있다.
만약 동일 패키지 내에서 상속을 받은 다른 클래스라면 private을 제외한 public, protected, default 객체 멤버에 모두 접근 할 수 있다.

package main.packageOne;

public class AA extends A{
  AA() {
      def = 10;
      pro = 10;
      pub = 10;
      A.def = 10;
      A.pro = 10;
      A.pub = 10;
  }
}

만약 다른 패키지에서 상속을 받았다면 어떻게 될까? 아래의 예제를 살펴보자.

package main.packageTwo;

import main.packageOne.A;

public class AB extends A {
    AB() {
        pro = 10;
        pub = 10;
    }
}

본인만 접근 가능한 private와 같은 패키지 내의 클래스에서만 접근 가능한 default를 제외한 protected(상속 또는 같은 패키지 내의 클래스), public(모든 접근 가능)의 경우 사용이 가능하다.

*참고

참조 변수의 복사

객체의 값에 대한 할당은 어떻게 될까?
흔히 Call By Reference(참조에 의한 호출) 또는 Call By Address(주소에 의한 호출) 이라고 하거나 Call By Value가 있다.

우선 Call By Value 예제를 살펴보자.

public class CallByValue {
 public static void main(String[] args) {
  int a = 10;
  int b = a;
  
  b = 20;
  
  System.out.println(a); // 10
  System.out.println(b); // 20
 }
}

위의 경우 기본 자료형 a와 b가 있다.
이때 a의 값은 10으로 할당하고 b의 값은 a의 값을 할당한다.
그리고 b에 20을 할당하면 결과 값은 a = 10, b = 20 이라는 결과 값이 나온다.
이때 b는 a가 가진 값을 단순히 복사 하고 b에 다시 20을 할당하면서 결과적으로 각기 다른 값을 가진 것이다.
즉, 두 변수간에는 아무런 연결이 없이 값만을 할당하고 있는 것이다.
다음으로 Call By Reference 예제를 살펴보자.

public class CallByReference {
 public static void main(String[] args) {
  Animal ref_a = new Animal();
  Animal ref_b = ref_a;
  
  ref_a.age = 10;
  ref_b.age = 20;
  
  System.out.println(ref_a.age); // 20
  System.out.println(ref_b.age); // 20
 }
}

 class Animal {
  public int age;
 } 

위의 예제에서는 그전의 예제와 별 다를게 없어보이는데 값이 모두 20으로 동일하게 출력되었다.
어떻게된 일일까? Call By Value의 예제에서는 기본 자료형의 값만을 복사해와서 서로 각기 다른 객체가 된 것이지만, 위의 경우에는 Animal이라는 인스턴스로부터 새로운 객체를 생성하여 주소값을 갖고 있는데, 이 주소 값을 ref_b와 공유하게 되면서 결과적으로 같은 인스턴스의 주소를 가르키게 된다. 그로인해 같은 주소의 값을 가져오는 ref_a와 ref_b의 값 중에 어느 하나가 변경시킬 경우 두 곳에 영향을 미치게 된다.

두 객체 모두 :Animal 의 같은 주소값을 가진 객체를 가르킨다.
ref_a와 ref_b는 완전히 변수지만 같은 주소 값을 활용해 같은 객체를 참조하고 있을 뿐이다. 예를들어 둘다 주소값이 1000으로 동일할 때 age의 값이 10 에서 20으로 변하게 된다면, 같은 주소값인 1000이므로 모두 20으로 출력 된다.
참조하고 있는 객체가 같으니 참조하고 있는 객체의 변화에 함께 영향을 받는 것이다. ref_a에 null을 할당하거나 다른 객체의 참조를 할당해 보면 ref_b에는 아무런 영향도 주지 못한다.

요약해보면 Call By Value와 Call By Reference는 다르다기보다 기본 자료형 변수는 저장하고 있는 값을 그대로 판단하고, 참조 변수는 저장하고 있는 값을 주소로 판단한다고 이해하는 것이 쉽다.




출처 : 스프링 입문을 위한 자바 객체지향의 원리와 이해

좋은 웹페이지 즐겨찾기