개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴을 읽고...
이 글은 객체지향을 공부하고 스터디했던 내용들을 정리한 글입니다.
OOP를 요리로 비유한다면?
-
요리도구 : 객체 지향의 4대 특성(캡슐화, 상속, 추상화, 다형성)
-
요리도구 사용법 : 객체 지향의 SOLID 개념
-
레시피 : 디자인 패턴
스프링은 객체 지향의 특성과 설계 원칙을 극한까지 적용한 프레임워크
객체 지향
- 처음에는 절차지향적인 프로그래밍으로도 괜찮았습니다.
- 그러나 시스템의 규모가 커질수록 변화에 대응하기 어렵고 그로 인해 유지보수가 어려워졌습니다.
- 객체지향은 데이터와 프로시저를 하나로 묶어 자신만의 기능을 제공하기 떄문에 관리가 쉽습니다.
-
또한 각 객체들은 서로 연결돼 다른 객체가 제공하는 기능을 사용할 수 있습니다.
- “객체가 갖는 책임은 어떻게 될까?” 이 결정을 하는 것이 바로 객체 지향 설계의 출발점입니다.
- 객체에 기능이 많아지거나 데이터를 공유하게 되면 절차 지향적인 구조를 갖게 됩니다.
- 그러므로 객체가 갖는 책임의 크기는 작을수록 좋습니다. (
SRP)
객체지향 설계 과정
- 제공해야 할 기능을 찾고 세분화하고, 그 기능을 알맞은 객체에 할당한다
- 객체 간 메세지를 어떻게 주고받을지 결정한다
- 1~2 반복 반복
- 객체지향의 설계는 한 번에 완성되지 않는다.
- 구현을 진행하는 과정에서 점진적으로 완성된다.
캡슐화
- 데이터 보존을 위해 코드와 데이터를 단일 단위로 묶는 프로세스
- 대표적인 예시로, 상태값의 접근제한자를
private으로 한 후,setter와getter를 추가해서 보존해 줄 수 있습니다. - 외부에서
setter와getter메서드를 쓸 뿐, 그 값이 어떻게 저장되는지, 어떻게 꺼내오는지는 알 수 없기 때문에 객체가 내부적으로 기능을 어떻게 구현한 지 모르게 됩니다. - 다만
setter메서드는 상태값을 변경시킬 여지가 있어 쓰면 안되고,getter메서드 또한 상태값 자체를 가져오기 때문에 도메인 로직을 작성할 때는 쓰지 않는 게 좋습니다. - 그래서 데이터를
get해서 가져오기보다 그 객체에게 메시지를 보내서 접근하는 것이 좋은데 이 방법 자체가 캡슐화를 한 것과 동일한 효과를 보입니다.
캡슐화를 위한 두 개의 규칙
- Tell, Don’t Ask
- 말 그대로 데이터를 읽지말고 실행해달라고 물어보는 것(메세지를 보내는 것)
- 데이터를 읽는 것(
getter)은 데이터를 중심으로 코드를 작성하게 만드는 원인이 되며, 이는 절차지향적인 코드를 작성하게 합니다.
- 데미테르의 법칙
- 메서드에서 생성한 객체의 메서드만 호출
- 파라미터로 받은 객체의 메서드만 호출
- 필드로 참조하는 객체의 메서드만 호출
- 쉽게 말해서 get 하지말고 두 번 들어가지 마라!
class A {
private B b;
public myMethod(OtherObject other) {
// ...
}
/* 디미터의 법칙을 잘 따른 예 */
public goodOfDemeter(Paramemter param) {
myMethod(); // 자신의 메소드
b.method(); // 자신의 필드의 메서드
Local local = new Local();
local.method(); // 직접 생성한 객체의 메서드
param.method(); // 메소드의 인자로 넘어온 메서드
}
/* 디미터의 법칙을 어긴 예 */
public badOfDemeter(Paramemter param) {
C c = param.getC();
c.method(); // 인자로 받은 객체에서 get()을 통해 가져온 뒤에 메서드를 꺼내는 경우
param.getC().method(); // 위와 같음.
param.paramValue.method(); // 위와 같음.
}
}
다형성과 추상 타입
- 인터페이스에 정의된 기능을 실제로 구현하는 클래스를 콘크리트 클래스(concrete class)라고 부른다
- 추상화는 언제할까?
- 기존 요구사항 : 파일에서 바이트 데이터를 읽어와…
- 추가 요구사항 : 소켓에서 바이트 데이터를 읽어와…
- 데이터를 읽어오는 것이 공통점이다. 이는 동일한 개념으로 추상화할 수 있다는 것을 의미한다.
- 그 공통점을 인터페이스로 정의해서 인터페이스의 타입을 사용할 수 있도록 한다.
- 인터페이스를 사용해 추상화를 적용하고 이를 통해 책임을 분리할 수 있었습니다.
- 이로 인해 유연성있는 구조를 가지게 되고, 쉽게 Mock 객체를 만들 수 있어 테스트에도 좋습니다.
상속보단 조립
상속의 단점
- 상위 클래스 변경의 어려움
- 상속받으면서 의존하기 때문에 클래스를 변경한 여파가 계층도를 따라 하위 클래스에 전파된다.
- 최악의 경우, 상위 클래스의 변화가 모든 하위 클래스에 영향을 줄 수 있다
- 상속의 오용
- 상위 클래스에 이미 작성된 메서드를 하위 클래스에서 재사용할 시에 클라이언트쪽에 혼동을 줄 수 있다.
- 즉, 그 메서드를 어떻게 쓰는지 알려면 상위 클래스에 내부 구현도 어떻게 되어있는지 직접 알아봐야 한다.
- 상위 클래스의 메서드를 모두 쓸 수 있기에 상속받은 하위클래스에 있는 메서드를 사용하고 싶어도 상위 클래스의 메서드를 모두 살펴봐야 한다.
- 예를 들어, Stack의
add()은 사실 Vector의add()을 그대로 사용하고 있다.
조립을 이용한 재사용
- 조립(Composition)은 여러 객체를 묶어서 더 복잡한 기능을 제공하는 객체를 만들어내는 것이다
- 필드에서 다른 객체를 참조해 조립할 수 있다.
- 클래스가 불필요하게 증가하지 않으며 런타임에 setter 등으로 객체를 교체할 수 있다.
- 필드에 있는 다른 객체를 통해 어떠한 요청을 그 다른 객체가 하도록 위임해줄 수 있다.
- 다만 클래스의 구조가 복잡해진다.
-> 상속은 기능이 완전히 똑같으면서 확장되는 곳에 쓰고 그게 아니라면 상속보다는 조립을 사용하자.
- 상속을 잘한 케이스는
LinkedHashMap가HashMap을 상속한 것. 상위 클래스가 그 기능을 완전히 똑같이 하고 확장만 한 형태니까.
DI(Dependency Injection)와 서비스 로케이터
- 소프트웨어를 두 개의 영역으로 설명할 수 있다.
- 구현을 포함한 어플리케이션 영역
- 어플리케이션이 동작하도록 각 객체들을 연결해주는 메인 영역
- 메인 영역은 어플리케이션 영역의 객체를 생성하고, 설정하고, 실행하는 책임을 갖는다
- 메인영역에서 객체를 생성하고 조립하기 때문에 모든 의존은 메인 영역에서 어플리케이션 영역으로 향한다
- 즉, 메인 영역의 코드를 수정하는 것은 어플리케이션 영역에는 어떤 영향도 끼치지 않는다
- Controller 에서 실행시키고 출력하는 것처럼
사용할 객체를 제공하는 책임을 갖는 객체를 서비스 로케이터(Service Locator) 라고 부른다
- 그러나 이렇게 서비스 로케이터를 사용해 필요로 하는 객체를
JobQueue jobQueue = Locator.getInstance().getJobQueue();를 통해서 객체를 직접 찾는 방식인데 몇 가지 단점이 존재한다. 그래서 보통은 사용할 객체를 주입하는 DI방식을 사용하는 것이 일반적이다.
의존
- 객체가 다른 객체를 생성(new)하거나 다른 객체의 메서드를 호출할 때, 파라미터로 전달받을 때, 이를 그 객체에 의존한다고 표현합니다.
- 다른 객체를 안에서 호출할 수 있으므로 지속적으로 안에서 호출하게 되면 그만큼 의존이 전파되어 의존하는 객체 모두 영향을 받습니다. 그래서 의존의 영향은 꼬리에 꼬리를 문 것처럼 전파되는 특징을 갖습니다.
- 예를 들어, 아래와 같은 코드의 경우
AuthenticationHandler클래스 안에서Authenticator객체를 생성하고 있으므로AuthenticationHandler가Authenticator에게 의존하고 있는 상황이 됩니다. 이는Authenticator클래스에게 변화가 생기면AuthenticationHandler클래스도 영향을 받게 된다는 의미입니다.auth.authenticate()의 반환 타입을boolean이 아니라 예외를 던지는void로 바꾸게 된다면AuthenticationHandler클래스의 로직도 바꿔야 합니다.
public class AuthenticationHandler {
public void handleRequest(String inputId, String inputPassword) {
Authenticator auth = new Authenticator();
if (auth.authenticate(inputId, inputPassword)) {
// 아이디와 암호가 일치할 때 처리
} else {
// 일치하지 않을 때 처리
}
}
}
- 쉽게 말하면 A가 B에 의존한다는 말은 A에서 B의 요소가 등장한다는 말입니다.
- 그 말은 어떤 객체 안에서 다른 객체를 사용하기 때문에 그 다른 객체가 변경될 때, 함께 변경될 수 있다는 것을 말합니다.
의존성의 종류
- 연관관계
class A {
private B b;
}
- 연관관계는 영구적인 관계를 맺는다
- 의존관계
class A {
public B method(B b) {
return new B();
}
}
- 파라미터, 리턴 타입에 타입이 나오거나 생성한다면 의존관계
- 일시적인 관계를 맺는다
- 상속관계
class A extends B {
}
- B가 바뀔 때 A도 바뀐다
- 구현이 변경되면 A도 영향을 받는다.
- 실체화관계
class A implements B{
}
- 시그니처(B)가 바뀌면 영향을 받는다.
양방향 의존성을 제거하라
- 양방향
class A {
private B;
}
class B {
private A;
}
- 단방향
class A {
private B;
}
class B {
- 가장 좋은 건 의존성이 없는 것. 의존성이 필요없다면 제거하도록 합니다.
- 이러한 의존이 발생하지 않도록 의존 역전 원칙(DIP) 라는 것이 있습니다.
의존성 주입(DI) 패턴
의존성 주입이란?
- 내부가 아니라 외부에서 객체를 생성해서 넣어주는 것.
의존성 분리
- 의존성 주입은 의존성을 분리시켜 사용한다.
- 의존성을 분리시키는 방법으로는 대표적으로 인터페이스가 있다.
- 이렇게 제어의 주체가 역전되는 상황을 IoC(Inversion Of Control)이라고 한다.
스프링에서의 의존성 주입은?
- Spring에서 Bean을 사용하기 위해 의존성 주입을 처리하는 방법은 크게 3가지가 있다.
- 생성자 주입 방식, 세터 주입 방식, @Autowired 를 이용한 주입 방식
- 생성자 주입 방식은 생성하는 시점에 객체가 주입된다.
- 생성자 방식을 사용한다면 객체를 생성하는 시점에 필요한 모든 의존 객체를 준비할 수 있고
- 따로 setter가 없어 immutable 하다.
- setter주입 방식은 객체를 찾거나 만든 후에 setter 메서드를 통해서 넣어준다.
- 순환 참조, 양방향 의존을 막아줘야 하는데 setter는 그러지 못한다.
- 또한 그래서 final도 못 써준다.
- 설정 메서드 방식은 잘못하면 Null 예외가 날 수 있다.
- @Autowired는 해당 객체를 생성한 후에 해당 어노테이션이 붙은 필드를 찾아 주입한다
- 생성자 주입 방식은 생성하는 시점에 객체가 주입된다.
스프링 에서 XML을 통한 DI 방식
- 여기서도 생성자 방식과 설정 메서드 방식이 있다. 스프링은 XML 방식을 통해서 해준다.
- constructor 태그를 통해서 생성자 방식으로 해줄 수 있고, property 태그를 통해서 설정 메서드 방식을 사용해줄 수 있다
- 그러면 이제
(Worker) context.getBean("worker")를 통해서 각자의 방식으로 불러올 수 있다. - XML 파일만 수정하면 되기 때문에 보다 유연하지만 XML을 디버깅하기 어렵다.
- 그래서 요즘에는 자바 코드 안에서 어노테이션을 통해서 스프링 프레임워크를 설정해준다.
@Bean - 그러나 이러면 XML 파일만 변경해주면 됐는데 자바 코드로 하게 되면 다시 컴파일하고 배포해줘야 한다.
DI와 테스트
DI의 장점
- DI를 사용하면 의존 객체를 Mock 객체를 통해 쉽게 대체할 수 있도록 함으로써 단위 테스트를 할 수 있도록 돕는다.
- 인터페이스를 상속한 클래스들은 Mock 객체를 이용해서 테스트할 수 있다.
- Mockito 등…
- DI를 사용하지 않고 테스트를 한다면 테스트를 하기 위해서 클래스를 고치고 그리고 그 고친 클래스 때문에 또 다른 클래스를 고쳐야한다.
서비스 로케이터를 이용한 의존 객체 사용
- DI 패턴을 적용할 수 없는 경우, 서비스 로케이터를 구현해 각 객체를 넣어준 후, 가져와야 한다.
- 접근을 위한 static 메서드를 만들고 원하는 곳에서 getInstance() 를 통해서 가져온 후, get 한다.
서비스 로케이터의 단점
- 동일 타입의 객체가 다수 필요할 경우, 각 객체 별로 제공 메서드를 만들어줘야 함(계속 필드에 추가해줘야 함)
- 서비스 로케이터 클래스에서 다른 구현체로 바꿔줘야 할 때 코드를 변경해줘야 한다.
출처
- https://coding-start.tistory.com/264
- 책 : 개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴
- https://ko.dict.naver.com/seo.nhn?id=30296303
- https://ko.dict.naver.com/#/search?range=word&query=%EC%9D%98%EC%A7%80%ED%95%98%EB%8B%A4
- https://velog.io/@codemcd/%EC%9A%B0%EC%95%84%ED%95%9C%ED%85%8C%ED%81%AC%EC%84%B8%EB%AF%B8%EB%82%98-%EC%9A%B0%EC%95%84%ED%95%9C%EA%B0%9D%EC%B2%B4%EC%A7%80%ED%96%A5-%EC%9D%98%EC%A1%B4%EC%84%B1%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%B4-%EC%84%A4%EA%B3%84-%EC%A7%84%ED%99%94%EC%8B%9C%ED%82%A4%EA%B8%B0-By-%EC%9A%B0%EC%95%84%ED%95%9C%ED%98%95%EC%A0%9C%EB%93%A4-%EA%B0%9C%EB%B0%9C%EC%8B%A4%EC%9E%A5-%EC%A1%B0%EC%98%81%ED%98%B8%EB%8B%98-vkk5brh7by
- https://youtu.be/dJ5C4qRqAgA