Rest Docs를 만들기 위해 빌드를 하는 도중 일어난 오류이다.

 

문제의 발단

 

./gradlew build 오류

 팀원이 Rest docs를 만들기 위해 ./gradlew build를 하는 도중 오류가 떴다. 웬만하면 각자 해결했겠지만 이번에는 집단지성이 필요했는데 인텔리제이에서는 잘 돌아갔는데 gradle로 빌드를 하면 오류가 떴기 때문이다. 심지어 오류 메세지도 아리송하다.

 400 에러는 Bad Request, 즉 서버에서 요구하는 Request에 맞지 않는 요청을 보냈다는 뜻이다. Postman으로 요청을 했다면 모를까 테스트에서는 직접 객체를 만들어 MockMvc.perform안에 넣었기 때문에 틀릴리가 없을텐데...? 일단 이 오류메세지 만으로는 알 수 없으니 인텔리제이에서 직접 확인해보도록 하자.

 

 

해결 과정

 

1. 인텔리제이에서 테스트 속성을 gradle로 하고 실행하기

인텔리제이 Settings

 프로젝트를 처음 시작할때 Build and run 속성을 Default인 Gradle로 해놓으면 느리기 때문에 두 속성을 "IntelliJ IDEA"로 바꿔놓은 경험이 있을 것이다. 나는 인프런의 김영한님 강의를 들으면서 어느순간 바꿔놓은 것 같다. 그리고 까먹고 있었다가 이 오류를 계기로 기억이 나서 바꿔보았다.

 

 

2. 테스트 돌리고 로그확인

gradle test 오류

아래쪽 오류로그에서 오류가 나는 곳으로 이동했다. 이때 런타임 로그를 보면 마지막에 WARN이 뜬 것을 볼 수 있다.

에러 로그

request 객체를 ObjectMapper를 이용해 바꾸는 과정에서 에러가 나는 것 같다. 

 

 

3. 인코딩 오류

인코딩 오류 로그

음.. 인코딩 어쩌구저쩌구 뭐라그러고 UTF-8 인코딩으로 읽으려고 하는데 알 수 없는 문장이라고 한다. 그렇다면 request가 UTF-8 형식으로 잘 바뀌지 않았나보다. 

 

 

4. 해결방법

 인코딩을 적용해주면 되는 문제라 여러가지로 풀 수 있었다.

 

4-1. getBytes()안에 UTF-8 명시

UTF-8 명시

 ObjectMapper쪽 문제인걸 인지하고 함수들을 살펴보다가 getBytes에 인자를 줄 수 있다는 것을 알게 되었다.

 

getBytes 설명

 java.nio.charset에 있는 클래스를 넣을 수 있다는 것이다. 저 패키지안에 있는 클래스들 중 StandardCharsets의 UTF-8 속성을 추가해주니 통과가 되었다.

 

 

4-2. writeValueAsBytes로 바꾸기

writeValueAsBytes

writeValueAsString.getBytes() -> writeValueAsBytes로 바꾸니 통과되었다.

writeValueAsBytes 상세 설명

writeValueAsBytes에 Ctrl를 누른 상태로 마우스를 클릭하면 함수 안의 내용이 나온다. 보면 JsonEncoding.UTF8로 기본설정을 하고 시작하는 것을 볼 수 있다.

 

 

4-3. gradle.properties 추가

gradle.properties 추가

gradle.properties 파일을 만들어 안에 인코딩 정보를 추가하였다.

그랬더니 아까 안됐던 writeAsValueAsString().getBytes()가 통과되는 것을 확인할 수 있다.

 

 

 

그렇다면 왜 이런 문제가 일어났을까?

 

 사실 여기가 메인이다. 이 오류가 일어났을 당시엔 로그를 보고 구글링을 통해 비교적 쉽게 오류를 고치고 밀린 코딩을 했었다. 그리고 돌이켜 생각해보니 평소엔 프로젝트하면서 많은 테스트를 작성했고 이런 인코딩 문제가 없었는데 갑자기 왜 이런 오류가 났을까 궁금했고, 그 이유를 찾아보기로 했다. 

 

1. getBytes()의 default encoding

원래 테스트 코드

 보통 테스트할때 이런식으로 writeValueAsString까지만 한다.

 

