본 글은 📚 스프링 입문을 위한 자바 객체지향의 원리와 이해 를 읽고 정리한 내용입니다.
요리와 객체 지향 프로그래밍(OOP)을 비교하면 아래의 표와 같다.
요리 | 객체 지향 프로그래밍(OOP) |
요리도구 | 4대 원칙(캡! 상추다) |
요리도구 사용법 | 설계 원칙(SOLID) |
레시피 | 디자인 패턴 |
디자인 패턴은 실제 개발 현장에서 비즈니스 요구 사항을 프로그래밍으로 처리하면서 만들어진 다양한 해결책 중에서 많은 사람들이 인정한 베스트 프랙티스를 정리한 것이다. 디자인 패턴은 당연히 객체 지향 특성과 설계 원칙을 기반으로 구현되어 있다.
어댑터 패턴 (Adapter Pattern)
어댑터를 번역하면 변환기(converter)라고 할 수 있다. 변환기의 역할은 서로 다른 두 인터페이스 사이에 통신이 가능하게 하는 것이다. 다양한 데이터베이스 시스템을 공통의 인터페이스인 ODBC 또는 JDBC를 이용해 조작할 수 있는데 이 때 ODBC/JDBC가 어댑터 패턴을 이용해 다양한 데이터베이스 시스템을 단일한 인터페이스로 조작할 수 있게 해주기 때문이다. 플랫폼 별 JRE 또한 어댑터 패턴이라고 할 수 있다. 어댑터 패턴은 개방 폐쇄 원칙을 활용한 설계 패턴이라고 할 수 있다. 아래의 예제를 통해 이해를 도와보자. 먼저 어댑터 패턴을 적용하기 전 코드이다.
// ServiceA 클래스
public class ServiceA {
void runServiceA() {
System.out.println("ServiceA");
}
}
// ServiceB 클래스
public class ServiceB {
void runServiceB() {
System.out.println("ServiceB");
}
}
// 실행 클래스
public class ClientWithNoAdapter {
public static void main(String[] args) {
ServiceA sa1 = new ServiceA();
ServiceB sb1 = new ServiceB();
sa1.runServiceA();
sb1.runServiceB();
}
}
// 결과
ServiceA
ServiceB
어댑터 패턴을 적용하지 전인 위의 코드의 다이어그램은 아래와 같다.
그럼 이제 어댑터 패턴을 적용한 코드와 다이어그램을 살펴보자.
// AdapterServiceA 클래스
public class AdapterServiceA {
ServiceA sa1 = new ServiceA();
void runService(){
sa1.runServiceA();
}
}
// AdapterServiceB 클래스
public class AdapterServiceB {
ServiceB sa1 = new ServiceB();
void runService() {
sa1.runServiceB();
}
}
ServiceA와 ServiceB의 메소드를 runService()라고 하는 같은 이름의 메소드로 호출해서 사용할 수 있게 해주는 변환기이다.
// 실행클래스
public class ClientWithAdapter {
public static void main(String[] args) {
AdapterServiceA asa1 = new AdapterServiceA();
AdapterServiceB asb1 = new AdapterServiceB();
asa1.runService();
asb1.runService();
}
}
// 결과
ServiceA
ServiceB
어댑터 패턴을 적용한 위의 코드의 다이어그램은 아래와 같다.
클라이언트가 변환기를 통해 runService()라는 동일한 메소드명으로 두 객체의 메소드를 호출하는 것을 볼 수 있다.
어댑터 패턴은 합성, 즉 객체를 속성으로 만들어서 참조하는 디자인 패턴으로, 한 문장으로 정리하면 다음과 같다.
"호출당하는 쪽의 메소드를 호출하는 쪽의 코드에 대응하도록 중간에 변환기를 통해 호출하는 패턴"
프록시 패턴(Proxy Pattern)
프록시는 대리자, 대변인이라는 뜻을 가진 단어이다. 누군가를 대신해 그 역할을 수행하는 것을 말한다. 프록시 패턴의 경우 실제 서비스 객체가 가진 메서드와 같은 이름의 메서드를 사용하는데, 이를 위해 인터페이스를 사용한다. 인터페이스를 사용하면 서비스 객체가 들어갈 자리에 대리자 객체를 대신 투입해 클라이언트 쪽에서는 실제 서비스 객체를 통해 메서드를 호출하고 반환값을 갖는지, 대리자 객체를 통해 메서드를 호출하고 반환값을 받는지 전혀 모르게 처리할 수도 있다.
예제 코드로 이해를 도와보자.
// IService 인터페이스
public interface IService {
String runSomething();
}
// Service 클래스
public class Service implements IService {
@Override
public String runSomething() {
return "Proxy Pattern";
}
}
// Proxy 클래스
public class Proxy implements IService {
IService service1;
@Override
public String runSomething() {
System.out.println("호출에 대한 흐름 제어가 주목적, 반환 결과를 그대로 전달");
service1 = new Service();
return service1.runSomething();
}
}
// 실행클래스
package study.weeok09.pattern;
public class ClientWithProxy {
public static void main(String[] args) {
// 프록시를 이용한 호출
IService proxy = new Proxy();
System.out.println(proxy.runSomething());
}
}
// 결과
호출에 대한 흐름 제어가 주목적, 반환 결과를 그대로 전달
Proxy Pattern
위 코드를 잘 보면 인터페이스를 중간에 두어 구체클래스들에게 영향을 받지 않게 설계되어 있다. 또한, 직접 접근하지 않고 Proxy를 통해서 한번 더 우회해서 접근하도록 되어있다. (제어 흐름을 조정하기 위함)
프록시 패턴의 중요 포인트를 짚어보면 다음과 같다.
- 대리자는 실제 서비스와 같은 이름의 메서드를 구현한다. 인터페이스를 사용한다.
- 대리자는 실제 서비스에 대한 참조 변수를 갖는다.(합성)
- 대리자는 실제 서비스의 같은 이름을 가진 메서드를 호출하고 그 값을 클라이언트에게 돌려준다.
- 대리자는 실제 서비스의 메서드 호출 전 후에 별도로 로직을 수행할 수도 있다.
여기서 대리자/대리인이라는 이름에 주목해보자. 프록시 패턴이 실제 서비스 메서드를 반환값에 가감하는 것을 목적으로 하지 않고 제어의 흐름을 변경하거나 다른 로직을 수행하기 위해 사용한다. 프록시 패턴을 한 문장으로 정리하면 다음과 같다.
"제어 흐름을 조정하기 위한 목적으로 중간에 대리자를 두는 것"
프록시 패턴은 개방 폐쇄의 원칙(OCP)과 역전 의존의 원칙(DIP)이 적용된 디자인 패턴이다.
데코레이터 패턴(Decorator Pattern)
데코레이터는 도장/도배업자를 의미한다. 데코레이터 패턴은 프록시 패턴과 구현 방법이 같다. 다만 프록시 패턴은 클라이언트가 최종적으로 돌려 받는 반환값을 조작하지 않고 그대로 전달하는 반면 데코레이터 패턴은 클라이언트가 받는 반환값에 장식을 덧입힌다.
프록시 패턴
- 제어의 흐름을 변경하거나 별도로 로직 처리를 목적으로 한다.
- 클라이언트가 받는 반환값을 특별한 경우가 아니면 변경하지 않는다.
데코레이터 패턴
- 클라이언트가 받는 반환값에 장식을 더한다.
프록시 패턴과 데코레이터 패턴은 클래스 다이어그램과 시퀀스 다이어그램이 서로 같으니 프록시 패턴의 두 다이어그램을 참조하고 여기서는 코드만 살펴보자.
// IService 인터페이스
public interface IService {
public abstract String runSomething();
}
// Service 클래스
public class Service implements IService{
@Override
public String runSomething() {
return "서비스 짱!";
}
}
// Decoreator 클래스
public class Decoreator implements IService{
IService service;
@Override
public String runSomething() {
System.out.println("호출에 대한 장식 주목적, 클라이언트에게 반환 결과에 장식을 더하여 전달");
service = new Service();
return "정말" + service.runSomething(); // "정말"이라는 장식을 더함
}
}
// 실행클래스
public class ClientWithDecolator {
public static void main(String[] args) {
IService decoreator = new Decoreator();
System.out.println(decoreator.runSomething());
}
}
// 결과
호출에 대한 장식 주목적, 클라이언트에게 반환 결과에 장식을 더하여 전달
정말서비스 짱!
데코레이션 패턴의 중요 포인트를 짚어 보자. 반환값에 장식을 더한다는 것을 빼면 프록시 패턴과 동일하다.
- 장식자는 실제 서비스와 같은 이름의 메서드를 구현한다. 이때 인터페이스를 사용한다.
- 장식자는 실제 서비스에 대한 참조 변수를 갖는다. (합성)
- 장식자는 실제 서비스의 같은 이름을 가진 메서드를 호출하고, 그 반환값에 장식을 더해 클라이언트에게 돌려준다.
- 장식자는 실제 서비스의 메서드 호출 전후로 별도의 로직을 수행할 수도 있따.
실제 서비스의 반환 값을 이쁘게 장식하는 패턴이 데코레이터 패턴임을 기억하자. 마지막으로 데코레이터 패턴을 한 문장으로 정리하면 다음과 같다.
"메서드 호출의 반환값에 변화를 주기 위해 중간에 장식자를 두는 패턴"
데코레이터 패턴이 프록시 패턴과 동일한 구조를 갖기에 데코레이터 패턴도 개방 폐쇄 원칙(OCP)과 의존 역전 원칙(DIP)이 적용된 설계 패턴임을 알 수 있다.
싱글턴 패턴(Singleton Pattern)
싱글턴 패턴이란 인스턴스를 하나만 만들어 사용하기 위한 패턴이다. 커넥션 풀, 스레드 풀, 디바이스 설정 객체 등과 같은 경우 인스턴스를 여러 개 만들게 되면 불필요한 자원을 사용하게 되고, 또 프로그램이 예상치 못한 결과를 낳을 수 있다. 싱글턴 패턴은 오직 인스턴스 하나만 만들고 그것을 계속해서 재사용한다.
싱글턴 패턴을 적용하게 되면 의미상 두 개의 객체가 존재할 수 없다. 이를 구현하려면 객체 생성을 위한 new에 제약을 걸어야 하고, 만들어진 단일 객체를 반환할 수 있는 메서드가 필요하다. 따라서 필요한 요소를 정리하면 다음 세 가지가 반드시 필요하다.
- new 를 실행할 수 없도록 생성자에 private 접근 제어자를 지정한다.
- 유일한 단일 객체를 반환할 수 있는 정적 메서드가 필요하다.
- 유일한 단일 객체를 참조할 정적 변수가 필요하다.
코드를 보며 이해해보자.
public class Singleton {
static Singleton singletonObject; // 정적 참조 변수
private Singleton(){}; // private 생성자
// 객체 반환 정적 메서드
public static Singleton getInstance(){
if (singletonObject == null) {
singletonObject = new Singleton();
}
return singletonObject;
}
}
위의 코드를 보면 단일 객체 생성을 위한 정적 참조 변수를 볼 수 있으며, new 키워드를 사용할 수 없게끔 private으로 생성된 생성자, 단일 객체를 반환하기 위한 getInstance() 메서드를 볼 수 있다.
getInstance() 메서드를 보면 정적 참조 변수에 객체가 할당되어 있지 않은 경우에만 new 를 통해 객체를 만들고 정적 참조 변수에 할당한다. 그리고 정적 참조 변수에 할당되어 있는 유일한 객체의 참조를 반환한다. 이제 이를 사용하는 코드를 작성해보자.
public class Client {
public static void main(String[] args) {
// private 생성자이므로 new 를 통해 인스턴스 생성 불가능
// Singleton s = new Singleton(); // 불가능
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
Singleton s3 = Singleton.getInstance();
System.out.println(s1);
System.out.println(s2);
System.out.println(s3);
s1 = null;
s2 = null;
s3 = null;
}
}
// 결과
study.weeok09.singletonPattern.Singleton@1b6d3586
study.weeok09.singletonPattern.Singleton@1b6d3586
study.weeok09.singletonPattern.Singleton@1b6d3586
s1~s3의 값을 null로 할당하기 전 T메모리 사진은 다음과 같다.
실행클래스의 결과를 보면 세개의 값이 모두 동일하다. 위의 사진을 보면 4개의 참조 변수(singletonObject, s1~s3) 가 하나의 단일 객체(Singleton)를 참조하는 것을 볼 수 있다. 이것이 바로 싱글턴 패턴의 힘이다. 단일 객체인 경우 결국 공유 객체로 사용되기 때문에 속성을 갖지 않게 하는 것이 정석이다. 단일 객체가 속성을 갖게 되면 하나의 참조 변수가 변경한 단일 객체의 속성이 다른 참조 변수에 영향을 미치기 때문이다. 이는 전역/공유 변수를 가능한 한 사용하지 말라는 지침과도 같다. 다만 읽기 전용 속성을 갖는 것은 문제가 되지 않는다. 이와 더불어 단일 객체가 다른 단일 객체에 대한 참조를 속성으로 가진 것 또한 문제가 되지 않는다. 이는 스프링 싱글턴 빈이 가져야 할 제약조건이기도 하다.
싱글턴 패턴의 특징은 다음과 같다.
- private 생성자를 갖는다.(new를 통한 인스턴스 생성을 금지)
- 단일 객체 참조 변수를 정적 속성으로 갖는다.
- 단일 객체 참조 변수가 참조하는 단일 객체를 반환하는 getInstatnce() 정적 메서드를 갖는다.
- 단일 객체는 쓰기 가능한 속성을 갖지 않는 것이 정석이다.
싱글턴 패턴을 한 문장으로 정리하면 다음과 같다.
"클래스의 인스턴스, 즉 객체를 하나만 만들어 사용하는 패턴"
이어지는 글 : [Spring] 스프링이 사랑한 디자인 패턴 (2)
'Spring' 카테고리의 다른 글
[Spring] AOP 에 대해서 (2) (0) | 2021.10.04 |
---|---|
[Spring] IoC/DI 제어의 역전/의존성 주입 (3) (0) | 2021.10.03 |
[Spring] IoC/DI 제어의 역전/의존성 주입 (2) (0) | 2021.10.02 |
[Spring] IoC/DI 제어의 역전/의존성 주입 (1) (0) | 2021.10.02 |
[Spring] 스프링이 사랑한 디자인 패턴 (2) (0) | 2021.10.01 |
댓글