티스토리 뷰

SMALL

요근래 kotlin+spring boot2 로 개발을 하고있다. 이중에 코드를 리팩토링하고싶은 부분이 있었고, 이를 리팩토링하는 과정에서 꽤나 삽질을 하게만든 경험을 포스팅하려한다. 뭔가 특정한 내용에 대해 포스팅한다기보다 실제 상황에서 이슈를 만나고, 그 이슈를 해결해가는 과정을 남겨보려한다. 실제 이슈는 회사에서 프로덕션 코드를 리팩토링하는 과정에서 발생한부분이고, 포스팅에서 나오는 코드는 해당 코드를 예제화한 코드이다.

 

참고로 아래 예제코드를 위한 개발환경은 다음과같다.

- kotlin

- spring boot 2.1.5

  - spring boot stater web

  - spring boot starter jdbc (필수)

1. 코드작성

abstract class BaseApiRepository(private var url: String? = null,
                                 protected var restTemplate: RestTemplate? = null) {
    @PostConstruct
    fun init() {
        this.restTemplate = RestTemplateBuilder()
                .setConnectTimeout(Duration.ofMillis(300))
                .setReadTimeout(Duration.ofMillis(1000))
                .build()
    }

    fun setUrl(url: String) {
        this.url = url
    }

    private fun buildUrl(map: MultiValueMap<String, String>): String = UriComponentsBuilder.fromUriString(url!!)
            .queryParams(map)
            .build()
            .toUriString()
}

 

위와 같은 추상클래스를 작성했다. 해당 클래스는 우리 프로젝트내에서 rest template을 이용해 http api를 동기적으로 호출하는 경우 사용할 공통 부분을 추출해 작성해놓은것이다. url은 @ConfigurationProperties 를 이용해서 외부 설정에서 주입받게된다. 해당 코드에는 timeout 관련 부분은 하드코딩되어있지만 실제 코드에는 url과 함께 timeout 설정들도 설정 외부화를 통해 외부에서 설정(yml)파일로 관리하게된다.

 

naver:
  url: http://www.naver.com
daum:
  url: http://www.daum.net

applicaton.yml 에는 이런형태로 설정이 들어가있다.

 

@Repository
@ConfigurationProperties("naver")
class NaverApiRepository: BaseApiRepository() {
    fun call(params: List<NaverParam>) {
        val paramList = params.joinToString(separator = ":", transform = NaverParam::toParam)

        val map = LinkedMultiValueMap<String, String>()
        map.add("paramList", paramList)
        map.add("caller", "test")

        val url = buildUrl(map)

        restTemplate!!.getForObject(url, String::class.java)
    }
}

 

그리고 Naver를 호출하는 Api Repository 를 작성한다. 해당 클래스는 위에서본 추상클래스를 사용받고, 간편하게 call 메서드만 구현해서 naver api를 호출하도록했다. 추후에 daum api 를 호출하는 구현체가 필요해지면 유사한 형태로 구현해주면된다. rest template에 설정할 내용들은 외부설정값들을 조정해주기만 하면된다.

 

이미 이 자체로 중북코드를 상당히 제거한 상태고, 하위 클래스만 추가해주면 naver, daum 이외의 api를 호출할때도 큰 문제가 없다.

 

@RunWith(SpringRunner::class)
@SpringBootTest
class NaverRepositoryTest {
    @Autowired
    private lateinit var naverApiRepository: NaverApiRepository

    @Test
    fun call() {
        val list = listOf(NaverParam(104585, 769))

        naverApiRepository.call(list)
    }
}

 

호출하는 통합테스트 코드이다. 달리 검증하는건 없고 Exception만 발생하지않는다면 성공으로 간주하는 테스트이다. 코드에도 큰 문제는 없고, 테스트도 잘 통과한다. 하지만 내가 이번에 리팩토링하기로한 코드는 이 코드이다.

 

이 코드를 리팩토링하기로한 이유는 다음과같다.

 

- call() 메서드만 구현해주면 되지만 그 call() 메서드가 하위 구현체마다 거의 대동소이할것같다.

- 하위 클래스(여기선 NaverApiRepository)는 BaseApiRepository 에 대해 알고있어야한다. 예를들어 현재코드에서는 BaseApiRepository에 rest template이 있다는걸 알고있어야하며, 이는 헐리우드 원칙 위배이다.