MockMultipartFile

하지만 이 테스트는 사진도 같이 받기때문에 Multipartfile 형식의 데이터가 필요했고, 그 요구사항이 byte[]여서 getBytes()를 써야 했던 것이다.

getBytes() 설명

 위에서도 봤던 getBytes()의 설명을 다시 읽어보면 플랫폼의 기본 문자열 집합을 사용한다고 나와있다. 지금이야 gradle.properties에 UTF-8이라고 명시해놨지만 원래는 무엇이었을까?

 

2. platform에 따른 인코딩

 

일단 platform이란 무엇일까? 말그대로 테스트를 돌리는 주체이다. 아까 Intellij Settings에서 "Run tests Using" 속성을 gradle로 했던것을 기억할 것이다. gradle.properties를 놔둔 상태에서 이 속성을 "Intellij IDEA"로 바꾸고 File Encodings을 모두 default(나는 windows를 사용하기 때문에 x-windows-949)로 바꿔보자. 그렇게 하면 테스트는 실패하고, 다시 "Gradle"로 바꾸면 성공한다. Gradle은 gradle.properties가 있기 때문이다. 길어질까봐 사진은 생략하고 어쨋든 지금은 테스트를 돌리는 주체는 뭐다? Gradle이다. 그렇다면 Gradle의 기본 인코딩을 찾아보자.

 

gradle forum 글

 

Gradle Forum이라는 곳에 올라온 글의 답글이다. Gradle Employee라고 되어있어서 들어가서 살펴보니 같은 집단에 Core Dev라는 직책도 있고 연관 링크에 Gradle 공홈이 걸려있는 것으로 보니 찐 Gradle 개발자들인 것 같다. 아무튼 설명을 보자면 Gradle은 JVMs platform의 인코딩을 따른다고 되어있다. 흠... 또 플랫폼?

 

https://discuss.gradle.org/t/is-there-a-way-to-tell-gradle-to-read-gradle-build-scripts-using-a-specified-encoding/7535

 

Is there a way to tell Gradle to read .gradle build scripts using a specified encoding?

Gradle seems to read .gradle files using UTF-8. However my build script was edited with ISO-8859-1 charset. So, I want to tell Gradle to read the build.gradle using ISO-8859-1 instead of UTF-8. How can I do that? I already tried to add the following to gra

discuss.gradle.org

 

3. Gradle의 기본 인코딩

JVM의 기본 인코딩

 

 이번엔 스택오버플로우를 참고해보자. 이 답변에서는 JVM은 돌아가는 시스템의 문자집합을 따라간다고 한다. 결국엔 Gradle -> JVM -> OS 순으로 참조하는 것이고 즉 OS의 기본 인코딩을 따라간다는 것이다. 그리고 내가 쓰는 windows의 기본 인코딩? MS949이다. 눈으로 확인할 길이 있을까?

 

JVM 설정 확인 방법

 

정말정말 친절하게도 답글을 내리다 보니 cmd창에 볼수 있는 명령어도 제시해주었다. 바로 쳐보자.

JVM 설정 CMD 화면

 file encoding을 MS949로 한다고 명시적으로 나와있다. 파고파서 결국엔 해답을 얻었다.

 

https://stackoverflow.com/questions/1006276/what-is-the-default-encoding-of-the-jvm

 

What is the default encoding of the JVM?

Is UTF-8 the default encoding in Java? If not, how can I know which encoding is used by default?

stackoverflow.com

