본문 바로가기

[OOP] 캡슐화 (객체 지향과 디자인 패턴)

 

들어가며

스타트업의 개발자로서 좋은 설계가 우선인가, 아니면 빠른 기능 개발이 우선인가 항상 고민하곤 했습니다. 제가 다녔던 스타트업은 빠르게 기능 개발을 해서, 시장에서 인정받아야 했기에, 빠른 시간 안에 많은 기능을 개발해야 했습니다. 그러다 문득 추가 기능을 개발하거나, 기존 기능을 수정해야 할 때, 많은 어려움을 겪곤 했습니다. 그렇게 일을 마치고, 더 나은 개발자가 되기 위한 공부를 하면서 제가 작성한 코드는 배려가 부족한 코드였다는 것을 깨달았습니다.

 

제가 개발한 코드는 당장의 기능 개발은 빠르게 할 수 있더라도, 재사용성이 대단히 떨어졌고, 가독성 또한 대단히 떨어졌습니다. 또한 기능을 수정하거나 추가할 때도 광범위한 코드를 건드려야만 했기에 효율성도 떨어졌습니다. 빠르게 기능을 개발하기 위해 적었던 코드가 언젠가는 발목을 잡을 수 있는 코드가 될 수 있다는 것을 깨달았습니다. 그렇다면 비즈니스에 도움이 되고, 다른 팀원들에게도 도움이 될 수 있는, 배려가 담긴 코드를 작성하기 위해서는 어떻게 해야 할까 고민하기 시작했습니다. 배려가 담긴 코드를 작성하기 위해 공부한 내용에 대해 작성해보고자 합니다. 이 글은 도서 객체 지향과 디자인 패턴을 바탕으로 작성됐습니다.

 

 

 

 

 


 

 

 

 

 

 

 

출처 : https://incheol-jung.gitbook.io/docs/q-and-a/architecture/undefined-1

 

 

캡슐화

캡슐화는 객체가 내부적으로 기능을 어떻게 구현하는지를 감추는 것입니다. 이를 통해 내부의 기능 구현이 변경되더라도 그 기능을 사용하는 코드는 영향을 받지 않도록 만들어 줍니다. 그럼 어떻게 캡슐화가 구현 변경의 유연함을 주는지 절차 지향 방식 코드와 캡슐화 방식 코드의 비교 분석을 통해 살펴보겠습니다. 

 

 

절차 지향 방식 코드

회원의 서비스 만료 날짜 여부에 따라 서비스를 제공하거나 안내 페이지를 보여줘야 한다면, 서비스 만료 날짜 여부를 확인하는 코드는 여러 곳에서 사용될 것입니다. 회원 정보를 담고 있는 클래스는 아래와 같이 만료 날짜 데이터를 담고 있을 것입니다.

 

 

class Member {
  private expiryDate: Date;
  private male: boolean;

  public getExpiryDate(): Date {
    return this.expiryDate;
  }

  public isMale(): boolean {
    return this.male;
  }
}

 

 

Member 객체를 이용해서 만료 여부를 확인하는 코드는 Member가 제공하는 expiryDate 데이터의 값과 현재 시간을 비교하게 됩니다. 아래와 같은 형태의 코드는 시간이 흐를수록 다양한 곳에서 사용될 것입니다.

 

 if ( this.getExpiryDate() !== null &&
      this.getExpiryDate().getDate() < new Date().getDate()) { 
      console.log('만료'); 
 }

 

 

그런데 서비스를 잘 운영해 오던 중에, 여성 회원의 경우 만료 기간이 지났어도 30일간은 서비스를 사용할 수 있도록 정책이 변경되었다고 가정해 보겠습니다. 만료 여부를 확인하는 규칙이 변경되었으므로 이를 코드에 반영해줘야 하는데, 변경되는 코드는 다음과 같이 복잡해질 것입니다.

 

 

if (
      (this.getExpiryDate() !== null &&
        this.getExpiryDate().getDate() < new Date().getDate()) ||
      (!this.isMale() &&
        this.getExpiryDate() !== null &&
        this.getExpiryDate().getDate() < new Date().getDate() - 30)
    ) {
      console.log('만료');
    }

 

 

만료 여부를 확인하는 코드는 이미 여러 곳에서 사용되는 중이기 때문에 그 코드들을 모두 찾아서 위와 같이 변경해야 합니다. 만료 여부를 확인하는 코드가 많을수록 수정해주지 않는 실수를 범할 가능성이 높아지고, 이는 버그로 직결됩니다. 

 

 

출처 : 객체 지향과 디자인 패턴

 

 

이 문제는 데이터를 중심으로 프로그래밍했기 때문에 나오는 문제입니다. 위의 그림처럼 데이터를 직접적으로 사용하는 코드는 데이터의 변화에 직접적인 영향을 받기 때문에, 요구 사항의 변화로 인해 데이터의 구조나 쓰임새가 변경되면 이로 인해 데이터를 사용하는 코드들도 연쇄적으로 수정해 줘야 합니다.

 

 

 

 

 

캡슐화 방식 코드