- 고로 call() 메서드에 대해서 추상화가 가능할것같다.

 

이런 이유로 call() 메서드를 하위 구현체가 아닌 BaseApiRepository 로 옮기기로했다.

 

2. 1차 Refactoring

내가 이루고싶은건 헐리우드 원칙을 위배하지않으면서 템플릿 메서드 패턴형태로 코드를 작성하고자한것이다.

call() 메서드를 상위 추상 클래스로 옮기면서 맞닥뜨린 가장 큰 문제는 call() 메서드의 파라미터이다.

 

fun call(params: List<NaverParam>)

 

기존 NaverApiRepository 에 선언되어있던 call() 메서드는 naver api에만 대응하면되므로 저런 구체적인 파라미터를 받는게 가능했다. 하지만 call() 메서드가 상위 클래스로 이동되면 naver, daum 모두에 대응해야하므로 저 파라미터는 문제가있었다. 난 이런 리팩토링을 종종 하는 편인데 이럴땐 중간에 추상화 계층이 하나 더 필요해진다.

 

어떤식으로 추상화를 하는게 좋을까를 고민하다가 generic 을 이용하기로했고, 결과물은 아래와같다.

 

abstract class BaseApiRepository<T>(private var url: String? = null,
                                    private var restTemplate: RestTemplate? = null) {
    @PostConstruct
    fun init() {
        this.restTemplate = RestTemplateBuilder()
                .setConnectTimeout(Duration.ofMillis(300))
                .setReadTimeout(Duration.ofMillis(1000))
                .build()
    }

    fun call(param: T) {
        val url = buildUrl(buildParam(param))

        restTemplate!!.getForObject(url, String::class.java)
    }

    protected abstract fun buildParam(t: T): MultiValueMap<String, String>

    fun setUrl(url: String) {
        this.url = url
    }

    private fun buildUrl(map: MultiValueMap<String, String>): String = UriComponentsBuilder.fromUriString(url!!)
            .queryParams(map)
            .build()
            .toUriString()
}
@Repository
@ConfigurationProperties("naver")
class NaverApiRepository : BaseApiRepository<List<NaverParam>>() {
    override fun buildParam(t: List<NaverParam>): MultiValueMap<String, String> {
        val paramList = t.joinToString(separator = ":", transform = NaverParam::toParam)

        val map = LinkedMultiValueMap<String, String>()
        map.add("paramList", paramList)
        map.add("caller", "test")

        return map
    }
}

 

call() 메서드에 파라미터를 안보내게할수는 없었다. 어떤식으로 추상화를 해야하나 하다가 추상클래스에 T 타입을 선언하고, 이를 이용하도록했다. 그런데 또 문제는 T 타입의 파라미터는 무언가 가공하는 형태가 필요할 수 밖에 없었는데 call() 메서드에 전달된 T타입을 그대로 하위클래스에서 구현해줘야하는 메서드의 파라미터로 보냈고, 추상클래스를 구현하는 쪽에서 그 파라미터를 구현하도록 작성했다.

 

이렇게 작성했을땐 하위 클래스가 상위 클래스를 알필요가 없어진다. 기존에 하위 클래스는 상위 클래스에 어떤요소들이 있는지를 알아야했고, 그리고 상위 클래스의 필드인 rest template을 직접 호출했어야했다.(이는 캡슐화도 깨뜨린다.) 하지만 이제는 하위클래스에서 구체적으로 필요한 요소만 작성하여 return 하면 되므로 상위 클래스의 필드가 뭐가있는지, 어떤걸 호출해야하는지도 알 필요가없다. 이 덕분에 기존에는 protected로 선언됐던 rest template을 private으로 변경해서 캡슐화 시킬수도있었다.

 

일단 내가 원했던건 어느정도 달성했는데 이 코드도 약간 맘에 들지않는 부분이있었다.

 

- return 타입인 도메인 모델을 타입 파라미터로 지정하는것도 아니고, 단순한 파라미터를 타입 파라미터로 사용하는게 올바른지 잘 모르겠다.

- call() 메서드로 전달한 파라미터를 그대로 buildParam() 메서드로 전달해서 쓰는것도 이게 올바른가.. 라는 의구심이 드는 코드였다.

 

3. 2차 Refactoring

추상화를 통해 리팩토링을 하긴했는데 뭔가 잘못된 추상화를 했다는 생각이 들어 한번 더 다른방향으로 리팩토링을 하기로했다.