정리

  1. Gradle로 테스트 돌릴때 인코딩 문제가 생김
  2. UTF-8로 변환할 수 없다는 내용이 주된 내용
  3. Gralde의 default charset은 JVM의 default charset을 따르고 JVM은 OS의 default charset을 따름
  4. 따라서 현재 OS(Windows)의 default charset인 MS949를 사용하고 있어서 에러가 생긴것임
  5. 인코딩 설정을 UTF-8로 바꾸니 해결됨

 이게 끝이라고 생각할 수도 있지만 사실 근본적인 궁금증이 하나 더 있긴하다. 오류 메세지를 보면 HttpMessageConverter에서 UTF-8로 변환할때 문제가 생긴 것 같은데 이 변환 과정에 대한 이해도 부족한 것이다.. 그래서 궁금해서 찾아봤는데 그 내용을 쓰려면 이만한 글을 하나 더 써야될 것 같다. 요약해서 말하자면 HttpMessageConverter는 @ResponseBody같은게 붙은 데이터를 자동으로 바꿔주는 것인데 String은 UTF-8로 인코딩해준다고 한다. 근데 테스트에서writeValueAsString으로 바뀐 request 객체를 getBytes()를 거치며 MS949형식의 Bytes로 바꿔버렸고 그걸 읽으려고 보니까 인코딩 형식이 서로 맞지않아 읽지 못하는 것이다.  

 

 일단은 이렇게 마무리한다. 저 내용도 중요한거라 공부하고 있는데 이 글과는 거리가 조금 있기도 하고 너무 길어지기도 해서.. 사실은 뇌가 터질 것 같기도 해서 그렇다. 아직 내가 안본 김영한님 강의에도 있는 내용인 것 같아서 슬슬 강의도 다시 듣기 시작해야겠다.

'공..부 > 프로젝트' 카테고리의 다른 글

Jenkins를 구축하다 Linux를 배우다  (0) 2023.09.26

 Jenkins를 이용한 CI/CD 서버를 구축하던 중에 겪은 어려움이었다. 결론은 Linux기반 운영체제에 대한 이해가 부족해서 생긴 문제였다. 운이 좋았다면 금방 끝났었을 수도 있는 문제였지만 돌고돌아 결국 서버 구축에 성공하는데에는 3명이서 5시간이 걸리고 말았다 ..

 

사건의 계기

 

CI과정에서 생긴 오류

 블로그들을 참고하며 진행해서 큰 어려움이 없었는데,, 바로 난관에 부딪혀 버렸다. 깃허브에 웹훅을 걸어 잘 오는 것이 확인된 후 Build를 하는데 0.7초만에 입구컷을 당해버린 것이었다! 앞으로 일어날 일도 모르고 천천히 원인을 좁혀가보기로 했다. 

 

 

왜 안될까?

 

1. 파일 자체문제 또는 gradle문제?

 

 일단 취합을 수동으로 한 후 였으므로 로컬로 돌려봤다. 결과는 잘됐고 사실은 여기까진 예상했다. 그렇다면 다른 점은 환경(로컬은 Windows, Jenkins는 ubuntu)이기에 여기서 생긴 문제리라 추측했었다.

 

 

2. 혹시나 Jenkins에서만 안되나?

 

 ubuntu에서 잘되면 Jenkins 설정 문제가 맞기에 find / -name '프로젝트명'으로 경로를 찾은 후 직접 들어가서 명령어를 실행시켜보았다. 여기서 우린 매우매우매우 큰 실수를 저질렀는데 "똑같이" 실험해보지 않은 것이었다. 처음에는 ./gradlew로 실행해서 permission denied가 나왔었다. 그래서 chmod -x ./gradlew를 실행 후 build해봤는데 되는 것이었다! 하지만 위 사진에서도 봤듯이 Jenkins에서는 sudo ./gradlew로 되어있다.

 '그냥 ./gradlew로도 되는데 sudo는 당연히 되는 거지'라는 내재된 생각 속에 우린 "아 그렇다면 이건 Jenkins문제다" 라는 결론을 내려버리고 만 것이었다.................

 

 

3. 에러 메세지 구글링

 

 언제나 그랬듯 무지성으로 에러 메세지를 복붙해서 구글링을 갈겨보았다. Jenkins에서 JAVA_HOME 변수가 세팅되지 않았다는 것으로 이해하고 세팅하는 법을 찾아보았다.

 

  1. cmd에서 직접 추가하기(export JAVA_HOME=...)
  2. Jenkins 설정에서 설정하기 

Jenkins 관리 화면

여러 방법으로 JAVA_HOME을 덕지덕지 붙여보았는데 여전히 되지 않았다. 이쯤에서 시간이 많이 소요되어 각자도생으로 각자 블로그들 찾아보고 각자 한번씩 실험해보기 시작했다. 여기서 살짝 우릴 멘붕으로 이끌었던 것은

??? 이건 왜 잘나옴??????

