RestTemplate..

스프링은 3.0 부터 자체적으로 HTTP Client Wrapper (적당한 표현인지 애매하다는 생각이 들지만..) 인 RestTemplate 을 제공해왔다.
3.0 이면 내가 개발자 생활을 시작하기도 전이므로 꽤 오래전부터 제공된 클래스건만 정작 개인적으로는 이 클래스를 제대로 사용 해본적이 없었다.

스프링에서 제공하는 이 공식 래퍼를 사용안했던 이유를 겪었던 프로젝트 상황을 크게 두가지로 나눠서 따져보면 다음과 같다.

  • 우선 가장 큰 이유. 레거시 프로젝트.
    업무상 건네받은 기존 프로젝트들은 RestTemplate 을 사용하지 않고 있었고,
    한 프로젝트 내에서 사용되던 HTTP Client 들이 하나로 통일된 형태로 관리되는 경우도 있고, 아닌 경우도 있다.
    • 어쨌거나 리팩토링할 여유가 없었다고..
      이 명분은 현실적으로 참 대단하지만, 참 된(?) 마인드로 보면 일종의 변명으로 치부할 수 있겠지
  • 새로 세팅하는 프로젝트의 경우
    수작업으로 메이븐으로 제공되는 Http 라이브러리들을 래핑해서 사용해왔는데,
    결국 래핑 클래스가 하나로 통일되는 정도가 보장되는 정돈은 되었을지언정
    기존 레거시 프로젝트에서 작업해왔던 것과 유사한, 익숙한 방식으로 작업해왔다.
    • 내가 원하는 정도로 필요할 때마다 기능을 추가하기도 쉽고
      잘못된 점의 수정이 필요할 때도 내 코드니까 남의 부담감도 덜하고 어쨌든 좀 더 수월한 정도의 차이?
    • 어쩌면 매번 조금씩 새롭게 래핑하면서 티끌만큼 더 그럴듯해져가는건 있는데,
      매번 세팅 때마다 알면서도 그 더딘 속도가 엄청난 속도를 내면서 쫘잔~ 대단한 래퍼가 나올꺼라 꺼라 기대를 빙자한 착각이 작용한다.
      개인적인 실력의 문제도 있고, 언제나 그렇듯 비즈니스 로직을 빨리 쳐내야 하는 상황적 문제도 있고..
  • 어쨌거나 저쨌거나 스프링에서 제공되는 래퍼라고해서 스프링 사용자가 반드시 쓸 이유는 없다!?

뭐 결국 일차적으로는 익숙한 방법 이 주된 원인이라 볼 수 있는데..

껄끄러웠던 && 껄그러운 추가적인 이유

그런데 RestTemplate 을 한번 써볼까? 하는 시도가 아예 없었던 건 아니다.
그럼에도 반영하지 않은 이유들이 있었는데.. 이번에 들여다보면서 이전에 한두번 지나쳤던 느낌이 다시 떠올랐다.

  • 핸들링하기 쉬우면서도, 정작 프로젝트의 용도에서 제대로 녹이기 까다롭다.
    • 이게 무슨 개소리인가.. 싶지만 사실이 그러하다.
    • 테스트 코드로 예제 붙여다가 대충 사용하는것이 아닌 프로덕션 레벨에서 여러 정황을 고려해서 반영하려면 우선 그 구조와 컨셉을 대략이라도 분석해야 한다.

