1인 개발자 인프라 생존기: 시말서로 배우는 배포

회사가 공중분해 된 날에도, 버그 수정 요청은 온다

입사 6개월 차, 회사의 사정으로 조직이 공중분해 되고 우리 팀만 덩그러니 독립하게 된 그날. 사무실은 어수선했고, 팀장님은 “난 이제 기술은 잘 몰라, 네가 다 해야 해”라며 인프라 관리의 전권을 나에게 넘겼다.

그 혼란스러운 와중에도 전화벨은 울렸다. 고객사였다. “저기요, 로그인 페이지에서 에러 나는데요? 빨리 고쳐주세요.”

머리가 하얘졌다. 예전 같았으면 로컬에서 고치고 git push만 하면, 타 부서 서버에 있던 젠킨스(Jenkins)나 깃랩(GitLab)이 알아서 운영 서버에 배포해 줬을 것이다. 하지만 그 연결고리는 끊어졌다.

이제 나를 도와줄 ‘배포 로봇’은 없다. 오직 내 손으로 이 코드를 운영 서버까지 옮겨야 한다. 막막했다. 하지만 그때, 지난 몇 달간 맨땅에 헤딩하며 익혔던 지식들이 머릿속을 스치고 지나갔다.

‘잠깐, 침착하자. 원리를 알면 수동으로도 할 수 있어. 하나씩 안전하게 옮겨보자.’

자동화가 멈춘 순간, 그동안 쌓아온 지식들이 나를 지켜줄 안전장치가 되었다.

Git과 GitLab: 코드의 저장소

우선 끊어진 다리부터 다시 놓아야 했다. 가장 먼저 필요한 건 ‘Git(깃)’과 ‘GitLab’이었다.

  • Git: 내 코드를 저장하고 버전(역사)을 관리해 주는 도구다. (타임머신)
  • GitLab: Git 저장소 기능뿐만 아니라, 배포 자동화(CI/CD) 도구까지 하나로 합쳐진 플랫폼이다.

“보통은 GitHub를 많이 쓰지 않나요?” 맞다. 대중적으로는 GitHub (GitHub Actions)를 가장 많이 쓰고, 전통적인 강자인 Jenkins를 따로 구축해서 쓰기도 한다.

하지만 우리는 외부 접속이 제한된 폐쇄적인 사내망 환경이었고, 우리 서버에 직접 설치해서 쓸 수 있는 GitLab(Self-Hosted)이 최선의 선택이었다. 도구는 달라도 ‘코드를 저장하고 자동으로 배포한다’는 본질은 모두 같다.

밤을 새워가며 서버에 GitLab을 설치하고 코드를 옮겼다.

“휴, 이제 다시 push 하면 배포되겠지?”

나는 설레는 마음으로 코드를 수정하고 git push origin master를 날렸다. GitLab 저장소에는 코드가 잘 올라갔다. 하지만 운영 서버는 요지부동이었다. 아무리 기다려도 기능이 업데이트되지 않았다.

수동 배포의 삽질: 잃어버린 과정을 찾아서

“어? 왜 배포가 안 되지?” 물어볼 사수도, 의지할 팀장님도 없다. 나는 스스로 답을 찾아야 했다.

곰곰이 생각해보니 이상했다. GitLab은 그냥 ‘코드를 저장하는 도서관’일 뿐이다. 도서관에 새 책이 들어왔다고 해서, 그게 자동으로 서점(운영 서버) 진열대에 깔리는 건 아니지 않은가? 예전엔 중간에 누군가가 이 배달을 해줬던 것이다.

“그럼 내가 직접 배달해야 하나?”

나는 수동으로 배포하는 과정을 하나씩 밟아보기 시작했다. 생각보다 훨씬 복잡하고 귀찮았다.

1단계: 테스트와 빌드 (Test & Build) – 불량품 걸러내기

가장 먼저 해야 할 일은 내 컴퓨터에 있는 자바 코드가 정상인지 검증하고, 실행 가능한 상태로 만드는 것이다. 단순히 파일을 복사하면 안 된다. 내가 수정한 코드가 기존 로직을 망가뜨리진 않았는지(테스트), 문법 오류는 없는지(빌드) 확인해야 한다.

나는 터미널을 열고 명령어를 쳤다. ./gradlew test clean build

