객체, 객체지향적인 설계

Ehigh·2024년 10월 1일

Object

목록 보기
1/2

오브젝트 책을 공부하고 정리한 글입니다.

우선, 내용을 요약하면 다음과 같다.

  • 객체를 자기 데이터를 스스로 처리할 수 있는 자율적인 존재로 만든다.
  • 필요한 부분만 외부에서의 접근을 허용하고, 나머지는 가린다.
  • 프로그램은 이런 객체들의 협력을 통해 구현한다.

이런 내용들과, 간단한 예제를 기반으로 실습한 내용을 정리했다.

객체지향적인 설계

아래에서 말하는 '인터페이스' 는 외부에서 접근할 수 있는, 객체의 공개된 부분을 의미한다.
자바의 인터페이스는, 자바 인터페이스라고 표현한다.

객체지향과 절차지향

책에서는 다음과 같이 표현한다.

  • 절차지향 프로그래밍 : 데이터와 이를 다루는 프로세스가 다른 위치에 있는 것
  • 객체지향 프로그래밍 : 데이터와 프로세스가 같은 위치에 있는 것

이는 불필요한 의존성을 줄여서 연쇄적인 수정을 막고, 유지보수성을 높이기 위함이다.
데이터와 그걸 다루는 로직이 같은 클래스 안에 있다면, 데이터에 변경이 생겼을 때 그에 따라 발생하는 변경사항을 모두 해당 클래스 내에서 처리할 수 있다.

객체의 자율성

객체를 자율적인 존재로 봄으로써, 이를 좀 더 쉽게 지킬 수 있다.
'데이터가 이 클래스에 있으니까 로직도 이 클래스에 넣어야지' 보다, '이 데이터는 이 클래스가 관리하는 것이니까, 로직도 여기에 넣어야지' 가 좀 더 자연스럽게 받아들여진다.

이를 지키기 위해, 실제 세상에서는 수동적인 것이더라도 의인화하여, 자율적인 존재로 생각하라고 한다. 이 또한 유지보수성을 높이기 위함이다.
예를 들어 보자.
실제 세상에서는 사람이 휴대폰을 사용하고, 사람이 차를 운전하고, 사람이 자전거를 타는 것이겠지만,
그대로 프로그램을 구현한다면 사람은 휴대폰, 차, 자전거에 모두 의존하게 되고, 이들에 수정사항이 생길때마다 연쇄적으로 수정될 여지가 생긴다.

외부 공개 범위의 결정

특정 객체에 의존한다는 건 곧, 그 객체의 어느 부분을 알고 있다는 것이다.
알고 있다면, 그 부분이 수정되면 같이 수정될 수 있다.
그래서 꼭 필요한 부분만 외부에 공개하고, 나머지는 내부로 감추는 것이 필요하다

캡슐화접근 제어를 통해, 객체를 두 부분으로 나눌 수 있다.

  • 퍼블릭 인터페이스 : 외부에서 접근 가능한 부분
  • 구현 : 내부에서만 접근 가능한 부분
    +) private, protected 메소드나 속성 등도 구현에 포함된다.
    +) 또 일반적으로, 객체의 상태는 숨기고 행동만 외부에 공개해야 한다.
    +) 변경될 가능성이 있는 부분을 내부로 감추는 결정도 가능하다.

캡슐화
데이터(=속성)와 기능(=행위)을 객체 내부로 함께 묶는 것

접근 제어
대부분 객체지향 언어들은, 접근 제어 방법들을 제공한다.
(ex. 자바의 접근 제어자)

구현의 은닉(정보 은닉)

특정 부분을 객체 내부로 감춰서, 2가지 장점을 얻을 수 있다.

  • 외부에서의 잘못된 접근을 막는다.
    => 외부에서 해당 부분을 알 필요가 없어진다.
  • 내부에서는 구현을 자유롭게 변경할 수 있게 된다.

자바의 Collection들이나 스프링 클래스들을 생각해보면 이해가 쉽다.
원한다면 내부 구현을 모른 채 활용할 수 있고,
버전이 업그레이드되며 내부 구현이 수정되어도 이를 사용한 코드에서 연쇄적인 변경이 일어나지 않는다.