그 결과물은 이렇다.

 

abstract class TemplateApiRepository(private var url: String? = null,
                                     private var restTemplate: RestTemplate? = null) {
    @PostConstruct
    fun init() {
        this.restTemplate = RestTemplateBuilder()
                .setConnectTimeout(Duration.ofMillis(300))
                .setReadTimeout(Duration.ofMillis(1000))
                .build()
    }

    fun call(param: Parameterizable) {
        val url = buildUrl(param.toParam())

        restTemplate!!.getForObject(url, String::class.java)
    }

    fun setUrl(url: String) {
        this.url = url
    }

    private fun buildUrl(map: MultiValueMap<String, String>): String = UriComponentsBuilder.fromUriString(url!!)
            .queryParams(map)
            .build()
            .toUriString()
}
@Repository("afterNaverApiRepository")
@ConfigurationProperties("naver")
class NaverApiRepository: TemplateApiRepository() {

}

 

지금까지 봐온 추상클래스와 하위클래스의 리팩토링버전이다.(살며시 눈치챘는지 모르겠는데 BaseApiRepository가 TemplateApiRepository로 이름이 바꼈다. 크게 중요한 내용은 아니다.) 이전버전이랑 달라진점은 제네릭이 사라졌고, call() 메서드가 Parameterizable 이라는 인터페이스를 받게됐다는 점이다. 그런데 그 구현체인 NaverApiRepository를 보면 구현된게 아무것도 없다. 원래 파라미터를 만들었던 역할은 어디로 간걸까?

 

interface Parameterizable {
    fun toParam(): MultiValueMap<String, String>
}
data class Param(val naverParams: List<NaverParam>): Parameterizable {
    override fun toParam(): MultiValueMap<String, String> {
        val map = LinkedMultiValueMap<String, String>()

        map.add("caller", "test")
        map.add("paramList", naverParams.joinToString(separator = ":", transform = NaverParam::toParam))

        return map
    }

    data class NaverParam(val apiId: Int,
                          val naverId: Int) {
        fun toParam() = "$apiId,$naverId"
    }

 

지금까지 코드를보면 여태 파라미터는 List<NaverParam> 이라는 타입으로 호출됐다. 그런데 누군가가 Parameterizable 을 구현해줘야하는데 List 에다가 그걸 구현하게 할 수는 없었다. 그래서 List<NaverParam> 을 컨테이너 형태로 들고있는 Param 클래스를 추가로 생성하고, 그 필드로 List<NaverParam> 을 들고있도록했다. 물론 Parameterizable 인터페이스는 이 Param 클래스가 구현하게된다. 이 때문에 아까부터 익숙하게봤던 MultiValueMap을 구현하는 코드가 현재 여기에 있게되는것이다.

 

 

개인적으로 1차 리팩토링 결과보다는 이 리팩토링 결과가 훨씬 맘에 들었다. 이 리팩토링의 결과로 인해 추후 http 호출을 하는 RestTemplate이 WebClient와 같은것으로 변경된다고 하더라도 추상클래스인 TemplateApiRepository 만 수정되면 다른곳들은 건드릴 필요가 없다. 이는 캡슐화를 잘 지킨 결과이며 캡슐화가 깨진상태인 리팩토링 이전의 코드라면 모든 구현체들의 call() 메서드를 수정해줘야 할것이다.

 

4. 이슈발생

만족스러운 리팩토링 결과를 얻고 정상동작하는지 테스트케이스를 돌렸다. 크게 복잡한 코드도아니고 안정적인 초록바를 보고싶었다. 하지만 결과는...

 

kotlin.KotlinNullPointerException

 

NPE가 발생한다! 발생포인트는 TemplateApiRepository의 url에 제대로 설정이 주입되지않았기때문이다. 다른데서 문제가 생기면 모르겠는데 이전 테스트에서는 잘 돌던, 전혀 상관없는 부분에서 NPE가 발생하니 좀 당황스러웠다. 왜 발생했을까?

 

5. 디버깅

가장 처음해본건 NaverApiRepository 에 적용된 @Repository 애노테이션을 @Compnent 로 변경하는것이다. 음.. 지금와서 생각해보면 왜 이걸 먼저했는지는 잘 모르겠다;;

 

근데 더 놀라운건 @Componenet 로 변경하면 문제없이 테스트가 성공한다는 점이다. 오히려 이게 더 날 혼란스럽게 만들었는데 이는 분명 애노테이션에 따라 뭔가 차이가있다는 점일거다. 이 차이를 확인해보자.

 

@Component
@Repository

 

디버깅을 찍어 어떤 차이가 있는지 확인해봤다. 캡쳐된 화면을 보면 알겠지만 @Component 로 빈을 생성하면 내가 작성한 클래스의 인스턴스를 그대로 사용하는걸 볼 수 있지만 @Repository 를 사용하면 proxy 인스턴스가 사용된걸 알 수있다. 뭔가 여기서 이슈가 발생한걸 알 수 있다.

 

참고로 말하면 포스팅 첫부분에 jdbc starter가 꼭 있어야한다고 말했는데 이게 없으면 @Repository 를 사용해도 프록시를 만들지않는다. 더 정확히 말하면 jdbc 보다 jdbc의 의존성 전이로 추가되는 spring-tx 가 있어야하는데, 스프링은 @Repository 를 사용할 경우 DB transaction 과 예외변환을 처리해주기때문에 spring-tx가 추가되면 프록시를 생성하게된다.

 

여튼 프록시를 만드는게 뭔가 원인인건 알겠는데 정확히 왜 proxy를 만들면 문제가 발생하는지는 아직 모른다.

 

여기서 한번 더 다른작업을 해봤다.

 

interface NaverApiRepositoryInterface {
    fun call(param: Parameterizable)
}

 

애노테이션은 @Component 가 아닌 @Repository 를 유지하면서 인터페이스를 추가한것이다. 사실 이런짓(?)을 한 이유는 다른것보다 프록시를 만들때 cglib가 아니라 jdk dynamic proxy 를 이용하도록 유도하기위함인데 내 유도랑 상관없이 cglib 프록시를 생성했다. 그런데 문제는 그럼에도 불구하고 테스트가 통과하는 것이다!

 

지금까지 이슈와 그 해결내용을 정리해보면

 

- call() 메서드를 하위 클래스에서 상위 클래스로 옮기도록 리팩토링 진행

- 상위 추상 클래스에서 제대로 주입되지않아 NPE 발생(리팩토링 이전엔 발생치않음)

- @Repository 애노테이션을 @Componenet로 변경했더니 이슈 해결

