이번에 Flutter라는 프레임워크를 공부해서 내 머릿속에 있는 아이디어를 앱으로 꺼내보려고 공부를 시작하려고 한다.

일단 목표는 내가 아이폰을 사용하고 있기 때문에 IOS 앱을 만들어서 직접 써보고, Android까지 확장해보려고 한다.

 

1. 직면한 오류

사실 해결하고 나서는 오류라고 적기도 웃긴 상황이었습니다.

일단 발생한 내용은 아래와 같다.

 

flutter doctor를 쳐보면, 위처럼 Flutter 가동에 필요한 요소들이 정상적으로 설정/설치되어 있는지를 확인해 볼 수 있다.

Android Studio, Xcode, Visual Studio Code 모두 정상적으로 설치하고, 설정도 완료했다.

근데, 가장 중요한 Flutter 쪽에서 오류가 발생했다.

 

2. 발생 원인

보면 "/Users/ooo/Developments/flutter/bin/flutter"에서 Flutter가 실행되고 있는데, 네가 설정한 경로는 "/Users/ooo/developments/flutter"라는 것을 말해주고 있습니다.

 

처음에 뭐가 다른데... 이러고 한참을 쳐다보고 있었는데, 자세히 보면 Developments의 대소문자가 다르게 설정되어 있었습니다. 제가 만든 Developments 파일과 제가 환경 변수에서 설정한 developments가 달라서 인식을 제대로 하지 못하고 있었습니다.

 

오류 내용만 Stack over flow에 검색해 보면 Dart가 이미 설치되어 있는지 확인하고, 지우라고 되어 있는데 저는 Dart를 설치한 적이 없었기 때문에 계속 뭐지... 만 하고 있었는데 허무한 원인이었습니다.

기존에 설정했던 플러터 환경변수

 

3. 해결

당연하게도 환경변수만 수정해서 매우 쉽게 해결해 볼 수 있었습니다.

이런 황당한 실수는 줄여서 어이없는 20~30분을 꼭... 아낍시다.

developments에서 Developments로 수정

이제 다시 flutter doctor를 쳐보면 이제 정상적으로 설정이 완료되었습니다.

 

이제 공부하러 가볼까요. ㅎ

이번 2024년도 1회 차 필기, 실기를 응시하고 자격을 취득했습니다.

 

제가 전공을 바꾸고 주변에서 가장 많이 들었던 말이 "개발자로 취업하는데 정보처리기사는 필요 없어."였습니다.

그런 안일한 말만 듣고 준비하지 않고 있었고, 어느 순간 깨닫게 되었습니다.

정보처리기사가 필요 없다는 말을 하는 사람들 중에 정보처리기사를 갖고 있지 않은 사람은 없었습니다.

뒤 늦게 깨닫게 된 저의 표정과 비슷하네요.

 

그래서 정보처리기사는 일단 기본적으로 따야겠다는 생각으로 단기간 빠르게 준비했습니다.

 

1. 필기는 CBT, CBT, CBT 그리고 또 CBT

저는 개정된 이후의 필기 교재를 구매해서 일단 1 회독을 시작했습니다.

처음 목표는 사실 필기가 널널하니깐 실기까지 잡으면서 간다는 마인드로 공부를 시작했습니다.

하지만 책에 나오는 모든 내용을 다 정독하면서 암기하기에는 정보처리기사에만 쓸 수 있는 시간은 없었습니다.

그래서 실기는 필기 붙은 다음에 생각하자는 마인드로 2주 안에 필기 끝내기를 시작했습니다.

 

1주일 동안은 CBT 사이트에서 2020년도부터 2022년도까지 문제를 풀었습니다.

 

하루에 하나씩만 풀어도 8일이면 다 풀 수 있습니다. 요약본을 읽고 나서 푸는 것이 안 본 것보다는 도움이 많이 되지만, 그냥 풀어도 상관없습니다.

 

1회 차 풀고, 맞은 문제, 틀린 문제 상관없이 해설을 읽으면서 모두 정독했습니다. 계속 이렇게 반복하다 보면 자주 나오는 유형들이 있습니다. 이런 문제는 절대 틀리시면 안 됩니다.

 

1주일 동안 문제를 풀었다면, 이제는 1주일 동안 선택 연도 말고, 아래 "모의고사"라는 모드가 따로 있습니다. 지정한 연도의 범위에서 문제가 출제되고, 막 섞여서 나옵니다. (풀었던 건데, 다 맞출 수 있지라고 생각하시지 말고 한번 풀어보세요.)

 

이렇게 2주만 CBT를 돌려보고, 필기를 봐도 무난하게 통과하실 수 있으실 거 같습니다.

 

 

2. 실기는 공부, 공부, 공부

실기는 사실 요령이라고 하면서 말씀드릴 수 있는 부분이 없을 것 같습니다.

필기처럼 CBT 문제를 계속 돌려볼 수도 없고, 기출문제가 있다고 한들 그대로 나오지 않기 때문입니다.

 

제가 사용했던 교재는 수제비입니다. 1, 2권으로 나누어져 있어서 따로 들고 다니기도 편했습니다.

https://www.yes24.com/Product/Goods/117206088

 

2023 수제비 정보처리기사 실기 1권+2권 합본세트 - 예스24

