[OOP] 캡슐화(Encapsulation)란?
안녕하세요? 제이온입니다.
이번 시간에는 저번 포스팅인 다형성에 이어서 캡슐화에 대해 알아보겠습니다.
캡슐화란?
위키피디아에 따르면, 캡슐화를 아래와 같이 정의하고 있습니다.
객체의 속성(data fields)과 행위(methods)를 하나로 묶고, 실제 구현 내용 일부를 외부에 감추어 은닉한다.
여기서 은닉이라는 단어 때문에 캡슐화와 은닉화를 혼동하는 분이 많습니다. 은닉화는 캡슐화를 통해 얻어지는 "실제 구현 내용 일부를 외부에 감추는" 효과입니다.
객체의 속성과 행위를 묶으면 응집도가 올라가므로 자율적인 객체가 된다는 장점이 있습니다. 자율적인 객체가 되면 단순히 데이터 전달자 역할이 아니라, 자신의 상태를 스스로 처리할 수 있습니다.
그런데, 이 상황에서 은닉화가 이루어지지 않는다면 어떨까요? 객체가 스스로의 상태를 처리하지 못하고, 외부에서 속성을 꺼내와서 상태를 수정하게 되면 높은 결합도와 낮은 응집도를 지닌 수동적인 객체가 됩니다. 이렇게 수동적인 객체는 프로그램의 유지 보수를 어렵게 하므로 은닉화를 통해 내부 구현을 감춰야 하는 것이죠.
또한, 돈과 같이 민감한 부분을 외부에 공개하게 되면, 악의적인 사용자가 돈의 양을 바꿔버릴 수도 있습니다. 보안적으로 치명적이게 되는 것이죠.
즉, 변경에 유연한 프로그램을 만드는 동시에 보안적인 프로그램을 만들기 위해서 캡슐화를 지켜야하는 것입니다.
은닉화를 지키는 방법
은닉화는 크게 필드 데이터의 은닉화와 기능(메소드)의 은닉화가 있습니다. 그리고 이러한 은닉화는 주로 접근 제한자를 통해 설정할 수 있습니다. 우선, 은닉화를 지키지 않은 코드를 보겠습니다.
이산이라는 남자 아이가 있는데, 그 친구가 배가 고픈지 안 배가 고픈지 알아보는 코드를 작성해보겠습니다.
public class YeeSan {
public final Stomach stomach;
public YeeSan(int capacity) {
this.stomach = new Stomach(capacity);
}
public Stomach getStomach() {
return stomach;
}
}
public class Stomach {
public final int capacity;
public Stomach(int capacity) {
this.capacity = capacity;
}
public int getCapacity() {
return capacity;
}
}
YeeSan 클래스 안에는 위장을 뜻하는 Stomach 클래스가 있고, Stomach 클래스의 필드로는 위장의 들어있는 음식물의 양을 뜻하는 capacity가 있습니다. 이제, 사용자 입장에서는 "이산아 너 배고프니, 배부르니?" 물어볼 때 다음과 같은 코드를 작성할 수 있습니다.
public class Main {
public static void main(String[] args) {
final YeeSan yeeSan = new YeeSan(0);
if (yeeSan.getStomach().getCapacity() == 0) {
System.out.println("이산이는 배가 고프다.");
} else if (yeeSan.getStomach().getCapacity() <= 5) {
System.out.println("이산이는 약간 배가 고프다");
} else {
System.out.println("이산이는 배부르다.");
}
}
}
처음에 YeeSan 객체를 만들고, YeeSan의 속성인 Stomach를 가져 오고, 또 Stomach의 속성인 capacity를 가져와서 행위를 정의해 주었습니다. 그리고 YeeSan의 속성인 Stomach, 그리고 Stomach의 속성인 capacity 모두 public이므로 생성자로 초기화해 준 이후 임의의 값을 변경할 수도 있습니다. 즉, 내부 구현이 외부에 노출되었고, 외부에서 이들을 맘대로 사용하고 수정할 수 있게된 셈이죠.
자, 이러한 상황에서 이산이는 더이상 사람이 아니고 사이보그가 되었다고 가정합시다. 사이보그는 위장 대신 바이오 전지를 사용할 수도 있을 것입니다. 그러면 YeeSan 클래스는 다음과 같이 바뀝니다.
public class YeeSan {
public final BioCell bioCell;
public YeeSan(int capacity) {
this.bioCell = new BioCell(capacity);
}
public BioCell getBioCell() {
return bioCell;
}
}
public class BioCell {
public final int capacity;
public BioCell(int capacity) {
this.capacity = capacity;
}
public int getCapacity() {
return capacity;
}
}
자, 그러면 기존의 getStomach()는 getBioCell()로 바꿔야 합니다. 그리고 이 변화의 연쇄 효과로 사용자 입장에서도 getStomach() 코드를 getBioCell()로 싹다 바꿔야 하죠.
public class Main {
public static void main(String[] args) {
final YeeSan yeeSan = new YeeSan(0);
if (yeeSan.getBioCell().getCapacity() == 0) {
System.out.println("이산이는 배가 고프다.");
} else if (yeeSan.getBioCell().getCapacity() <= 5) {
System.out.println("이산이는 약간 배가 고프다");
} else {
System.out.println("이산이는 배부르다.");
}
}
}
이렇듯 수동적인 객체인 YeeSan은 변화에 취약할 수 밖에 없습니다. 그래서 우리는 객체의 속성을 가져와서 로직을 작성하지 말고 객체에게 메시지를 보내야 합니다. 여기서 메시지는 '어떻게' 수행될 것인지 명시하는 것이 아닌 '무엇을' 수행할지 알려주는 것을 의미합니다. 예를 들어, 이산이에게 "너 배가 고프냐?" 정도로만 물어봐야지, 이산이의 위장을 꺼내와서 까본 다음에 내용물이 있나 없나 확인하는 것은 잔인하기도하고 효율적이지도 못합니다.
자, 이제 위 코드를 객체에게 메시지를 던져보도록 바꿔보겠습니다. 그리고 접근 제한자를 통해 은닉화도 지켜보겠습니다.
public class YeeSan {
private final Stomach stomach;
public YeeSan(int capacity) {
this.stomach = new Stomach(capacity);
}
public void showHungryStatus() {
stomach.showHungryStatus();
}
}
public class Stomach {
private final int capacity;
public Stomach(int capacity) {
this.capacity = capacity;
}
public void showHungryStatus() {
if (capacity == 0) {
System.out.println("이산이는 배가 고프다.");
} else if (capacity <= 5) {
System.out.println("이산이는 약간 배가 고프다");
} else {
System.out.println("이산이는 배부르다.");
}
}
}
속성을 가져오는 getter를 모두 지우고, 메시지를 뜻하는 showHungryStatus() 메소드만 정의를 합니다. 그리고 해당 책임을 다시 Stomach에게 위임을 하고, 그 객체 안에서 showHungryStatus() 메소드를 정의합니다.
public class Main {
public static void main(String[] args) {
final YeeSan yeeSan = new YeeSan(0);
yeeSan.showHungryStatus();
}
}
그러면 위와 같이 사용자 입장에서는 내부 구현을 모르더라도 단순히 메시지만 던지는 방식으로 원하는 결과를 얻어올 수 있습니다.
이제, 이산이가 사이보그라고 가정하고 다시 이산이에게 배가 고픈지 물어보겠습니다.
public class YeeSan {
private final BioCell bioCell;
public YeeSan(int capacity) {
this.bioCell = new BioCell(capacity);
}
public void showHungryStatus() {
bioCell.showHungryStatus();
}
}
public class BioCell {
public final int capacity;
public BioCell(int capacity) {
this.capacity = capacity;
}
public void showHungryStatus() {
if (capacity == 0) {
System.out.println("이산이는 배가 고프다.");
} else if (capacity <= 5) {
System.out.println("이산이는 약간 배가 고프다");
} else {
System.out.println("이산이는 배부르다.");
}
}
}
YeeSan의 속성이 Stomach에서 BioCell로 바뀌었습니다. 하지만, YeeSan의 메시지였던 showHungryStatus()는 어느 하나 바뀐 코드가 없습니다. 단지, BioCell에서 배고픈 조건을 추가해줄 뿐이죠. (물론, BioCell과 Stomach는 굉장히 비슷한 역할을 하므로 저번 시간에 배웠던 다형성을 이용해 볼 수도 있겠습니다.)
public class Main {
public static void main(String[] args) {
final YeeSan yeeSan = new YeeSan(0);
yeeSan.showHungryStatus();
}
}
결과적으로 '무엇을' 실행하는지에 대한 내용은 동일하기 때문에 사용자 입장에서도 코드 한 줄 건드리지도 않습니다. 어떤가요? 훨씬 변화에 유연한 코드가 되었습니다. 그리고 외부에서는 객체의 내부 구현을 알 필요 없이 원하는 결과를 가져올 수도 있으며, 악의적인 사용자가 객체의 속성을 임의로 바꿀 수도 없습니다.
그렇다면, 우리는 접근 제한자를 잘 만들어주고 객체에게 메시지만 잘 던진다면 과연 은닉화를 잘 지킨다고 할 수 있을까요? 아래에서는 은닉화를 잘 한 것 같으면서도 실제로는 잘 못 한 예시를 보겠습니다.
은닉화를 제대로 지키지 못한 예시
public class Movie {
private Money fee;
public Money getFee() {
return this.fee;
}
public void setFee(Money fee) {
this.fee = fee;
}
}
해당 코드는 속성인 fee도 private으로 설정했으므로 외부에서는 getFee()를 통해서만 접근이 가능합니다. 언뜻 보면 외부에서 내부 구현을 모르는 것처럼 느껴집니다. 하지만, 우리는 "getFee"라는 메소드명을 통해서 해당 객체의 필드에는 fee가 있겠다고 추측할 수 있습니다. 즉, 인스턴스 필드에 곧바로 접근을 하지 않더라도 내부 구현을 알게 되어버리는 것이죠. 이러한 이유와 fee의 자료형이나 이름이 바뀌면 해당 getter문을 사용한 모든 객체의 코드를 바꿔야 하므로 getter문을 자제하라는 것입니다.
public class DiscountCondition {
private DiscountConditionType type;
private int sequence;
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
public DiscountConditionType getType() { ... }
public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime time) { ... }
public boolean isDiscountable(int sequence) { ... }
}
해당 코드는 getter문이 있으나 내부 구현과는 연관이 없습니다. 그리고 나머지 2개 메소드도 메소드명 가지고는 내부 구현을 판단하기 어렵습니다. 하지만, 위 코드도 캡슐화를 지키지 못했습니다. 왜냐하면 isDiscountable() 메소드의 파라미터들이 해당 객체의 속성을 가지고 있기 때문이죠. 만약, 해당 객체의 속성이 변한다면 isDiscountable() 메소드의 파라미터를 수정해야 하고, 이 메소드를 사용하는 입장에서도 전부 코드를 수정해야할 것입니다.
public class Movie {
private String title;
private Duration runningTime;
private Money fee;
private List<DiscountCondition> discountConditions;
private MovieType movieType;
private Money discountAmount;
private double discountPercent;
public MovieType getMovieType() { ... }
public Money calculateAmountDiscountedFee() { ... }
public Money calculatePercentDiscountedFee() { ... }
public Money calculateNoneDiscountedFee() { ... }
}
해당 코드는 내부 구현과 연관 없는 getter문이 있고, 메소드 파라미터의 객체의 속성을 담지도 않았습니다. 그러나 위 코드도 캡슐화를 위반하였습니다. 왜냐하면, calculateXXX() 메소드를 통해 해당 객체에는 3가지 할인 정책이 있다는 것을 외부에 노출하였기 때문입니다. 만약, 할인 정책이 추가되거나 변경된다면 또 연쇄적인 변경이 발생할 것입니다. 따라서, 이 상황에서는 할인 정책이라는 객체를 따로 분리하고 그 안에서 함수형 인터페이스 등을 통해 다형성을 구현하는 것이 적절합니다.
정리
이처럼 캡슐화를 지켜야 변화에 유연한 소프트웨어를 만들 수 있습니다. 개인적인 생각으로는 객체에게 메시지를 던지는 사고가 캡슐화를 지키는 지름길이라고 생각합니다. 메시지를 던진다는 것 자체가 '어떻게'가 아닌 '무엇을'을 물어보는 것에 지나지 않기 때문이죠. 객체에게 메시지를 전달하다보면 자연스럽게 getter문이 줄어들고, 객체의 내부 구현을 감추면서 다른 객체에게 원하는 결과만 전달하기 용이합니다.
그리고 이와 더불어 외부에 내부 구현을 알리는 메소드명이나 파라미터명을 쓰지 않도록 자제하면 좀 더 캡슐화를 지키기 쉬울 것입니다. 물론.. 이름을 짓는 것이 참 어려운 일이지만요 ㅎㅎ
참고 자료
'개발 이야기 > OOP' 카테고리의 다른 글
[OOP] 추상화(Abstraciton)란? (0) | 2021.06.01 |
---|---|
[OOP] 상속(Inheritance)이란? (2) | 2021.05.31 |
[OOP] 다형성(Polymorphism)이란? (1) | 2021.05.28 |
[SOLID] 의존 역전 원칙(DIP)이란? (3) | 2021.03.12 |
[SOLID] 인터페이스 분리 원칙(ISP)이란? (1) | 2021.03.11 |
댓글