프록시 패턴과 데코레이터 패턴

조갱·2024년 4월 20일
0

스프링 강의

목록 보기
14/16

프록시란?

프록시 서버(영어: proxy server, 문화어: 대리봉사기)는 클라이언트가 자신을 통해서 다른 네트워크 서비스에 간접적으로 접속할 수 있게 해 주는 컴퓨터 시스템이나 응용 프로그램을 가리킨다. 서버와 클라이언트 사이에 중계기로서 대리로 통신을 수행하는 것을 가리켜 '프록시', 그 중계 기능을 하는 것을 프록시 서버라고 부른다.

출처 : 위키피디아 - 프록시 서버

정의가 복잡하지만, 한 문장으로 설명하자면
클라이언트와 서버 사이에 중계 기능을 담당하는 것을 의미한다.

프록시 구성도

우선, 프록시를 사진으로 이해해보자.

프록시를 사용하지 않으면, A -> B를 다이렉트로 호출하지만
프록시를 사용하면 중간에서 Proxy가 중계 역할로 요청을 받아, 서버에 대신 전달해준다.

또한, 프록시에서 프록시를 호출함으로써 프록시마다 단일 책임 원칙을 가지며 여러 기능을 적용할 수도 있다.

이러한 프록시를 통해 기능을 확장할 수 있다.

프록시의 기능

프록시로 할 수 있는 것은 크게 2가지로 나뉜다.

  • 접근 제어
    • 권한에 따른 접근 차단
    • 캐싱
    • 지연 로딩
  • 부가 기능 추가
    • 원래 서버가 제공하는 기능에 더해서 부가 기능을 수행한다.
      예) 요청 값이나, 응답 값을 중간에 변형한다.
      예) 실행 시간을 측정해서 추가 로그를 남긴다.

프록시가 되려면

위 사진을 보면, 아무 객체나 프록시가 될 수 있을것 같다.
하지만 객체에서 프록시가 되려면 클라이언트 입장에서 프록시에게 요청한 것인지, 서버에 요청한 것인지 몰라야한다. 즉, 클라이언트의 코드를 변환해서는 안되고, 그러기 위해서는 프록시와 서버는 동일한 인터페이스를 사용해야한다.

-> 클라이언트에서는 ServerInterface를 DI(의존성 주입) 받을 수도 있게 된다!
-> 클라이언트에서는 코드의 수정 없이 Proxy 객체를 호출하고 사용할 수 있다.

데코레이터 패턴?

GOF 패턴인 프록시 패턴과 데코레이터 패턴은 모두 프록시를 사용한다.
데코레이터 패턴이라고 프록시를 안쓰는게 아니다.!!

그러면 차이는 무엇일까?
GOF 에서는, 사용 목적에 따라 패턴을 나누고 있다.

  • 프록시 패턴: 접근 제어(ACL, 권한 검사, 캐싱 등)가 목적
  • 데코레이터 패턴: 부가기능이 목적

코드로 구현해보기

예제코드를 통해 프록시 패턴과 데코레이터 패턴을 실습해보자.

위에서 언급한것 처럼, 구현 방법엔 차이가 없고 목적에 따라 달리 쓰기 때문에 예제는 하나만 구현해본다.!

요구사항

  • 서버는 클라이언트로부터 문자열을 입력받아, 무언가 로직을 수행하고 문자열을 그대로 리턴한다.
  • 서버에서 로직을 처리하는데 걸리는 시간은 1초이다.
  • 프록시를 통해 리턴된 문자열을 보기 좋게 만들어본다.
  • 프록시 체인을 통해 캐싱 프록시도 추가한다.

기본 코드

Server

@Component
class Server {
    fun makeStringPrettier(input: String) : String {
        Thread.sleep(1000) // 무언가 1초 걸리는 로직
        return input // 있는 문자열 그대로를 출력해준다.
    }
}

Test (Client)

@SpringBootTest
class DirectCallTest(
    @Autowired private val server: Server,
) {
    @Test
    fun test(){
        val result = server.makeStringPrettier("MAKE ME PRETTY")
        println(result)
    }
}
MAKE ME PRETTY

직접적으로 코드 수정하기

Server

@Component
class Server {
    fun makeStringPrettier(input: String) : String {
        Thread.sleep(1000) // 무언가 1초 걸리는 로직
        return "result = $input" // 문자열을 보기좋게 만들어준다.
    }
}

Test (Client)

@SpringBootTest
class DirectCallTest(
    @Autowired private val server: Server,
) {
    @Test
    fun test() {
        val result = server.makeStringPrettier("MAKE ME PRETTY")
        println(result)
    }
}
result = MAKE ME PRETTY

인터페이스를 상속한 프록시

Server를 인터페이스로 만들고, 이를 구현하는 ServerImpl 클래스를 만들어보자.

Server (Interface)

interface Server {
    fun makeStringPrettier(input: String): String
}

ServerImpl

@Component
class ServerImpl : Server {
    override fun makeStringPrettier(input: String): String {
        Thread.sleep(1000)
        return input
    }
}

여기까지는 이전이랑 동일하게 동작한다.
이제 문자열을 이쁘게 만들어주는 프록시를 만들어보자.

PrettierProxy

@Component
@Primary
class PrettierProxy(
    private val serverImpl: ServerImpl,
) : Server {
    override fun makeStringPrettier(input: String): String {
        return "result = " + serverImpl.makeStringPrettier(input)
    }
}