비전공자를 위한 최고의 수험서!!- 2023년 합격을 위한 NCS 기반 모의고사 수록- 궁극의 암기비법(두음쌤)과 학습 Point 수록- 최적의 맞춤 학습을 위한 커뮤니티 운영- 암기비법 PDF 제공(커뮤니티 내

www.yes24.com

내용을 읽고, 바로 챕터별 문제를 풀어 볼 수 있었습니다.

수제비 카페에 궁금한 점을 올리면 고수분들이 잘 알려주시기도 합니다.

강의도 있다는데 저는 따로 듣지는 않았습니다.

 

위 교재를 보면 2권으로 나누어져 있지만, 양이 정말 방대하고 저는 실기 준비 기간에 작은 회사에.. 취업을 하게 되어서 투자할 수 있는 시간이 많지 않았습니다.

 

그래서 전략을 먼저 세웠습니다.

  1. 자신 있는 파트의 문제는 무조건 맞는다.
  2. 자주 나오는 유형에서 비어있는 점수를 채우자.

정말 간단하지만, 아주 괜찮은 전략이었습니다.

 

저는 비전공자이긴 하지만, 프로젝트와 타 전공 수업을 들으면서 C, Java, Python을 모두 다루어 봤고, 데이터베이스 전공 수업도 신청해서 들었었기 때문에 코딩과 데이터베이스를 틀리면 안 되는 파트라고 설정했습니다.

 

또한 제가 봤을 때, 자주 나왔던 부분은 OO 하는 보안방법은?, OO하는 해킹방법은? 이런 문제들이 많이 나오는 것을 확인했습니다.

 

모든 코딩 문제와 데이터베이스 그리고 추가로 2~3문제를 맞히면 합격선에 들어가게 된다는 것을 깨닫고 전략대로 집중 공략했습니다.

 

저는 도움을 많이 받았던 블로그가 있어서 올려 봅니다.

https://starrykss.tistory.com/1856

 

[정보처리기사 실기] 단원별 정리 & 예상 문제 & 기출 문제

정보처리기사 단원별 정리 & 예상 문제 & 기출 문제정보처리기사 실기 시험을 준비하면서 블로그에 올렸었던 글들을 한 페이지에 정리해본다. 개념 정리 2020년 NCS 개편 후의 내용들수험서, 인터

starrykss.tistory.com

여기에 작성자분이 정말... 엄청나게 정리를 해두셨습니다.

중간에 광고가 많이 나오긴 하는데, 그런 건 중요하지 않습니다.

오타나 오답으로 작성된 부분도 있긴 했는데 공부하면서 틀린 부분도 찾아내고 도움을 많이 받았습니다.

Java, Python, C에 대한 기본 문법을 다지고 아래 있는 블로그에 기출, 예상 문제들을 계속 풀면서 준비했습니다.

https://starrykss.tistory.com/1797

 

[정보처리기사 실기] 프로그래밍 기출 문제 정리 (2017년~2022년)

프로그래밍 기출 문제 정리 (2017년~2022년) 정보처리기사 실기 기출 문제 중에서 프로그래밍(C, Java, Python)과 관련된 문제를 정리해 본다. 이 문제들은 복원을 한 것으로, 실제 출제된 문제와 다를

starrykss.tistory.com

코딩 문제 연습해 보기 정말 좋습니다. 3~4일 동안은 퇴근 후 코딩문제에 올인을 하면서 보냈습니다.

코딩 문제를 한 바퀴 돌렸다면 바로 다음 파트 "데이터베이스" 기출문제를 풀었습니다.

(파트별로 요약집이라도 1 회독 꼭 해보시고 푸시길)

https://starrykss.tistory.com/1840

 

[정보처리기사 실기] 데이터베이스 기출 문제 정리 (2017년~2022년)

데이터베이스 기출 문제 정리 (2017년~2022년) 정보처리기사 실기 기출 문제 중에서 데이터베이스(Database)와 관련된 문제를 정리해 본다. SQL(Structed Query Language) 이 문제들은 복원을 한 것으로, 실제

starrykss.tistory.com

한 1~2주 정도 빡세게 잡고 해 보시면 코딩 문제, 데이터베이스를 한 바퀴 돌려볼 수 있을 거 같습니다.

 

시험 1주 전부터는 코딩문제, 데이터베이스 보면서 틀리거나 헷갈렸던 부분들을 다시 보고 계속 틀리는 부분은 어디 적어두면 좋습니다. (시험 당일날 도움이 많이 됩니다.)

 

이와 함께 이제 자신이 2~3문제를 맞히기로 선택한 파트를 노려줍니다. 여기는 그냥 암기.. 암기이기 때문에 어쩔 수 없이 외워줍니다.

 

 

이렇게 준비를 해서 시험을 봤고, 필단형 72점을 받고 합격했습니다.

세웠던 전략이 통해서 시험 보고 나오는 날에도 합격한 것 같은 기분이 들면서 나왔습니다.

 

이번 시험은 예상했던 대로 코딩과 데이터베이스 문제의 비중에 굉장히 컸습니다. 또한 특정 기술, 보안에 대한 문제도 적지 않게 나와서 합격할 수 있었습니다.

 

 

 

비동기 구현을 하려는 이유


프로젝트에서 서버에서 발생하는 5xx Exception을 모니터링하기 위해서 Slack WebHook을 사용해서 구현했습니다.

 

현재 로직은 5xx 예외가 발생 -> Slack 메시지 전송 -> 클라이언트에게 응답까지의 과정이 동기적으로 진행됩니다.

 

여기서 사용하고 있는 Slack 서버는 제가 제어할 수 없는 부분이기 때문에 만약 Slack 서버에 지연이 생겨 응답을 늦게 준다면 어떻게 될지 생각을 해봤습니다.

 

Slack 메시지 전송이 지연되어 정작 더 중요한 다음 과정인 "클라이언트에게 응답"이 제대로 수행되지 않는다면... 끔찍한 상황이 벌어질 수도 있다고 생각했습니다.

 

실제로 Slack 메시지를 보내는 push() 메서드에서 5초의 지연 시간을 주고 테스트 해보니 클라이언트에게 응답이 가는 시간도 그만큼 지연이 되는 것을 확인했습니다.

요청이 들어온 시간과 클라이언트에게 응답되는 시간 사이의 5초간의 지연 발생

 

 

그래서 Slack으로 메시지를 보내는 부분을 비동기로 처리하여 클라이언트에게 응답을 제공하는 부분에 지연이 생기지 않도록 구현하기로 했습니다.

 

 

 

🔎 Spring의 @Async


구현을 하기 전에 @Async를 조금 알아보면, 비동기 처리를 간단하게 할 수 있도록 Spring에서 제공해주는 어노테이션입니다.

 

사용 방법은 @Async를 사용하겠다고 @EnableAsyc를 붙여주고, 비동기로 처리할 메서드 위에 @Async를 붙여주면 됩니다.

 

@Async를 사용할 때는 주의 해야할 점이 2가지 있습니다.

  1. private 메서드에 붙여서는 안 됩니다. (컴파일 에러로 확인 가능)
  2. 같은 클래스 내부에서 스스로 호출(self-invocation)을 하면 안 된다. (컴파일 에러로 확인 불가)

 

1번은 private 메서드에 @Async를 붙여보면 IntelliJ에서 컴파일 에러를 확인해 볼 수 있습니다.

private 메서드에 @Async 붙였을 때

 

2번은 아래와 같이 컴파일로 확인이 되지 않습니다.

같은 클래스 내부에 있는 test() 메서드를 self invocation

 

이제 외부에서 MonitoringProvider의 push() 메서드를 호출 했을 때, 내부에 있는 test()가 당연히 비동기 처리 되겠지 하고 생각하고 놔두면 이제 디버깅 지옥을 맛볼 수 있습니다.

 

실제로 외부에서 push() 메서드를 여러 번 요청해서 비동기 처리가 되는지 확인해보면 다음과 같이 순차적으로 동기적으로 처리되는 것을 확인할 수 있습니다.

순차적으로 동기적으로 처리된 모습

 

자가 호출을 하면 동작하지 않는 이유는 Spring 컨테이너에 등록되어 있는 스프링 빈의 메서드가 호출되었을 때, @Async가 붙어있다면 AOP로 처리되어 스프링이 메서드 요청을 가로채서 다른 Thread Pool에서 실행시켜주는 구조이기 때문이라고 합니다.

 

그래서 @Async를 private에 붙이거나 내부 호출을 하게 되면 스프링이 가로채서 프록시 처리를 할 수 없기 때문에 2가지 주의점이 있습니다.

 

 

 

✨ 적용하기


이런 주의점을 확인하고, 기존 로직에 비동기 처리를 진행해줍니다.

 

먼저, @Async를 사용할 수 있도록 @EnableAsync를 붙여줍니다.

@EnableAsync
@SpringBootApplication
public class PraisePushApplication {
    public static void main(String[] args) {
        SpringApplication.run(PraisePushApplication.class, args);
    }
}

 

비동기 처리를 원했던 메시지를 보내는 push() 메서드에 @Async를 붙여줍니다.

public class SlackMonitoringProvider implements MonitoringProvider {

    @Value("${error.webhook.url}")
    private String webHookUrl;

    @Async
    @Override
    public void push(final Exception exception, final HttpServletRequest request) {
        SlackApi slackApi = new SlackApi(webHookUrl);
        slackApi.call(createSlackMessage(exception, request));
    }
    
    ... 생략 ...
}

 

요청 시간 로그를 설정하고, push 로직에서 5초의 지연을 두고 요청을 확인해 보면 메시지를 보내는 로직이 비동기적으로 처리되는 것을 확인할 수 있습니다.

 

Slack으로 메시지 보내는 로직이 비동기로 처리되어 클라이언트에게 응답되는 시간이 먼저 처리되는 것을 확인

 

 

이번에 시작한 프로젝트에서 Jenkins를 사용해서 CI/CD 파이프라인을 구성하고 있습니다.

develop 브랜치에서 작업을 하고, push를 하면 Jenkins Pipeline이 동작하면서 자동화 배포 작업이 진행됩니다.

 

우리 프로젝트에서는 다음과 같은 Pipeline이 동작해서 배포를 진행합니다.

  1. Git Clone
    • Github Repository의 develop 브랜치에 변화가 발생되면 Jenkins 동작이 시작됩니다.
    • develop 브랜치를 Jenkins 서버로 clone 해서 가져옵니다.
  2. DEV-Build
    • 이 과정에서 프로젝트의 Build와 Test를 진행합니다.
  3. Deploy
    • Jenkins 서버에서 Develop 서버로 SSH 통신을 통해 배포 스크립트를 실행합니다.
    • 이 글에서는 이 "Deploy" 과정에서 스크립트 실행 후 발생한 "ERROR: script returned exit code 255"에 대해서 다룰 예정입니다.

 

1. 기존 스크립트


기존 스크립트는 다음과 같습니다.

#!/bin/bash
source ~/.bashrc

REPOSITORY=/home/praise-push
echo "### check running application... ###"
CURRENT_PID=$(pgrep -f praise-push)
echo "### current pid = $CURRENT_PID"

if [[ -z ${CURRENT_PID} ]]; then
    echo "### Not found running application ###"
else
    echo "### Found application!! try to kill process $CURRENT_PID ###"
    kill -15 $CURRENT_PID
    sleep 3
fi
echo "### Ready to deploy ###"

JAR_NAME=$(ls $REPOSITORY | grep 'praise-push' | tail -n 1)

echo "### Source file Name: $JAR_NAME"
chmod +x $REPOSITORY/$JAR_NAME
nohup /usr/bin/java -jar $REPOSITORY/$JAR_NAME >> $REPOSITORY/nohup.out 2>&1 &
echo "success"

 

[ 실행 중인 프로세스가 있는지 확인 ]

간단하게 설명하면 현재 실행 중인 java application의 PID를 찾아서 CURRENT_PID에 저장합니다.

현재 실행중인 프로세스가 있다면 kill 명령어를 통해 중지시키고, 없다면 그냥 넘어가도록 합니다.

 

[ 새로운 프로세스를 실행합니다. ]

Jenkins 서버에서 Build, Test 과정이 끝난 jar 파일을 Develop 서버로 복사해서 가져오게 됩니다.

이 jar 파일을 nohup을 이용해서 서버를 실행합니다.

 

Jenkins에서 Deploy Pipeline은 다음과 같습니다.

stage('Deploy') {
    steps {
        sshagent(credentials: ['dev_server']) {
            sh "ssh -o StrictHostKeyChecking=no -p 9001 root@123.11.11.11 'uptime'"
            sh "scp -P 9001 /var/jenkins_home/workspace/praise-push-dev/build/libs/praise-push-0.0.1-SNAPSHOT.jar root@123.11.11.11:/home/praise-push"
            sh "ssh -t -p 9001 root@123.11.11.11 'cd /home/praise-push && ./deploy.sh'"
        }
    }
}

 

ssh으로 uptime을 요청하여 첫 SSH 연결을 진행합니다.

scp를 이용하여 Jenkins 서버에서 Build, Test 과정을 마치고 생성된 jar 파일을 Develop 서버로 복사합니다.

ssh를 이용하여 원격으로 "/home/praise-push/deploy.sh" 스크립트를 실행하여 배포를 작업을 완료합니다.

 

 

 

2. 문제 분석


먼저 script에서 어떻게 해야 255 exit code가 발생되는지 찾아봤습니다.

 

Remote(원격) 서버에 SSH가 설치되어 있지 않거나, 올바른 Connection이 아닌 경우

 

처음에는 SSH 연결이 잘 안 되고 있구나라고 판단하여 이쪽으로 찾아보기 시작했었습니다.

 

클라이언트(Jenkins)에서 Key Pair를 생성하여 공개키(pub)를 원격서버(Develop)로 보내서 서버의 "authrized_keys"에 클라이언트의 공개키를 저장해야 합니다.

 

SSH 요청 시 클라이언트와 서버 사이의 세션키(대칭키) 생성을 위한 키 교환이 시작되는데, 이때 "authrized_keys"에 있는 이 공개키가 사용됩니다.

 

그래서 저는 SSH 연결이 잘 못 된 것은 아닌지 Jenkins 서버와 Develop 서버를 다시 연결을 해봤습니다.

 

그래도 결과는 달라지지 않았습니다. 다시 한번 확인해 보니 위 스크립트에서 이 부분 이전까지는 스크립트가 실행되는 것을 확인했습니다.

echo "### Ready to deploy ###"

 

이 echo 명령어전까지는 실행이 되었다는 것은 Jenkins -> Develop의 SSH 연결에서 발생한 문제는 아니라는 것을 의미합니다.

 

정상적으로 SSH 연결을 통해 "/home/praise-push/deploy.sh" 명령까지 들어와서 실행을 했지만, 어떤 이유 때문에 중간에 script가 비정상적으로 종료되어 script가 exit 255 code를 리턴했던 것이라는 판단을 하게 되었습니다.

 

즉, 문제는 deploy.sh 스크립트에 있다!

 

deploy.sh를 하나씩 실행하며 디버깅을 해봤습니다.

 

1. 첫 번째 시도

#!/bin/bash
source ~/.bashrc
REPOSITORY=/home/praise-push

 

당연히 문제는 없겠지만, 이틀 넘게 SSH가 원인인 줄 알고 삽질을 해서 아주 꼼꼼하게... 진행해 보기 위해서 스크립트의 시작 부분부터 진행했습니다.

 

결과는 당연히 성공입니다!

 

 

2. 두 번째 시도

#!/bin/bash
source ~/.bashrc
REPOSITORY=/home/praise-push
echo "### check running application... ###"
CURRENT_PID=$(pgrep -f praise-push)
echo "### current pid = $CURRENT_PID"

 

이 부분도 별 다른 특별한 명령어는 없었기 때문에 성공했습니다!

 

 

3. 세 번째 시도

#!/bin/bash
source ~/.bashrc
REPOSITORY=/home/praise-push
echo "### check running application... ###"
CURRENT_PID=$(pgrep -f praise-push)
echo "### current pid = $CURRENT_PID"

if [[ -z ${CURRENT_PID} ]]; then
    echo "### Not found running application ###"
else
    echo "### Found application!! try to kill process $CURRENT_PID ###"
    kill -15 $CURRENT_PID
    sleep 3
fi

 

예상했던대로 세 번째 시도에서 실패했습니다. 그럼 이 if 블록 내부에 문제가 있다는 것을 의미합니다.

문법적인 오류는 아닌 것으로 보였고, 의심이 갔던 부분은 "kill -15 $CURRENT_PID" 부분이었습니다.

 

 

 

4. 네 번째 시도

#!/bin/bash
source ~/.bashrc
REPOSITORY=/home/praise-push
echo "### check running application... ###"
CURRENT_PID=$(pgrep -f praise-push)
echo "### current pid = $CURRENT_PID"

if [[ -z ${CURRENT_PID} ]]; then
    echo "### Not found running application ###"
else
    echo "### Found application!! try to kill process $CURRENT_PID ###"
    sleep 3
fi

 

의심이 들었던 kill 명령어를 제거한 후 다시 시도해봤는데, Deploy Pipeline이 성공했습니다.

역시 원인은 이 부분이었습니다.

 

 

 

3. 원인 분석


kill 명령어로 CURRENT_PID를 종료하고 있었기 때문에 Deploy Pipeline을 실행시켜 두고, Develop 서버에서 계속 "ps -ef | grep praise-push"을 실행하며 콘솔을 확인해 봤습니다.

root@praise-push-dev:/home/praise-push# ps -ef | grep praise-push
root     22623     1  3 19:01 pts/0    00:00:16 /usr/bin/java -jar /home/praise-push/praise-push-0.0.1-SNAPSHOT.jar
root     23741 16549  0 19:09 pts/0    00:00:00 grep --color=auto praise-push
root@praise-push-dev:/home/praise-push# ps -ef | grep praise-push
root     22623     1  3 19:01 pts/0    00:00:16 /usr/bin/java -jar /home/praise-push/praise-push-0.0.1-SNAPSHOT.jar
root     23789 23720  0 19:09 ?        00:00:00 bash -c                              cd /home/praise-push                             ./deploy.sh
root     23794 16549  0 19:09 pts/0    00:00:00 grep --color=auto praise-push

 

이상한 점을 확인했습니다. Deploy Pipeline이 실행되는 중간에 "praise-push"가 포함된 새로운 프로세스가 하나 실행되는 것을 보았습니다.

root     23789 23720  0 19:09 ?        00:00:00 bash -c                              cd /home/praise-push                             ./deploy.sh

 

살펴보니 Deploy Pipeline에서 실행했던 "cd /home/priase-push" 명령어에서 시작되었던 프로세스가 kill의 대상인 "pgrep -f praise-push"에 발견되어 계속 종료되고 있었습니다.

 

찾은 원인은 이렇습니다.

 

  1. Jenkins에서 SSH를 통해 Develop 서버의 cd /home/praise-push/deploy.sh를 실행 (PID 1)
  2. deploy.sh 스크립트에서 현재 실행 중인 praise-push-0.0.1.jar(PID 2)를 찾기 위해서 "pgrep -f praise-push"을 실행
  3. "pgrep -f praise-push"의 결과를 CURRENT_PID 변수에 저장합니다.
    • 여기서 CURRENT_PID에는 의도대로라면 (PID 2)만 있어야 합니다.
    • 하지만, 결과에는 (PID 1)(PID 2)가 들어가게 됩니다.
    • 이유는 "cd /home/praise-push/deploy.sh"에 praise-push가 포함되어 탐색 대상이 되었던 것입니다.
  4. "kill -15 $CURRENT_PID"에서 (PID 1) (PID 2)를 모두 죽이게 됩니다.
  5. 여기서 deploy.sh의 비정상적인 종료가 되고, exit code 255를 리턴하게 됩니다.

 

 

 

4. 해결


"pgrep -f praise-push" 스크립트를 통해 찾으려는 대상은 "praise-push-0.0.1.jar"인 jar 프로세스이기 때문에 아래와 같이 변경했습니다.

CURRENT_PID=$(pgrep -f praise-push-*.jar)

 

이 부분만 이렇게 수정한 후 Jenkins CI/CD Pipeline을 실행시켜 보니 모든 stage가 통과되었고, 정상적으로 배포에 성공했습니다!

 

 

 

생각보다 굉장히 사소한 실수로 시작된 문제인데, 꽤나 오랜 시간 잡고 있었어서 기록해 봤습니다.

 

1. 기존 아키텍처와 배포 스크립트 분석


 

프로젝트 진행 과정에서 초반에 간단한 인프라 설계를 위처럼 진행했습니다.

먼저 EC2(Ubuntu) 내부에 Node 16 버전을 포함해서 필요한 것들을 모두 수동으로 설치했습니다.

그 후 deploy.sh라는 스크립트 파일을 만들어 배포 스크립트를 작성했습니다.

이제 Github Actions에서 SSH 연결을 통해 EC2에 접근하여 deploy.sh를 실행하여 자동화 배포가 되도록 설정하여 완료했습니다.

 

기존의 스크립트는 아래와 같이 git 저장소에서 프로젝트 최근 파일을 가져온 후 추가된 npm 라이브러리를 확인하고 설치합니다.

프로젝트를 build 하고, 빌드가 성공하면 현재 실행 중인 프로세스를 확인하고 중지시켜 줍니다.

그 후 백그라운드 실행을 위해서 nohup을 통해서 application을 실행시켜 줬습니다.

# deploy.sh

echo "1. project root로 이동"
cd ~/server/ginger-hotel-server

echo "2. git pull"
git pull origin develop

echo "3. 추가된 라이브러리 설치"
npm install

echo "4. project build"
npm run build

if [ $? -eq 0 ]; then
  # 빌드 성공 시
  echo "5. 실행중인 프로세스 확인"
  CURRENT_PID=$(pgrep -f node)

  echo "6. 실행중인 프로세스 중지"
  sudo kill -15 $CURRENT_PID

  echo "7. 홈 경로 이동 후 프로젝트 재실행"
  nohup npm run start:prod > ~/nohup.out 2> ~/nohup.err < /dev/null &
else
  echo "프로젝트 Build 실패"
  exit 1
fi

 

이 상태로도 자동화 배포환경에는 문제가 없었고, 개발 단계에서 크게 불편한 점은 못 느꼈었습니다.

개발이 거의 마무리될 때쯤 두 가지 의문점이 들었습니다.

서버(EC2)가 증설되었을 때, EC2에 필요한 환경(특정 버전 Node)을 모두 수동으로 설치해줘야 하는 건가? Node 말고, 환경이 추가된다면?

실제 서비스를 운영중일 때, Application이 실행되고 있는 서버(EC2)에서 Git Pull을 하고, Build를 하는 과정이 부하를 주어 서버 성능에 영향을 주지는 않을까?

 

이러한 불편했던 점을 해결하기 위해서 "도커"를 활용하여 필요한 환경까지 함께 이미지로 만들고, Build 환경을 운영 서버와 분리해 보자는 생각을 하게 되었습니다.

 

 

 

2. Ubuntu에 도커 설치하기


1. ubuntu 패키지를 먼저 업데이트합니다.

sudo apt-get update

 

2. Docker 설치에 필요한 패키지를 설치합니다.

sudo apt-get install apt-transport-https ca-certificates curl gnupg-agent software-properties-common -y

 

3. Docker 공식 GPG 키를 추가합니다.

curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

 

4. Docker 공식 apt 저장소를 추가합니다.

sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"

 

5. 시스템 패키지를 다시 업데이트합니다.

sudo apt-get update

 

6. Docker를 설치합니다.

sudo apt-get install docker-ce docker-ce-cli containerd.io -y

 

7. Docker의 실행 상태를 확인합니다.

sudo systemctl status docker

 

8. 도커 허브 로그인을 진행합니다.

sudo docker login
  • Docker Hub에서 가입한 이메일과 비밀번호로 로그인을 진행할 수 있습니다.
  • Docker Hub는 무료로 계정 1개당 1개의 Private 저장소를 사용할 수 있도록 제공해 줍니다.
  • 저는 이전에 Private 저장소를 쓴 적이 없기 때문에 이번 프로젝트에서 Private 저장소를 만들어 사용했습니다.

 

 

 

3. 프로젝트의 Root 경로에 DockerFile 생성


여기서 DockerFile은 도커에 존재하는 이미지를 기반으로 하여 내가 작성한 스크립트 파일을 통해 나만의 이미지를 생성할 수 있는 파일입니다. 즉, 저는 여기에 필요한 Docker 이미지를 골라 담아서 제가 만들고 있는 서버에 필요한 이미지를 생성하는 DockerFile을 생성했습니다.

# Node Base Image
FROM node:16-alpine

# RUN mkdir -p /app
WORKDIR /app

# Current Local . to /app/
ADD . /app/

# install Library
RUN npm install

# Build
RUN npm run build

# PORT
EXPOSE 8080

# Start
ENTRYPOINT npm run start:prod
  • 먼저 저는 Node 16 버전을 Base로 해서 Application을 실행시키고 있기 때문에 Base 이미지를 Node의 16 버전을 사용합니다.
  • RUN, CMD, COPY 등이 실행되는 기본 경로를 WORKDIR로 "/app"으로 설정합니다.
  • ADD 명령어를 통해 현재 프로젝트 경로(.)에 있는 소스파일을 WORKDIR로 설정한 도커의 경로(/app/)로 복사합니다.
  • 이제 순서대로 npm 라이브러리를 설치하고, build를 진행합니다.
  • EXPOSE를 통해서 도커 컨테이너가 실행되었을 때, 요청을 기다리고 있을 Listen 포트를 8080으로 지정합니다.
  • 마지막으로 ENTRYPOINT 명령어를 통해서 컨테이너가 생성되고, 최초로 실행될 때 수행하는 명령어를 설정하고 마무리합니다.

 

 

 

4. EC2 서버에서 실행할 스크립트 파일 생성


기존에 작성했던 배포 스크립트에서는 Git을 통해 EC2로 직접 Pull 하여 코드를 통합하고, Build 하는 과정도 모두 EC2에서 진행했었습니다. 하지만 이제 코드 통합과 Build는 DockerFile에 설정된 대로 Github Actions에서 진행하여 Docker Hub의 Private 저장소로 저장하게 됩니다.

 

즉, 이제 EC2에서는 이 Private Docker Hub 저장소에 있는 이미지를 가져와서 실행만 시켜 Deploy 과정을 마무리하는 스크립트를 실행하면 완료됩니다.

# start-docker.sh

echo "1. Pull Docker Image"
sudo docker pull khsrla9806/ginger-hotel:lastest

echo "2. Stop and Remove Container / Remove <none> Image"
sudo docker stop ginger-hotel
sudo docker rm -f $(sudo docker ps -qa -f status=exited)
sudo docker rmi -f $(sudo docker images -qa --filter "dangling=true")

echo "3. 새로운 이미지 실행"
sudo docker run -d --name ginger-hotel -p 80:8080 -v ./logs:/app/logs khsrla9806/ginger-hotel:lastest
  • Github Actions에서 코드 통합, Build를 거친 새로운 Docker Image를 가져옵니다.
  • 현재 실행되고 있는 컨테이너는 종료하고, 기존의 컨테이너와 이미지는 모두 삭제합니다. (계속 누적되는 것을 방지)
  • 새롭게 가져왔던 이미지를 실행시키고 배포를 완료합니다.

 

 

 

5. Github Actions WorkFlow 설정


이제 DockerFile까지 설정을 완료했기 때문에 Github Actions를 통해서 제가 설정한 DockerFile대로 이미지를 만들어 제 Private Docker Hub 저장소에 이미지를 올리고, SSH로 EC2에 접속하여 새롭게 작성한 start-docker.sh 스크립트 파일을 실행할 수 있도록 Github Actions WorkFlow를 설정해야 합니다.

name: GINGER PRODUCTION SERVER DEPLOY

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Source Code
        uses: actions/checkout@v3

      - name: Nodejs 16
        uses: actions/setup-node@v3
        with:
          node-version: 16.20.2
          cache: 'npm'

      - name: Setting Production Env
        run: echo "${{ secrets.PRODUCTION_ENV }}" >> .env.prod

      - name: Login Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_PASSWORD }}

      - name: Build and Push Docker Image
        uses: docker/build-push-action@v4.0.0
        with:
          context: .
          file: ./Dockerfile
          push: true
          tags: ${{ secrets.DOCKERHUB_REPO }}/ginger-hotel:lastest

      - name: SSH Remote Command
        uses: appleboy/ssh-action@v0.1.4
        with:
          host: ${{ secrets.HOST }}
          username: ${{ secrets.USERNAME }}
          key: ${{ secrets.KEY }}
          port: ${{ secrets.PORT }}
          timeout: 40s

          script: ./start-docker.sh

 

