개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴을 읽고...
이 글은 객체지향을 공부하고 스터디했던 내용들을 정리한 글입니다.
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