신입 개발자의 온보딩 후기
정현김팀스파르타 개발팀에서의 2주간 온보딩이 끝났다. 몰랐던 많은 지식들을 알아가고, 나의 잘못된 습관을 발견하는 과정이었기에 너무 중요한 시간이었다. 기록하지 않으면 아마 관성적으로 무지와 악습으로 돌아가려고 할 것이다..😂 “무엇을 알게 되었을까?” 와 “느낀점 그리고 다짐” 파트로 나누어 이번에 배운 것들을 기록해보고자 한다.
무엇을 알게 되었을까?
- TDD (Test Driven Development)
- Docker
- ECR
- ECS
- CodePipeline
TDD
경험하며 느낀 TDD의 장점
TDD란 말 그대로 테스트가 주도하는 개발이기 때문에, 사후적 검증용으로만 테스트를 사용하지 않는다. 즉 본 코드가 수행해야 할 기능에 대한 테스트코드 작성이 선행된다. 그리고 코드를 수정하거나 기능이 추가되는 과정에서도 테스트코드는 일종의 실시간 감시자 역할을 해줄 수 있다.
당연히 본코드가 완성되기도 전에, 그리고 작성 과정자체에 테스트 코드가 관여를 해야하기 때문에, TDD의 순서는 다음과 같다.
이런 TDD 방식은 왜 권장될까? 주로 다음의 이유들이 이야기된다.
- “잦은 피드백”을 통해 코드의 “신뢰도”를 높인다.
- “깨끗한 코드”** 작성에 도움이 된다.**
- “협력”에 큰 도움이 된다.
이번 온보딩에서 나는 조금이나마 위의 이점을 느껴볼 수 있었다. 앞으로 본격적인 업무에 들어서서는 특히 ‘협력’에서의 TDD의 이점 또한 잘 경험할 수 있으면 좋겠다.
잦은 피드백을 주는 테스트코드
먼저 첫 번째를 살펴보자. TDD 장점을 표현하는 문장 중 하나는 “피드백과 결정의 갭을 줄인다”는 것이다. 피드백이란 ‘해당 코드가 원하는 기능을 정말 수행하니?’ 라는 것을 알려주는 테스트코드 쪽의 역할이고, 결정은 의도를 가지고 코드를 작성해나가는 개발자의 작업이다. 즉 이 두 가지의 간격을 줄인다는 것은 그만큼 코드의 신뢰성을 높여준다는 것이다. 이번 온보딩에서도 결제 모듈에 대한 테스트 코드를 작성하는 과정에서도 주는 ‘잦은 피드백’의 이점을 느껴볼 수 있었다.
약소한 예를 살펴보자. 다음은 주문 개시함수(initOrder)를 검증하는 테스트들이다.
나는 주문 대상을 온라인 강의로 가정했다. 이 경우에도 특정 인원수 한정으로 얼리버드 특가가 적용될 수 있는데, 그렇다면 재고부족시 주문이 불가해야 한다. 처음에는 이 ‘재고부족’이라는 사태만을 가려내면 된다고 생각하고, initOrder라는 함수에 product 정보만 파라미터로 전달하는 식으로 작성했다.
describe('재고에 따른 주문 개시 여부 테스트', () => {
// dummy data for test
// outOfStockProduct는 재고0으로 설정한 dummy product
// enoughStockProduct는 재고충분히 설정한 dummy product(매 테스트마다 생성됨)
test('재고가 없다면 주문을 개시할 수 없다. (실패)', () => {
expect(() =>initOrder(outOfStockProduct).toThrow(StockError));
});
test('재고가 있다면 주문을 개시할 수 있고, 재고가 하나 줄어든다. (성공)', () => {
conststockBefore = enoughStockProduct.stock;
initOrder(enoughStockProduct);
expect(enoughStockProduct.stock).toEqual(stockBefore -1);
});
});이렇게 아주 조촐하지만 작은 테스트코드를 성공시켰다. 그러나 이후에 initOrder에 대해 사용자의 이전 구매내역에 따른 검증도 추가하고 싶어졌다. ‘평생 소장하는 강의라고 가정하면, 이미 구매한 강의를 또 구매하는 일은 없어야 하지 않을까?’ 싶었기 때문이다. 그래서 다음의 모양을 가진 테스트코드가 추가되었다.
describe('사용자 구매내역에 따른 주문 개시 여부 테스트', () => {
let same;
let different;
let different2;
beforeEach(() => {
same =new Product({
name: "test1",
price: 5000000,
stock: 5000000
});
different =new Product({
name: "test2",
price: 5000000,
stock: 5000000
});
addProduct(user, same.id);
});
afterEach(() => {
user.products = [];
});
test("기존에 구매했던 강의는 구매할 수 없다. (실패)", () => {
expect(() =>initOrder(user, same)).toThrow("User has bought this product before.");
});
test("기존에 구매하지 않았던 강의라면 구매할 수 있다. (성공)", () => {
initOrder(user, different);
});
});위의 테스트코드를 성공시키도록 InitOrder를 수정한다. 그렇다면 이렇게 본 코드 변경이 일어난 후에도 재고부족에 대한 처리를 제대로 하고 있는가를 확인해야 하고, 테스트코드의 초록불을 통해 안심할 수 있었다. (물론 user라는 파라미터를 테스트코드에서도 추가로 넣어주어야 했다.)
조촐한 사례이지만, 코드에 기능이 추가되거나 수정되는 일이 잦다면, 기존의 것들에 어떤 영향을 주는지가 굉장히 중요할 것이다. 그 역할을 테스트코드가 우뚝 서서 **“잦은 피드백”**을 줌으로써 수행해주고 있었다. 물론 번거로운 점도 있었다. 내가 테스트 코드를 작성하는 방법이 잘못되었기 때문일 수도 있지만, 변경마다 테스트 코드 쪽에도 변화를 반영 또는 고려해야 하는 경우도 있어서 조금 손이 더 간다고 느끼기도 했다. 하지만 업데이트와 수정이 잦은 작업환경에서는 혹시라도 산으로 갈 위험을 방지해줄 수 있는 큰 장점이 있다고 느꼈다.
본코드가 단일책임에 가까워지도록 하는 테스트코드
솔직히 아직 본코드를 깔끔하게 작성하지 못하기에, 테스트코드가 주는 이 이점을 충분히 누리지 못했다. 그럼에도 테스트코드가 왜 ‘깔끔한 코드’ 작성에 기여할 수 있는지를 조금이나마 느껴볼 수 있었다. 왜냐하면, 단위테스트 작성을 먼저 해두다보니, 어떤 “최소기능”이 요구되는가를 먼저 명료히 볼 수 있었기 때문이다.
예를 들어, 주문 개시에 대해 그냥 본코드를 바로 생각했다면 나는 아마 다음과 같이 펑퍼짐(?)하게 생각했을지도 모른다. “사용자로부터 입력도 잘 받아와서(api관련됨), 재고확인하고(비즈니스로직1), 사용자 주문내역 확인하고(비즈니스로직2), .. 주문 개시 성공이면 db에 반영하고(db접근)..” 물론 관례를 따라서 컨트롤러와 서비스, 디비접근 적어도 이 세 가지 단위는 끊어서 코딩을 했겠지만, 특히나 비즈니스로직 부분은 하나의 함수 안에서 처리했을 수도 있겠다는 생각이 들었다. 하지만 단위테스트를 먼저 작성하려다보니, 검증하려는 각 단위가 명료하게 보였기에 그에 따라 분리된 함수들을 작성하게 되었다.
내가 경험한 이런 정도의 효과를 두고 테스트코드의 장점이라고 하기는 너무나 조촐하지만..^^ 그럼에도 최소기능 및 책임을 먼저 명시하고 본코드를 작성하는 것은 확실히 사족을 줄여주겠구나 하는 것을 느낄 수 있었다.
협업에 도움이 되는 테스트코드
테스트가 미리 작성되어 기록으로 보유될 때의 큰 장점은, 이를 공유할 수 있게 된다는 것이다. 이 공유는 개발 과정 어느 시점에서나 유용하다. 예를 들어 해당 코드를 처음 보는 사람 입장에서도 코드에 대한 이해를 좀 더 빠르게 수월하게 할 수 있다. 그리고 여러 개발자가 협업하는 환경에서도, 나의 코드 변경으로 인해 다른 개발자가 보장해놓은 기능들에 영향을 미치지 않았는지 테스트코드를 통해 확인할 수 있다.
이 이점은 본격적으로 업무에 들어서면서 앞으로 더 잘 경험하고픈 이점이다. 물론 온보딩단계에서도 조금이나마 이러한 이점을 느꼈다. 다른 팀원분들의 테스트코드를 보니, 본코드가 어떤 비즈니스로직을 갖고 있는지를 이해하는 데에 도움을 받을 수 있었기 때문이다. 협업에서도 신뢰성, 피드백, 그리고 상호 코드에 대한 이해를 높이는 데에 TDD가 줄 수 있는 이점을 잘 경험하고 체득해가고 싶다.
🐳 Docker
docker는 어플리케이션을 패키징할 수 있는 툴이다. 여기서 중요한 것은 컨테이너이다.
컨테이너란 격리된 소프트웨어 단위로서 어플리케이션과 그에 필요한 환경설정, 의존성 등을 묶어놓고, 다른 컴퓨터나 운영체제에서도 동일하게 실행할 수 있도록 하는 것이다.
도커가 할 수 있는 일은 요약하자면 다음과 같다.
- 컨테이너(를 위한 이미지)를 만든다** **→ 배포 및 구동한다
각 단계별로 어떤 작업들을 해야 하는지 살펴보자.
- Build: 컨테이너(를 위한 이미지)를 만든다.
여기서 중요한 것은 Dockerfile이다. 이것은 하나의 컨테이너를 만들기 위한 문서, 레시피라고 볼 수 있다.
# node앱서버용 컨테이너를 위한 dockerfile
FROM node:alpine
WORKDIR /usr/src/app
COPY ["package*.json", "./"]
RUN npm ci
COPY [".", "."]
EXPOSE 3000
CMD ["npm", "start"]이런 식으로 생긴 dockerfile은, 기본이 될 이미지를 가져오고, 필요한 파일을 COPY하는 등의 명령어 수행, 환경변수를 명시하는 등의 역할을 한다. 즉 이 도커파일을 가지고 “아~ 너의 어플리케이션은 이런 레시피로 구동하면 되는구나”라고 생각할 수 있고, 이를 바탕으로 도커 이미지를 만들 수 있다.
docker build -f <dockerfile명> -t <태그> .
// 도커이미지를 빌드한다. f: dockerfile명, t: 이미지 이름을 지정 , ".": build context ("."이므로 -> dockerfile은 현재 경로에서 찾아봐) 물론 이렇게 이미지 여러개를 만들어 함께 운용할 수 있다. 예를 들어 이번 온보딩에서는 nginx 프록시서버를 위한 이미지가 별도로 필요했고, 그 외에도 데이터베이스를 위한 이미지 등이 웹어플리케이션과 더불어 필요할 수 있다. 이 때 그 구성, 연결관계를 명시하는 것이 docker-compose.yml 파일이다.
services 라는 하위에 필요한 컨테이너들에 대한 정보들을 명시한다. 이미지를 가져와서 명시한 정보대로 컨테이너를 형성하거나 해당 경로의 dockerfile을 찾아 build를 하는 것도 가능하다. 그리고 각 컨테이너들이 동일 compose파일에 있다면 기본적으로 네트워크를 공유하지만, 아래 파일처럼 명시적으로 생성해줄 수 있다.
version: '3'
services:
mongo_db:
container_name: db_container
image: mongo:latest
restart: always
ports:
- 27017:27017
volumes:
- mongo_db:/data/db
networks:
- jh-network
web-backend:
build: .
ports:
- 3000:3000
volumes:
- .:/usr/src/app
container_name: web
networks:
- jh-network
proxy:
image: nginx:latest
container_name: proxy
volumes:
- ./proxy/nginx.conf:/etc/nginx/nginx.conf
ports:
- 80:80
depends_on:
- web
- mongo_db
networks:
- jh-network
volumes:
mongo_db: {}
networks:
jh-network:
external: true- 배포 및 구동한다.
위의 과정을 통해 만들어진 것을 ‘도커 이미지’라고 한다. 즉 컨테이너라는 객체(들)에 대한 스냅샷 혹은 클래스와 같은 것이 도커 이미지이다. 그럼 이런 이미지는 왜 쓸까? 당연하게도, 이 이미지를 이용해 쉽게 어디서나 어플리케이션을 구동할 수 있기 때문이다. 그런데 “어디서나” 이미지를 가져오려면 당연히 원격 저장소가 필요하다. 가장 보편적으로** **이미지를 저장하는 원격저장소 중 하나는 docker hub이 있고, 많은 회사에서 private한 원격저장소로 사용하는 것은 **aws의 ECR(Elastic Container Registry)**이다. 이번 온보딩에서는 팀스파르타의 배포라인에 포함되어 있는 ECR을 다루어보게 되었다.
ECR(Elastic Container Registry)
ECR은 도커 이미지로서 패키징된 해당 어플리케이션의 원형(?)을 저장해두는 곳이다. aws의 서비스인만큼 이후 배포 서비스와 연동하기 좋다는 장점도 당연히 갖는다.
하나의 ECR 저장소는 한 종류의 이미지에 대한 관리를 한다. 이때 동일한 저장소에 대해 태그를 달리하면서 push를 하면 버전관리도 할 수 있는데, 마치 github에서의 push와 비슷하다. 사실 비슷하기만 할 뿐만 아니라, 실제로 태그에 git commit코드를 사용한다. TAG := ``_$$_``(git log -1 --pretty=format:%h)
또한 물론 push할 때마다 태그를 latest, git commit code 둘 모두 달아주어서, 항상 최신 버전의 것을 인지하고 pull할 수 있도록 설정한다.
# Makefile 에서 이미지 build시 저장소 이름으로 사용할 상수설정인데, 이때 두 개의 태그
ECR_IMG_COMMIT_SERVER :=${ECR_ENDPOINT_SERVER}:${TAG}
ECR_IMG_LATEST_SERVER :=${ECR_ENDPOINT_SERVER}:latest이때** push의 주체가 되는 docker host에는 ECR로 이미지를 업로드를 할 수 있는 권한이 있어야** 하므로 _“AmazonEC2ContainerRegistryFullAccess Policy”_를 해당 호스트에 로그인된 IAM user에 허용된 정책으로서 연결시켜주어야 한다. ( +나의 tmi.. 이번 온보딩에서 조금씩 더 많은 aws서비스를 접하면서, 각 서비스마다 요구하는 IAM의 권한들을 연결해주고, 또는 직접 정책을 만들어 추가하는 것이 필요하다는 것을 경험할 수 있었다. )
그렇게 push가 성공하면 해당 Repository에 태그가 붙은 채로 이미지가 업로드 된다. (untagged 는 git commit이 추가되지는 않은 채로 로컬에서 build 및 push한 결과 이전 태그가 untagging되었기 때문에 생겼다. 이것은 이 뒤에 pipeline에 연결하여 github 의 새로운 push에만 의존하는 것으로 변경한다면 발생하지 않을 일..!)