위에서 ${{ secrets.USERNAME }} 이런 부분들은 Github Actions에서 사용할 수 있는 Secret 변수들을 설정해 둘 수 있습니다.

 

 

 

6. 동작 확인


이제 main 브랜치에 Merge가 되는 순간 작성해 둔 WorkFlow대로 Github Actions가 실행됩니다.

 

모든 Flow가 완료되면 자동화 배포 작업이 완료됩니다.

 

이렇게 변경한 후의 아키텍처는 다음과 같습니다.

 

 

지금까지 프로젝트의 CI/CD를 할 때, 처음에 소개했던 것 처럼 EC2 서버에게 너무 많은 책임을 부여했던 것 같아서 늘 도커로 해보자라고 생각을 했었는데 드디어 해볼 수 있게 되어 값진 경험을 가져간 것 같습니다.

 

 

 

20210번 파일 탐색기

 

문자열 유형을 혼내주고 있는 도중 내가 혼나버렸다.

문제를 골랐는데, 항상 정답률을 보고 적당한 문제들을 골랐었는데 하하 왜 안 보고 골랐을까.

일단 골라버렸고, 코드는 작성해버렸고 문제를 풀었다.

 


1. 문제 분석


문제를 처음 보고나서 조건이 되게 많다는 것을 느꼈다.