그리고 그 컨셉을 분석하다보면.. 아.. 이거 쓰기 어렵겠는다 하는 잘못된게 아님에도 실사에는 애매하게 난감해지는 상황이 오는데.

  1. HttpClientErrorExceptionHttpServerErrorException 으로 명확하게 응답 상태 코드에 따라 날아가는 Exception

    • 아니 이게 왜? 싶지. 처음엔 오히려 오 이거야. 싶기도 하다. 반갑다.
    • 근데 막상 그 상태 코드가 전반적으로 잘 지켜지고 있느냐? 그렇지 않은 경우가 많다. 적어도 내가 경험한 연동 대상들에서는. 그것이 HTTP 의 규약임에도.
      • 보안을 이유로 사내 강제 가이드가 되는 곳도 있고, 그 상태 코드에 대해 중요치 않게 보는 실무자의 컨셉일수도 있고. 여튼 무조건 200 OK.
      • 그리고 에러 상황이라면 추가 정의된 비즈니스 코드를 body 에 준다.
        • 별도의 비즈니스 코드가 필요한 것은 분명히 맞는데, 단순한 문제까지 비즈니스코드로 해석을 해야 할까?
          정의된건 좋은데 비즈니스코드 의존적인 면이 좀 있는 것 같다.
      • 여튼 상황이 그렇다보니, 던져지는 저 두 익셉션 처리는 하면서 별도로 매번 body 파싱 결과에 따른 예외 처리를 추가로 해야한다.
        • 연동 대상에 따라 달라지는건 뭐 별도로 래퍼를 만들어 쓴다해도 다를건 없지만, 알게 모르게 중복적이지만 별도로 처리하게되는 예외 상황이 발생한다.
        • 규약에 따라 잘 쓰라고 짜여진 판이 오히려 일이 더 까다롭게 꼬이는 측면의 위험성이 있다고 할까.
        • 사실 이 문제는 코딩이 살짝만 진행되다가 생각만 이렇게 저렇게 확장하다보니 골치아플 것 같다 싶어서 때려친 이유이긴 하다. ;
  2. 응답 값을 순수한 상태로 확인할 수 있게 제공되는 과정이 없다.

    • 이 부분은 개발자의 성향에 따라 받아들여질 것 같긴한데.. 여튼 나는 순수한 응답 값을 그대로 확인할 수 있는 과정이 있는게 속 편하다는 입장

    • RestTemplate 은 기본적으로 성공 응답에 대해서는 파싱할 Body 의 타입을 제공받아 알아서 파싱해준다. 참 편하다..

      RestTemplate restTemplate = new RestTemplate();
      ReternValues values = restTemplate.getForObject("https://blahblah.com", ReturnValues.class);
      
      • 뭐 이런식으로
      • 근데 내가 지정한 그 타입이 뭔가 불완전하면 어쩌지?
    • API 초기에는 filedC 라는 키의 값이 없다가 갑자기 생겼고, 내가 정의한 타입 클래스엔 fieldC 가 없다면, 그런 값이 왔었는지를 알 수 없다.
      아니면 내가 정의한 타입 클래스에서 오타가 났다거나, 기타 등등. 그냥 버려지는거다.
      물론 응닥 스펙에 변화가 생기면 API 버전이 올라 구분 되야겠지만.. 현실이 꼭 그렇지도 않다.

    • 의도적으로 불필요할 것 같아서 버릴 수는 있다지만, 어쨌든 온 결과 그대로와 파싱된 결과를 비교는 할 수 있어야 하지 않겠나.

    • ReponseEntity 로 래핑된 객체 역시 getBody() 를 하면 파싱된 타입의 객체가 리턴된다.
      getBodyOriginalValue() or getBodyOriginalString() 뭐 이런식의 원본을 확인할 수 있는 경로가 없음. byte[] 건 String 이건

    • 이걸 굳이 확인하는 편법이라면 String 으로 파싱 타입을 지정하는건데,
      restTemplate 에 등록된 String 타입 컨버터를 거쳐서 해석되고, 그 String 을 받아서 다시 원하는 타입으로 파싱해야한다.
      해석 과정이 두번 이뤄지는 셈이다.

    • 어차피 응답의 Stream 을 String 으로 변환하는게 한번 거치는건 똑같지 않느냐.. 할 수도 있지만,
      로깅을 남기던 뭘하던 내가 원하는 형태로 핸들링하기 위한 내 코딩이 늘어난다는게 중요하고(!), 지나가는 로그도 없다.

    • 뭐 이래 저래 길게 썼는데, 쉽게 말해 편하게 접근할 수 있는 순수한 상태의 응답 body 의 파악과 내 필요에 의한 핸들링 경로가 필요하다.

      • 운영 상태에서 만약을 위한 연동 대상과의 연동 결과 기록은 당연한거잖아! 가 내 입장.
      • 요청에 대한 영수증이나 마찬가지 아닌가.
      • 혹시 RestTemplate 이 래핑하고 있는 실 클라이언트는 혹시 지나가는 로그라도 남기나? 싶어 확인해봤지만, 아니다.
        // SimpleClient
        03:26:11.364 [main] DEBUG org.springframework.web.client.RestTemplate - HTTP GET https://www.google.com/
        03:26:11.369 [main] DEBUG org.springframework.web.client.RestTemplate - Accept=[]
        03:26:11.761 [main] DEBUG org.springframework.web.client.RestTemplate - Response 200 OK
        // Netty Client
        02:25:41.676 [main] DEBUG io.netty.util.ResourceLeakDetectorFactory - Loaded default ResourceLeakDetector: io.netty.util.ResourceLeakDetector@f0c8a99
        02:25:41.677 [main] DEBUG org.springframework.web.client.RestTemplate - HTTP GET https://www.google.com/
        02:25:41.679 [main] DEBUG org.springframework.web.client.RestTemplate - Accept=[]
        02:25:41.704 [main] DEBUG io.netty.channel.DefaultChannelId - -Dio.netty.processId: 9120 (auto-detected)
        02:25:41.707 [main] DEBUG io.netty.util.NetUtil - -Djava.net.preferIPv4Stack: false
        02:25:41.707 [main] DEBUG io.netty.util.NetUtil - -Djava.net.preferIPv6Addresses: false
        02:25:42.070 [main] DEBUG io.netty.util.NetUtil - Loopback interface: lo (Software Loopback Interface 1, 127.0.0.1)
        02:25:42.071 [main] DEBUG io.netty.util.NetUtil - Failed to get SOMAXCONN from sysctl and file \proc\sys\net\core\somaxconn. Default: 200
        02:25:42.423 [main] DEBUG io.netty.channel.DefaultChannelId - -Dio.netty.machineId: 4c:cc:6a:ff:fe:0a:04:ea (auto-detected)
        02:25:42.441 [main] DEBUG io.netty.buffer.PooledByteBufAllocator - -Dio.netty.allocator.numHeapArenas: 16
        02:25:42.441 [main] DEBUG io.netty.buffer.PooledByteBufAllocator - -Dio.netty.allocator.numDirectArenas: 16
        02:25:42.441 [main] DEBUG io.netty.buffer.PooledByteBufAllocator - -Dio.netty.allocator.pageSize: 8192
        02:25:42.441 [main] DEBUG io.netty.buffer.PooledByteBufAllocator - -Dio.netty.allocator.maxOrder: 11
        02:25:42.441 [main] DEBUG io.netty.buffer.PooledByteBufAllocator - -Dio.netty.allocator.chunkSize: 16777216
        02:25:42.441 [main] DEBUG io.netty.buffer.PooledByteBufAllocator - -Dio.netty.allocator.tinyCacheSize: 512
        02:25:42.441 [main] DEBUG io.netty.buffer.PooledByteBufAllocator - -Dio.netty.allocator.smallCacheSize: 256
        02:25:42.441 [main] DEBUG io.netty.buffer.PooledByteBufAllocator - -Dio.netty.allocator.normalCacheSize: 64
        02:25:42.441 [main] DEBUG io.netty.buffer.PooledByteBufAllocator - -Dio.netty.allocator.maxCachedBufferCapacity: 32768
        02:25:42.441 [main] DEBUG io.netty.buffer.PooledByteBufAllocator - -Dio.netty.allocator.cacheTrimInterval: 8192
        02:25:42.441 [main] DEBUG io.netty.buffer.PooledByteBufAllocator - -Dio.netty.allocator.cacheTrimIntervalMillis: 0
        02:25:42.441 [main] DEBUG io.netty.buffer.PooledByteBufAllocator - -Dio.netty.allocator.useCacheForAllThreads: true
        02:25:42.441 [main] DEBUG io.netty.buffer.PooledByteBufAllocator - -Dio.netty.allocator.maxCachedByteBuffersPerChunk: 1023
        02:25:42.446 [main] DEBUG io.netty.buffer.ByteBufUtil - -Dio.netty.allocator.type: pooled
        02:25:42.446 [main] DEBUG io.netty.buffer.ByteBufUtil - -Dio.netty.threadLocalDirectBufferSize: 0
        02:25:42.446 [main] DEBUG io.netty.buffer.ByteBufUtil - -Dio.netty.maxThreadLocalCharBufferSize: 16384
        02:25:42.609 [nioEventLoopGroup-2-1] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.maxCapacityPerThread: 4096
        02:25:42.610 [nioEventLoopGroup-2-1] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.maxSharedCapacityFactor: 2
        02:25:42.610 [nioEventLoopGroup-2-1] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.linkCapacity: 16
        02:25:42.610 [nioEventLoopGroup-2-1] DEBUG io.netty.util.Recycler - -Dio.netty.recycler.ratio: 8
        02:25:42.940 [nioEventLoopGroup-2-1] DEBUG io.netty.handler.ssl.SslHandler - [id: 0x10160a5a, L:/192.168.219.100:10562 - R:www.google.com/172.217.26.132:443] HANDSHAKEN: TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
        02:25:43.207 [main] DEBUG org.springframework.web.client.RestTemplate - Response 200 OK