  - @Repository 를 이용하면 cglib proxy 를 생성하고, @Component 를 사용하면 POJO를 그대로 사용

- 애노테이션 변경없이 interface를 추가했더니 이슈 해결

  - 이슈가 발생할때와 똑같이 cglib proxy 를 생성하지만, 이슈는 해결됨

 

일단은 뭐가 문제인지 확인해보기위해 프록시를 만드는 코드 디버깅에 집중하도록 했다.

 

class PersonTest {
    @Test
    fun implementInterface() {
        val p = PersonImpl()
        p.init()

        val proxyFactory: ProxyFactory = ProxyFactory()

        proxyFactory.setTarget(p)

        val proxy = proxyFactory.proxy as PersonImpl

        println(proxy.javaClass)

        assertThat(proxy.introduce()).isEqualTo("age: 31 name: LichKing")
    }

    @Test
    fun notImplementInterface() {
        val p = Person()
        p.init()

        val proxyFactory: ProxyFactory = ProxyFactory()

        proxyFactory.setTarget(p)

        val proxy = proxyFactory.proxy as Person

        println(proxy.javaClass)

        assertThat(proxy.introduce()).isEqualTo("age: 31 name: LichKing")
    }
}

 

스프링이 생성하는 프록시 객체를 직접 만들어서 확인해보기위해 ProxyFactory 를 이용해서 결과물을 확인해보기로했다. 여기서 사용된 Person 클래스와 PersonImpl 클래스는 위에서 본 Repository 를 본따 만든 예제 클래스들이다. 두 테스트 모두 cglib 를 이용한 프록시를 생성해내고 introduce() 메서드를 호출한다. Repository 때와 마찬가지로 인터페이스를 구현한 PersonImpl의 introduce() 는 정상적으로 테스트를 통과하는 반면 interface가 없는 Person의 introduce()는 테스트가 실패한다.

 

6. 이슈해결

어떤 차이가있고, 어떤 문제때문에 이렇게 두 결과가 다른지 코드 디컴파일을 이용해 문제를 확인해봤다.

 

public abstract class AbstractPerson public constructor(age: kotlin.Int? /* = compiled code */, name: kotlin.String? /* = compiled code */) {
    public final var age: kotlin.Int? /* compiled code */

