windows에서 spring boot native 사용(with native-image, graalvm)

Spring boot native 준비

springboot native를 사용하는 방법은 아래 링크에 아주 친절하게 설명되어 있다.

Spring Native documentation

devtools 종속성 제거

기존 pom.xml에 위 링크에 있는 설정들을 추가 후 package를 하면 아래와 같은 에러가 발생하는 경우가 있다.

Failed to execute goal org.springframework.experimental:spring-aot-maven-plugin:0.9.0:test-generate (test-generate) on project pms: Build failed during Spring AOT test code generation: Could not generate spring.factories source code: Devtools is not supported yet, please remove the related dependency for now. -> [Help 1]

Spring project를 자동으로 생성했다면 dependencies에 아래 종속성이 있을것이다.

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-devtools</artifactId>
  <scope>runtime</scope>
  <optional>true</optional>
</dependency>

위에 있는 devtools종속성을 주석처리나 삭제하면 pacakage가 정상적으로 수행될 것이다.

windows에서 native-image 생성 준비 작업

windows에 graalvm을 설정하는 방법은 아래 링크에 잘 나와 있다.

Installation on Windows Platforms

windows의 native-image는 Visual stuio의 cl컴파일러를 사용하므로 Microsoft Windows SDK가 반드시 필요하다. 위 링크의 마지막 섹션을 참고해서 SDK를 설치하자.

windows에서 native-image 생성

설정 완료 후 mvn -Pnative-image package를 수행 하면 아래와 같은 에러 메시지가 나오는 경우가 있다.

Error: Default native-compiler executable 'cl.exe' not found via environment variable PATH
Error: To prevent native-toolchain checking provide command-line option -H:-CheckToolchain
Error: Use -H:+ReportExceptionStackTraces to print stacktrace of underlying exception
Error: Image build request failed with exit status 1

cl.exe를 PATH에서 찾을 수 없다는 에러다. cl.exe의 path를 잡아야 하는데 “windows 시작”에서 x64 Native Tools Command Prompt로 검색하면 cl.exe와 Microsoft Windows SDK를 사용하는데 필요한 모든 path가 미리 설정된 prompt(cmd)창을 찾을 수 있다. 해당 prompt에서 mvn을 수행해야 한다.

자 이번에는 아까와 같이 cl.exe관련된 에러는 없을 것이다. 하지만 아래와 같은 에러가 발생할 것이다.

Error: Native-image building on Windows currently only supports target architecture: AMD64 ((x64) unsupported)
Error: To prevent native-toolchain checking provide command-line option -H:-CheckToolchain
Error: Use -H:+ReportExceptionStackTraces to print stacktrace of underlying exception
Error: Image build request failed with exit status 1

지원하지 않는 architecture라는 에러가 출력된다. github에서 graalvm 소스코드의 architecture를 validation하는 코드를 찾아보다 아래와 같은 코드를 발견했다.


 /*
 * Some cl.exe print "... Microsoft (R) C/C++ ... ##.##.#####" while others print
 * "...C/C++ ... Microsoft (R) ... ##.##.#####".
 */
 if (scanner.findInLine("Microsoft.*\\(R\\) C/C\\+\\+") == null & scanner.findInLine("C/C\\+\\+.*Microsoft.*\\(R\\)") == null) {
    return null;
}

아마도 cl.exe를 실행해서 나오는 정보를 parsing해서 architecture를 추측하는 모양이다. cl.exe를 한 번 수행해 보면 아래와 같은 정보가 출력 된다.

Microsoft (R) C/C++ 최적화 컴파일러 버전 19.28.29336(x64)
Copyright (c) Microsoft Corporation. All rights reserved.

사용법: cl [ option... ] filename... [ /link linkoption... ]

추측해보면 아마도 locale이 한국이고 한글로된 메시지를 파싱하다 에러가 발생한듯 싶다. 일단 compiler를 validation 하는 부분을 skip하면 정상적으로 동작한다. pom.xml에 추가한 native-image 플러그인에 다음 옵션을 추가하자.

<buildArgs>-H:-CheckToolchain</buildArgs>

아래와 같이 추가하면 된다.