하지만 한번 써보기로 했다.

어쩌다보니 길게 쓰게 된 위 두가지 이유로 한번 써볼까? 하다가 말았던 기억이 이번에 되살아났는데,
되살아난 이유가 다시 한번 써보려고 이래저래 까본거고, 결국 이번에 썼기 때문이다.

그래서 이번에는 왜 쓰게 되었는가에 대해 한번 짚어보면..

  1. 스프링에 의해 제공되는 래퍼
    • 위에서 스프링에 의해 제공된다고 꼭 쓸 이유가 없다고 썼지만, 한편으로는 그게 쓸만한 이유가 된다.
    • 내가 필요할때마다 유지보수하는 커스텀 래퍼는 나중에 누군가 이어받았을 때 그만큼 부담스러운 레거시 코드가 될 확률이 높다. (아니면 다행이구)
    • 반면 RestTemplate 은 뭔가 내 맘에 좀 안드는 부분나 핸들링하기 껄끄러운 부분이 있어도 어떤 의미에선 공용어니까,
      레거시 취급은 덜 받을테고, 스프링에 의해 제공되니까 필요한 부분이 생기면 래퍼 자체는 어쨌든 알아서 잘 유지보수될 것 이라는 기대
    • ..여기까지가 약간 명분을 위한 명분 이고
  2. 같이 일하는 개발자가 상태 코드에 대한 이해가 살짝 부족하더라
    • 다른 회사, 다른 부서, 다른 프로젝트 인원이면 몰라도,
      함께 같은 프로젝트를 담당하는 개발자가 상태 코드 핸들링을 가볍게 넘어가는걸 보니 조금이라도 더 리딩비스무리한걸 하는 입장에선 그대로 넘어가기 어려웠다.
    • 마침 레거시 프로젝트고, 래퍼가 여기저기 산개한 상황. 그 래퍼 중 하나가 RestTemplate
    • 다행히? 이 프로젝트는 주 연동 대상들이 구글, 애플 이라 상태 코드를 상황에 따라 주는 상대들..
    • 옳타쿠나 상태 코드에 민감하게 반응하는 이놈으로 통일하자! 상태 코드를 좀 제대로 인지해! 라는 바램으로 통일, 리팩토링..
    • 가볍게 넘어가는 그 부분은 본 순간 짚고 넘어가긴 했지만, 그것만으로는 뭔가 성에 덜 차다. 어떤면에선 내로남불인가.
      • 그렇다고 그 개발자가 나쁜 개발자라고 말하고 싶은건 아니다. 잘 한다.
      • 다만 실무의 레거시 코드에 익숙해져가다보니 어쩌다 그런거다.
      • 그래서 이 기회에 제대로 접하게 하고 싶었다. 다시 잡을 일 많은 실무 코드에서. 약간 즉흥적이랄까. 쓰다보니 잘한 짓인지 모르겠다.