그럼 절차 지향이 아닌 캡슐화를 활용해서 코드를 작성하면 다음과 같이 작성할 수 있습니다. 

 

 

class Member {
  private expiryDate: Date;
  private male: boolean;

  public getExpiryDate(): Date {
    return this.expiryDate;
  }

  public isMale(): boolean {
    return this.male;
  }

  public isExpired(): boolean {
    return (
      this.getExpiryDate() !== null &&
      this.expiryDate.getDate() < new Date().getDate()
    );
  }
}

 

Member 클래스의 isExpired() 메서드는 만료 여부 확인 기능을 제공하는데, 다른 클래스에서는 Member 클래스가 isExpired() 메서드를 어떻게 구현했는지 알지 못합니다. 단지, 회원의 서비스 사용이 만료되었으면 isExpired() 메서드가 true를 리턴한다는 것만 알고 있습니다. 만료 여부에 따라 다르게 동작해야 하는 코드는 다음과 같이 isExpired() 메서드를 사용하게 됩니다. 만약 isExpired 메서드의 기능이 변경되더라도, Member 클래스의 isExpired 메서드의 내용만 변경하면 됩니다. 즉 캡슐화를 통해 내부 구현이 변경되더라도, 기능을 사용하는 곳의 영향을 최소화할 수 있습니다. 

 

 

 

 

 

 

 


 

 

 

 

 

출처 : https://incheol-jung.gitbook.io/docs/q-and-a/architecture/undefined-1

 

 

캡슐화를 위한 두 개의 규칙

지금까지 캡슐화를 활용하면 구현이 변경되더라도 수정을 최소화할 수 있다는 것을 살펴봤습니다. 그럼, 캡슐화를 할 수 있으려면 코드를 어떻게 작성해야 할까요? 캡슐화를 할 수 있도록 돕는 규칙이 있는데, 바로 'Tell, Don't Ask', 와 데미테르의 법칙 규칙입니다. 

 

Tell, Don't Ask

규칙은 간단합니다. 데이터를 물어보지 않고, 기능을 실행해 달라고 말하라는 규칙입니다. 위의 절차 지향 방식에서의 코드를 다시 살펴보면, 

 

 if ( this.getExpiryDate() !== null &&
      this.getExpiryDate().getDate() < new Date().getDate()) { 
      console.log('만료'); 
 }

 

절차 지향 방식은 만료 일자 데이터를 가져와서, 직접 만료 여부를 확인했습니다. 데이터를 읽는 것은 데이터를 중심으로 코드를 작성하게 만드는 원인이 되며, 따라서 절차 지향적인 코드를 유도하게 됩니다. 데이터 대신 기능을 실행해달라고 명령을 내리려면 만료 일자 데이터를 가진 객체에게만 만료 여부를 확인해 달라고 해야 합니다. 즉, 아래 코드와 같이 기능 실행을 요청하는 방식으로 코드를 작성하게 됩니다.

 

 

if (member.isExpired()) {
	// 만료에 따른 처리
}

 

 

기능 실행을 요청하는 방식으로 코드를 작성하다 보면, 자연스럽게 해당 기능을 어떻게 구현했는지 여부가 감춰집니다. 즉, 기능 구현이 캡슐화되는 것입니다. 

 

 

 

데미테르의 법칙

데미테르의 법칙은 다음과 같이 간단한 규칙으로 구성됩니다.

 

  • 메서드에서 생성한 객체의 메서드만 호출
  • 파라미터로 받은 객체의 메서드만 호출
  • 필드로 참조하는 객체의 메서드만 호출

 

앞서 데이터를 이용한 회원 만료 여부를 확인하는 코드는 다음과 같이 메서드의 일부분일 수 있고, 파라미터를 통해서 member 객체를 전달받을 수 있습니다. 

 

 

public processSome(member: Member) {
    if(member.getExpiryDate().getTime() < ...) // 데미테르 법칙 위반 
}

 

 

이때 위 코드는 데미테르의 법칙을 어긴 것입니다. 데미테르의 법칙에 따르면 파라미터로 전달받은 객체의 메서드만 호출하도록 되어 있는데, 위 코드의 경우 파라미터로 전달받은 member의 getDate() 메서드를 호출한 뒤에, 다시 getDate()가 리턴한 Date 객체의 getTime() 메서드를 호출했기 때문입니다. 

 

따라서 데미테르의 법칙을 따르려면, 위 코드를 member 객체에 대한 한 번의 메서드 호출로 변경해 주어야 합니다. 이는 결국 데이터 중심이 아닌 기능 중심으로 코드를 작성하도록 유도하기 때문에, 기능 구현의 캡슐화를 향상해 줍니다. 

 

 

 

 

 

 

 


 

 

 

 

 

출처 : https://incheol-jung.gitbook.io/docs/q-and-a/architecture/undefined-1

 

 

캡슐화 사례

그렇다면, 접근 제한자를 잘 만들어주고 객체에게 메시지만 잘 던진다면 은닉화를 잘 지킨다고 할 수 있을까요? 아래에서는 은닉화를 잘한 것 같으면서도 실제로는 잘 못 한 예시를 살펴보겠습니다. 

 

 