.....
<plugin>
  <groupId>org.graalvm.nativeimage</groupId>
  <artifactId>native-image-maven-plugin</artifactId>
  <version>21.1.0</version>
  <configuration>
    <mainClass>com.malshan.example<mainClass>
    <buildArgs>-H:-CheckToolchain</buildArgs>
  </configuration>
  <executions>
    <execution>
      <goals>
        <goal>native-image</goal>
      </goals>
      <phase>package</phase>
    </execution>
  </executions>
</plugin>
......

이제 다시 mvn -Pnative-image package를 실행해 보자. BUILD SUCCESS 메시지가 출력 되었다면 원래 jar파일이 생성되던 폴더에 exe파일이 하나 생겼을것이다. 실행해보면 신셰계를 경험하게 될것이다. 로딩 없이 엄청 빠르게 수행된다.

»


정적 분석이 필요한 이유

아주 정상적으로 잘 동작하는 소프트웨어가 있다고 해보자. 잘 동작한다고 모두 좋은 소프트웨어 일까?
마블의 영화 “블랙팬서”에서 내가 인상 깊게 본 장면이 있다.

“잘 만들었어도 얼마든지 개선의 여지가 있거든!”
좋은 소프트웨어도 마찬가지이다. 잘 동작하고 문제가 없어도 개선의 여지는 항상 남아있고, 아직 발견되지 못한 버그가 도사리고 있을 수도 있다(참고: 테스트 커버리지 100%의 함정). 이 글에서는 동적 테스트로 잡지 못하는 버그와 정적 분석의 필요성 그리고 결함 수정이 필요한 이유에 관해 이야기해보고자 한다.

동적 테스트가 발견하지 못하는 버그

동적 테스트에서 미처 발견하지 못하는 버그를 찾아내는 방법에는 어떤 것들이 있을까? 우선 버그가 발생하는 이유에 관해 이야기해 보자. 버그의 이유는 셀 수 없이 많이 있겠지만 크게 아래 4가지로 구분해 보려 한다.

  1. 고려하지 못한 케이스(요구사항 누락)
  2. 기대하지 않은 동작(요구사항 오해)
  3. 예상치 못한 입력(예외 처리에 대한 누락)
  4. 단순한코딩실수로인한버그

1번과 2번은 동적 테스트로 어느 정도 커버가 가능하다. 잘 정리된 요구사항과 100%의 커버리지 그리고 QA 프로세스로 대부분의 버그를 잡아낼 수 있다. 우리가 놓치기 쉬운 나머지 3번과 4번 에 대해 생각해보자.

예상치 못한 입력(예외 처리에 대한 누락)
예상치 못한 입력을 동적 테스트로 잡아내지 못하는 이유는 무엇일까?
예상치 못한 입력은 동적 테스트의 케이스로 잘 만들어지지 않는다. 동적 테스트케이스로 만들었 다면 이미 예상한 입력이기 때문에 버그가 발생할 일은 없다.
가장 쉬운 예로 divide by zero나 buffer overrun 같은 버그가 있다. 이런 부류의 버그에 가장 큰 문제는 대부분이 소프트웨어가 릴리즈되고 난 후에 발생한다는 것이다.

단순한 코딩 실수로 인한 오동작
동적 테스트로 발견하지 못하는 코딩 실수들에는 어떤 것들이 있을까? 대표적인 예로 memory leak이 있다. 보통 memory leak은 프로그램이 오랜 시간 동작하면서 차곡 차곡 해제되지 않은 memory를 쌓아야만 드러난다. 개발 단계에서 빨리빨리 결과를 확인해야 하는 동적 테스트에서 memory leak을 발견하기란 쉽지 않다. 또한 memory leak 같은 경우 해당 버그가 발견되었음에도 어느 부분에서 leak이 발생하는 지 찾아내기도 힘들다. 앞서 알아본 예상치 못한 입력이나 코딩 실수로 인한 버그는 개발 단계가 아닌 운영 중인 소프트 웨어에서 발견되는 경우가 많다. 설명의 편의를 위해 이런 버그들을 통틀어 실행 시간 오류

(Runtime Error)라고 하자. 다음으로 실행 시간 오류를 방지하는 방법에 관해 이야기해 보자.

실행 시간 오류를 방지하는 방법