그렇다면 $JAVA_HOME은 잘 지정되었단 뜻이니 쉘 스크립트(gradlew)에서 문제점을 찾아봐야할 것 같다.

 

 

4. 에러 메세지의 출처를 따져보자

gradlew 파일

에러메세지는 찾았는데 단순한 if문이라 뭔가를 도출해낼 수 없었다. 그러던 중

맨 위에 #!/bin/sh를 보았다. 이건 쉘 스크립트를 실행할 때 지정한 쉘을 사용한다는 뜻이었는데 우리는 ubuntu를 사용해서 기본 쉘이 dash로 된다. 이때 번뜩 떠오른 생각이 있었다.

 

 Jenkins를 만들때 ubuntu->docker(ubuntu)->Jenkins로 만들었다. 2번을 시도할 때 docker 내부로 접근하기 위해

docker exec -it jenkins /bin/bash

 이 명령어를 사용했는데 /bin/bash를 실행하는 것을 볼 수 있다. bash와 dash는 기능의 차이가 있어서 bash에선 되는데 dash에선 안되는 것들이 있다는 글을 보아서 설마 bash에서만 돌아가는 것이 아닐까...?? 라는 생각을 하게 되었다.

 

 

5. Shell executable에서 쉘 설정

 

Jenkins 설정 화면

다 왔다고 생각했고 실행해보았는데 드디어 처음으로 초록색 화면들만 보았다.

첫 빌드 성공

이렇게 끝난줄 알았다. 확실한 원인규명을 위해 bash와 dash의 어떤 차이때문에 이런 일이 벌어졌는지 찾기 시작했다. 도중에 에러 메세지를 다시 한번 보려고 Shell executable에서 /bin/bash를 빼고 실행해보았는데...

빌드 성공 log

??? 왜 잘나옴???2

 

다행히 이번엔 왜인지 빨리 찾을 수 있었다. sudo를 지운 분이 자진납세해서 그러고보니 sudo도 지웠다고 하셨기 때문이다. 그래서 sudo를 다시 붙여서 실행해보니

sudo 실패 log

정겨운 에러메세지가 나왔다. 이때는 제발 에러메세지가 나와달라고 기도했던 것 같다. 여기서 적잖은 충격을 받았는데 sudo는 만능이라 무조건 sudo를 붙이면 다 되는줄 알았었고, 실제로 이제까진 다 됐기 때문이다. 그렇다면 sudo를 붙이면 왜 저런 에러가 뜨는 것일까?

 

 

sudo 파헤쳐보기

 

1. sudo의 실행원리

 

sudo는 실행될 때 /etc/sudoers 파일을 보고 기본 설정을 한다.

cat /etc/sudoers

이 때 env_reset이 Defaults로 설정되는데 env_reset은 sudo가 실행되면 몇 가지 환경변수를 제외하고 나머지 환경변수를 env_keep에 정의된 것 빼고는 모두 reset 시킨다고 한다. 그렇기 때문에 사용자가 설치한 경로인 $JAVA_HOME은 사라지게 되는 것이다. 

 

 

2. sudo 사용시 JAVA_HOME 설정 방법

 

2-1. env_keep에 $JAVA_HOME 추가

 

Defaults	env_keep += "JAVA_HOME"

/etc/sudoers파일에 들어가서 한 줄 추가하면 된다. 그러면 env_reset이 되어도 $JAVA_HOME이 유지되게 된다. 이 와중에 sudoers는 vi sudoers 이런식으로 수정하면 안되고 visudo 명령어로 수정해야된다. 그 이유는 동시 접근 제어를 해주고 기본 문법 오류를 잡아주기 때문이라고 한다.

 

2-2. sudo -E

 

-E 옵션은 존재하는 환경변수를 보존하겠다는 의사를 security policy에 명시하는 것이다. 이것도 user에 따라 권한이 없으면 실행되지 않을 수도 있다는데 우린 어차피 root라 그런지 됐다.

sudo -E 사용시 빌드성공

 

 

마무리

  이유도 알고 원인도 알았으나 그냥 sudo를 빼면 되는 일이라 결국 최종 코드는 ./gradlew clean build였다.