public class Movie {
    private fee: Money;
    
    public getFee(): Money {
        return this.fee;
    }
    
    public setFee(fee: Money): void {
        this.fee = fee;
    } 
}

 

 

위의 코드는 속성인 fee도 private으로 설정했으므로 외부에서는 getFee()를 통해서만 접근이 가능합니다. 언뜻 보면 외부에서 내부 구현을 모르는 것처럼 느껴집니다. 하지만, 우리는 "getFee'라는 메서드명을 통해서 해당 객체의 필드에는 fee가 있겠다고 추측할 수 있습니다. 즉, 인스턴스 필드에 곧바로 접근을 하지 않더라도 내부 구현을 알게 되어버립니다. 이러한 이유와 fee의 자료형이나 이름이 바뀌면 해당 getter문을 사용한 모든 객체의 코드를 바꿔야 하므로 getter문을 자제하라는 것입니다. 

 

 

 

public class DiscountCondition {
    private type: DiscountConditionType;
    private sequence: number;
    private dayOfWeek: DayOfWeek;
    private startTime: LocalTime;
    private endTime: LocalTime;
    
    public getType(): DiscountConditionType { ... }
    
    public isDiscountable(DayOfWeek dayOfWeek, LocalTime time): boolean { ... }
    
    public isDiscountable(int sequence): boolean { ... }    
}

 

 

위의 코드는 getter문이 있으나 내부 구현과는 연관이 없습니다. 그리고 나머지 2개 메서드도 메서드명 가지고는 내부 구현을 판단하기 어렵습니다. 하지만 위의 코드도 캡슐화를 지키지 못했습니다. 왜냐하면 isDiscountable() 메서드의 파라미터들이 해당 객체의 속성을 가지고 있기 때문입니다. 만약, 해당 객체의 속성이 변한다면 isDiscountable() 메서드의 파라미터를 수정해야 하고, 이 메서드를 사용하는 입장에서도 전부 코드를 수정해야 합니다. 

 

 

public class Movie {
    private title: String;
    private runningTime: Duration;
    private fee: Money;
    private discountConditions: DiscountCondition;
    
    private movieType: MovieType;
    private discountAmount: Money;
    private discountPercent: string;
    
    public getMovieType(): MovieType { ... }
    public calculateAmountDiscountedFee(): Money { ... }
    public calculatePercentDiscountedFee(): Money { ... }
    public calculateNoneDiscountedFee(): Money { ... }
}

 

 

위의 코드는 내부 구현과 연관 없는 getter문이 있고, 메서드 파라미터의 객체의 속성을 담지도 않았습니다. 그러나 위 코드도 캡슐화를 위반했습니다. 왜냐하면 calculate XXX 메서드를 통해 해당 객체에는 3가지 할인 정책이 있다는 것을 외부에 노출했기 때문입니다. 만약, 할인 정책이 추가되거나 변경된다면 또 연쇄적인 변경이 발생할 것입니다. 따라서, 이 상황에서는 할인 정책이라는 객체를 따로 분리하고 그 안에서 함수형 인터페이스 등을 통해 다형성을 구현하는 것이 적절합니다.

 

즉 외부에 내부 구현을 알리는 메서드 명이나 파라미터명을 쓰지 않도록 한다면 좀 더 캡슐화를 지킬 수 있다고 생각합니다. 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

마치며

객체지향 프로그래밍의 필요성을 점점 느끼고 있습니다. 언젠가, 코드에 대한 역할과 책임을 명확하게 보여줄 수 있는 코드를 작성할 수 있다면 동료들이, 일을 더 수월하게 할 수 있지 않을까 생각합니다. 배려가 담긴 코드를 작성하기 위해 꾸준하게 노력하고 싶습니다. 

 

 

 

 

 

 

 


 

 

 

 

 

 

출처

 

객체 지향과 디자인 패턴 - YES24

객체 지향 안내서. 객체, 책임, 의존, 캡슐화 등 객체 지향의 주요 개념들을 쉬운 예제와 그림을 통해 이해하기 쉽게 설명한다.

www.yes24.com

 

객체 지향 프로그래밍 입문 - 인프런 | 강의

잘 하는 개발자가 되기 위해서는 유연한 코드를 작성할 줄 알아야합니다. 객체 지향을 이용해서 변경하기 좋은 유연한 코드를 만드는 방법을 알아보세요., - 강의 소개 | 인프런...

www.inflearn.com

 

객체 지향

이번 포스트에서는 객체지향에 대해 포스트를 진행하겠다.절차지향과 객체 지향객체 지향을 잘 하려면 이와...

blog.naver.com

 

[OOP] 캡슐화(Encapsulation)란?

안녕하세요? 제이온입니다. 이번 시간에는 저번 포스팅인 다형성에 이어서 캡슐화에 대해 알아보겠습니다. 캡슐화란? 위키피디아에 따르면, 캡슐화를 아래와 같이 정의하고 있습니다. 객체의

steady-coding.tistory.com