자바 인터페이스의 필요성
객체지향 설계에서, 프로그램은 객체들 간 상호작용을 통해 구현된다.
이를 위해서는, 어쩔 수 없이 객체 간 의존성이 생긴다.
이는 곧 연쇄적인 변경이 발생할 여지가 생기는 것이다.

이를 보완하는 것이 자바 인터페이스의 장점 중 하나라고 생각한다.
자바 인터페이스를 사용하면, 의존성은 여전히 있는 대신, 의존하는 부분들이 변경되지 못하게 강제함으로써 연쇄적인 변경을 방지한다.
코드를 읽을 때의 가독성 면에서도 물론 장점이 있다.

'협력' 과 '메소드 호출'

객체가 다른 객체와 상호작용하는 방법은, 요구사항을 처리해달라고 메시지를 전송하는 것뿐이다.
메시지를 수신한 객체는, 메시지를 처리할 방법을 자율적으로 결정한다.
수신된 메시지를 처리하기 위한 그 객체만의 방법을 메소드라고 한다.

우리가 메소드를 호출한다라고 부르던 동작은 사실,

  1. 객체에게 요구사항을 처리해달라고 메시지를 전송한다.
  2. 수신한 객체가 적절한 메소드를 선택하여 실행한다.

라는 2가지 과정으로 이뤄진다. 이는 다형성과도 연결되는 개념이다.

'수신한 객체가 메소드를 선택한다' 고 표현한 이유는, Ruby등 동적 타입 언어에서는 메시지에 명시된 것과 다른 시그니처를 가진 메소드로 요청이 처리될 수 있기 때문이다.

예를 들어, Ruby 언어에서 객체가 메시지를 받았을 때 필요한 메소드가 없다면, method_missing 이라는 메소드로 처리된다.
상세한 내부 동작은 모르겠지만 개념적인 면에서, 객체가 메소드를 선택한다고 볼 수 있다.

상속과 다형성

다형적인(다형성을 가진) 협력에 참여하는 객체들은, 모두 같은 메시지를 이해할 수 있어야 한다.
즉, 인터페이스가 동일해야 한다.

이렇게 동일한 인터페이스를 갖게 할 수 있는 방법들 중 하나가 상속이다.

여기서 상속은, 자바의 클래스 상속과 인터페이스 구현을 모두 포함한다.

그러나 상속만이 다형성을 구현하는 유일한 방법은 아니다.

구현 상속

상속은 목적에 따라, 구현 상속인터페이스 상속으로 구분할 수 있다.

구현 상속(=SubClassing)

  • 코드(=구현 코드) 재사용 목적의 상속

인터페이스 상속(=SubTyping)

  • 인터페이스 공유 목적의 상속
  • 다형적인 협력을 위해, 부모 클래스와 자식 클래스의 인터페이스 공유가 필요할 때 사용

'구현 상속은 지양해야 한다' 에 대해

https://programmer-ririhan.tistory.com/408

위 글에 내용 정리가 잘 돼 있어서 참고했다.

구현 상속은 코드의 재사용성을 높일 수 있으나, 다음과 같은 단점들이 있다고 한다.
1. 상위 클래스의 코드를 바로 보기가 어렵기에, 이해하기 어려울 수 있다.
2. 캡슐화를 깨뜨린다.

개인적으로, 지금 알고 있는 지식들로만 보면 1번은 잘 와닿지 않는다. Composition 방식과 비교했을 때도, 특정 기능의 코드를 읽기 위해 부모 클래스를 타고 올라가며 보는 것과, 호출되는 필드 객체를 타고 가며 보는 것의 차이가 크지 않을 것 같다.

Composition 방식

  • 한 객체가 다른 객체를 소유하는 관계
  • 자바에서, 특정 클래스가 다른 클래스를 필드로 갖는 것
  • 상속(is-a 관계)와 비교하여, has-a 관계라고 표현한다.
  • is-a 관계에 비해 느슨한 결합이 가능하다(캡슐화가 되므로)

2번은 큰 단점이라고 생각한다. 캡슐화의 장점을 갖지 못하고, 상황에 따라 찾기 어려운 오류를 유발할 수 있기 때문이다.

구현 상속 시 발생 가능한 오류

예시