ECS(Elastic Container Service)
이미지로까지 저장해놓은 우리들의 컨테이너는 어떻게 실제로 운용해야 할까? 웹 서비스에서 단일 컨테이너만을 사용하는 경우는 흔치 않다. 즉 여러 컨테이너들이 서로 어떻게 조응하도록 운용할지에 대한 결정/작업이 필요하다. 이를 Container Orchestration이라고 한다. aws의 ECS는 이 Orchestration를 위한 것으로서, “완전 관리형 Container Orchestration Tool”이다. 앞서 ECR에 저장한 이미지들이 재료라면, 이것들을 가져와 실제 운용될 서비스로 구성하는 것이 ECS가 하는 일이다.
여기서 다루어지는 몇가지 핵심 개념은 Task, Service, Cluster 등이 있다.
-
Service
-
정의해 둔 Task(작업)이 실제로 구동되도록 하고 이를 관리하는 것이다.
-
ELB(Elastic Load Balancer)와의 연동 등이 이 레벨에서 이루어진다.
-
Cluster
-
클러스터는 도커 컨테이너를 실행할 수 있는 가상의 공간, 논리적 단위.
-
보통은 프로젝트 단위, 업무 단위로 구분짓는다고 한다.
즉, ECS를 다루는 과정을 거칠게 요약하면 다음과 같다.
- 내가 운용하고자 하는 작업의 명세를 작성한다. (작업정의)
- 내가 생성한 클러스터에서 작성한 작업을 재료로 서비스를 생성한다. (클러스터에서 서비스 생성)
CodePipeline
그럼 이제 지금까지 다룬 것이 어떻게 연결되는지 살펴보자. 즉 크게 코드로 작성된 source레벨, 그리고 해당 소스코드가 유효한 컨테이너 이미지로서 build되는 레벨, 마지막으로 deploy레벨이 있다.
- source
- 단순히 새로운 커밋이 github에 푸시되면, aws codebuild가 이를 감지하고 소스코드를 가져온다.
- 단 이때 git에 대한 접근 권한이 aws에서 source 가져오는 arn에 있어 허용되어야 한다.
- build
- github에서 소스코드를 잘 가져왔다면 buildspec에 따라 이미지를 build하고 ECR에 push한다.
- 물론 buildspec파일에서 모두 처리할 수도 있으나 make명령어를 통해 Makefile에 접근하여 이미지에 관한 build 및 push 처리하도록 한다.
- deploy
- ECS에 서비스로서 연결한 작업이 ECR로부터 이미지를 가져온다.
- 운용과 배포에 관한 ECS 설정에 따라 서비스 관리 및 배포가 진행된다. 온보딩에서는 따로 EC2 Instance를 세팅해두지 않는 serverless 방식이라는 Fargate를 사용했다.
이 과정이 나에게는 참 방대하고, 앞서서 설정한 것이 어떻게 서로 연결되는 것인지에 대한 감을 잡지 못해 한참 헤맸었다. 그래도 엄청난 우당탕한 시행착오들을 지나면서 한 번의 흐름을 만들어보고 나니 그나마 마음의 평안이 찾아왔다..^^
아직 이 흐름 속에서 구체적으로 더 알아가야 할 것들이 굉장히 많다. 예를 들면,
- Fargate는 EC2를 미리 세팅하는 것과 어떻게 다르게 작동하는 것인가
- VPC 설정은 어떻게 해야 하는가
- 각 aws서비스 단계에서 arn에 대해 허용되어야 하는 정책들은 무엇인가 등등 아주 많다ㅎㅎ
느낀점 그리고 다짐
온보딩은 나의 무지와 나쁜 습관을 발견하는 시간이었다. 그래서 몇 가지 다짐은 다음과 같다!
- 에러메시지와 로그를 보자👀 들으려고 하는 태도, 보려고 하는 눈이 있어야 한다..!
- 처음 접하는 aws서비스든, 프레임워크든 모르는 게 너무 방대하다고 느껴질 때는 조각조각🍕 내서 적어보자. 나는 덩어리째로 소화할 수 없으니, 한 문장 안에서 명료한 단어로 몇 개로 나의 모름을 조각내서 확인하자.
- 너무 개념적인 수준의 무지라서 질문하기 부끄럽다?고해도 질문하자! 동일한 무지에 상태에 머무르는 것이 무지를 들키는 것보다 나쁘다..!😂
- 매일 시간을 내어서 웹 서비스 환경, 인프라, 그리고 CS에 대한 공부를 꾸준히 해야겠다. 낯선 개념이 적어지면 그 이후의 러닝커브가 더 좋아지지 않을까?🙏
이렇게 건강진단(?)을 받은 느낌의 유익한 온보딩…! 이 기간 없이 본 업무에 들어섰다면 어땠을지 살짝 아찔하다. (물론 지금의 나도 아찔하다) 그래도 발견한 것들만큼은 계속 개선해서 매일 더 나아지는 개발자가 되고 싶다☺️