    public final var name: kotlin.String? /* compiled code */

    public final fun init(): kotlin.Unit { /* compiled code */ }

    public final fun introduce(): kotlin.String { /* compiled code */ }
}
public abstract class AbstractPersonImpl public constructor(age: kotlin.Int? /* = compiled code */, name: kotlin.String? /* = compiled code */) : com.yong.cglib.person.PersonInterface {
    public final var age: kotlin.Int? /* compiled code */

    public final var name: kotlin.String? /* compiled code */

    public final fun init(): kotlin.Unit { /* compiled code */ }

    public open fun introduce(): kotlin.String { /* compiled code */ }
}

 

위에껀 인터페이스가 없는 Person이고, 아래껀 인터페이스가 있는 PersonImpl 이다. 두 결과의 차이를 알겠는가?

introduce() 메서드의 시그니처를 보면 인터페이스가 없을땐 final 이지만 인터페이스가 있으면 open이다 !

 

문제가 발생한 원인은 이렇다.

 

- cglib는 proxy 객체를 만들면서 기존 메서드들을 오버라이딩하게된다.

- 인터페이스가 없는 경우 kotlin 은 java 와 달리 기본스펙이 final 이므로 프록시를 만들때 오버라이딩하지 못한다.

  - cglib 는 프록시를 만들때 오버라이딩하지못하면 예외를 발생시키지않는다.

- 오버라이딩이 되게되면 메서드를 성공적으로 호출하지만 오버라이딩 되지않으면 그렇지못하다. (정확히 표현하면 메서드 호출은 되는데 필드가 null이다. 이 때문에 NPE가 발생하게된다.)

 

자, 이제 거의 다 왔다.

 

여기까지 확인하고 내가 가진 의문은 이렇다.

"오버라이딩 하지 못하는건 알겠는데 오버라이딩 하지 못했다고 예외발생하면 몰라도 그렇다고 왜 NPE가 발생하지?"

 

이 의문을 해결하기위해 직접 cglib가 되어 프록시를 만들어보자.

 

open class Person(private val age: Int? = null) {
    open fun introduce(): String {
        return "age: $age!!"
    }
}

 

상속이 가능하고, 오버라이딩이 가능하도록 open 키워를 붙인 Person을 정의한다.

 

class PersonProxy(private val p: Person): Person() {
    override fun introduce(): String {
        return p.introduce()
    }
}

 

Person 을 상속받고, Person을 필드로 들고있는 프록시 클래스를 정의한다. 프록시 클래스이니 실제 로직을 처리하는 필드에게 메서드 호출을 위임한다. 이게 아주 기본적은 프록시 패턴이다. 프록시 객체는 직접 메서드를 마무리하지않고 내부로 들고있는 실제 객체의 메서드를 호출한다. 그런데 여기서 introduce() 메서드가 오버라이딩 되지못한다면 어떻게 될까?

 

class PersonProxy(private val p: Person): Person() {
    
}

 

오버라이딩하지못하게되면 이런 코드가 될것이다. 이때 PersonProxy 클래스는 기본적으로 Person을 상속받은 상태이기때문에 원래 Person에 정의되어있는 age 필드는 프록시 클래스에도 존재하게된다. 하지만 내가 원하는 age는 프록시 age가 아니라 실제 Person 객체의 age이다. 때문에 PersonProxy 는 메서드를 오버라이딩하여 내부 객체의 age를 보여줘야하는데 이를 오버라이딩 하지 못했으므로 자기 자신의 age를 보여주게되고, 자기 자신의 age 는 당연히 null 이므로 NPE가 발생하는 것이다!

 

7. 아직 해결되지않은 이슈

위까지 다 따라왔다면 내용들을 정리해보자.

 

- interface 를 사용하면 문제가 없고, 사용하지않으면 문제가 생기는 이유

  - cglib는 상속과 오버라이딩을 기반으로 프록시 클래스를 만든다.

  - kotlin은 기본스펙이 상속과 오버라이딩을 막는다. (open 키워드로 열어줘야하며 기본이 허용이고 final 로 방지하는 자바와는 정반대다.)