얼핏 보기에는 뭔가 간단하게 그냥 조건 따라서 정렬하면 되는 문제라고 판단됐다. 그래서 선택했을지도...

 

  1. 둘 다 숫자인 경우
  2. 둘 중 하나만 숫자인 경우
  3. 둘 다 숫자가 아닌 경우

크게 이렇게 3가지 경우로 나눌 수 있었다.

 

  1. 둘 다 숫자라면?
    • 십진법으로 두 숫자의 크기를 비교한다.
    • 더 작은 숫자가 우선순위가 더 높다.
    • 만약 두 숫자의 크기가 같다면, 앞에 0의 개수가 적은 것이 우선순위가 더 높다.
    • 두 숫자의 크기도 같고, 앞에 0의 개수도 동일하다면 우선순위가 동일하다. (저는 여기서...시간낭비를...)
  2. 둘 중 하나만 숫자라면?
    • 숫자가 문자보다 우선순위가 높다.
  3. 둘 다 문자라면?
    • 문자의 우선순위는 다음과 같은 우선순위를 갖는다.
    • 두 문자가 같으면(A와 A) 우선순위가 동일하다.
    • 두 문자의 종류가 같으면(A와 a) 대문자가 우선순위가 높다.
    • 두 문자의 종류가 다르면(A와 c) AaBbCcDd....XxYyZz와 같은 우선순위를 갖는다. (문제 제대로 안 읽으면 여기서 고생)

