객체지향의 특성

hjkim·2022년 2월 20일
1

개발인생에 있어서 영혼의 울림을 준 책이 있다.

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

책을 읽고 나니 그동안 얼마나 생각없이 코드를 짜고 있었는지 반성하게 되었다.

"컴퓨터 프로그래밍 책을 보면서 감동 받기는 난생 처음입니다."
"저자님께서 개발자의 인생을 변화시켜준 3개의 책을 꼽으셨는데, 저는 이 책이 될 것 같습니다."

책의 뒷 표지에 적혀있는 짧은 감상글들을 보며 너무 오버하는 것 아닌가 싶은 생각도 들었으나, 다 읽고 나니 왜 저러한 감상글들이 나왔는지 알 것 같았다.


객체지향의 4대 특성 : 캡! 상추다

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

1. 캡슐화, 정보은닉

public, private 접근제어자는 알고 있었지만, protected와 default 접근제어자는 존재만 알고 있을 뿐 잘 사용하지도 않았고 제대로 알고 있지도 못했다. protected의 경우 상속한 클래스에서 접근 가능하다는 것은 알고 있었으나 같은 패키지 내의 클래스에서도 접근 가능하다는 것을 새로 알게 되었다.

이론에서 그치지 않고 자바에서 접근 제어자가 필요한 이유는 무엇일까에 대해서도 코드와 연관지어 생각해보았다. (여기서부터는 책의 내용이 아닌 개인의 견해)

class RequestDto {
	public serviceCode;
    public title;
}

위와 같은 requestDto가 존재한다고 가정한다. api를 사용하는 클라이언트가 여러 서비스일 경우 각 api가 어떤 서비스로부터 호출된 것인지 구분하기 위해 serviceCode를 request parameter로 지니고 있다. 이 경우 serviceCode라는 parameter는 요청이 끝날 때까지 절대 변해서는 안되는 값이다. 각 api가 어디에서 호출되었는지 식별해주는 값이기 때문이다.
그런데 위와 같이 RequestDto의 값으로 public 접근제어자를 갖는 serviceCode를 선언해두면 요청을 받아 처리하는 도중 실수로 serviceCode의 값을 변경하게 될 수 있다. DB 요청을 처리하는 도중이 될 수도 있고 비즈니스 코드를 수행하는 도중이 될 수도 있다. public 접근제어자를 갖도록 선언해두면 이러한 실수를 방지할 도리가 없다.

@Getter
class RequestDto {
	private serviceCode;
    private title;
}

따라서 위와 같이 수정한다면 request로부터 받아온 serviceCode를 Getter를 통해 read만 할 수 있으므로 값이 변할 염려가 없다.

이렇듯 모든 변경에 열려있지 않고 제한을 걸어주는 역할을 하는 것이 '캡슐화'의 핵심이라 생각한다.

2. 상속, 재사용

상속을 단순히 부모 클래스에 있는 속성과 메서드를 자식 클래스에서 쓸 수 있는 것이라 생각했다. 하지만 책에서 부모와 자식 간의 상속 관계를 "kind of"라고 명시하고 있었다.

고래 is a kind of 포유류
포유류 is a kind of 동물

이 마법의 단어 "kind of"를 접하고 나니 부모 클래스의 속성을 자식 클래스가 갖는 것이 너무 자연스러워졌다. 고래는 포유류의 한 분류이다. 따라서 포유류가 갖는 특성, 포유류가 하는 행동을 고래도 할 수 있는 것이다. 마찬가지로 참새는 조류이므로 조류가 갖는 특성을 갖고 조류가 하는 행동을 할 수 있다.

참새라는 class를 만들어서 '알을 낳다'라는 행동(method)를 구현했다. 이후 펭귄이라는 class를 만들어서 동일한 '알을 낳다'라는 행동(method)를 구현했다. 무언가 냄새가 난다! '알을 낳다'라는 메서드를 중복되게 구현하는 것이 굉장히 언짢다! 이 냄새를 막을 수 있는 방법이 바로 조류 class를 만들어 '알을 낳다' 메서드를 정의하고 참새와 펭귄 class에서 조류 class를 상속받는 것이다.
불필요하게 두번 정의된 '알을 낳다'라는 메서드가 조류 class에서만 정의된다. 만약 이 '알을 낳다'라는 메서드가 변경되어야 한다면 참새와 펭귄 class 전부 코드를 수정해야 했는데, 이젠 조류 class의 '알을 낳다' 메서드 하나만 수정하면 된다.

'상속' 특성을 잘 사용했더니 리팩토링하는 데 드는 시간이 줄어들었고, 코드도 훨씬 직관적으로 변했다.