돌아돌아돌아돌아돌아 왔지만 결국엔 해결이 되었다...!

 오후 5시에 모여서 시작한 Jenkins서버 구축하기는 이를 포함한 여러문제와 함께 오후 10시경에 끝났다. 이런게 이론으로는 못배우는 실전인 것 같다. 얼떨결에 여러가지 Linux 운영체제 관한 이해도 갖게 되었고 docker 명령어도 조금 알게되었다. 돌이켜보면 바보같이 디버깅을 했고 다음에도 이런 원인모를 디버깅을 해야할 때는 더 잘할 수 있을거란 자신감도 얻게되었다. 블로그들에서는 Linux에 관련된 것들이 간략하게 나와서 단번에 이해가 안갔는데 linux man page를 참고하라는 글을 보고 가보니 설명이 잘 나와있었다. 이래서 결국 공식문서를 보나보다.. 앞으로 이런 일들을 자주 부딪혀 지식도 늘고 해결능력도 늘었으면 좋겠다 ! !

'공..부 > 프로젝트' 카테고리의 다른 글

Gradle 빌드시 encoding 문제  (0) 2023.11.07

이 문제는 2023 KAKAO BLIND RECRUITMENT 4번째 문제이다.

https://school.programmers.co.kr/learn/courses/30/lessons/150367

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

이게 기억나는게 알고리즘 공부하면서 처음 본 코테였다. 내 기억으로 2022년 9월말인가? 봤었는데 광탈하고

지금에서야 다시 문제를 봤는데 조금 생각이 나면서도 새롭네 ㅎ

 

그때도 문제를 정확히 이해못했는데 지금은 놀랍게도 여전히 이해가 안갔다.

포화 이진트리가 뭔지도 헷갈려서 ㅎㅎ;

주어진 테스트 케이스를 모두 그리고 뚫어지게 쳐다보니 그때서야 이해가 갔다.

역시 노가다가 최고여

 

예제를 바꿔보면 이렇게 됐고, 

되는 것과 안되는 것에 차이는 루트 노드부터 맨 아래 리프노드까지 붕뜨는 노드가 없어야한다는 것이었다.

쉽게 말해 부모 노드가 0인데 자식 노드가 1인 경우가 없어야한다.

 

이걸 판별하기 위해서는 모든 노드를 봐야해서

문자열을 반씩 잘라가면서 진행하고 결과를 다시 합치는 분할정복으로 구현했다.

 

쨋든 알고리즘을 시작하기 전에 선행되야할 것이 하나 있었는데

제시된 숫자를 이진법으로 바꾸는 것이었다. 

위의 예제처럼 7 -> 111, 42 -> 101010 등으로 바꾼다음에 판별하는 것이다.

여러 방법이 있겠지만 심플하게 생각난 2를 나누고 난 나머지로 2진법 문자열을  만들었다.

요거 ㅋ

string to_2(long long num){
    string str;
    while(num/2!=0){
        str.insert(0, to_string(num%2) );
        num/=2;
    }
    str.insert(0, to_string(num));
    
    return str;
}

 

 

코드로 하면 이렇게 된다.

 

근데 여기서! 예제와 차이점을 알아채야 한다.

보면 맨 위 그림에서는 42가 0101010으로 맨 앞에 0이 하나 더 붙어있다.

그렇다. 문제 조건이 포화 이진트리이기 때문에 문자열의 길이는 무조건 1(2¹-1), 3(2²-1), 7(2³-1) ... 으로 가야한다.

따라서 이 길이가 안된다면 추가로 부족한만큼 앞에 0을 붙여주는 작업을 해야한다.

이것을 코드로 하면 이렇게 된다.

string to_2(long long num){
    string str;
    while(num/2!=0){
        str.insert(0, to_string(num%2) );
        num/=2;
    }
    str.insert(0, to_string(num));
    //////////////////////////////////////////부족한 만큼 앞에 0을 추가
    if( log2(str.size()+1)!=(int) log2(str.size()+1) ){
        int n = pow(2, ((int)log2(str.size()+1)) + 1) - 1;
        n-=str.size();
        for(int i=0; i<n; i++){
            str.insert(0, "0");
        }
    }
    ///////////////////////////////////////
    return str;
}