또 고려해야 할 것이 있다. 이어져있는 숫자를 하나의 문자열로 사용을 하는데, 주어지는 숫자는 2^63을 초과할 수 있기 때문에 일반적인 Long 타입은 사용할 수 없다.

 

문제를 다 풀고 나서 다른 분들의 답을 봤는데, 반복문과 Index를 아주 매력적으로 사용해서 푸신 분들을 많이 봤는데... 존경스럽다.

나는 아주 큰 수도 저장할 수 있는 Java의 BigDecimal을 사용했다.

 

왜냐! BigDecimal은 Comparable 인터페이스를 구현하고 있어서 compareTo를 사용할 수 있기 때문에 사용해 봤다.

 

 

 

2. 소스코드


import java.io.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.PriorityQueue;

public class Main {
    public static void main(String[] args) throws IOException {
        BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));

        int N = Integer.parseInt(reader.readLine());
        PriorityQueue<Value> queue = new PriorityQueue<>();

        for (int i = 0; i < N; i++) {
            queue.add(new Value(reader.readLine()));
        }

        StringBuilder builder = new StringBuilder();
        while (!queue.isEmpty()) {
            builder.append(queue.poll()).append("\n");
        }
        System.out.println(builder);
    }
}

class Value implements Comparable<Value> {
    String value;
    ArrayList<String> seperatedValue;