더 어마어마한 장점은! 참새와 펭귄을 조류 class로 받을 수 있다는 점이다! 코드로 살펴보면 다음과 같다.

Bird sparrow = new Sparrow();
Bird penguin = new Penguin();

책에서는 sparrow와 penguin을 Bird[2]와 같은 배열에 담을 수 있다는 예제를 소개하고 있다. 필자는 여기에 더해 Generic method를 사용할 때 상속을 사용했던 경험을 소개하고 싶다.

<T> public void flying(T dto)

위와 같은 메소드가 존재한다고 가정한다. 해당 generic type은 어떠한 타입도 받을 수 있는 상태이다. 즉, 포유류인 Mammal이란 객체도 T가 될 수 있는 것이다. 하지만 포유류가 날 수 있는가? 불가하다. 필자는 코딩을 하다가 실수로라도 T라는 타입에 Mammal이 들어가기를 원하지 않는다.
그래서 상속을 이용하였다.

<T extends Bird> public void flying(T dto)

위와 같이 코드를 작성하면 flying이란 메소드의 input parameter로는 Bird를 상속한 class들만 들어갈 수 있다. 따라서 포유류 객체는 flying의 input parameter가 될 수 있다. 상속을 통해 sparrow와 penguin을 조류로 분류해두었고, mammal은 bird에 속하지 않게 상속 관계를 만들어두었기 때문에 input parameter의 제한이 가능했다.

상속은 단순히 '자식 클래스가 부모 클래스의 속성을 갖게 하는 일'이라고 생각하기 보다는 객체들의 공통점, 차이점을 따져 객체들을 '분류하는 일'이라고 생각하는 게 더 올바른 접근 방법인 것 같다는 생각이 들었다.

3. 추상화, 모델링

'사람'은 정말 많은 특성을 갖는다. 사람에겐 나이도 있고, 직업도 있고, 핸드폰도 있고, 밥도 먹고, 숨도 쉬고, 운동도 한다. 내가 개발하려는 어플이 운동과 관련된 어플이라고 가정한다. 그렇다면 나는 여러 '사람'들 중에서도 내 어플에 가입한 '유저(User)'들에게 관심을 가질 것이다. 그리고 그 유저들의 특성 중에서도 나이, 몸무게, 키, bmi 지수, 밥을 먹는 행동, 운동하는 행동에 관심을 가질 것이다.

객체는 세상을 반영한다. 그 중에서도 추상화를 통해 '내가 관심이 있는' 세상만 반영한다. 따라서 나는 사람이라면 반드시 갖고 태어난 온갖 기본적인 속성들로 객체를 만들지 않는다. 그렇게 만든 User 클래스는 다음과 같다.

public class User {
	private int age;
    private int weight;
    private int height;
    private int bmi;
    public void eat() {}
    public void exercise() {}
}

모델링은 설계 단계에서 정말 중요한 부분이다. 객체를 설계할 때에도 추상화가 사용되지만 데이터베이스를 설계할 때에도 추상화를 통한 모델링이 이루어진다. 설계가 프로그래밍에서 정말정말 중요하고 보통 시니어 개발자분들이 맡아서 하는 경우가 많은 이유는 어쩌면 추상화 때문인 것 같다. 설계하는 주체가 '관심 있는' 부분을 바탕으로 설계가 이루어지는데, 개발 경험이 부족한 개발자는 관심을 가져야 할 부분에 관심을 주지 못하는 일이 발생하는 것이다.
다른 개발자분들의 코드를 보며 이 세상에 존재하는 현상들 중 어느 부분에 관심을 갖고 어떻게 코드에 녹여냈는지 살펴보아야 겠다!!

4. 다형성, 사용편의

2번의 예제를 다시 가져와본다.

조류는 알을 낳는다. 그래서 조류 class에 '알을 낳다'라는 메서드를 공통으로 정의했었다. 하지만 참새가 알을 낳는 장소와 펭귄이 알을 낳는 장소는 완전히! 다르다. 참새는 우리나라에서 알을 낳았고, 펭귄은 남극에서 알을 낳았다. 그리고 나는 요 조류들이 어디서 알을 낳았는지 관심이 있다. 조류 class에서 이미 알을 낳는 메서드를 공통으로 정의해뒀으니 어디에서 낳았는지에 대한 정보를 넣을 틈새가 보이지 않는다. (input 파라미터로 주는 방법도 있겠지만 잠시 논외)
그래서 등장한 것이 바로 override이다.
조류 class에 정의된 '알을 낳다' 메서드를 참새 class에서 override를 통해 재정의하고 펭귄 class에서 재정의하는 것이다.