문제는.. 위에서 구구절절 시시콜콜 늘어놓은 껄끄러운점은 그대로라는건데.

  • 1번의 상태 코드에 따륵 예외 발생 문제는 상태 코드에 부딪혀보라는 하나의 의도된 사항이니까 문제가 아니다.
    • 설령 그 의도 때문에 나중에 더 피곤하게 꼬일 일이 발생할 지라도...
    • 이걸 안쓰더라도 어차피 이래저래 연동 대상에 따라 이래저래 핸들링 해야한다.
  • 진짜 문제는 순수 응답값을 알 수 없다는 2번인데..
    • ... 답이 없다 사실. 어쩔 수 없지.
    • 레거시 프로젝트이기 때문에 그나마 연동하며 주고 받는 정보 자체는 어느정도 정해진 수준이고,
      평소 연동 대상의 스펙을 좀 더 잘 파악하고 파악해둔다면. 사실상의 큰 문제는 거의 일어나지 않는다.. 는 타협
    • 구구절절 원본 파악의 필요성을 강조하긴 했지만, 약간 내려놓으면 사실 넘어갈 수 있는 영역으로 볼 수도 있다. ...

위와 같이 입장으로 껄끄러운 점들을 넘어가고 사용 중이고, 다행히 괜한(?) 걱정에 비해 아직까지 큰 문제는 없다.

다만 그냥 넘어가지 못했던 하나

우선 RestTemplate 이 실 연동 컴포넌트들로부터 응답 결과를 받은 뒤 개발자가 지정한 타입으로 전환되는 과정을 대략 살펴보면 다음과 같다.

  1. RestTemplate 은 응답을 받은 뒤 등록된 HttpMessageConverter 들에 의해 개발자가 지정한 타입 클래스로 body 를 변환시킨다.
  2. 등록된 각 컨버터들은 자신이 HttpMessageConverterExtracter 에 의해 돌아가면서 canRead 메소드를 호출받으며,
    이 body 를 파싱할 수 있는지 없는지에 대한 여부를 body 의 InputStream 내용을 까보기 전에 미리 판단하여 boolean 값을 리턴한다.
    • canRead 는 응답 헤더에서 전달된 ContentType 을 기반으로 1차적으로 내가 변환하고자 하는 body 가 맞는지 판단하고
      • MappingJackson2HttpMessageConverter 는 생성자에서 application/json 만을 파싱 가능한 content type 으로 지정하며,
      • StringHttpMessageConverter 는 모든 타입을 파싱 가능하다고 생성자에서 지정하고 있다.
    • 지원하려는 content-type 이 맞더라도, 내가 변환 생성할 수 있는 타입 클래스인지를 판단한다.
      • MappingJackson2HttpMessageConverter 는 Jackson ObjectMapper.canDeserialize 를 활용하고,
      • StringHttpMessageConverter 는 String 이 아니면 무조건 false
  3. 등록된 모든 컨버터가 모두 변환할수 없다고 판단하면, HttpMessageConverterExtracterRestClientException 을 날린다.

그냥 넘어가지 못했던 부분은 마지막 3번 부분이다.

  • 변환할 능력이 있는 컨버터를 찾지 못했다고 해서 4xx, 5xx 의 응답도 아닌
    2xx 의 응답을 받은 결과 처리 중 RestClientException 을 날려버리는 것.
  • 400, 500 에 대응하는 HttpClientErrorException 이나 HttpServerErrorException 은 내부에 응답 결과 정보들을 보존하는 것에 반해
    RestClientException 은 예외를 던지며 참고로 적어두는 '해석가능한 컨버터가 없다' 라는 코멘트 외엔 그 응답 결과에 대한 어떤 단서도 제공하지 않는다.
  • 이건 위에서 껄끄러운 부분으로 언급했던 부분 순수 응답 body 파악의 필요성과 매우 직접적으로 맞닿는다.
  • 정상 응답에 대한 파싱 실패라는 어플리케이션의 한계로 인해 발생한 명백하고 심각할 수 있는(!) 예외를 트래킹하기 위한 단서가 부족해진다는 것이다.