    public Value(String value) {
        this.value = value;
        setSeperatedValue(value);
    }

    private void setSeperatedValue(String value) {
        this.seperatedValue = new ArrayList<>();
        StringBuilder tempBuilder = new StringBuilder();

        for (int i = 0; i < value.length(); i++) {
            char temp = value.charAt(i);

            if (Character.isDigit(temp)) {
                tempBuilder.append(temp);
                continue;
            } else if (tempBuilder.length() > 0) { // 숫자 아니고, 빌더에 값이 있는 경우
                seperatedValue.add(tempBuilder.toString());
                tempBuilder = new StringBuilder();
            }
            seperatedValue.add(String.valueOf(temp));
        }

        if (tempBuilder.length() > 0) { // 다 돌고 빌더에 남아 있는 것이 있으면 마저 넣어줌
            seperatedValue.add(tempBuilder.toString());
        }
    }

    @Override
    public int compareTo(Value other) {
        ArrayList<String> seperatedValueOfOther = other.seperatedValue;
        int smallSize = Math.min(seperatedValueOfOther.size(), this.seperatedValue.size());
        for (int i = 0; i < smallSize; i++) {
            String valueOfThis = this.seperatedValue.get(i);
            String valueOfOther = seperatedValueOfOther.get(i);

            boolean isNumberOfThis = isNumber(valueOfThis);
            boolean isNumberOfOther = isNumber(valueOfOther);

            // 둘 다 숫자인 경우
            if (isNumberOfThis && isNumberOfOther) {
                BigDecimal decimalOfThis = convertToDecimal(valueOfThis);
                BigDecimal decimalOfOther = convertToDecimal(valueOfOther);

                if (decimalOfThis.equals(decimalOfOther)) {
                    // 값도 같고, 0의 개수도 같은 경우
                    if (valueOfThis.length() == valueOfOther.length()) {
                        continue;
                    }
                    return valueOfThis.length() - valueOfOther.length();
                }
                return decimalOfThis.compareTo(decimalOfOther);
            }

            // 둘 중 하나만 숫자인 경우
            if (isNumberOfThis || isNumberOfOther) {
                return isNumberOfThis ? -1 : 1;
            }

            // 둘 다 문자인 경우
            int comparedValue = compare(valueOfThis, valueOfOther);

            if (comparedValue == 0) {
                continue;
            }

            return comparedValue;
        }

        return this.value.length() - other.value.length();
    }