이게 추가한 것인데... 약간 아리송하지만 당장 떠오르는 방법이라 이렇게 구현했다.

1, 3, 7... 은 2ⁿ-1꼴이기 때문에 2진법으로 만든 문자열의 길이에 +1을 하고 log2를 취하면 정수가 나와야한다.

if문에서 이것을 판별하고 n.xxxx꼴이 나온다면 n+1이 될때까지 앞에 0을 붙여줬다.

 

자, 이렇게 하고 나서야 본격적인 알고리즘에 들어갈 수 있다.

알고리즘은 위에서 말한대로 분할정복(divde-conquer: dq)방식으로 하며

자식 노드가 1인데 부모 노드가 0일 경우 -1을 return하고, 정상이면 부모 노드를 return한다.

아래서 한번이라도 -1이 나왔으면 쭉끌고 위로 올라와서 -1을 return 한다.

 

그림으로 하면 대충..뭐,, 이렇게

int dq(string str){
    if(str.size()==1) return str[0] - '0';//1
  
    long long half = str.size()/2;
    int left = dq(str.substr(0, half));//2-1
    int right = dq(str.substr(half+1));//2-2
    if(left==-1 || right==-1) return -1;//3
    if(str[half]=='0' && left+right!=0 ) return -1;//4
    return str[half]- '0';//5
}

//1: 리프 노드면 해당 리프노드를 반환

//2-1: 왼쪽으로 분할

//2-2: 오른쪽으로 분할

//3: 어느 한쪽에서 -1이 나왔으면 -1을 return;

//4: 부모 노드가 0인데 자식노드가 0이 아닐 경우 -1을 return;

//5: 정상적일땐 부모노드의 숫자를 return;

 

요롷게 해서 숫자마다 -1이 나오면 answer벡터에 0을 추가하고

-1이 아닐 경우 1을 추가하면 된다.

 

#include <string>
#include <vector>
#include <iostream>
#include <cmath>

using namespace std;

string to_2(long long num){
    string str;
    while(num/2!=0){
        str.insert(0, to_string(num%2) );
        num/=2;
    }
    str.insert(0, to_string(num));
    
    if( log2(str.size()+1)!=(int) log2(str.size()+1) ){
        int n = pow(2, ((int)log2(str.size()+1)) + 1) - 1;
        n-=str.size();
        for(int i=0; i<n; i++){
            str.insert(0, "0");
        }
    }
    
    return str;
}

int dq(string str){
    if(str.size()==1) return str[0] - '0';
  
    long long half = str.size()/2;
    int left = dq(str.substr(0, half));
    int right = dq(str.substr(half+1));
    if(left==-1 || right==-1) return -1;
    if(str[half]=='0' && left+right!=0 ) return -1;
    return str[half]- '0';
}

vector<int> solution(vector<long long> numbers) {
    vector<int> answer;
    
    for(int i=0; i<numbers.size(); i++){
        string s = to_2(numbers[i]);
        if(dq(s)==-1) answer.push_back(0);
        else answer.push_back(1);
    }
    
    return answer;
}

이렇게 해서 풀코드가 나온다...

 

 이 문제를 푼 소감이라면 사실 설명에 나온대로 순탄하게 풀지 못했다. 예제를 보고 이걸 알아채는 자체도 늦었고, 중간에 return 값을 부모노드가 아니라 자식노드로 해서 정답률이 25퍼밖에 안나오기도 했다. 그런데 이런 문제를 포함해서 적어도 1, 2, 3, 4번 문제는 제한시간안에 다 풀어야 통과라.. 허허 갑자기 자존감이 많이 하락하지만 뭐.. 그래도 저번엔 못풀었는데 이번엔 풀었잖아? ㅎ 이게 어디야 ~

 

개선점

 문자열을 자르지말고 start, end 인덱스 인자를 넘겨서 인덱스로 했다면 시간이 좀더 줄까? 라는 생각을 했지만 문제를 맞췄단 기쁨에 그냥 생각만 하고 넘어갔다. 차이점은 substr을 안쓴다는 점인데, 이게 시간에 얼마나 차이를 줄지는 모르겠다. 사실 시간보단 공간이 줄거같긴하다. 끗

+ Recent posts