이 문제가 맞닥들여질 상황은 대충 다음과 같은 상황을 예상할 수 있는데,

  1. 서버가 application/json 으로 응답을 주기로 약속된 연동에서, 서버 오류로 인해 갑자기 text/plain 으로 에러 페이지를 출력한다면..
    얄궂게도 응답은 200. (500이면 다행히 본문을 확인할 수 있는 HttpServerErrorException 이 발생한다.
    • 아니면 200 상태 코드에 body 도 정해진 규격으로 왔는데, 헤더에 content-type 정보가 빠진다던가. 아래 처럼..
  2. 애초에 호출 서버 (호출을 하는) 개발자가 개발 스펙을 잘못 이해해서 변환될 수 없는 타입 클래스를 RestTemplate 에 지정한 경우이거나
  3. 혹은 개발자의 부주의로 적당한 HttpMessageConverter 를 지정하지 못한 경우
    (인자 없이 생성자를 호출하면 사실 대부분의 상황에서 파싱할 수 있는 컨버터들을 RestTemplate 이 알아서 챙기긴 하지만)

위 세가지 모두 운영상 흔한 경우는 아니긴 하지만, 또 절대 맞닥들이지 않는 상황은 아니다. (1번에 예시로 첨부된 연동 대상은 무려 애플이다)

특히 2, 3 번은 사실상 개발 단계에서 발생할 상황이니 운영적으로 치명적 문제는 아니지만,
개발 중에 안풀리고 왜 그런지 파악하기 힘들면 그건 그것대로 힘드니까.

그래서 문의

  • 어느정도 마음 가짐의 타협을 하고 RestTemplate 을 쓰려했음에도 보다보니 보인 위 문제만은 그냥 넘어갈 수 없었다.
  • 그래서 처음엔 CustomRestTemplate .. 으로 RestTemplate 상속 구현 클래스를 만들까 싶었는데,
    그럼 사실상 아무런 이점도 남지 않는데다, 일은 복잡해지고 시간은 없어지겠다 싶었다.
  • 사실 저 문제를 고치는 코드는 매우 간단하다.
    정말 간단하게는 HttpMessageConverterExtractor 에만 코드 몇줄 3~4 줄 추가하거나,
    굳이 좀 더 그럴듯하게 하려면 추가 Exception 을 정의한 뒤 추가한 3~4 줄의 코드에서 새로 추가한 Exception 으로 날리면 된다.

그래서 spring-framework github 에 다음과 같이 이슈를 올렸다.
https://github.com/spring-projects/spring-framework/issues/24964

  • 오픈 프로젝트에 이런 이슈를 올린적이 첨이라 매우 떨렸고,
  • 그래서 구글 번역기의 도움을 받아 매우 장황한 문장을 써서 올렸는데..
  • 문장이 장황해서 문제였는지, 처음엔 메인 컨트리뷰터가 의도를 제대로 이해하지 못하고 야 그거 의도된거임 하고 이슈를 닫아버렸다.
  • 그래서 문장은 간략하게, 의도한 상황과 해결의 코드 예시를 적용한 테스트 코드를 첨부해 보여주니,
  • 바로 의도를 이해했고, pull request 를 보내도 된다고 확인도 받았다.

하지만 그 pull request 가 merge 되는 일은 없었다..

  • 시덥잖은(?) 코드 몇줄로 spring framework 에 기여할 수 있게 됐다는 사실에 매우 신난 상태로 pull request 를 날릴 코드를 작성하고 있었는데,
  • 코드를 작성하다보니 issue 에서 제기된 사항 뿐만이 아닌 제기한 의도를 약간 확장해서,
    converter 가 body 를 파싱하는 중 IOException 이 발생하면 그대로 부족한 정보를 가진 RestClientException 이 날아가는 상황도
    응답 시 받은 Header 들의 보존하는 코드를 덧붙여 pull request 를 날렸다가 일단 거부 당했다.

https://github.com/spring-projects/spring-framework/pull/24994

  • 이유는 간단한데,

    • body 가 변환되는 중 IOException 이 발생하면 어차피 추가적으로 할 수 있는게 없다.
    • 어차피 body stream 는 날아가고 정작 가장 의미있는 정보는 어차피 보존되지 못하므로 나머지를 보존하는 것이 큰 의미 없어 보인다는 의견
  • 개인적으로는 이 부분에 100% 동의하고 싶지 않았지만, body 가 가장 중요하다는 것은 사실이고 동의하기에..

    • 사실 그래서 변환 중 InputStream 이 날아갈 것을 대비해 미리 캐싱 래핑하는 것을 건의하고 싶었지만..
      (ContentCachingRequestWrapper 같은 성격의.. 사실 ContentCachingRequestWrapper 도 좀 불만이다! 완전 후처리만 고려했잔아!)
  • 또한 일단 영어로 블라블라 하기 무서웠고?

  • 무엇보다 이슈에 대한 논의가 오고 간 메인 컨트리뷰터의 의견이기에 스프링이 가진 그정도 심플함의 컨셉은 존중하는게 더 맞을 것 같았다.

  • 그래서 말없이 추가했던 코드를 원복하고,
    테스트 코드를 작성하던 중 뭔가 깨진 InputStream 이 발생한 케이스를 고려하고 싶었는데,
    깨진 InputStream 을 발생시킬 적당한 방법을 찾지 못하고 잠깐 홀드되다가 정신줄 놓고 이 건에 대해 며칠간 잊어 버리고 말았다. ...

  • 그리고 며칠 후 컨트리뷰터가 기다리다가 지쳤는지, 스스로 논의됐던 코드를 구현해버리고 의견줘서 고맙다는 코멘트와 함께 이슈를 종료했다.

  • 컨트리뷰터의 의견을 확인하고 보고 코드를 고치는 것을 우선 생각하다보니 그에 대응하는 답변을 제 때 달지 못한 것이 큰 이유가 된 것 같다.

    • 한마디로 밉보인거 아닐까.. =_=
  • ...그렇게 나의 첫 pull request 시도는 허무하게 끝났다. 스프링인데.. 심지어 동의까지 받은 건을..

  • 여기서 참고해야 할 교훈(?)은..

    • 오픈소스 기여를 시도하고자 한다면 의견이 오고가는 작업이 빠르게 대응해야 할 것이며
    • 어쩌면 작업이야 내용에 따라 작업 양이 달라질 수 있으니 일단 커뮤니케이션이라도 빨리 잘 대응 해야 한다는 것.
    • 돈받고 하는 작업이 아니라고 살짝 정신줄 놓으면 안된다. ...!!

컨트리뷰터는 어떻게 구현했나 살펴보기

  • pull request 실패는 내가 엉기적대다가 생긴 문제니까.. 어쩔 수 없는 문제고.
  • 어쨌든 원했던 기능적 결과는 얻었다. 마일스톤은 5.2.7. 4.x 버전까지 백포트가 될지는 잘 모르겠다.
  • 여튼 구현 중 고민 포인트가 몇가지 있었기에, 컨트리뷰터는 어떻게 구현을 했는지 좀 살펴봤다.

https://github.com/spring-projects/spring-framework/commit/aa97563853535d5fea773e18980a8ac964d3e618

  • 보다보니 살짝 의아한 부분이 한 곳 있는데, 바로 이 부분이다.
private static byte[] getResponseBody(ClientHttpResponse response) {
    try {
        return FileCopyUtils.copyToByteArray(response.getBody()); <-- 여기도.. spring 내장 StremUtils 를 써도 되는데.. 굳이 FileCopy?
    }
    catch (IOException ex) {
        // ignore <--- 바로 여기
    }
    return new byte[0];
}
  • 컨버터를 찾지 못해도 Body 정보를 유지하기 위해 InputStream 을 byte[] 로 변환하는 과정이 있다.

  • 당연히 그 변환 과정에서 IOException 이 발생할 수 있다. stream 핸들링이니까 필수적으로 발생하는 상황.

  • 그렇게 발생 가능한 IOException 을 아무런 핸들링 없이 그냥 ignore 해버린다.

    • ... 음.. 스프링 정도의 소스에서도 저런 코드가 들어가네? ;
    • 관점에 따라서 크게 중요하지 않은 사항이니까 간단히 처리해버릴 수도 있다고 생각은 하지만.. 음.. 그래도 좀 찝찝함이 드는 방법이다.
  • 여기서 발생할 수 있는 IOException 을 어떻게 넘기느냐가 사실 내 구현 과정에서 고민 사항 중 하나였다.

    • 신규로 추가된 UnknownContentTypeExceptionRestClientException 을 상속받고 있지만,
      내가 구현하던 중에는 RestClientResponseException 을 상속 받았고,
      RestClientResponseException 은 응답 정보에 유지를 위한 와꾸가 갖춰져 있는 장점이 있지만,
      조상 클래스인 RestClientException 이 가진 Throwable 을 인자로 받는 생성자에 대응하는 생성자가 존재하지 않았다.
    • 해서 추가 예외 클래스를 정의하면서 발생할 IOException
      할아버지에게 넘기기 위해 부모 클래스에 없던 새로운 생성자를 추가하는 것이 조금 부담스러웠다.
    • 부담스러웠음에도 결국 그렇게 구현했는데, 발생한 IOException 을 root cause 로 전달하는 것이 더 의미있다고 생각했기에
  • 하물며 컨트리뷰터가 추가한 UnknownContentTypeException
    Throable 을 인자로 받는 생성자가 있는 할아버지 ( RestClientException ) 를 상속 받았음에도 굳이 저런 ignore 주석 코드로 구현했는지 잘 모르겠다.

    • 위에도 언급했듯이 별거 아니니까.. 정도의 해석 밖에 되질 않는데.. ...찝찝하다.
  • 그래서 사실 이 의아함을 코멘트로 정말 물어보고 싶었는데,
    반응이 늦어서 컨트리뷰터가 직접 구현하게 만든 주제(?)에 꼬치고치 이런 사항들을 물어보기가 어렵더라.

    • merge 되지 못한 것도 속상한데, 물어보지도 못했다. ..
      평소에 잘해야 한다는 (사실 평소랄 것도 없는 관계임에도) 만고불변의 진리를 다시 느끼면서..

'개발 로그' 카테고리의 다른 글

Spring RestTemplate 살짝 뜯어 본 이야기  (0) 2020.05.16

문제 정보

문제

풀이

  • 호수가 1 이면 값은 무조건 1
  • 층수가 0 이면 값은 무조건 호수값
  • 그외 층, 호수는 아래층 동일 호수 의 값 + 동일 층 이전 호수의 값
    • 재귀 함수 = f(x,y) = f(x-1, y) + f(x, y-1)

보완

  • 재귀 함수의 반복 깊이가 깊어질수록 효율이 떨어진다. 당연하게도..
    • 재귀가 무한으로 깊게 들어가기 전에 값을 쪼갤 수(?) 있는 규칙이 있는지
      꽤 오랫동안 들여봤지만 못찾겠다. ...
  • 재귀로 한번 계산된 값은 두번 계산되지 않도록 메모리에 저장 (캐싱)
  • 문제는 입력 범위가 비교적 작은 범위로 제한되어 있으니 이중 고정 배열을 쓸 수 있지만
    범위가 일정치 않다고 제한을 풀면 범위에 동적으로 대응할 수 있는 Map 이 좋을 듯
    • Map 을 사용할 시 floor 와 number 로 구성된 데이터 클래스를 만들어
      객체의 hashCode() 를 사용하려 했으나, 일정한 값이 보장되지 않았음
    • 편법으로 hashCode 를 override 후 일정한 해시코드 보장됨
1
18
18
8597496600
## i : 0, Recursive Execute Time : 13160 -- 재귀호출 매번 반복
8597496600
## i : 0, RecursiveWithCacheArray Execute Time : 0 -- Array Cache
8597496600
## i : 0, RecursiveWithCacheMap1 Execute Time : 14 - Map Cache

주의사항

  • Map 의 putIfAbsent 는 Map 에 Key 가 있건 없건 없을 경우 넣을 값을 미리 계산한 뒤 key 가 없으면 넣는다.
    • cache 의 이미가 없음 (cache 확인 전 무조건 재귀 계산을 해버리니까)
    • 오히려 더 성능이 떨어지게 됨
    • Optional 의 orElse 도 마찬가지..
    • 현실 코드에서 단순 로직에 이와 같은 동작을 하는건 성능상 큰 문제가 없겠지만 이건 재귀 동작이니 문제가 됨
  • computeIfAbsent 는 HashMap 을 사용할 경우 ConcurrentModificationException 가 일어나고
    ConcurrentHashMap 을 사용할 경우 recursive 오류가 발생함.

코드

import java.util.HashMap;
import java.util.Map;
import java.util.Scanner;

public class Main {

    private static Map<Integer, Long> cache = new HashMap<>();

    private static long[][] arr = new long[25][25];

    public static long recursiveWithCacheArray(int floor, int number) {
        if (number == 1)
            return 1;

        if (floor == 0)
            return number;

        if (arr[floor][number] == 0) {
            arr[floor][number] = recursiveWithCacheArray(floor, number - 1) + recursiveWithCacheArray(floor - 1, number);
        }

        return arr[floor][number];
    }

    public static long recursiveWithCacheMap(int floor, int number) {
        if (number == 1)
            return 1;

        if (floor == 0)
            return number;

        Couple couple = new Couple(floor, number);
        if (cache.containsKey(couple.hashCode())) {
            return cache.get(couple.hashCode());
        } else {
            long value = recursiveWithCacheMap(floor, number - 1) + recursiveWithCacheMap(floor - 1, number);
            cache.put(couple.hashCode(), value);
            return value;
        }
    }

    public static long recursive(int floor, int number) {
        if (number == 1)
            return 1;

        if (floor == 0)
            return number;

        return recursive(floor, number - 1) + recursive(floor - 1, number);
    }

    public static void main(String[] args) {

        Scanner scanner = new Scanner(System.in);
        int count = Integer.parseInt(scanner.nextLine());

        for (int i = 0; i < count; i++) {

            int floor = Integer.parseInt(scanner.nextLine());
            int number = Integer.parseInt(scanner.nextLine());

            long start = System.currentTimeMillis();
            System.out.println(recursive(floor, number));
            System.out.println("## i : " + i + ", Recursive Execute Time : " + (System.currentTimeMillis() - start));

            long start2 = System.currentTimeMillis();
            System.out.println(recursiveWithCacheArray(floor, number));
            System.out.println("## i : " + i + ", RecursiveWithCacheArray Execute Time : " + (System.currentTimeMillis() - start2));

            long start3 = System.currentTimeMillis();
            System.out.println(recursiveWithCacheMap(floor, number));
            System.out.println("## i : " + i + ", RecursiveWithCacheMap1 Execute Time : " + (System.currentTimeMillis() - start3));
        }

        System.out.println(cache.size());
    }

    public static class Couple {
        private int floor;
        private int number;

        public Couple(int floor, int number) {
            this.floor = floor;
            this.number = number;
        }

        public String toString() {
            return "# Floor : " + floor + ", # Number : " + number;
        }

        public int hashCode() {
            return ("F" + floor + "N" + number).hashCode();
        }

    }

}

문제 정보

문제

풀이

  • 출구로부터 가까운 거리 기준으로는 저층부터 고층까지 1번방부터 예약하고.
    각 층의 1번방이 다 차면 2번방, 3번방 차례로 체워나감
  • 모든 층의 N 번째 호수가 체워져야 다음 N+1 번 호수가 체워진다.
  • (차례 / 층수) = N
  • 나눠진 몫 만큼 호수가 다 체워진 것을 의미
    나머지가 있다면 전체 층의 해당 호수가 체워진 것이므로 몫 +1 = 호수
    나머지가 없다면 몫 = 호수
  • 나머지가 없다면, 다음 호수까지 갈 필요가 없으므로, 최상층을 의미 = 층수
    나머지가 있다면, 나머지 값이 현재 채워야할 호수의 층

코드

import java.text.DecimalFormat;
import java.util.Scanner;

public class Main {

    private static final DecimalFormat decimalFormat = new DecimalFormat("00");

    public static String solve(int height, int width, int number) {

        int divided = number / height;
        int rest = number % height; // 층

        int targetHeight = rest == 0 ? height : rest;
        int targetWidth = divided + (rest == 0 ? 0 : 1);

        return targetHeight + decimalFormat.format(targetWidth);

    }

    public static void main(String[] args) {
        // 6 12 10
        // 30 50 72
        Scanner scanner = new Scanner(System.in);
        int count = Integer.parseInt(scanner.nextLine());

        for (int i = 0 ; i < count; i++) {
            String[] test = scanner.nextLine().split("\\s");
            System.out.println(solve(Integer.parseInt(test[0]), Integer.parseInt(test[1]), Integer.parseInt(test[2])));
        }

//        int height = 30;
//        int width = 50;
//        int number = 72;
    }
}

문제 정보

문제

풀이

  • 마지막 전일 까지의 평일 이동 거리 (M) = 낮동안 올라간 거리 (A) - 미끄러진 거리만큼 이동 (B) = M
  • 마지막 날은 낮동안 모두 다 올라서므로 무조건 미끄러지는 일이 없다.
    • 그러므로 평일 이동해야하는 거리는 전체 거리 (T) 중 낮동안 올라가는 거리 (A) 를 제외 = t
  • 평일 이동 이동해야 하는 거리에서 평일 이동 거리를 나누면 평일 이동 일수가 구해짐 = t / M
    • 단, 나눈 나머지가 0 이면 나누기 몫 그대로,
    • 0 이 넘으면 몫 + 1 을 하면 실제 이동한 평일 수
  • 구해진 평일 일수 + 마지막 일을 하루 더하면 최종 이동 일수

코드

  • Java 에서 Input 을 Stream 으로 Integer 변환 처리하니까 속도 문제로 실패 처리 됨. 그 얼마되지도 않는 시간이..

import java.util.Scanner;

public class Main {
    public static int solve(int forward, int back, int instance) {
        int cha = forward - back;
        int lastInstance = instance - forward;
        int divided = lastInstance / cha;
        int rest = lastInstance % cha;
        return rest > 0 ? divided + 2 : divided + 1;
    }

    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        String[] input = scanner.nextLine().split("\\s");
        //Arrays.stream().mapToInt(Integer::parseInt).toArray();

        int forward = Integer.parseInt(input[0]);
        int back = Integer.parseInt(input[1]);
        int instance = Integer.parseInt(input[2]);

        System.out.println(solve(forward, back, instance));
    }
}