실행 시간 오류들을 예방하기 위해서는 어떤 과정이 필요한지 알아보자.
가장 좋은 방법은 많은 연습이나 경험을 쌓는 것이다. 노련한 개발자들은 많은 코딩 경험을 통해 실행 시간 오류 들을 미리 고려해가며 개발을 한다.
그렇다면 경험과 연습이 부족한 개발자는 실행 시간 오류로부터 무방비 상태로 개발을 해야 할까? 보통 이런 버그들을 미연에 방지하기 위해 짝코딩, 몹코딩, 코드리뷰 같은 프로세스를 개발 중간 에 수행한다. 하지만 일정에 쫓기고 빨리빨리 제품을 시장에 내놓아야 하는 경우 현실적으로 이 런 활동을 할 시간은 잘 주어지지 않는다. 또한 경험 많은 개발자의 시간은 비용이 크기 때문에 리뷰나 짝코딩, 몹코딩 같은 활동은 회사나 팀의 입장에서 효율적이지 못하다.

정적 분석(Static Analysis)이 필요한 이유

적은 비용으로 경험 많은 개발자의 활동들을 대체하는 방법이 있다. 바로 정적 분석이다.
정적 분석을 수행하면 실행 시간 오류 같은 버그를 적은 노력으로 미연에 방지할 수 있다. 정적 분석은 경험 많은 개발자들의 노하우나 이미 필드에서 많이 발생한 여러 실행 시간 오류들의 사 례로부터 규칙들을 만든다. 그리고 만들어진 규칙을 사용하여 소스 코드 전반에 걸쳐 분석을 수 행한다. 소스 코드 전반이라는 말은 우리가 미처 예상하지 못한 입력이나 프로그램의 흐름 혹은 리뷰에서 실수로 빼먹은 코드에 대해서도 검사를 수행한다는 의미이다. 즉 동적 테스트나 코드 리뷰 같은 활동으로 잡지 못하는 버그도 찾을 수 있다.
정적 분석은 코드의 실행이 필요 없다. 완성되지 않은 코드에 대해서도 분석이 가능하기 때문에 동적 테스트보다 이른 시점에 버그를 찾아낼 수 있다. 그리고 동적 테스트보다 비교적 적은 시간 에 버그를 찾아낼 수 있다는 장점도 있다.

CODESCROLL STATIC Buffer Overrun 검출 화면 CODESCROLL STATIC Buffer Overrun 검출 화면

정적 분석이 필요한 다른 이유에 대해서도 알아보자.
글 도입부에 언급했듯이 잘 동작하는 소프트웨어도 얼마든지 개선의 여지가 있다. 정적 분석은 버그뿐만 아니라 개선의 여지가 있는 코드들도 찾아준다.
정적 분석은 코딩 스타일 가이드에 관한 규칙과 코드의 품질 메트릭도 제공한다.
코딩 스타일 가이드 규칙 같은 경우 언어가 제공하는 가이드라인 혹은 팀이나 회사에서 정한 가 이드라인을 잘 따라서 구현했는지 검사해준다. 이런 가이드라인은 코드의 유지보수와 가독성 등 을 고려하여 만들어지기 때문에 반드시 지키는 것이 좋다.
코드 품질 메트릭은 코드의 복잡성 확장성 이식성 등을 수치를 통해 보여준다. 품질 메트릭 관리 를 개발 초기에 적용하지 않으면 그 비용이 점점 커져 결국에는 개선을 포기해야 하는 경우가 생 긴다. 개발 초기 단계부터 품질 메트릭 관리를 한다면 적은 기술 부채를 가지고 제품을 릴리즈할 수 있다.

CODESCROLL STATIC Metric 화면 CODESCROLL STATIC Metric 화면

동적 테스팅 VS 정적 테스팅

동적 테스트는 테스트 방법 중에서도 비용이 많이 드는 작업이다. 소스 코드가 늘어나고 복잡해 짐에 따라 유지보수 해야 하는 테스트 코드의 양도 같이 증가한다. 또한 테스트 코드 역시 코딩 과정이므로 당연히 실수가 발생하고 테스트 코드의 실수는 제품의 품질에 큰 영향을 미친다. 물 론 동적 테스팅과 정적 테스팅이 발견하는 버그의 종류나 성격은 조금 다르다. 두 가지 활동을 전부 하는 게 가장 좋겠지만…. 같은 일정과 비용으로 둘 중 하나의 테스트를 선택해야 한다면 주 저 없이 정적 분석을 추천해 주고 싶다.

