프록시 서버(영어: proxy server, 문화어: 대리봉사기)는 클라이언트가 자신을 통해서 다른 네트워크 서비스에 간접적으로 접속할 수 있게 해 주는 컴퓨터 시스템이나 응용 프로그램을 가리킨다. 서버와 클라이언트 사이에 중계기로서 대리로 통신을 수행하는 것을 가리켜 '프록시', 그 중계 기능을 하는 것을 프록시 서버라고 부른다.
출처 : 위키피디아 - 프록시 서버
정의가 복잡하지만, 한 문장으로 설명하자면
클라이언트와 서버 사이에 중계 기능을 담당하는 것
을 의미한다.
우선, 프록시를 사진으로 이해해보자.
프록시를 사용하지 않으면, A -> B를 다이렉트로 호출하지만
프록시를 사용하면 중간에서 Proxy가 중계 역할
로 요청을 받아, 서버에 대신 전달해준다.
또한, 프록시에서 프록시를 호출함으로써 프록시마다 단일 책임 원칙을 가지며 여러 기능을 적용할 수도 있다.
이러한 프록시를 통해 기능을 확장할 수 있다.
프록시로 할 수 있는 것은 크게 2가지로 나뉜다.
위 사진을 보면, 아무 객체나 프록시가 될 수 있을것 같다.
하지만 객체에서 프록시가 되려면 클라이언트 입장에서 프록시에게 요청한 것인지, 서버에 요청한 것인지 몰라야한다. 즉, 클라이언트의 코드를 변환해서는 안되고, 그러기 위해서는 프록시와 서버는 동일한 인터페이스를 사용해야한다.
-> 클라이언트에서는 ServerInterface를 DI(의존성 주입) 받을 수도 있게 된다!
-> 클라이언트에서는 코드의 수정 없이 Proxy 객체를 호출하고 사용할 수 있다.
GOF 패턴인 프록시 패턴과 데코레이터 패턴은 모두 프록시를 사용
한다.
데코레이터 패턴이라고 프록시를 안쓰는게 아니다.!!
그러면 차이는 무엇일까?
GOF 에서는, 사용 목적에 따라 패턴을 나누고 있다.
예제코드를 통해 프록시 패턴과 데코레이터 패턴을 실습해보자.
위에서 언급한것 처럼, 구현 방법엔 차이가 없고 목적에 따라 달리 쓰기 때문에 예제는 하나만 구현해본다.!
@Component
class Server {
fun makeStringPrettier(input: String) : String {
Thread.sleep(1000) // 무언가 1초 걸리는 로직
return input // 있는 문자열 그대로를 출력해준다.
}
}
@SpringBootTest
class DirectCallTest(
@Autowired private val server: Server,
) {
@Test
fun test(){
val result = server.makeStringPrettier("MAKE ME PRETTY")
println(result)
}
}
MAKE ME PRETTY
@Component
class Server {
fun makeStringPrettier(input: String) : String {
Thread.sleep(1000) // 무언가 1초 걸리는 로직
return "result = $input" // 문자열을 보기좋게 만들어준다.
}
}
@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 클래스를 만들어보자.
interface Server {
fun makeStringPrettier(input: String): String
}
@Component
class ServerImpl : Server {
override fun makeStringPrettier(input: String): String {
Thread.sleep(1000)
return input
}
}
여기까지는 이전이랑 동일하게 동작한다.
이제 문자열을 이쁘게 만들어주는 프록시를 만들어보자.
@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 타입으로 빈 주입이 안된다.
타입이 여러개일 때 빈을 주입받을 수 있는 방법은
등이 있지만, 클라이언트 코드를 수정 안하기 위해 서버단에 @Primary 어노테이션을 사용했다.
테스트(클라이언트) 코드를 수정하지 않고 다시 실행해보면 아래와 같은 결과를 얻는다.
result = MAKE ME PRETTY
이제는 캐시 프록시를 테스트하기 위해 서버의 코드를 3회 호출해보자.
@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초가 소요됐음을 알 수 있다.
이제 프록시 체인을 통해 새로운 프록시를 연결해보자.
이전에 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초가 소요된 모습이다.
@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) }
}
}
여기서는 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)
}
}
@Component
class Server {
fun makeStringPrettier(input: String): String {
Thread.sleep(1000)
return input
}
}
@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가지 방법에 대해 숙지해보자.