초록색 체크 표시와 함께 모든 테스트가 통과했다는 메시지가 떴다. 다행이다. 만약 여기서 에러가 났다면? 그건 ‘불량품’이다. 서버에 올라가면 안 되는 코드다. 이 과정은 코드가 최소한의 자격을 갖췄는지 검사하는 ‘첫 번째 안전장치’였다.

2단계: 이미지 생성 (Dockerize) – 환경 박제하기

이제 이 jar 파일을 서버로 보내야 한다. 그냥 파일만 보내면 될까? 아니, ‘Works on My Machine’ 시리즈에서 뼈저리게 느꼈다. 내 컴퓨터(Java 17)와 서버(Java 8)의 환경이 다를 수 있다는 것을. 그래서 우리는 ‘환경을 통째로 얼려서’ 보내기로 했다. 바로 ‘도커(Docker)’다.

나는 프로젝트 루트에 Dockerfile을 만들고, 익숙하게 작성했다.

FROM openjdk:17-alpine
COPY build/libs/my-app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

그리고 이미지를 구웠다. docker build -t my-app:v1 .

이제 내 코드는 어떤 서버에 던져놔도 똑같이 돌아가는 불멸의 ‘컨테이너 이미지’가 되었다. 도커는 환경 차이로 인한 에러를 막아주는 ‘두 번째 안전장치’였다.

3단계: 운송 (Push & Pull) – 공용 창고 이용하기

이미지는 준비됐다. 이제 이걸 운영 서버로 옮겨야 한다. 다행히 ‘Docker Hub’는 사내 서버가 아니라 인터넷에 있는 외부 서비스(SaaS)였다. 회사가 공중분해 되면서 사내망에 있던 GitLab 연결은 끊겼지만, 외부 망으로 통하는 길은 아직 열려 있었던 것이다.

나는 떨리는 손으로 이미지를 공용 물류 창고인 Docker Hub로 쏘아 올렸다.

  1. 내 컴퓨터에서 업로드: docker push my-id/my-app:v1

잠시 후 업로드가 완료되었다는 메시지가 떴다. 이제 반대편인 운영 서버에 접속할 차례다. 서버실로 뛰어가는 게 아니다. ‘SSH(Secure Shell)’를 통해 안전하게 원격 접속을 한다.

ssh user@production-server

접속된 검은 화면(CLI)에서, 아까 올려둔 이미지를 내려받는다. docker pull my-id/my-app:v1

4단계: 배포 (Run) – 교체하기

드디어 마지막 단계다. 서버에는 이미 구버전 서버가 돌아가고 있다. 이걸 끄고 새 버전을 띄워야 한다. 리눅스 명령어들이 손가락 끝에서 나갔다.

  1. 기존 컨테이너 중지 및 삭제: docker stop my-app && docker rm my-app
  2. 새 컨테이너 실행: docker run -d --name my-app -p 80:8080 my-id/my-app:v1

엔터를 치자마자 긴 해시값이 출력되고, docker logs -f my-app으로 로그를 확인하니 익숙한 스프링 로고가 떴다. 고객사에서 다시 연락이 왔다. “어? 이제 잘 되네요. 감사합니다!”

힘들었지만 내 손으로 해냈다. 원리를 이해하니 수동으로도 배포가 가능했다.

하지만 ‘사람’은 결국 실수한다

첫 수동 배포는 성공적이었다. 나는 자신감에 찼다. “뭐야, 혼자서도 할 만하네?” 하지만 그 오만함은 오래가지 않았다. 반복되는 수동 작업 속에서 인간의 한계는 금방 드러났다.

위기 1. 외나무다리에서 만난 버그 (Git Branch의 부재)

며칠 뒤, 고객사 A의 요청으로 ‘로그인 기능’을 수정하고 있었는데, 갑자기 고객사 B에서 ‘결제 기능’에 치명적인 버그가 터졌으니 당장 고쳐달라는 연락이 왔다.

나는 당황했다. Git을 쓰고는 있었지만, 혼자 개발한다는 핑계로 master 브랜치 하나에서 모든 작업을 하고 있었기 때문이다. 로그인 기능을 고치느라 코드는 이미 엉망진창으로 섞여 있었다.

“어? 지금 이 상태로 결제 버그만 고쳐서 배포하면, 미완성된 로그인 코드까지 같이 올라가는데?”