가능하면 반드시 수정해라

정적 분석을 수행하고 수정하지 않는 경우를 많이 보게 된다. 사소한 부분이라 넘어가거나 프로 그램의 동작과 무관한 결함이기 때문에 넘어가는 경우가 있다. 시간과 비용을 잘 고려해서 수정 여부를 결정하는 것도 중요하지만 가능하면 코드를 수정해서 모든 결함을 줄이 는 것을 추천한다.
경험 많은 개발자는 실수를 적게 한다. 경험이란 습관과 관련이 있고 습관은 반복에서 나온다. 지 속해서 결함이 있는 코드를 수정하면 제품에도 도움이 되지만 개발자 자신의 코딩 습관에도 많은 도움이 된다. 경험 많은 개발자란 좋은 습관을 지닌 개발자이다. 정적 분석이 귀찮은 작업이 아닌 개발자 본인의 실력 향상과 커리어에 도움이 된다고 생각하자. 언젠가 내가 작성한 코드를 정적 분석기에 처음 돌렸을 때 결함이 0개라면 얼마나 기분이 좋을지 생각해보자. 정적 분석과 결함의 수정은 릴리즈되는 소프트웨어가 아니라 개발자 본인에게 더 큰 이득을 가져다준다.

잘 만들었어도 얼마든지 개선의 여지가 있다. 정적 분석이 필요 없는 훌륭한 개발자가 되는 날까 지 가능하면 반드시 개선하고 그 경험을 내 것으로 만들자.

»


테스트 커버리지 100%의 함정

커버리지를 올리는 작업은 지루하고 힘들다. 하지만 커버리지 100% 달성의 뿌듯함과 성취감은 지루하고 힘든 시간의 보상으로 충분하고도 남는다. 우리가 커버리지의 함정에 빠지기 가장 좋은 순간 역시 100% 커버리지가 달성되는 순간이다. 커버리지가 100%임에도 우리가 기뻐하고 행복해하면 안 되는 이유를 알아보자.


커버리지 100%의 의미

“Bug Free Software!!”를 증명하는 것이 가능할까? 물론 단순한 소프트웨어는 가능할지 모르겠으나 코드의 양이 많아질수록 소프트웨어의 무결성을 증명하는 것은 어려워진다.왜 일까?

완성된 소프트웨어는 복잡계(complex system)의 특성을 가진다.

“복잡계(複雜系, 영어: complex system, complexity system)는 완전한 질서나 완전한 무질서를 보이지 않고, 그 사이에 존재하는 계로써, 수많은 요소들로 구성되어 있으며 그들 사이의 비선형 상호작용에 의해 집단성질이 떠오르는 다체 문제이다 [1]. 최근 자연과학 및 사회과학에서 활발히 연구되고 있다. 물리적, 생물학적, 사회학적 대상을 수학적으로 분석하는 것이 복잡계 과학의 목적이다. 독일의 막스플랑크 연구소와 미국의 산타페 연구소가 복잡계 과학 연구로 유명하다. 위키백과.”

날씨, 뇌에서 일어나는 시냅스들 간의 상호 작용, 박쥐 때의 움직임, 전염병 등등 자연계 대부분의 현상이 복잡계에 속한다.
복잡계의 속성 하나하나는 어느 정도 예측할 수 있다. 하지만 속성들이 서로 상호 작용을 하는 순간 전체 시스템은 카오스 상태에 들어가게 된다. 한 마리 나비의 날갯짓이 돌풍을 일으키듯이 작은 변수의 변화가 전체 시스템의 상태를 변화시킬 수 있는 것이다.

소프트웨어 역시 함수, 변수, 사용자의 행동, OS, 하드웨어 등등…. 수많은 요소로 구성된 일종의 복잡계이다.
수많은 변수가 상호 작용 하면서 돌아가는 소프트웨어의 모든 경로와 출력을 예측하여 빈틈없는 테스트 케이스를 작성하는 것은 거의 불가능에 가깝다.