ServerImpl과 PrettierProxy 모두 Server 인터페이스를 상속받기 때문에,
테스트 코드 (Client)에서 Server 타입으로 빈 주입이 안된다.

타입이 여러개일 때 빈을 주입받을 수 있는 방법은

  • 클라이언트의 빈 주입 시 @Qualifier 어노테이션 사용
    @Autowired @Qualifier("prettierProxy") private val server: Server
  • 클라이언트에서 빈을 주입받는 변수명에 빈 이름을 명시
    @Autowired private val prettierProxy: Server

등이 있지만, 클라이언트 코드를 수정 안하기 위해 서버단에 @Primary 어노테이션을 사용했다.

테스트(클라이언트) 코드를 수정하지 않고 다시 실행해보면 아래와 같은 결과를 얻는다.

result = MAKE ME PRETTY

이제는 캐시 프록시를 테스트하기 위해 서버의 코드를 3회 호출해보자.

Test (Client)

@SpringBootTest
class DirectCallTest(
    @Autowired private val server: Server,
) {
    @Test
    fun test() {
        println("시작 : ${LocalDateTime.now()}")
        server.makeStringPrettier("MAKE ME PRETTY").also { println(it) }
        server.makeStringPrettier("MAKE ME PRETTY").also { println(it) }
        server.makeStringPrettier("MAKE ME PRETTY").also { println(it) }
        println("끝 : ${LocalDateTime.now()}")
    }
}
시작 : 2024-04-10T15:07:11.774753
result = MAKE ME PRETTY
result = MAKE ME PRETTY
result = MAKE ME PRETTY
끝 : 2024-04-10T15:07:14.806061

3초가 소요됐음을 알 수 있다.
이제 프록시 체인을 통해 새로운 프록시를 연결해보자.

CacheProxy

이전에 PrettierProxy에 적용한 @Primary는 제거해야 한다.!

@Component
@Primary
class CacheProxy(
    private val prettierProxy: PrettierProxy
) : Server {
    val cache: MutableMap<String, String> = mutableMapOf()

    override fun makeStringPrettier(input: String): String {
        return cache.getOrPut(input) { server.makeStringPrettier(input) }
    }
}

테스트코드를 다시 실행해보면 아래와 같은 결과를 얻을 수 있다.

시작 : 2024-04-10T15:19:37.376378
result = MAKE ME PRETTY
result = MAKE ME PRETTY
result = MAKE ME PRETTY
끝 : 2024-04-10T15:19:38.389402

최초 1회는 처음 로직을 실행하여 1초의 로직 소요시간이 걸렸고,
나머지 2회는 캐시를 사용했기 때문에, 최종적으로 1초가 소요된 모습이다.

구체클래스를 상속한 프록시

CacheProxy

@Component
@Primary
class CacheProxy(
    private val prettierProxy: PrettierProxy,
) : Server() {
    val cache: MutableMap<String, String> = mutableMapOf()

    override fun makeStringPrettier(input: String): String {
        return cache.getOrPut(input) { prettierProxy.makeStringPrettier(input) }
    }
}

PrettierProxy

여기서는 Server 구체클래스를 별도로 빈 주입받지 않는다.
빈을 주입받으면, CacheProxy 도 Server 타입인데 @Primary 이기 때문에 순환참조가 발생한다.
CacheProxy -> PrettierProxy -> Server(=CacheProxy) -> PrettierProxy...
상속이기 때문에 super로 풀어나간다.

@Component
class PrettierProxy : Server() {
    override fun makeStringPrettier(input: String): String {
        return "result = " + super.makeStringPrettier(input)
    }
}

Server

@Component
class Server {
    fun makeStringPrettier(input: String): String {
        Thread.sleep(1000)
        return input
    }
}

Test (Client)

@SpringBootTest
class DirectCallTest(
    @Autowired private val server: Server,
) {
    @Test
    fun test() {
        println("시작 : ${LocalDateTime.now()}")
        server.makeStringPrettier("MAKE ME PRETTY").also { println(it) }
        server.makeStringPrettier("MAKE ME PRETTY").also { println(it) }
        server.makeStringPrettier("MAKE ME PRETTY").also { println(it) }
        println("끝 : ${LocalDateTime.now()}")
    }
}
시작 : 2024-04-10T15:33:11.331257
result = MAKE ME PRETTY
result = MAKE ME PRETTY
result = MAKE ME PRETTY
끝 : 2024-04-10T15:33:12.349148

결론

프록시를 적용하기 위해서는 인터페이스나, 상속이나 둘 다 사용할 수 있다.
상속을 사용할 때의 장점은 별도로 인터페이스를 만드는 번거로움을 없앨 수 있지만, 상속이 가져오는 단점 (다중상속 불가 등)을 그대로 안게된다.

인터페이스를 사용할 때의 장점은 여러 인터페이스를 상속받을 수 있기 때문에 확장성이 크다는 점이지만, 반대로 구조를 수정해야 하는 번거로움이 있다.

다음 포스팅에서 언급할 프록시는 아래와 같은 성격을 지닌다.
JDK 동적 프록시 : 인터페이스가 있는 경우
CGLIB : 인터페이스가 없는 경우 (구체 클래스 상속)

위 두개를 이해하기 위해 프록시를 구현하는 2가지 방법에 대해 숙지해보자.

profile
A fast learner.

0개의 댓글