    private int compare(String value1, String value2) {
        if (value1.equals(value2)) {
            return 0;
        }
        if (value1.equalsIgnoreCase(value2)) {
            if (value1.toUpperCase().compareTo(value1) == 0) {
                return -1;
            }
            return 1;
        }

        return value1.compareToIgnoreCase(value2);
    }

    private boolean isNumber(String value) {
        return Character.isDigit(value.charAt(0));
    }

    private BigDecimal convertToDecimal(String value) {
        return new BigDecimal(value);
    }

    @Override
    public String toString() {
        return value;
    }
}

 

처음에 BigDecimal의 생성자를 무자비하게 그냥 사용했었는데, 속도가 많이 떨어지는 것을 느꼈다.

 

그래서 isNumber()라는 메서드를 분리해서 해당 문자열이 숫자인지 확인한 후에 숫자인 경우에만 BigDecimal을 사용하도록 변경했더니 속도가 어느 정도 개선은 됐다.

 

그래도 확실히 속도는 조금 떨어지는 것 같아 보이기는 했다. 집념으로... 풀어서 그래도 오늘 문제 해결해서 잠들 수는 있을 거 같다.

 

집념의 흔적....isNumber()를 적용한 후에 1848ms에서 1384ms까지 속도가 올라가는 것을 확인할 수 있었다.

+ Recent posts