티스토리 뷰
프로그래밍을 하다보면 추상화를 이용하고, 응집도가 높고 결합도는 낮은 애플리케이션을 개발해야 한다는 얘기를 많이 듣습니다. 그래서 이번에는 객체 지향 프로그래밍의 핵심 특징들인 캡슐화, 응집도, 결합도에 대해 알아보고자 합니다. 아래의 내용들은 클린 코드, 오브젝트 등에서 참고한 내용들이 많이 있습니다.
1. 캡슐화(Encapsulation)
[ 캡슐화(Encapsulation) ]
객체지향 프로그래밍이 강력한 이유는 요구사항이 새롭게 추가되거나 바뀌어도 변경이 전파되지 않도록 막을 수 있는 장치들을 제공하기 때문이다. 그리고 이를 위한 대표적인 장치 중 하나가 캡슐화이다. 캡슐화를 이해하기 위해서는 퍼블릭 인터페이스와 구현에 대해 알아야 한다.
객체의 행동은 크게 퍼블릭 인터페이스(public Interface)와 구현(Implementation) 2가지로 나뉘어지고, 각각은 다음과 같다.
- 퍼블릭 인터페이스: 외부에서 접근 가능한 객체의 행동, 변경이 거의 없음
- 구현: 외부에서 접근이 불가능하고 오직 내부에서만 접근 가능한 부분, 변경이 발생할 수 있음
즉, 공개된 퍼블릭 메소드들이 퍼블릭 인터페이스이고, 프라이빗 메소드들이 구현에 해당한다.
SOLID 관련 포스팅에서 살펴봤던 인터페이스 분리의 법칙이 이야기하는 것이 바로 객체의 퍼블릭 인터페이스와 구현을 분리하라는 것이다. 객체의 상태와 행동을 모아 변경 가능성이 높은 부분은 구현으로 숨기고, 외부에는 상대적으로 변경이 거의 없이 안정적인 부분만 공개함으로써 변경의 여파를 통제할 수 있는 것이다. 그리고 이것은 캡슐화와 연결된다.
캡슐화란 외부에서 알 필요가 없는 부분을 감춤으로써 대상을 단순화하는 추상화의 한 종류이다. 캡슐화가 중요한 이유는 불안정한 부분(구현)과 안정적인 부분(퍼블릭 인터페이스)을 분리하여 변경의 영향을 통제할 수 있기 때문이다. 캡슐화를 통해 변경 가능성이 높은 부분을 객체 내부로 추상화하면 변경을 최소화할 수 있다.
예를 들어 다음과 같이 새로운 사용자의 아이디와 비밀번호를 입력으로 받아 암호화하여 데이터베이스에 저장하는 로직이 있다고 하자.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public void addUser(final String email, final String pw) {
final String encryptedPassword = passwordEncoder.encryptPassword(pw);
final User user = User.builder()
.email(email)
.pw(encryptedPassword).build();
userRepository.save(user);
}
}
여기서 비밀번호를 암호화하는 PasswordEncoder의 퍼블릭 인터페이스는 encryptPassword이다. 새로운 사용자를 추가하는 UserService의 입장에서 중요한 것은 입력된 비밀번호를 주면 암호화된 비밀번호를 반환받는다는 것이다. 그에 반해 어떠한 암호화 알고리즘을 사용하는지 등은 알 필요가 없으며, 그러므로 PasswordEncoder는 세부 알고리즘을 구현으로 숨겨둔 것이다.
객체 내부에 캡슐화해야 하는 것은 변경될 수 있는 모든 것들이며, 객체의 상태는 숨기고 행동만 외부에 공개해야 한다. 그리고 어떤 행동이 내부에서만 가능하다면 그 행동 역시 숨겨야 한다.
다음과 같이 우리가 흔히 사용하는 객체의 Getter/Setter는 사실 캡슐화를 위반하는 것이다.
@Getter
@Setter
public class User {
private String name;
private int age;
}
왜냐하면 우리는 Getter와 Setter를 통해 해당 객체가 어떠한 데이터를 내부에 가지고 있는지 알 수 있기 때문이다. 제대로 된 객체지향 프로그래밍을 위해서는 무지성으로 Getter와 Setter를 만드는 것이 아니라 객체에게 필요한 행동만을 퍼블릭 인터페이스로 만들어야 한다.
이렇게 필요한 부분만 공개하고, 숨겨 놓은 부분에 대해 임의로 접근할 수 없도록 방지함으로써 내부 구현을 마음대로 변경할 수 있도록 하는 것을 내부 은닉이라고 한다. 이를 이용하는 사람은 내부 구현은 무시한 채 인터페이스만 알고 있어도 클래스를 사용할 수 있으므로 머릿속에 담아둬야 하는 지식의 양을 줄일 수 있다. 반면에 이를 구현하는 사람은 인터페이스를 바꾸지 않는 한 외부에 미치는 여향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있다.
객체의 외부와 내부를 구분함으로써 알아야 할 지식의 양이 줄어들고, 구현을 변경할 수 있는 폭이 넓어진다. 그러므로 클래스를 개발할 때마다 인터페이스와 구현을 깔끔하게 분리하기 위해 노력하는 것이 중요하다.
2. 응집도(Cohension)와 결합도(Coupling)
[ 응집도(Cohension) ]
응집도는 모듈에 포함된 내부 요소들이 연관돼 있는 정도를 나타낸다. 모듈 내의 요소들이 하나의 목적을 위해 긴밀하게 협력한다면 그 모듈은 높은 응집도를 갖는 것이다. 반대로 모듈 내의 요소들이 서로 다른 목적을 추가한다면 그 모듈은 응집도가 낮아지게 된다. 객체지향의 관점에서 응집도는 객체 또는 클래스에 얼마나 관련 있는 책임들을 할당했는지를 나타낸다.
응집도가 높은지 검사해보는 가장 좋은 방법은 모든 메소드들에 대하여 인스턴스 변수를 사용하는 비율이 높은지 보는 것이다. 어떤 클래스의 응집도가 높다면 메소드와 변수가 서로 의존하고 있을 것이고, 응집도가 낮다면 상태와 기능의 논리적 연결이 약할 것이고, 이는 클래스를 더욱 분리할 수 있음을 암시한다.
예를 들어 아래의 Stack Class는 size를 제외한 모든 함수에서 인스턴스 변수를 활용하므로 응집도가 높은 클래스이다.
public class Stack {
private int topOfStack = 0;
List<Integer> elements = new LinkedList<Integer>();
public int size() {
return topOfStack;
}
public void push(int element) {
topOfStack++;
elements.add(element);
}
public int pop() throws PoppedWhenEmpty{
if(topOfStack ==0)
throws new PoppedWhenEmpty();
int element = elements.get(--topOfStack);
elements.remove(topOfStack);
return element;
}
}
[ 결합도(Coupling) ]
결합도는 의존성의 정도를 나타내며 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도이다. 어떤 모듈이 다른 모듈에 대해 너무 자세히 알고 있다면 두 모듈은 높은 결합도를 가진다. 반대로 어떤 모듈이 다른 모듈에 대해 꼭 필요한 지식만 알고 있다면, 두 모듈은 낮은 결합도를 가진다. 객체지향의 관점에서 결합도는 객체 또는 클래스가 협력에 필요한 적절한 수준의 관계만을 유지하고 있는지를 나타낸다. 만약 객체의 인터페이스가 제대로 분리되어 있지 않고, 불필요하게 많은 정보를 알고 있다면 이는 결합도가 높은 것이다.
예를 들어 위의 예제에서 UserService가 의존하는 PasswordEncoder의 클래스가 다음과 같다고 하자.
public interface PasswordEncoder {
String encryptPassword(final String pw);
}
@Component
public class BCryptPasswordEncoder implements PasswordEncoder, PasswordChecker {
@Override
public String encryptPassword(final String pw) {
...
}
@Override
public String isCorrectPassword(final String rawPw, final String pw) {
final String encryptedPw = encryptPassword(rawPw);
return encryptedPw.equals(pw);
}
}
UserService가 만약 PasswordEncoder 인터페이스가 아닌 BCryptoPasswordEncoder 클래스를 직접 주입받았다면 UserService는 encryptPassword 외에도 isCorrectPassword라는 행동에 접근할 수 있었을 것이다. 그리고 이는 UserService의 입장에서 불필요한 정보이므로 결합도가 높아 좋지 못한 설계였을 것이다. 하지만 UserService는 PasswordEncoder에 의존함으로써 결합도를 최대한으로 낮추었고, 좋은 설계를 가질 수 있었다.
일반적으로 좋은 설계란 높은 응집도와 낮은 결합도를 갖는 모듈로 구성된 설계를 의미한다. 다시 말해 애플리케이션의 각 요소들이 응잡도가 높고, 서로 느슨하게 결합되어 있다면 그 애플리케이션은 좋은 설계를 가졌다고 볼 수 있다.
우리는 미래의 변경에 손쉽게 대응할 수 있도록 캡슐화를 하고 높은 응집도와 낮은 결합도를 갖는 설계를 해야 한다.
'나의 공부방' 카테고리의 다른 글
[오픈소스] SpringBoot 오픈 소스 프로젝트에 컨트리뷰트 하기 (4) | 2022.01.30 |
---|---|
[OOP] 코드의 재사용, 상속(Inheritance)보다 합성(Composition)을 사용해야 하는 이유 (13) | 2021.12.01 |
[OOP] 객체지향 프로그래밍의 5가지 설계 원칙, 실무 코드로 살펴보는 SOLID (38) | 2021.11.02 |
[개발서적] 클린 코더(Clean Coder) 핵심 요약 및 정리 (10) | 2021.09.04 |
[TDD] 단위 테스트와 TDD(테스트 주도 개발) 프로그래밍 방법 소개 - (1/5) (26) | 2021.08.16 |