위 글의 내용 중 이런 상황이 있다.

  • Airplain 이라는 추상 클래스를 만들고, fly()라는 메소드는 구현하여 공통 기능으로 사용하기로 했다.
  • 이를 상속해서 AModel과 BModel 클래스를 만들었고, 잘 사용했다.
  • 이후 CModel을 만들었다. 이 모델은 fly()를 수행하는 방식이 달라서, 별도로 구현해야 했다.
  • 그러나 개발자가 실수로 구현하지 않았고, 공통 기능인 Airplain.fly()가 실행되게 되었다.
  • 결과적으로 의도와 다르게 동작하지만, 컴파일 오류는 발생하지 않는 프로그램이 만들어졌다.

위는 has-a 관계(CModel이 Airplain을 필드로 갖는 관계)였다면, CModel.fly()를 구현하지 않았으므로 컴파일 타임에 발견되었을 오류이다.

구현 상속이 필요한 상황

이러한 단점들로 인해, 구현 상속은 두 클래스가 명확한 is-a 관계일 때 사용하라고 말한다.
예를 들어, SmartPhone is a Phone, ElectricCar is a Car 같은 경우이다.

생각하기로는, "우리 프로그램에서" 두 클래스가 명확한 is-a 관계일 때 가 더 맞지 않을까 한다.

예를 들어, 자동차와 관련이 없는 프로그램에서라면 ElectricCar 와 Car를 상속 관계로 묶어도 되겠지만,
이 둘의 동작방식을 세세하게 구현하고 있는 프로그램에서는 그렇게 했을 때 문제가 발생할 수 있다.

실습 - 티켓 판매 시스템

클래스 구조도

요구사항

영화 관람객에게 티켓을 판매하는 간단한 로직을 구현한다.

관람객이 초대장을 가지고 있으면 티켓을 무료로 주고, 아니면 현금을 받고 판매한다. 해당 로직이 Theater.enter() 를 통해 진행된다.

  • TicketOffice는 보유 현금, 티켓들을 속성으로 가진다.
  • TicketSeller는 TicketOffice에 의존하며, 초대장 or 현금을 받고 티켓을 지급한다.
  • Bag은 현금, 초대장, 티켓을 속성으로 가진다. Audience가 Bag을 소지하고 있다.

단순 구현 코드

객체지향을 신경쓰지 않고, Theater.enter()에서 로직이 대부분 진행되게 구현한 코드이다.

Theater.java

public class Theater {  
    private TicketSeller ticketSeller;  
  
    public Theater(TicketSeller ticketSeller) {  
        this.ticketSeller = ticketSeller;  
    }  
  
    public void enter(Audience audience) {  
        // 초대권이 있으면, 티켓으로 바꿔준다.  
        if (audience.getBag().hasInvitation()) {  
            Ticket ticket = ticketSeller.getTicketOffice().getTicket();  
            audience.getBag().setTicket(ticket);  
        } else { // 초대권이 없으면, 돈을 받고 바꿔준다.  
            Ticket ticket = ticketSeller.getTicketOffice().getTicket();  
            audience.getBag().minusCash(ticket.getFee());  
            ticketSeller.getTicketOffice().plusCash(ticket.getFee());  
            audience.getBag().setTicket(ticket);  
        }  
    }
}

위 코드는 다음과 같은 문제점이 있다.

  • Audience, TicketSeller가 Theater의 통제를 받는다.
    - Audience의 Bag을 열어볼 권한을 Theater가 가지고 있다.
    - TicketOffice의 tickets와 cashAmount에도 Theater가 직접 접근할 수 있다.
    - 이러면, 티켓 구매 등 Audience와 TicketOffice의 상태를 조작하는 동작이 Theater(=예상 외의 지점)에서 일어날 수 있다. => 유지보수성이 떨어진다.
    - 기능을 직관적이지 않은 로직으로 처리하여, 이해가 어려워진다.
  • Bag, TicketOffice 또한 Theater가 알고 있다.
    => Bag과 TicketOffice에 변경이 일어났을 때, Theater에서 연쇄적인 변경이 일어날 수 있다.
    => 코드를 읽고 이해하는 사람에게도 부담이다.

개선 1 - TicketOffice 접근 로직 이동

기존 Theater.enter()의 로직을 모두 TicketSeller.sellTo()로 옮겼다.

  • Theater - TicketOffice간 불필요했던 의존성을 제거했다.
  • TicketOffice에 TicketSeller만 접근 가능하게 가렸다.

Theater.java

public class Theater {  
    private TicketSeller ticketSeller;  
  