문제 정보

문제

풀이

  • 출발점 1/1 부터 이동하는 경로는 요약하면 1 -> 2 -> 3 -> 4 로 확장되는 패턴을 가진다.
  • 1 -> 2 -> 3 -> 4 확장 중 가로는 분자가 확장된 숫자로 표현되고, 세로는 분모로 확장된 숫자가 표현된다. 확장된 숫자 = 라인 번호
  • 라인 번호가 짝수면 가로에서 세로 방향으로 내려오고,
    라인 번호가 홀수면 세로에서 가로 방향으로 올라가며 이동한다.
  • 확장된 라인 번호 까지의 모든 칸의 숫자는 라인 번호까지의 모든 라인 숫자의 합
    • ex. 4번 라인까지의 모든 칸 수는. 1 + 2 + 3 + 4 = 10 칸.
    • 이를 재귀 함수화하면 f = f(x-1) + x
  • 1부터.. n 까지 라인 번호 칸수의 합 N 을 구하다가 N 이 입력된 이동 값 X 보다 커지면
    n 은 이동이 마무리되는 현재 라인 번호이고,
    X - (n-1 라인 의 칸 수) 는 현재 라인 첫재칸에서부터 이동해야 할 횟수 가 된다.
  • 라인 홀짝 여부에 따라 분모 or 분자를 라인 번호로 설정하고 조건에 따라 분모 분자를 더하거나 빼면서 이동