결국 나는 원시적인 방법으로 작업하던 코드를 임시 파일로 백업하고, git reset으로 코드를 되돌려야 했다. 그 과정에서 실수로 몇몇 코드를 날려 먹는 참사까지 벌어졌다.

위기 2. 나에겐 동료가 없다 (Test Code의 부재)

우여곡절 끝에 결제 버그를 수정하고 배포했다. 한숨 돌리려던 찰나, 이번엔 ‘회원가입’이 안 된다는 연락이 왔다. 알고 보니 결제 로직을 수정하면서 공통으로 쓰는 유저 검증 함수를 건드린 게 화근이었다.

“아… 누군가 내 코드를 봐줬더라면(Code Review) 이런 실수는 안 했을 텐데.”

하지만 나에겐 코드를 봐줄 사수도, 동료도 없었다. 나는 혼자였다. 사실 회원가입 기능은 너무 정신없이 만드는 바람에 유닛 테스트(Unit Test)를 짜두지 못했다. 만약 그때 귀찮더라도 테스트 코드를 만들어뒀다면, 내가 결제 로직을 건드려 회원가입을 망가뜨렸을 때 기계가 빨간불을 켜줬을 것이다.

위기 3. “서버가 죽었어요!” (Docker Tag의 부재)

그리고 며칠 뒤, 결정타가 터졌다. 급하게 기능을 추가해서 배포했는데, 서버가 켜지자마자 비즈니스 로직 에러로 뻗어버린 것이다. (테스트를 생략한 게 화근이었다.)

고객사에서 전화가 빗발쳤다. “빨리 이전 버전으로 롤백해 주세요!” 나는 떨리는 손으로 서버 터미널을 열었다. 하지만 롤백할 방법이 없었다.

나는 매번 귀찮다고 이미지 이름을 my-app:latest로만 덮어씌워서 올리고 있었다. v1, v2 같은 버전 태그를 따로 붙이지 않으니, 이전 버전의 정상 이미지는 이미 사라지고 없었다. 결국 나는 멈춰버린 서버를 뒤로하고, 로컬에서 다시 코드를 고치고 처음부터 다시 빌드해야 했다. 이미지를 만들고, 운영서버로 옮겨 컨테이너를 재실행하는데 까진 10분이란 시간이 소요됐고  나는 서버가 멈춘 사건의 경위를 작성하여 보고해야 됐다.

마치며: 이 모든 과정의 이름이 ‘파이프라인’이었다

그 10분의 지옥 같은 다운타임과 맞바꾼 시말서를 쓰며, 나는 지난 며칠간 내가 겪은 일들을 되짚어 보았다.

  1. Git Branch: 코드를 안전하게 격리하고 관리하는 시작점.
  2. Test & Code Review: 불량품이 다음 단계로 넘어가지 못하게 막는 거름망.
  3. Build & Dockerize: 코드를 실행 가능한 환경으로 포장하는 과정.
  4. Docker Tag & Push: 만약의 사태를 대비해 버전을 기록하고 저장하는 과정.
  5. Deploy: 안전한 이미지를 서버에서 실행하는 마지막 단계.

나는 그동안 이 과정들을 파편화된 지식으로만 알고 있었다. 때로는 귀찮아서 생략했고, 때로는 몰라서 건너뛰었다. 하지만 실무에서 겪은 사고들은 이 모든 단계가 하나라도 빠지면 안 되는 ‘필수 안전장치’임을 증명해 주었다.

그리고 깨달았다. 내가 그토록 찾아 헤매던 ‘자동화’의 정체가 무엇인지. 그것은 단순히 명령어를 대신 쳐주는 기계가 아니었다. 이 1번부터 5번까지의 과정을 물 흐르듯 끊김 없이, 그리고 사람의 실수 없이 자동으로 흘러가게 만드는 길.

그것이 바로 CI/CD 파이프라인(Pipeline)이었다.

“팀장님, 저 이제 알겠습니다. 우리에게 필요한 건, 이 안전장치들이 자동으로 돌아가는 ‘파이프라인’입니다.”

다음 시간에는 이 설계도를 바탕으로, 나만의 배포 로봇 CI/CD 파이프라인을 실제로 구축해 보자.

지금까지의 모든 과정을 하나로 잇는 것, 그것이 파이프라인이다.

댓글 남기기