    public Theater(TicketSeller ticketSeller) {  
        this.ticketSeller = ticketSeller;  
    }  
  
    public void enter(Audience audience) {  
        ticketSeller.sellTo(audience);  
    }  
}

TicketSeller.java

public class TicketSeller {  
    private TicketOffice ticketOffice;  
  
    ...
  
    public void sellTo(Audience audience) {  
        Bag bag = audience.getBag();  
        Ticket ticket = ticketOffice.getTicket();  
        if (!bag.hasInvitation()) {  
            bag.minusCash(ticket.getFee());  
            ticketOffice.plusCash(ticket.getFee());  
        }  
        bag.setTicket(ticket);  
    }  
}

개선 2 - 티켓 구매 로직 이동

초대장 or 현금을 통해 티켓을 받는 로직을 Audience 내부로 옮겼다.

  • TicketSeller - Bag간 의존성을 제거했다.
  • Audience가 자기 데이터를 직접 처리하게 되었다. 자율성을 갖게 했다.

TicketSeller.java

public class TicketSeller {  
    private TicketOffice ticketOffice;  
  
    ...
  
    public void sellTo(Audience audience) {
    	ticketOffice.plusCash(audience.buy(ticketOffice.getTicket()));  
    }
}

Audience.java

public class Audience {  
    private Bag bag;  
  
    public Audience(Bag bag) {  
        this.bag = bag;  
    }  
  
    public Long buy(Ticket ticket) {  
        if (bag.hasInvitation()) {  
            bag.setTicket(ticket);  
            return 0L;  
        } else {  
            Long fee = ticket.getFee();  
            bag.setTicket(ticket);  
            bag.minusCash(fee);  
            return fee;  
        }  
    }
}

개선 3 - 티켓 교환 로직 이동

초대장 유무 체크, 현금 차감, 받은 티켓 저장 부분을 Audience -> Bag으로 옮겼다.

  • Audience - Invitation간 의존성을 제거했다.
  • Bag이 자율성을 가지고 자기 데이터를 직접 관리하게 되었다.

Audience.java

public class Audience {  
    private Bag bag;  
  
    public Audience(Bag bag) {  
        this.bag = bag;  
    }  
  
    public Long buy(Ticket ticket) {  
        return bag.hold(ticket);  
    }  
}

Bag.java

public class Bag {  
    private Long cashAmount;  
    private Invitation invitation;  
    private Ticket ticket;

	...
  
    public Long hold(Ticket ticket) {  
        setTicket(ticket);  
        if (hasInvitation()) {  
            return 0L;  
        } else {  
            Long fee = ticket.getFee();  
            minusCash(fee);  
            return fee;  
        }  
    }
}

개선 4 - 매표소 티켓, 현금 접근 로직의 이동

TicketOffice의 속성에 접근하는 로직을 TicketSeller에서 객체 내부로 옮겼다.

  • TicketOffice에서 티켓을 꺼내오고, 현금을 추가하는 로직을 해당 객체 내부로 캡슐화했다.
  • TicketOffice가 자기 데이터를 직접 처리한다. TicketSeller는 TicketOffice의 현금, 티켓을 더 알 필요가 없다.
  • 트레이드 오프가 발생했다. TicketOffice - Audience간 새로운 의존성이 생겼다.

이를 통해 알 수 있는 점은, 마치 DB의 정규화처럼, 객체지향이 만능이 아니라는 점이다.
이번 개선사항은 적용하지 않는 편이 (유지보수성 면에서) 더 좋을 수 있기에, 어느 쪽이 더 좋을지 고민하고 결정하는 것이 필요하다.

TickerSeller.java

public class TicketSeller {  
    private TicketOffice ticketOffice;  
  
    ...
  
    public void sellTo(Audience audience) {  
        ticketOffice.sellTicketTo(audience);  
    }  
}

TicketOffice.java

public class TicketOffice {  
    private Long cashAmount;  
    private Queue<Ticket> tickets = new ArrayDeque<>();  
  
    public void sellTicketTo(Audience audience) {  
        plusCash(audience.buy(getTicket()));  
    }  
    private Ticket getTicket() {  
        return tickets.poll();  
    }  
  
    private void plusCash(Long amount) {  
        this.cashAmount += amount;  
    }  
}



참고 자료

0개의 댓글