재귀 보완

  • 재귀함수 f =f(x-1) + x 를 그대로 사용하면 1부터 입력값까지 전부 계산이 들어가 비효율적이다.
  • 입력값이 양수일 경우 (1 + 입력값) x (입력값 / 2) 가 결국 1~n 까지의 합이되므로, 입력값이 커질수록 효율적이 될 것
    • ex. 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 10 = 55.
    • (1 + 11) * (10 / 2) = 55

예시

  • 이동 값 X = 13
  • 1 + 2 + 3 + 4 + 5 = 15. 라인 번호는 5
  • 15 - 13 = 2 = 5번 라인에서 이동해야 할 횟수
  • 5/1 -> 4/2 (1) -> 3/3 (2) - > 3/3

코드

import java.util.Scanner;

public class Main {


    public static int sumOfNumber(int input) {
        if (input <= 1)
            return input;

        if (input % 2 == 0)
            return (input + 1) * (input / 2);

        return sumOfNumber(input - 1) + input;
    }

    public static String solve(int input) {

        int prevSum = 1;
        int currentSum = 1;
        int curIt = 0;

        for (int i = 0; currentSum < input; i++) {
            curIt = i;
            prevSum = currentSum;
            currentSum = sumOfNumber(i);
        }

        int minusValue = input - prevSum;
        boolean isOdd = curIt % 2 == 0;

        int child = isOdd ? 0 : curIt + 1;
        int mother = isOdd ? curIt + 1 : 0;

        for (int i = 1; i <= minusValue; i++) {
            if (isOdd) {
                mother--;
                child++;
            } else {
                mother++;
                child--;
            }
        }

        return child + "/" + mother;
    }


    public static void main(String[] args) {
        Scanner scanner = new Scanner(System.in);
        int input = scanner.nextInt();
        System.out.println(solve(input));
    }
}

+ Recent posts