다시 원점으로 돌아가서 생각해보자. 커버리지 100%의 의미는 무엇일까?
내일 일기예보를 위해 모든 나비의 날갯짓을 전부 확인해야 하는가? 현실적으로 불가능하다. 소프트웨어 역시 마찬가지이다.
소프트웨어의 모든 입력과 변수들 간의 상호 작용을 테스트하려면 거의 영원에 가까운 시간이 걸릴지도 모른다. 언젠가는 테스트를 멈추고 릴리즈를 해야 한다. 이때 사용되는 테스트의 종료 조건이 바로 커버리지 이다.
커버리지 100%는 소프트웨어의 무결성을 증명하는 지표가 아니라 “테스트의 종료 조건을 만족했다.”는 의미이다.
커버리지 100%의 함정이란 바로 커버리지 100%가 소프트웨어 무결성의 충분조건이 아니라는 것을 의미한다.


커버리지 100% 함정의 예

아래 예제는 간단한 요구사항과 산출물인 코드 그리고 해당 코드의 커버리지를 100% 만족하는 케이스이다.

requirements
두 개의 정수와 연산자 하나를 입력받는 간단한 사칙연산(+,-,*,/) 기능을 수행하는 calc 함수를 작성하시오.

source

float calc(int a, int b, char c){
    float result = 0;
    switch (c)
    {
        case '+':
            result = a+b;
            break;
        case '-':
            result = a+b;
            break;
        case '/':
            result = a/b;                
            break;
        default:
            break;
    }
    return result;
}

test cases

  • a=1, b=1, c=‘+’
  • a=1, b=1, c=‘-’
  • a=1, b=1, c=‘*’
  • a=1, b=1, c=‘/’

위의 경우 커버리지 100%이기 때문에 무결성 코드라고 할 수 있을까? 테스트를 종료하고 릴리즈 해야 할까?
위의 코드가 가지고 있는 버그와 누락된 케이스들을 살펴보자.

누락된 기능
calc함수는 요구사항의 사칙연산 중 곱하기 연산이 누락되었다. 누락된 기능에 대한 테스트는 커버리지로 찾아내지 못한다. 커버리지는 코드가 동작한다는 것을 증명할 뿐 요구사항을 모두 구현하였는지는 테스트 할 수 없다.

잘못된 기능
a=1, b=1, c=‘-‘ 인 테스트 케이스의 기대하는 반환 값은 0이다. 하지만 calc에서는 2를 반환한다. 뺄셈이 잘못 구현된 것이다. 커버리지는 대상 소스코드가 요구사항대로 동작하는지는 테스트 할 수 없다.

누락된 케이스
a=1, b=0, c=‘/‘ 인 테스트 케이스는 divide by zero 예외를 발생시킨다. 커버리지는 시스템에 입력 가능한 모든 케이스를 수행했다는 것을 말하지 않는다. 요구사항을 모두 만족하고 커버리지가 100%일 때도 코드의 무결성은 확보되지 않는다. 테스트를 수행할 때는 코드의 문맥과 개발자의 의도 그리고 예외 상황을 꼼꼼히 살펴보아야 한다.


정리

앞서 설명했듯이 소프트웨어는 복잡계의 성질을 가진다. 입력 변수가 3개뿐인 함수도 매우 다양한 결과를 반환한다. 소스코드의 100% 무결성 이란 가능한 모든 입력 변수와 기대되는 모든 반환 값을 살펴보아야 가능하다. 만약 소스코드가 동시성(concurrency)의 특성을 가진다면 가능한 모든 입력 변수를 수행해도 기대되는 반환 값이 다를 수 있기 때문에 100% 무결성을 증명하기 어렵다. 이러한 특성들을 가지는 코드의 커버리지 100%는 모든 경로를 수행해 보았다는 것만을 의미한다. 코드의 모든 경로가 이상 없이 수행된다는 보장은 할 수 없고 요구사항에 맞게 동작하는지도 알 수 없다.

커버리지 100%를 달성한 테스터 혹은 개발자는 충분히 기뻐할 자격이 있다. 그리고 나는 그들의 노력에 주저 없이 박수를 보낼 것이다. 하지만 함정에 빠지지는 말자. 잠시 휴식을 취한 뒤 다시 모니터 앞에 앉아서 더 꼼꼼한 테스트의 그물을 쳐보자. 분명 그물 사이로 빠져나가는 버그들을 발견할 수 있을 것이다.