  - 프록시 객체는 오버라이딩되지않으면 실제 타겟 객체에게 메서드 호출을 위임하지못해 문제를 발생시킨다.

 

- @Componenet 를 쓰면 문제가 없고, @Repository 를 쓰면 문제가 발생하는 이유

  - @Componenet 는 프록시를 만들지않으므로 오버라이딩 할필요도없다. 그러니 문제가 없다.

  - @Repository 는 spring-tx 가 의존성에 있을경우 프록시를 만들게되는데 이때 오버라이딩이 되지못해 문제를 일으킨다.

 

문제가 다 해결된것 같지만 아직 한가지가 남아있다.

 

- 오버라이딩 하지못해서 문제가 발생하는건 ok.

- 그럼 처음 코드에서 call() 메서드가 하위에있을땐 문제가 없는데 상위로 올라가니까 문제가 발생하는 이유는?

- 구현 클래스든 추상 클래스든 코틀린은 기본적으로 상속과 오버라이딩을 막으므로 문제가 생길거면 둘다 생겨야 하는거 아닌가?

  - 추상클래스에 대해 좀 더 정확히 말하면 추상클래스의 상속과 추상클래스 내의 추상메서드에 대해선 오버라이딩을 막지않는다. 추상클래스 내에 있다더라도 구현된 메서드의 경우(지금의 call() 메서드처럼.) 에만 오버라이딩을 막는다. 뭐 이건 매우 당연하다. 추상메서드의 오버라이딩을 막는건 이해하기힘든 상황이기 때문.

 

위 의문에 대한 해답은 kotlin-spring plugin에 있다.

koltin + spring 으로 프로젝트를 생성하게되면 아래와같은 gradle 플러그인이 자동으로 추가되어있다.

 

kotlin("plugin.spring") version "1.2.71"

(난 gradle 을 사용하고있지만 메이븐도 비슷한 플러그인이 있을것으로보인다.)

 

저 플러그인이 하는일이 뭔지를 이해해야하는데, 위에서 누누히 말했듯 kotlin은 기본적으로 상속과 오버라이딩을 막는다.

그런데 spring-tx 가 추가되자 프록시를 만드는것처럼 스프링은 암묵적인 확장과 오버라이딩을 적극적으로 사용하고있다. 이때 kotlin 스펙을 그대로 적용하게되면 개발자들은 kotlin을 쓸때 습관적으로 open을 붙여줘야할것이다. 이는 매우 귀찮고 생산성저하를 일으킬텐데, 이를 해결하기위해 spring 기술들에 자동으로 open을 붙여주는 역할을 하는게 위 플러그인이다.

 

그런데 저 플러그인은 spring 애노테이션이 적용된 클래스에 대해서만 open을 붙여주게된다. 즉 @Repository 가 적용된 NaverApiRepository 에 call() 메서드가 존재할때는 gradle 플러그인으로인해 open이 붙게되어 문제가 발생하지않았지만, TemplateApiRepository 는 spring 애노테이션이 적용되지않아 call() 메서드를 열어주지(open) 않아 문제가 발생하는 것이다.

 

이때문에 기본 스펙이 상속과 오버라이딩에 대해 열려있는 자바의 경우에는 똑같은 코드를 작성하게되도 아무런 이슈가 발생하지않는다.

 

8. 마무리

사실 처음에 @Componenet 로 바꾸자 이슈가 해결되었을때, 또 interface 를 추가하자 문제가 해결되었을때 별 생각없이 해결됐으니 끝이라고 생각했었다. 하지만 java로 똑같은 코드를 작성했을때 문제가 발생하지않는걸보고 이는 Kotlin 이슈라는 생각을 하게됐고, 그때부터 원인을 찾아내야겠다고 생각했었다. 이렇게 이슈를 분석하고 나니 '해결되면 끝' 이라는 안일한 생각을했던 때가 부끄럽기도하다. 누군가에게 도움이 되길 바라며 글을 마무리한다.

코드가 필요하다면 여길 참고하면 된다. https://github.com/LichKing-lee/kotlin-issue

LIST
댓글
  • 프로필사진 hjlee 와 저랑 똑같은 문제를 겪으셨어요. open 때문에 하루종일 삽질하고 이 포스팅 보고 해결하고 갑니다;;;;
    프록시 설명까지 너무 깔끔하네요. 정말 감사합니다.
    2021.09.06 20:32
댓글쓰기 폼