public class Bird {
	public void layEgg() {
        System.out.println("알을 낳는다!");
    }
}

public class Sparrow extends Bird {
	public void layEgg() {
    	System.out.println("우리나라에서 낳는다!");
   }
}

public class Penguin extends Bird {
	public void layEgg() {
    	System.out.println("남극에서 낳는다!");
   }
}

이렇게 공통적인 특징을 하위 클래스에서 다시 하위 클래스에 맞게 재정의할 수 있다. 이렇게 재정의를 할 경우 동일하게 layEgg()를 호출하고 있는데도 참새냐 펭귄이냐에 따라 다른 내용이 수행될 수 있다.

Bird[] birds = new Bird[5];
birds[0] = new Sparrow();
birds[1] = new Penguin();

for (Bird item: birds) {
	item.layEgg();
}

즉, 위의 코드는 override를 하기 전과 후 모두 동일하다. 변함이 없다. 하지만 override가 되면 프로그램의 수행 결과 (1)에서 (2)로 바뀐다.

(1)
알을 낳는다!
알을 낳는다!

(2)
우리나라에서 낳는다!
남극에서 낳는다!

layEgg를 호출하는 쪽의 코드를 변경하지 않고 수행 결과를 각각의 class마다 다르게 가져갈 수 있도록 한다는 점에서 override의 진가가 드러난다!

overload는 layEgg()와 동일한 메소드 이름을 갖지만 parameter를 다르게 갖는 함수를 정의하는 것이다. 예시는 아래와 같다.

public class Bird {
	public void layEgg() {
        System.out.println("알을 낳는다!");
    }
    
    public void layEgg(String location) {
    	System.out.println(location);
    }
}

layEgg()로 함수를 전부 사용하고 있는 상황에서 layEgg(String location)이 필요한 상황이 되었다. 이미 layEgg()로 구현해 둔 곳이 많은 경우 layEgg()를 layEgg(String location)으로 고치게 되면 너무 많은 부분을 수정하게 된다. 이러한 경우에는 overload를 사용해 location이라는 String input parameter를 갖는 메서드를 재정의해서 사용한다. 그러면 기존의 코드에서는 layEgg()를 사용하고 location이 필요한 곳에서는 layEgg(String location) 메서드를 호출해 사용하면 된다.

overload와 override는 이렇듯 개발자가 개발하는 데 있어서 사용 편의성을 제공하고 있다.


여태껏 객체지향을 잘못 이해하고 있었던 부분들이 많았다. 이 책을 통해 깨달음을 얻고 내 나름대로 코드에 적용하며 느낀점들을 함께 정리해보았다. 코드에 잘못 적용하고 있는 부분들도 많을 것이고, 지금 정리한 내용 중에도 맞지 않는 부분이 있을 수 있다. 그러한 부분을 계속해서 다듬어나가는 것이 앞으로의 할 일이다.

컨벤션이나 네이밍에 주의를 기울이지 않았던 점도 많이 반성하게 되었다. 예제를 들 때에도 함부러 들면 안되겠다는 생각도 들었다.
예를 들어 class의 '상속'이란 단어 때문에 가족들의 가계도를 생각했었다.

이렇게 되면

할아버지 aaa = new 아버지(); // 실행 가능한 코드

위와 같은 코드를 죽었다 깨어나도 이해할 수 없다.
아버지는 할아버지이다! (?)

하지만 부모 클래스가 bird, 자식 클래스가 sparrow인 예시를 살펴보자.
그러면 아래의 코드를 납득하기 훨씬 쉬워진다.

Bird aaa = new Sparrow();

참새는 조류이다!

처음 읽을 때에는 의미만 통하면 되지 뭘 이렇게 세세하게 정의하고 들어가는지 이해할 수 없었으나, 이렇게 하나하나 짚고 넘어가는 것들이 꼭 필요했던 작업이라는 것을 알 수 있었다. 사소하다고 여겨질 수 있는 잘못된 예시들이 모여 객체지향 프로그래밍의 이해를 방해하고 있었기 때문이다.

이 책에는 객체지향 외에도 SOLID, 디자인 패턴도 함께 다루고 있다. 한번에 이해하고 코드에 적용하기에는 쉽지 않아 시행착오를 겪고 있다. 이 부분들도 차차 이해한 내용을 바탕으로 정리해보고자 한다.


[이미지 참조] https://server-engineer.tistory.com/219
[도서 정보] http://www.yes24.com/Product/Goods/17350624

profile
피드백은 언제나 환영입니다! :)

0개의 댓글