»


Typescript 를 사용한 Electron+React 개발 환경 구축하기

이번 포스팅은 https://gist.github.com/matthewjberger/6f42452cb1a2253667942d333ff53404#prerequisites 를 참고하여 작성하였습니다.

준비물

설치하기

React app과 Electron을 다운 로드한다.

create-react-app electron-react-ts-app --scripts-version=react-scripts-ts
cd electron-react-ts-app
yarn add electron --dev
yarn add electron-builder --dev
yarn global add foreman # for process management
yarn install

소스 추가

src폴더에 electron-starter.ts 파일을 추가하고 아래와 같이 소스를 작성한다.

import { app, BrowserWindow  } from 'electron';
import * as path from 'path';
import * as url from 'url';

let mainWindow: Electron.BrowserWindow;

function createWindow () {
    mainWindow = new BrowserWindow({width: 800, height: 600});

    const startUrl = process.env.ELECTRON_START_URL || url.format({
      pathname: path.join(__dirname, '/../build/dist/index.html'),
      protocol: 'file:',
      slashes: true
    });
    mainWindow.loadURL(startUrl);
}

app.on('ready', createWindow);

app.on('activate', function () {
  if (mainWindow === null) {
    createWindow();
  }
});

src폴더에 electron-wait-react.ts파일을 추가하고 아래와 같이 소스를 작성한다.

import * as net from 'net';

const port: number = process.env.PORT ? (Number(process.env.PORT) - 100) : 3000;

process.env.ELECTRON_START_URL = `http://localhost:${port}`;

const client = new net.Socket();

let startedElectron = false;
const tryConnection = () => client.connect(`${port}`, () => {
        client.end();
        if (!startedElectron) {
            startedElectron = true;
            const exec = require('child_process').exec;
            exec('npm run electron');
        }
    }
);

tryConnection();

client.on('error', (error) => {
    setTimeout(tryConnection, 1000);
});

tsconfig.json 수정

module의 설정을 commonjs로 수정한다.
exclude 에서 사용하지 않는 것들 을 제거한다.

{
  "compilerOptions": {
    "outDir": "build/dist",
    "module": "commonjs",
    "target": "es5",
    "lib": ["es6", "dom"],
    "sourceMap": true,
    "allowJs": true,
    "jsx": "react",
    "moduleResolution": "node",
    "rootDir": "src",
    "forceConsistentCasingInFileNames": true,
    "noImplicitReturns": true,
    "noImplicitThis": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "suppressImplicitAnyIndexErrors": true,
    "noUnusedLocals": true
  },
  "exclude": [
    "node_modules",
    "build"
  ]
}

typescript컴파일 명령어를 사용하여 build/dist폴더에 .js파일들이 잘 생성 되는지 확인한다.

tsc

package.json 수정

main, build옵션은 새로 추가하고 script는 수정한다.

"main" : "build/dist/main.js",
  "homepage": "./",
  "scripts": {
    "start": "nf start -p 3000",
    "build": "react-scripts-ts build",
    "test": "react-scripts-ts test --env=jsdom",
    "eject": "react-scripts-ts eject",
    "electron": "electron .",
    "electron-build": "tsc",
    "electron-start": "node build/dist/electron-wait-react",
    "react-start": "react-scripts-ts start",
    "pack": "build --dir",
    "dist": "npm run build && build",
    "postinstall": "install-app-deps"
  },
  "build": {
    "appId": "com.electron.electron-with-create-react-app",
    "win": {
        "iconUrl": "https://cdn2.iconfinder.com/data/icons/designer-skills/128/react-256.png"
    },
    "directories": {
        "buildResources": "public"
    }
  }

procfile 생성

루트 폴더에 procfile파일을 생성하고 아래와 같이 작성한다.

react: npm run react-start
electron: npm run electron-start

실행

yarn start
»


Typescript React 프로젝트 구성하기

create-react-app Command Line Interface 설치하기

npm i create-react-app -g

기본 프로젝트 설치하기

create-react-app [프로젝트 이름] --scripts-version=react-scripts-ts

yarn이 있으면 yarn으로 설치 없을 경우 npm으로 설치해준다.

»