본문으로 건너뛰기

newara-dx: 개발용 컨테이너 만들기

· 약 27분
주예준 (triangle)
여러분 휠 세미나 꼭 들으세요

시작하기 앞서, 현재 블로그 주제는 이 주제가 아닌, Kubernetes에 관련된 주제였으나, 몇일 전에 의도치 않게 해당 주제로 많은 시간을 사용해 뉴아라 백엔드 개발용 컨테이너를 완성했기 때문에, 급하게 회고록 형태로 블로그 주제를 바꾸었습니다. 추후 연재? 될 Kubernetes 이야기도 기대해 주세요!

시험 몇일 전에 공부하기 싫어서 작성하는 글이어서, 퀄리티가 약간 떨어질수 있으나, 양해 부탁드립니다.

서론

newara-dx 는 Newara Development Experience 의 약자이다. 노션의 뉴아라의 온보딩 페이지를 보면 알겠지만, 개발 환경 셋팅, 특히 백엔드는 매우 복잡하다. 따라서 이전부터 여유가 되는 SPARCS의 물리 서버를 활용해 개발 컨테이너를 만들자는 이야기가 나왔었다.

최종 목표는 여러 유저들이 독립된 환경에서 빠르게 개발 환경을 갖출 수 있게 하는 것이었다. 완성된 결과는 ‣ 에서 확인할 수 있다.

사실 몇주 전만 해도 개발용 컨테이너를 만들어야겠다는 생각은 전혀 없었었다. 비록 백엔드 온보딩 셋팅이 어렵다는 이야기는 계속해서 나왔었으나, 방학때나 나중에 여유있을때 천천히 만들 생각이었다. 그리고 우리의 NewAra PM님께서도 계속해서 개발용 이미지를 만들고 있다고 해서, 조금씩 도와줄 정도로만 생각했었고, 이렇게 많은 시간을 사용해서 학기 중에 완성하게 될 줄은 상상도 하지 못했다.

컨테이너 제작 시기는 저번주 공동 코딩 시간이었다. 그때 뉴아라 부원분께서 M1 Mac을 쓰시는데, 자꾸 파이썬 환경이 문제를 일으켜 개발을 하지 못한다는 소식을 들었다. 몇주동안 해결을 하는데 어려움을 겪고 있다고 하셔서, PM님께 내가 빠르게 개발용 컨테이너 이미지를 만든다고 했다.

기영 : 또 파이썬이 안돼 인준아

인준 : dockerfile 만들고 컨테이너로 작업하자, 간단해 보이는데 예준이 형이랑 10분안에 해줄래?

이때까지만 해도 나는 한시간, 오래 걸려도 공동 코딩 시간에 끝날 줄 알았다. 왜냐하면 기존에 프론트, 백엔드 CI 를 이전 PM님과 함께 구축을 하기도 했었고, 이번에는 production용이 아닌 develop용이어서 multi-stage build 등등 일부 요소들은 생각해도 안했기 때문에 빨리 끝날 것으로 예상했다.

75-years-later

하지만 거의 5일 꼬박 걸려서 완성했는데, 그 시간동안 왜 이리 오래 걸렸는지, 그리고 어떻게 구현을 해 나갔는지 소개를 하려고 한다.

Dockerfile과 docker-compose.yml, 그리고 docker-entrypoint.sh

처음에는 Dockerfiledocker-compose는 CI용 Dockerfile과 dev 서버에 있는 docker-compose를 거의 그대로 사용해 보려고 했다. 하지만 문제는 production용과 달리 dx는 사용자가 수정한 내용을 저장할 수 있도록 /root 디렉토리와 볼륨 매핑을 해야만 한다. 그렇지만 볼륨 매핑을 하게 되면, 이미지 내 /root 디렉토리의 파일들은 전부 사라져 버리는 문제가 발생한다!

따라서, 약간의 꼼수가 필요한데, 이미지 단에서 생성된 파일은 /tmp 폴더에 저장하고, 컨테이너가 실행 될 시 이 폴더로부터 복사해 원하는 디렉토리에 붙여넣는 것이다. 이를 위해서 Dockerfile의 entrypoint로 /docker-entrypoint.sh 를 실행하게 해, 해당 쉘 스크립트에서 /tmp 폴더에서 우리가 원하는 폴더로 복사토록 했다.

매핑된 볼륨이 비어있을 때만 초기화(initialize) 스크립트가 실행하게 해야 되므로, if ! [ "$(ls -A /root/api/apps)" ]; then 와 같은 조건문을 사용했다. 해당 디렉토리가 비어있는 경우, if 문 안을 실행한다.

여기서 잠깐, Dockerfile 에서 RUNENTRYPOINT 의 차이를 알아야한다. CMD 도 있으나, ENTRYPOINT 와 거의 유사하다.

  • RUN : 이미지를 제작하는 과정에서 실행되는 명령어, RUN apt-get update 등 이미지를 구성하는데 필요한 환경 설정을 하는데 사용한다.
  • ENTRYPOINT : 이미지가 실행되어 컨테이너로 되었을 때, 실행할 명령어. 컨테이너에서 1번 프로세스로 실행된다. 만약 컨테이너를 올렸을 때 바로 React 앱을 띄워지게 하고 싶으면 ENTRYPOINT ["npm", "run", "serve"] 의 형태로 사용하면 된다. 그리고 개발용 컨테이너를 만들고 싶으면 ENTRYPOINT ["sleep", "infinity"] 를 하면 된다. 1번 프로세스가 죽지 않게 만들어야 하기 때문이다. bash를 띄워도 되지만, interactive + tty 옵션을 주어야 하는 번거로움이 있다.

ENTRYPOINT 로 실행해야할 명령어가 많으면, 따로 docker-entrypoint.sh 로 분리를 한 뒤, 이미지 빌드시 COPY 를 통해서 이 스크립트를 / 디렉토리로 복사한 뒤, ENTRYPOINT ["bash", "-c", "/docker-entrypoint.sh"] 로 실행하는 것이 좋다.

Files

실행하는데 반드시 필요한 .env 파일을 포함해 아래와 같이 구성했다.

api/

  • Dockerfile
  • .env
  • docker-entrypoint.sh

web/

  • Dockerfile
  • .env
  • docker-entrypoint.sh : 최종적으로 npm run serve 실행 - 백엔드 개발자가 프론트 컨테이너를 건드리지 않아도 됨

docker-compose.yml : api web mysql redis elasticsearch

NOTE

  • Dockerfile 의 경우 명령어의 순서가 중요하다. CACHING되는 수준이 다르기 때문이다.
  • CI용 이미지를 그대로 쓰니 몇몇 python 관련 문제가 나타났다. 어찌저찌해서 잘 해결했고, virtualenv를 쓰지 말자는 얘기도 나왔지만 그냥 poetry의 virtualenvin-project 옵션을 활성화하는 식으로 해결했다.
  • entrypoint 혹은 사용자에 의해 변경되는 모든 것은 반드시 /root 볼륨 안에 있어야 한다. 그렇지 않으려면 이미지를 만들때 반영이 되어야 한다. 그 이유는 컨테이너를 재부팅하면 변경사항이 모두 사라지기 때문이다. 그렇기 때문에 virtualenv 를 사용해 project 내에 .venv 를 만든 것이다.
  • 프론트엔드가 받을 수 있는 백엔드 api는 proddev 단 두개였는데, 이를 환경변수를 통해서도 받을 수 있도록 수정해 주었다.
  • 로컬에서 작업했는데 이미지 빌드 후, 컨테이너가 초기화되는데까지 시간이 너무 오래 걸렸다. 따라서 명란 서버로 옮겼는데, 획기적으로 빌드 및 초기화 속도가 개선되었다! 명란 서버에서 빌드 시 아래 24코어를 전부 사용하고 있는 것을 보니 뿌듯했었다 😀

Build on server

  • Dockerfiledocker-entrypoint.sh 를 수정하는데 대부분의 시간을 할애했다. 처음 컨테이너를 실행할 때 조금이라도 원하는대로 되지 않으면 다시 컨테이너를 완전히 없앴다가 수정 한 후, 다시 실행해야 했기 때문이다. poetry install 은 명란 서버 덕분에 빠르게 되었지만 make migrate 하는데 시간이 많이 소요되었다. CPU도 1~2개의 코어만 사용하고, 아직도 그 이유를 모르겠다. 대신 컨테이너를 완전히 없앨 때는 volumes 디렉토리만 삭제하면 되서 빠르게 내릴 수 있었다.
  • 어떤 것을 Dockerfile 에 둘 것인지, 어떤 것을 docker-entrypoint.sh 에 둘 것인지 잘 결정해야 한다. 처음에는 git cloneDockerfile 에 두고, entrypoint 때 최신 develop 브랜치를 가져와 poetry install 하게 했다. 그랬더니 재부팅 한 후에는 예전 마스터 브랜치로 돌아가는 일이 발생했다. 아직도 정확한 원인은 파악하진 못했지만, git clone 도 entrypoint의 초기화 작업때 하도록 바꾸었더니 문제가 해결되었다. .envCOPY 를 통해 이미지내 포함되게 만들었지만, 불필요하게 이미지가 많이 늘어나고 .env 가 바뀔 때마다 이미지를 빌드하게 해야 되는 단점이 있었다. 따라서 .env 도 처음에 볼륨 매핑으로 연결하고, entrypoint에서 복사해서 사용하는 식으로 바꾸었다.

https 달기: 서브 도메인 할당

장고의 Set-Cookie가 작동을 하지 않는다는 문제를 발견해, 강제로 Security를 끄는 식으로 어찌저찌 프론트, 백 모두 띄워 연결까지는 마무리했다. 아마 프론트, 백 포트가 다른 것이 문제인듯 했다. 문제는 다음에 발생했다.

바로 얼마 전에 뉴아라에 도입한 알림 기능이 켤 수 없게 비활성화 되어 있었다.

문제를 찾아보니, 다음과 같은 도큐에서 그 원인을 알 수 있었다.

Notification

https://developer.mozilla.org/ko/docs/Web/API/Notifications_API/Using_the_Notifications_API

망했다.. https를 달아야만 했다. 그러기 위해서는 nginx와 certbot이 필요하다. https를 다는 것은 어려운 일이 아니다. (휠 세미나만 잘 들었다면) 근데 문제는 단일 엔드포인트가 아닌 유저별 엔드포인트가 필요하다는 것이다. 유저 한명이 추가될 때마다 route53에서 도메인을 만드는 것은 번거롭고 깔끔하지 못하다. wildcard로 *.newaradx.sparcs.org 하나만 DNS에 등록하고, nginx 컨테이너를 띄워서 docker 수준에서 관리하는 것이 용이해 보였다. 관건은 위 도메인에 https를 붙일수 있는지였다.

다행히 이 목적에 정확히 적합한 방식이 있었다. letsencrypt에서 https 인증서를 발급받기 위해 두가지 방법이 있다.

  1. HTTP-01 - 유효한 서버인지 인증: 해당 도메인과 연결된 서버에 파일을 저장하고, 웹서버가 해당 파일을 잘 전달한다면 인증서를 발급해준다.
  2. DNS-01 - 유효한 도메인인지 인증: 특정 토큰을 던져 준뒤, _acme-challenges.<domain> 의 TXT 레코드로 해당 토큰을 잘 전달한다면 인증서를 발급해준다.

SPARCS의 모든 서버는 지금까지 HTTP-01 방식을 이용해 https를 달았었다. certbot 이 알아서 nginx 셋팅을 해 자동으로 갱신까지 해 주기도 하고, 하나의 서비스에 하나의 도메인만 필요했었기 때문이다. 하지만 wildcard 도메인으로는 HTTP-01 방식을 사용할 수 없다. 대신 DNS-01 방식으로 route53에서 TXT 레코드를 수정해주어야 한다. 이렇게 하면 triangle.newaradx.sparcs.org 로 들어가든, yuwol.newaradx.sparcs.org 로 들어가든 모두 https 가 적용이 된다!

route53에 다음과 같이 레코드를 만든 뒤 (_acme-challenge의 TXT 레코드는 아래 명령어에서 리턴하는 값으로 나중에 바꿔야 한다.)

DNS record

sudo certbot certonly --manual --preferred-challenges dns -d "*.newaradx.sparcs.org" -d "newaradx.sparcs.org" 를 실행한다. 여기서 리턴하는 TXT레코드를 route53에 입력한다.

이후 서버 nginx 설정에 ssl_certificate 로 발급된 fullchainprivkey 를 연결하면 다음과 같이 https가 적용된 것을 확인할 수 있다.

HTTPS

하지만 certbot에서 3개월마다 자동으로 갱신까지 해 주진 못하므로, 수동으로 갱신을 해 주어야 하는 것으로 보인다. 아마 renew-hook을 위한 route53 api가 있을 것으로 추정되지만 여기까지는 알아보진 않았다.

nginx은 2번 거치게 된다. 하나는 서버의 nginx이고, 다른 하나는 docker 컨테이너로 띄워진 nginx이다. 역할은 다음과 같다.

  • 서버 nginx: *.newara.sparcs.org 로 오는 모든 요청을 https 로 받는 역할을 하고, nginx 컨테이너로 reverse proxing 한다. 80번의 요청들은 전부 443번으로 리디렉트한다.
  • 컨테이너 nginx: *.newara.sparcs.org 를 유저별로 구분한 뒤, /api 로 요청이 온 경우, 해당 유저의 백엔드 컨테이너로, / 로 요청이 온경우 해당 유저의 프론트 컨테이너로 reverse proxing 한다.

컨테이너 nginx를 위해 conf.d 내에 각 유저별로 하나의 .conf 파일이 필요하다. 이를 빠르게 생성할 수 있는 domain.sh 라는 쉘 스크립트를 만들었다. server_name 의 닉네임, proxy_pass 의 컨테이너 이름만 사용자별로 달라지면 되므로, 비교적 쉽게 스크립트를 만들었다.

Files

실행하는데 반드시 필요한 .env 파일을 포함해 아래와 같이 구성했다.

api/

  • Dockerfile
  • .env
  • docker-entrypoint.sh

web/

  • Dockerfile
  • .env
  • docker-entrypoint.sh

docker-compose.yml : nginx api web mysql redis elasticsearch

nginx

  • nginx.conf → 컨테이너 nginx 설정 중 하나, 존재하지 않은 유저에 대한 default domain case에 501 리턴
  • newaradx.sparcs.org.backup → 서버 nginx 설정, /etc/nginx/sites-enabled/newaradx.sparcs.org 와 soft link 설정

domain.sh : 유저별 컨테이너 nginx conf 생성기

nginx-reload.sh : docker exec nginx-dx nginx -s reload 명령어, 말 그대로 컨테이너 nginx 재실행 목적

유저별 컨테이너 분리, ssh를 붙여 마무리

위의 파일 구조를 보면 알 수 있겠지만, 현재 docker-compose.yml 이 하나밖에 없고, 여기에 현재 6개의 서비스가 띄워져 있는 상태였다.

현재는 유저 한명만 있는 상황인데, 만약 유저를 추가하고 싶다면, docker-compose.yml 에 nginx을 제외한 5개의 서비스를 위한 설정 파일을 같은 파일에 복사 붙여넣기를 해야 한다. 깔끔하지 않을 뿐더러 서비스 이름이 겹치지 않도록 수정까지 해야 하므로 번거로워진다는 단점이 있다.

여기서 생각해 본 것은 과연 유저별로 이미지를 빌드를 해야 할 필요가 있을까였다. docker-compose.yml 에서 build: . 로 백엔드와 프론트 이미지를 빌드를 하고 있었는데, 빌드는 다른 곳에서 진행해 이미지는 미리 만들어 놓고, 유저별 컨테이너에서 image: newaradx/web 이런 식으로 같은 이미지를 공유하고, 볼륨 매핑으로 .env 만 다르게 가져가면 되지 않을까 라는 생각이 들었다.

그러면 자연스럽게 유저별로 docker-compose.yml 을 가져가고, .env 도 유저별로 다르므로, 이것들을 유저 디렉토리에 분리를 해야겠다는 생각이 들었다. Dockerfiledocker-entrypoint.sh 는 이미지 빌드때 사용되고, 같은 이미지를 공유하므로 image 디렉토리에 분리했다.

Files

기울임은 사용자가 생길 때마다 생성되는 파일 및 디렉토리이다.

docker-compose.yml : nginx

image

  • api
    • Dockerfile
    • docker-entrypoint.sh
  • web
    • Dockerfile
    • docker-entrypoint.sh
  • build.sh : docker build api --tag=newara-dx/api; docker build web --tag=newara-dx/web

sample : cp -r sample <user> 로 유저별 디렉토리 (<user>) 복제해 사용

  • api
    • .env
  • web
    • .env
  • docker-compose.yml : api web mysql redis elasticsearch
    • 볼륨 매핑으로 <user>/volumes 디렉토리 생성됨
  • findmy.sh : docker compose exec api cat /root/.ssh/id_rsa; docker compose ps -a
    • 후술할 ssh 키 파일 확인 및 사용중인 ssh, db 포트 확인 용

nginx

  • nginx.conf
  • newaradx.sparcs.org.backup

domain.sh : 유저별 docker nginx 설정 추가 스크립트 (nginx/<user>.conf 생성됨)

nginx-reload.sh : docker exec nginx-dx nginx -s reload

한가지 고려해 주어야 할 것은 nginx 컨테이너와 유저 컨테이너들 간에 같은 네트워크를 사용할 수 있도록 해주어야 한다는 점이다. 따라서 다음과 같이 네트워크를 연결했다. nginx와 유저 컨테이너들은 다른 docker-compose 파일에 위치하므로, external: true 를 붙여 외부 컨테이너간의 연결을 활성화해야 한다.

# docker-compose.yml
nginx:
...
networks:
- newara-network

networks:
newara-network:
name: newara-network

# sample/docker-compose.yml
5개의 컨테이너들:
...
networks:
- newara-network

networks:
newara-network:
name: newara-network
external: true

놀라운 점은 특정 디렉토리 안의 docker-compose.yml 을 실행함으로써 유저 컨테이너들을 실행했을때, sample-api-1 이런 식으로 유저 및 컨테이너 별로 unique 한 도메인 이름을 생성해 준다는 것이다. 따라서 저 sample 디렉토리를 복사해 자신의 닉네임으로 바꾼 뒤 컨테이너를 실행하면 nickname-api-1 이런 식으로 도메인 이름을 만들어 준다. 한 파일에서 서비스 이름이 겹치면 안되지만, 다른 docker-compose.yml 에 위치한 서비스 이름이 겹치는 것은 괜찮다. 덕분에 docker-compose.yml 파일은 전혀 수정하지 않아 주어도 된다! (포트 매핑에서 포트가 겹치지 않는다면)

이를 이용해 docker nginx에서triangle.newaradx.sparcs.org 로 오는 요청은 http://triangle-web-1 로, triangle.newaradx.sparcs.org/api 로 오는 요청은 http://triangle-api-1 로 reverse proxy 할 수 있도록 설정했다.

그리고 장고에서 db, redis, elasticsearch에 연결할 때도 triangle-db-1 , triangle-redis-1 triangle-elasticsearch-1 로 접속할 수 있도록 .env 안에 명시되어있는 host 경로를 바꾸어준다.

그러면 거의 모든 셋팅이 끝났다. 마지막으로 사용자가 ssh를 통해 컨테이너를 접속할 수 있게 해주어야 한다. image/api/docker-entrypoint.sh 에서 아래 코드를 추가한다. Dockerfile 에서 openssh-server 패키지를 먼저 설치해 준다.

if ! [ "$(ls -A /root/api/apps)" ]; then
...
echo -e "\n"|ssh-keygen -t rsa -N ""
touch /root/.ssh/authorized_keys
cat /root/.ssh/id_rsa.pub >> /root/.ssh/authorized_keys
...
fi;
...
service ssh start;
sleep infinity;

자동으로 키파일을 생성해 서버의 공개키를 등록하는 과정이다.

이제 마지막으로 해야 하는 것은 docker compose exec api cat /root/.ssh/id_rsa; 명령어를 통해 비밀 키를 확인하고, db 및 ssh 포트를 유저에게 전달해 ssh 셋팅을 하도록 하면 된다!!!

newara-dx 를 통해 얼마나 삶이 편리해졌을까?

지금까지 어떻게 dx를 구축을 했는지 장황하게 설명했는데, 더 복잡한거 아니야? 라고 생각하는 사람도 있을 것이다. 하지만 이는 필자만 이 복잡한 거만 하면 되는 것이고, 유저를 추가할 PM, 이 컨테이너들을 사용한 유저들의 입장에서는 해야 할 일은 매우 적어졌다.

PM이 해야 할 것

  1. sample 디렉토리 <닉네임> 으로 복사

  2. <닉네임>/api/.env <닉네임>/web/.env 에서 환경변수 수정

    api

    • 유저에게 전달받은 SSO_CLIENT_IDSSO_SECRET_KEY
    • NEWARA_DB_HOST=<닉네임>-db-1
    • NEWARA_REDIS_ADDRESS=<닉네임>-redis-1
    • NEWARA_ELASTICSEARCH_HOST=<닉네임>-elasticsearch-1

    web

    • VUE_APP_API_HOST='https://<닉네임>.newaradx.sparcs.org'

    (Optional) <닉네임>/docker-compose.yml 에서 포트 매핑에서 호스트 포트 번호 안겹치게 (랜덤하게 하는 경우 수정 필요 없음)

  3. docker compose up -d 실행

    • 프론트는 npm run serve 까지 실행해 자동으로 띄워짐
    • 백엔드는 poetry install make migrate 까지 진행됨, 초기화가 끝나면 환경변수를 로드할 수 있는 activate 라는 파일 생성됨
    • 백엔드 다 띄워지면 다시 재부팅해야 함 (공유 파일 인식 목적)
  4. ./domain.sh on <닉네임> 실행

    • nginx 설정 파일 자동 생성
  5. <닉네임>/findmy.sh 실행

    • <(1) 키파일 내용>과 <(2) ssh 포트번호>, <(3) db 포트번호> 확인 가능 → 유저에게 전달하기

유저가 해야 할 것

  1. SSO Development Center에서 SSO Test service 추가 후 SSO_CLIENT_IDSSO_SECRET_KEY 전달
    • alias: new-ara-dx
    • Main URL: https://<닉네임>.newaradx.sparcs.org
    • Login Callback URL: https://<닉네임>.newaradx.sparcs.org/api/users/sso_login_callback
  2. PM님께 전달받은 <(1) 키파일 내용>과 <(2) ssh 포트번호>, <(3) db 포트번호>를 가지고 ssh와 vscode, datagrip 세팅을 진행
  3. make run 실행 후, 초기 db 세팅
  4. https://<닉네임>.newaradx.sparcs.org 에서 자유롭게 개발 가능!

정말 간단해졌다!

이제 m1 mac이라서 안되는 문제도 없고, 초기 환경설정에 몇주씩 걸릴 일도 없어졌다.

프론트엔드는 고려하지 않았지만, 백엔드보다 훨씬 간단할 것으로 예상한다.

이 글에서 코드는 최소한으로 하려고 했는데, 전체 코드는 https://github.com/sparcs-kaist/new-ara-dx 에서 확인 가능하다.

dx 컨테이너를 통해 더이상 개발자들이 복잡한 환경 설정에 더이상 힘을 쏟지 않고, 개발에 좀 더 집중하길 희망한다. 그리고 SPARCS 내의 다른 프로젝트에서도 이런 dx 환경을 한번쯤 구축해 사용하면 좋을 듯 하고, 이 글이 구현에 큰 도움이 되길 바란다.

WEB 3.0

· 약 6분
고예준 (arcticfox)
종강 주세요

1990년대 초반, 월드와이드웹이 개발되며 정보화 시대가 가속화 되었다. 이후 웹은 많은 기술들이 개발되고 사용자들이 늘어가며 빠른속도로 발전해왔다. 처음 웹이 상용화 되었을 때는 클라이언트(사용자)가 서버로부터 컨텐츠를 제공만 받던 형태였다. 이를 web1.0이라 한다. 이후 2004년 부터 인터넷 벤처들을 중심으로 웹 기술과 인터넷 산업 전반에 대한 새로운 시도들이 등장하며 web2.0이 시작되었다. wbe2.0의 가장 큰 변화는 AJAX의 등장으로 클라이언트와 서버가 상호작용하며 서버에 기록이 가능해졌다는 점이였다. 이를 통해 사람들간의 커뮤니테이션과 정보 전달 방식에 많은 변화가 생겼으며 서비스들이 생활에 가깝게 접근할 수 있게 되었다. 하지만 서버와 상호작용이 가능해지며 해커들이 악의적인 목적으로 사용자들의 개인 정보를 탈취하는 문제들이 발생하기 시작하였다. 또한, 이러한 개인정보의 보안은 서비스를 제공하는 기업을 믿고 의지하는 방법 뿐이였다. web3는 이러한 문제점들을 보완하고자 등장하였다.

WEB3란?

web3는 분산 웹이라고도 불리며 간단히 요약하면 모든 정보들이 분산화된 차세대 네트워크 구조를 의미한다. web3는 중앙집권 적이던 과거의 플래폼에서 벗어나, 모든 유저가 플랫폼이 될 수 있는 인터넷이다. 이를 구현하는 가장 핵심적인 기술은 블록체인이다.

web3의 기술스택

dApp(분산 앱)은 만들기 위해서는 컴퓨팅 자원, 파일 스토리지, 외부 데이터 등의 요소들을 필요로 한다. dApp의 개념이 2014년에 처음 등장했을 때 까지만해도 이를 구성하기에 많은 어려움들이 있었지만, 현재는 최소한의 자원으로도 dApp을 개발할 수 있도록 기술이 발전했다. web3를 구성하는 기술 스택은 다음 그림과 같다.

web3의 기술스택

web3의 장점

  • 웹에 참여하고 있는 모든 클라이언트가 서비스를 사용할 권한을 가진다.
  • 결제는 자체 토큰을 사용한다. ex. ether
  • 탈중앙화되어 있어 검열이 불가능하다.
  • 분산화되어있는 노드들로 구성되어 있어 특정 노드에 문제가 발생하더라도 서비스가 지속적으로 유지될 수 있다.(Single Point of Failure가 없다.)

WEB3.js

이더리움(Ethereum)은 web3의 상용화를 위해 web3.js라는 JS API를 제공한다(https://github.com/ethereum/web3.js/). web3.js는 이더리움 네크워크와 상호작용할 수 있는 다양한 기능들을 제공한다. 이를 이용해 다른 사용자에게 이더를 전송하거나 스마트 컨트랙트를 만들고 스마트 컨트랙트에서 데이터를 읽고 쓸 수 있다.

web3.js에는 다음과 같은 모듈들이 있다.

  • web3-eth : 이더리움 블록체인과 스마트 컨트랙트 모듈이다.
  • web3-utils : dApp 개발을 위한 헬퍼 함수를 모아둔 모듈이다.
  • web3-bzz : 탈중앙화 파일 스토리지를 위한 스왐프로토콜 모듈이다.
  • web3-shh : P2P 커뮤니테이션과 브로드캐스트를 위한 위스터 프로토콜 모듈이다.

마무리

클라이언트-서버로 구성된 웹에서 분산웹으로의 전환은 서서히 변화해 나갈 것이다. 이러한 변화는 중앙화된 형태에서 일부만 분산된 형태에서, 최종적으로 완전히 분산된 형태로 바뀌어 나갈것이다. 분산화된 웹에서는 네트워크에서 발생하는 오류들을 더욱 매끄럽게 처리할 수 있으며, 외부의 공격으로부터도 공격받는 구심점이 없기 때문에 보안성도 더욱 뛰어날 것이다. 현재까지는 속도적인 면에서 열위가 있지만 꾸준한 발전을 통해 초 연결 사회의 주요기술로 자리잡게 될것으로 보인다.

읽어주셔서 감사합니다

Clean Code

· 약 10분
안태찬 (return)
평범한 카이스트 전산학부 4학년생

안녕하세요 스팍스에서 활동하고 있는 안태찬입니다. 벌써 전산학부 수업을 들은지도 햇수로 3년 정도가 되지만 아직도 코딩과는 별로 친하지 않은 것 같습니다 ㅎㅎ

한때 전산학부, 또는 그 범주의 컴퓨터 공학부를 졸업한 학생들은 무엇을 배웠고, 어떤 능력을 키워야 하는가 고민을 많이 해봤고 저는 우리들이 전산학부를 졸업한다면 크게 2가지를 배우게 된다고 생각했습니다.

  • 첫번째는 컴퓨터 시스템이 돌아가는 원리를 이해하는 것입니다.
  • 두번째는 컴퓨터를 통해서 문제를 해결하는 능력, 즉 어떤 현실의 문제를 정의하고 이를 프로그래밍 할 수 있는 능력이 생기는 것입니다.

학교 수업에서는 시스템, AI 등 다양한 전산의 분야를 기초부터 배울 수 있었고 저도 학교 수업을 들으면서 위에서 말한 첫번째 내용을 배울 수 있었어서 좋았습니다.

하지만 저는 첫번째만큼이나 두번째 역시 매우 중요하게 배워야 할 교육과정이라고 생각합니다. 특히 두번째에서 문제를 잘 정의하고 이를 해결할 수 있는 방법을 제안하고 프로그래밍하는 방법에 있어서는 사람마다 자신이 원하는 스타일대로 코딩하기 때문에 그 스타일도 다양합니다. 100명의 개발자에게의 같은 기능을 하는 하나의 프로그램을 짜오라고 한다면 각기 다른 100개의 코드로 짜여진 프로그램이 완성될 것입니다.

전산학부 수업을 들으면서 과제가 나오면 코드를 깔끔하게 잘 짜고 싶었습니다. 하지만 밀려오는 시간의 압박과 과제의 난이도 때문에 항상 과제를 시간 내에 제출하기에도 벅찼고 항상 완주하는 것에 만족해야 했기에 아쉬움이 있었습니다.

어떻게 하면 코딩을 잘 짜는지, 또는 잘 쓴 코드와 그렇지 않은 코드에는 어떤 차이점이 있는지, 어떻게 하면 코딩을 훨씬 효율적으로 할 수 있을까라는 생각이 많이 들었고, 우리가 좋은 글을 작성하는 방법을 배우듯이 좋은 코드를 작성하는 방법에 대해 최소한의 기준을 알고 싶었습니다.

그러던 중 몰입캠프를 하면서 같이 했던 타 대학교 형의 소개로 "Clean Code"라는 책을 알게 되었고, 제가 평소에 코딩을 하면서 어떻게 좋은 코드일까에 대한 모호했던 기준을 정확하게 세울 수 있었습니다.

아래에는 제가 책을 읽으면서 느꼈던 좋은 코드의 기준을 몇 가지 적어봤습니다.

좋은 코드란?


개발자로 회사에 들어가서 취업을 한다면 혼자 코딩을 하는 것과 다른 점이 있습니다.

  • 같은 일을 더 적은 시간 안에 처리할수록 좋다(효율),
  • 현실의 문제는 학부 수업에서 다루는 과제보다 훨씬 복잡하고 어렵기 때문에 이를 해결하기 위한 코드의 양도 많고 복잡하다. 그래서 문제를 해결하기 위해 여러 명의 개발자들이 협업한다.

위의 관점에서 생각해볼 때 제가 생각하는 좋은 코드는 아래와 같습니다.

  • 같은 기능을 수행할 때 더 짧고 간단한 코드
  • 3자가 읽기 쉽고 고치기 쉬운 코드

의미있는 이름


  • 의도를 분명히 밝히기
    변수명을 지을 때 a, b 이렇게 짓는 것보다 그 변수가 담고 있는 의미를 나타내는 적절한 이름을 붙여줄 때 코드를 이해하기 훨씬 쉬울 것입니다.

  • 의미있게 구분하기
    data, information 등 의미가 모호할 수 있는, 널리 쓰이는 단어(불용어)는 변수명으로 지양합니다.

  • 검색하기 쉬운 단어를 사용하기
    단어의 길이가 길더라도 의미를 명확하게 전달할 수 있다면 긴 단어가 짧은 단어보다 좋습니다.
    코드에서 상수를 사용하는 것보다 반드시 변수에 저장하고 적절한 이름을 붙여줍니다.

  • 일반적으로 통용되는 변수명의 규칙
    클래스 이름은 명사, 메소드 이름은 동사를 사용합니다.
    get, set, is를 사용합니다. camelCase 문법 또는 경우에 따라 _를 사용하고, 전체 코드를 통일합니다.

함수


프로그래밍을 하는 것은 함수를 짜는 것과 같습니다. 그렇다면 기능을 구현하기 위해서 함수를 몇 개까지 만들고, 이를 어떻게 구별하면 좋을까요?
이 책에서 말하고자 하는 함수에 관한 가장 중요한 규칙은 "하나의 함수는 하나의 일만 수행해야 한다" 입니다.

  • 단일책임원칙(SRP, single responsible principle) 하나의 함수는 반드시 하나의 기능을 수행해야 합니다. 특정 기능에 문제가 생겼을 때 우리는 해당 함수만 고치면 됩니다.

  • 함수의 인수
    함수의 인수는 0개, 1개, 2개 순으로 적을수록 좋습니다. 3개 이상은 지양합니다. 3개 이상이면 독자적인 클래스를 생성하는 것도 고려해보면 좋습니다.
    함수의 인수로 boolean 타입은 피합니다.
    아무래도 함수의 인수가 적을수록 해당 함수의 input을 파악하는 데 시간이 적게 걸리다보니 빠르게 코드가 읽혀집니다. boolean 타입의 경우, 참, 거짓에 따라 다른 기능을 수행하기 때문에 지양하는 것이 좋습니다.

  • 명령과 조회 분리하기
    함수는 무언가를 조회하거나, 명령하는 것 둘 중 한가지만 수행해야 합니다.

  • 함수 형식
    함수 안의 줄 수는 작을 수록 좋으며, 들여쓰기 레벨로 최대 2단 정도까지 있는 것이 바람직합니다.

주석


주석은 가능한 안 쓰는게 좋습니다. 주석이 없이도 제 3자가 알아보기 쉽도록 깔끔하게 코드를 적는 것이 더 중요합니다.

좋은 주석

  • 코드로 표현하지 못하는 정보를 제공하는 주석
  • 의도를 설명하는 주석
  • 결과를 경고하는 주석
  • 중요성을 강조하는 주석

나쁜 주석

  • 코드로 설명할 수 있는데, 코드를 설명하는 주석
  • 주석으로 처리한 코드
    특히 코드를 작성하는 과정에서 주석으로 처리한 코드를 지우지 않는 경우가 많은데, 이 경우에 주의합니다.
  • 이력을 기록하는 주석

이외에도...


이외에도 visual studio code 에디터를 사용한다면 clean code를 위해서 아래의 extension을 추천합니다.

  • Code prettier
  • ESLint

코드의 형태와 문법적 오류를 자동으로 잡아주는 extension으로 clean code 작성에 유용할 것입니다.

읽어주셔서 감사합니다!

Kagong Day 0 - 카이스트 공부 서비스, 카공

· 약 8분
황인준 (yuwol)
6월에 태어나서 유월입니다.

기획

카공을 간단히 소개하자면 카이스트 과목별 게시판이라고 할 수 있다. 최근 에브리타임에 강의실 이라는 기능이 추가되었는데 이와 비슷하다고 볼 수 있겠다. 기능이 추가된 지 얼마 지나지 않아 해당 기능의 사용자는 적어 보인다. 현재 생각하고 있는 기능들로는 아래의 것들이 있다.

  • 과목별 게시판
  • 시험 정보 공유 (카이스트 전산학부의 경우 역대 시험지를 모두 공개하자는 류 교수님의 말씀이 있었다.)
  • 포인트 제도
    • 질문에 대해 답변을 하면 포인트를 얻을 수 있다.
    • 숙명여자대학교에서는 커뮤니티 포인트로 족보를 거래한다.
    • 프로필 커스터마이징 기능 (이런 기능을 좋아하는 사용자들이 꽤 된다. 필자 또한 그중 한 명이다.)
  • 다이렉트 메시지
  • 카공은 사랑을 싣고…

개발 환경 세팅

필자는 2022년 가을 KAIST 공식 커뮤니티 서비스인 NewAra의 PM이다. Vue + django로 개발되고 있는 서비스로, 카공의 모티브가 되었다. 카공은 django를 이용해 개발할 계획이다 (사실 django를 연습할 프로젝트를 구상하다가 카공을 기획하게 되었다). 본 프로젝트를 통해 django를 더 잘 이해하고, 얻은 지식을 NewAra에 적용할 수 있기를 희망한다.

웹은 React 18을 이용해 개발할 계획이다. Vue를 사용하는 프로젝트의 매니저로서 부끄러운 사실이지만 필자는 Vue를 사용한 적이 없다. React보다 러닝 커브(learning curve)가 낮다고 하지만 근 네 달 정도 React만 다루다 보니 아무래도 익숙한 기술을 사용하는 것이 낫지 않을까 생각했다.

Most loved, dreaded, and wanted web frameworks and technologies

DockerFile

개발 환경 세팅은 언제나 힘들다. NewAra 백엔드의 경우 새로운 팀원이 들어오면 환경 세팅에 대부분의 시간을 할애한다. 이에 개발용 이미지를 만드는 것이 낫다고 생각하여 Dockerfile부터 생성한다.

개발을 진행할 디렉토리로 이동하고 git init 명령을 입력한다. 그 후 에디터를 연다. 필자는 VSCode를 사용한다.

$ mkdir kagong
$ cd kagong
$ git init
Initialized empty Git repository in /Users/yuwol/Dev/Projects/kagong/.git/
$ code .

우선은 백엔드용 Dockerfile만 작성하고 프론트는 필요할 때 만들어 사용하겠다. 내용은 python 3.10 이미지에 poetry를 설치하고 (패키지 관리자로 pip이 아니라 poetry를 이용할 계획이다) 디렉토리의 모든 파일을 복사하는 것이다.

배포용 이미지를 생성할 때는 .dockerignore 파일을 생성하여 불필요한 파일들을 배제하지만 본 이미지는 개발용이므로 해당 파일을 작성하지 않는다.

CI 용도로는 Poetry 버전을 명시하는 것이 좋으나 개발용 이미지이므로 늘 최신 버전을 다운받도록 한다. 이 부분은 추후 다시 작성하도록 하겠다. (Poetry CI recommendations)

# /Dockerfile

FROM python:3.10

RUN apt update && apt install -y curl

# Install Poetry
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH=/root/.local/bin:$PATH

WORKDIR /kagong

COPY . .

EXPOSE 9000

Compose

이제 개발용 컨테이너를 띄울 docker-compose.yml 파일을 작성한다. DB로는 MySQL을 사용한다. 특별한 이유는 없다. 한 가지 주의해야 할 점은 services: api에서 tty: true 설정을 해야 한다는 것이다. 이를 설정하지 않으면 컨테이너가 시작되자마자 종료된다.

# /docker-compose.yml

version: "3"

services:
api:
build: .
container_name: api
ports:
- "9000:9000"
tty: true
depends_on:
- db

db:
image: mysql:8.0
container_name: db
restart: always
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=passw0rd
- MYSQL_DATABASE=kagong
volumes:
- dbdata:/var/lib/mysql

volumes:
dbdata:

위 두 파일과 같은 경로에서 아래 명령을 입력하여 컨테이너를 띄운다.

docker compose up -d

Kagong container running

Dev Containers

VSCode를 사용함의 장점은 Dev Containers 라는 익스텐션을 사용할 수 있다는 것이다. 본 익스텐션을 사용하면 선택한 컨테이너 환경에서 VSCode를 열 수 있다. 필자는 Docker 익스텐션도 설치하였다.

Visual Studio Code Marketplace - Dev Containers Attach container in Visual Studio Code

사이드바에서 kagong-api 컨테이너를 우클릭한 후 "Attach Visual Studio Code"라는 옵션을 선택하면 컨테이너 환경에서 새 VSCode 창이 열린다.

Visual Studio Code opened in a container

GitHub

Gitmoji

여기까지 하고 커밋을 한 번 한다. 필자는 gitmoji를 이용한 커밋을 선호하는 편이다. 이모지를 사용해 커밋 메시지의 종류를 나타내는 방식으로, 50자로 제한되는 커밋 메시지 제목에 뜻을 함축적으로 적을 수 있어 좋다. (사견으로는 윈도우의 이모지가 더 귀엽고 예쁘다.)

Gitmoji

Gitmoji에는 프로젝트의 시작을 뜻하는 tada(🎉) 이모지가 있다. 필자는 프로젝트 제목만 있는 README.md 파일을 만들어 "🎉 begin a project" 메시지로 커밋을 한 번 한 후 실질적인 커밋을 시작한다.

  • 🎉 begin a project
  • 🧑‍💻 add docker & compose files for development

GitHub Kagong repository

맺는말

다음 글에서는 django 개발 환경을 세팅하고 정상 동작하는지 간단하게 확인할 계획이다. 마치 잘 아는 것처럼 적었지만 필자는 Docker와 그리 친하지 않다. 개발을 진행하며 많은 문제가 발생할 것이다. 카공 연재는 튜토리얼이라기보다 필자의 개발 일지에 가깝다. 얼마나 길어질지는 모르겠으나 꾸준히 작성할 예정이다.

기다림에 지친 그대에게: 비동기 프로그래밍

· 약 15분
황제욱 (jeuk)
달면 삼키고 쓰면 배운다

삶은 기다림의 연속이다

오늘도 점심을 먹으러 식당에 갔습니다. 주문하고 자리에 털썩 앉았지만, 볶음밥에 들어갈 당근이 아직도 밭에 묻혀 있는 건지 준비될 기미도 보이지 않습니다. 심심해서 주변을 둘러보다 은근슬쩍 휴대전화를 꺼내 봅니다.

비동기 프로그래밍

기다림은 불가피하지만, 사람은 기다리는 걸 싫어합니다. 그래서 느린 프로그램도 사랑받기 어렵습니다. 오래 걸리는 일이 마무리되길 기다리는 동안 다른 일을 하려는 시도는 더 빠른 프로그램을 만들기 위한 시도입니다. 물리적으로 멀리 떨어진 서버와 통신하기 위해, 사용자에게서 권한을 부여받거나 입력받기 위해서는 오랜 시간을 기다려야 할 수 있습니다. 비동기 프로그래밍은 이렇게 잠재적으로 오래 실행된 작업을 시작한 후 작업이 끝나기 전에 다른 이벤트를 처리할 수 있도록 도와주는 기술입니다. 이 글이 크게 영감을 받은 비동기적 JavaScript에 대한 MDN 문서도 참고하시면 큰 도움이 되리라고 생각합니다.

Callback

비동기적 프로그래밍을 사용하는 때에는 작업의 완료 순서가 달라질 수 있기에 작업 A를 비동기적으로 처리하게 되면 작업 A의 결과에 기반한 작업 B는 A의 실행이 완료된 뒤에 실행할 수 있음을 명시적으로 알려주어야 됩니다. 그렇지 않으면 작업 B를 하던 도중에 완료되지 않은 작업 A로 인해 잘못된 정보가 사용될 수 있습니다. JavaScript는 이러한 의존성을 callback의 형태로 표현해 왔습니다. 다시 말해, 비동기적으로 처리할 함수의 인자로 그다음에 실행할 작업을 대개 함수의 형태로 넘겨주는데 이때 넘겨주는 실행 가능한 코드를 callback이라고 부릅니다. 현재 실행되는 작업이 끝나면 다음에 어떤 작업을 할 것인지에 관한 내용이 담겨있는 겁니다.

function makeHotdog(sausage, bread, ketchup) {
grillSausage(
sausage,
(grilledSausage) => {
combine(
grilledSausage,
bread,
(breadWithSausage) => {
pourSauce(breadWithSausage, ketchup);
},
failureCallback()
);
},
failureCallback()
);
}

callback을 이용한 코드는 작업 간의 순서는 잘 드러내지만, 위와 같이 callback이 연달아 사용되는 경우 지나친 들여쓰기와 함수의 중첩으로 인해 가독성이 떨어지고 잘못된 코드를 작성하기 쉬워집니다. 이러한 callback의 문제점은 개발자들 사이에서 callback 지옥이라고도 불립니다. 또한 단계마다 실패하는 경우를 대비하기 위한 callback을 작성해야만 합니다.

Promise

앞서 설명한 callback 기반 코드의 문제점을 해결하기 위해 보다 최근에 생겨난 JavaScript의 기능이 Promise입니다. 지금은 아니지만, 언젠가 제대로 된 값을 반환하리라고 약속한다는 개념입니다. 이를 이용하면 들여쓰기와 함수가 중첩되지 않아 callback에 비해 더 가독성이 좋은 코드를 작성할 수 있습니다. 전체 작업이 실패할 때에 대해서만 오류를 처리하여도 충분해서 코드가 더 간결해질 수 있습니다.

function makeHotdog(sausage, bread, ketchup) {
grillSausage(sausage)
.then((grilledSausage) => combine(grilledSausage, bread))
.then((breadWithSausage) => pourSauce(breadWithSausage, ketchup))
.catch(failureCallback);
}

또한 Promise에는 부가적인 기능들도 있어 더 복잡한 논리 구조도 이해하기 쉽게 표현할 수 있습니다. 이를 위해 이전의 예제를 더 복잡하게 만들어보겠습니다.

function makeHotdog(sausage, bread, ketchup) {
grillSausage(
sausage,
(grilledSausage) => {
sliceBread(
bread,
(slicedBread) => {
reheatBread(
slicedBread,
(warmBread) => {
combine(
grilledSausage,
warmBread,
(breadWithSausage) => {
pourSauce(breadWithSausage, ketchup);
},
failureCallback()
);
},
failureCallback()
);
},
failureCallback()
);
},
failureCallback()
);
}

위 예제를 자세히 들여다보면 소시지 굽기와 빵 데우기는 서로 의존성이 없는 별개의 과정임을 알 수 있습니다. 이를 고려하며 Promise를 이용해 예제를 다시 작성해보겠습니다.

function makeHotdog(sausage, bread, ketchup) {
const preparedSausage = grillSausage(sausage);
const preparedBread = sliceBread(bread).then((slicedBread) =>
reheatBread(slicedBread)
);
Promise.all([preparedSausage, preparedBread])
.then(([grilledSausage, warmBread]) => combine(grilledSausage, warmBread))
.then((breadWithSausage) => pourSauce(breadWithSausage, ketchup))
.catch(failureCallback);
}

Promise.all는 주어진 모든 Promise의 작업이 완료될 때까지 기다리는 Promise를 반환하는 함수로 사이트 로딩 전 필요한 글꼴이나 사진이 준비되었는지를 확인하기 위해 사용할 수 있습니다. 동시에 Promise가 하나라도 실패하는 경우 실패에 관한 내용을 담은 Promise를 반환하기 때문에 빠르게 실패하여 부작용을 줄이는 코드를 작성하기 위한 도움을 줄 수 있습니다. Promise는 Promise.all과 같은 여러 부가적인 기능을 제공하고 있습니다. 갈수록 더 많은 코드에 callback 대신 Promise에 기반하여 작성되고 있으므로 Promise를 이해하는 것은 중요합니다. Promise에 대해서 알고 싶으신 분들은 Promise에 대한 MDN 문서를 참고해보세요.

여담으로 JavaScript는 단일 스레드로 구성되어 있어 이 예제의 경우에는 사실 Promise.all을 하여도 두 Promise를 빠르게 번갈아 가면서 처리할 뿐 Promise.all을 사용하지 않아도 속도에 있어서 유의미한 차이가 없으리라고 예상합니다. 하지만 서로 다른 서버에 독립적인 정보를 요청하면 각 요청이 끝난 뒤에 다음 요청을 보내는 것을 반복하기보다는 모든 요청을 보내고 기다리는 편이 더 효율적인 방법이 될 것입니다. 두 방법을 비교한 간략한 예시를 준비해 보았습니다.

function fasterFetch() {
fetchData1().then(fetchData2()).then(fetchData3()).catch(failureCallback);
}

function slowerFetch() {
Promise.all([fetchData1(), fetchData2(), fetchData3()]).catch(
failureCallback
);
}

JavaScript의 비동기 프로그래밍의 원리에 대해서 보다 깊게 알고 싶으신 분께는 Philip Roberts의 강연비동기 프로그래밍과 병렬 프로그래밍의 차이에 대한 Martin Thoma의 글을 추천합니다.

async/await

async와 await는 구문적 설탕으로 새로운 기능을 추가하지 않는 눈속임에 불과하지만, Promise를 더 쉽게 사용하도록 도와줍니다. 앞선 예제를 async와 await를 이용해 다시 한번 작성해보도록 하겠습니다.

async function makeHotdog(sausage, bread, ketchup) {
try {
const unpreparedGrilledSausage = grillSausage(sausage);
const warmBread = await reheatBread(bread);
const unpreparedSlicedBread = sliceBread(warmBread);
const [grilledSausage, slicedBread] = await Promise.all([
unpreparedGrilledSausage,
unpreparedSlicedBread,
]);
const breadWithSausage = await combine(grilledSausage, slicedBread);
const hotdog = await pourSauce(breadWithSausage, ketchup);
} catch (err) {
handleError(err);
}
}

async/await를 이용하면 기존에 많이 작성해 본 동기적인 코드와 비슷한 형태로 코드를 작성할 수 있다는 장점이 있습니다. 비동기적으로 진행하고 싶은 작업에 await를 붙이고 await가 쓰인 모든 함수에 async를 붙이면 됩니다. 그러나 async가 붙은 함수는 항상 Promise를 반환하기 때문에 자칫 잘못하면 callback 지옥처럼 async와 await가 끊임없이 증가하는 모습을 볼 수도 있습니다. Aditya Agarwal의 글에 따르면, 이때는 코드 간의 관계를 분석하여 async와 await를 남용하지 않아야 하며 Promise.all을 통해 여러 Promise를 한 번에 처리하면 된다고 합니다.

Promise의 활용

Promise에 익숙해지면 다양한 일을 할 수 있습니다. 간단하게는 특정 시간 동안 작동을 멈추는 함수를 다음과 같이 작성할 수 있습니다.

async function waitFor(timeInMS) {
await new Promise((resolve, reject) => {
setTimeout(resolve, timeInMS);
});
}

callback 형태를 지원하는 API를 Promise로 바꾸면 Promise의 여러 기능과 async/await 문법을 사용할 수 있습니다. NodeJS에서 지원하는 callback 기반 함수인 fs.readfile를 Promise를 반환하는 함수로 바꾸는 예시를 Zellwk의 글 Converting callbacks to promises에서 인용하겠습니다.

function readFilePromise(...args) {
return new Promise((resolve, reject) => {
fs.readFile(...args, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data);
}
});
});
}

window.onload와 같은 웹 API도 대표적인 callback의 사례입니다.

function waitLoad() {
return new Promise((resolve, reject) => {
window.onload = resolve;
});
}

자주 사용되는 웹 API인 document.addEventListener와 같은 이벤트 처리기 또한 callback 기반 코드이기 때문에 Promise로 바꾸고 싶을 수 있습니다. 이벤트 처리기를 Promise로 바꾸면 특정 키보드 키가 눌릴 때까지 기다리는 함수를 만들어 사용할 수 있어서 매우 폭넓은 활용이 가능해집니다. 일반적인 접근은 아니기에 협업하는 경우보다는 프로토타입을 만들거나 게임과 같은 특정 분야에서만 사용하길 권장해 드립니다.

callback 기반 API를 Promise 기반 API로 쉽게 바꾸기 위해서 저는 다음과 같은 속임수를 이용하곤 합니다.

function makeRemotePromise() {
let resolver = () => undefined;
let rejector = () => undefined;
const promise = new Promise((resolve, reject) => {
resolver = resolve;
rejector = reject;
});
return { promise, resolver, rejector };
}

Promise의 생성하는 코드와 Promise를 제어하는 코드가 별개로 존재할 수 있으므로 저는 이를 remotePromise라고 부르곤 합니다. remotePromise를 이용하여 웹 API와 socket.io에 있는 이벤트 처리기를 Promise 형태로 바꾸어 보겠습니다.

// Web
function waitDOMEvent(type, options) {
const { promise, resolver } = makeRemotePromise();
window.addEventListener(type, (e) => resolver(e), options || { once: true });
return promise;
}

// socket.io
function sendAndWaitResponse(socket, eventName, ...data) {
const { promise, resolver } = makeRemotePromise();
socket.once(eventName, (args) => {
resolver(args);
});
socket.emit(eventName, ...data);
return promise;
}

remotePromise를 적절히 사용하면 반대로 외부 라이브러리 등에 의존하지 않고 Promise 기반 이벤트 처리기를 만들 수도 있습니다. 그러나 아래에 소개된 Waiter는 코드의 흐름을 전역에서 바꾸어 동작을 이해하기 어렵게 만들기 때문에 되도록 사용하지 않길 권장합니다.

class Waiter {
constructor() {
this.waitMap = new Map();
}
getEvent(eventName, args) {
const { promise } = this.accessEvent(eventName);
if (args && args.once) {
this.deleteEvent(eventName);
}
if (args && args.timeLimit) {
const timeLimit = Waiter.waitTime(
args.timeLimit.timeInMS,
args.timeLimit.value
);
return Promise.race([promise, timeLimit]);
}
return promise;
}
setEvent(eventName, data) {
this.accessEvent(eventName).resolver(data);
}
deleteEvent(eventName) {
this.waitMap.delete(eventName);
}
accessEvent(eventName) {
if (!this.waitMap.has(eventName)) {
this.waitMap.set(eventName, Waiter.makeRemotePromise());
}
return this.waitMap.get(eventName);
}
static waitTime(timeInMS, value) {
const { promise, resolver } = Waiter.makeRemotePromise();
setTimeout(resolver.bind(null, value), timeInMS);
return promise;
}
static makeRemotePromise() {
let resolver = () => undefined;
const promise = new Promise((resolve) => {
resolver = resolve;
});
return { promise, resolver };
}
}

마무리

지금까지 비동기 프로그래밍의 의미부터 이를 실제로 사용하는 방법인 async/await에 대해서까지 알아보았습니다. 제 글이 조금이나마 도움이 되길 바라며 이만 마칩니다. 읽어주셔서 감사합니다.

참고 자료 및 추천 자료

모든 자료는 2022년 11월 4일에 접속되었습니다.

작가의 말

빵을 데우고 잘라야 할지 빵을 자르고 데워야 할지 고민하던 중 써브웨이에서 답을 찾았습니다. 소시지랑 빵도 한 번에 데우려다가 예제가 성립하지 않아 포기했습니다. :P

작동하지 않는 예제나 잘못된 내용에 대한 소중한 의견은 익명 설문지나 댓글 혹은 jeukhwang.dev@gmail.com으로 남겨주시면 수정하도록 하겠습니다

소켓 통신, Socket.IO

· 약 11분
장하준 (miru)
새로운 것을 만들며 배웁니다.

안녕하세요, SPARCS에서 개발자로 활동 중인 Miru 입니다.


웹에서 사용하는 통신

개발자들이 웹 서비스를 개발하면서 필수적으로 갖추어야 할 기능은 종류에 따라 다르지만, 가장 기본적인 구성은 백 엔드와 프론트 엔드에서 시작됩니다. 백 엔드는 모든 클라이언트, 즉 사용자들의 컴퓨터에 전달 되어야 할 정보를 총괄하는 역할을 하며, 프론트 엔드의 코드는 각 클라이언트들에게 보여지는 서비스의 표면적인 구동을 담당한다는 사실은 흔하게 알려져 있을 것입니다.

하지만 이 과정에서 반드시 수반되어야만 하는 기능이 존재하는데, 당연하게도, 이 모든 것들은 백 엔드의 코드와 프론트 엔드의 코드가 소통하지 못하면 이루어내지 못하는 것입니다.

웹 서비스에서 사용되는 통신이 단순히 백 엔드와 프론트 엔드의 정보 송수신을 위한, 좁은 의미에서 탄생한 것은 아니지만, 오늘 날에는 그 기능의 상당 부분을 차지하게 되었습니다.


HTTP 통신

이 글을 읽게 된 이상 HTTP라는 단어가 생소한 사람들이 많지는 않을 것이라고 생각을 합니다. HTTP 통신이란 브라우저와 웹 서버가 통신할 수 있도록 통신의 규칙과 절차를 규정한 통신 프로토콜이라고 생각할 수 있습니다. HTTP의 규칙을 따르겠다고 합의된 이상, 그에 따른 적절한 순서와 절차를 통해 원하는 파일과 데이터를 송수신 할 수 있도록 마련된 것입니다.

HTTP 통신은 기본적으로 요청과 응답으로(Request & Response) 이루어진다. 하나의 컴퓨터에서 다른 쪽으로 특정한 정보 혹은 파일을 요청하고, 그 신호를 인식한 상대 쪽 컴퓨터에서는 그에 대응하는 응답을 돌려주는 방식입니다.

이 방식은 많은 경우에 있어서 합리적인 통신 수단이지만, 그만큼 한계도 명확한 방식이 된다. 두 컴퓨터 간의 연결을 지속하는 방식 대신 단발적으로 요청이 수신 되었을 때만 연결을 허가하고, 응답까지 마무리된 후에는 연결을 해제하는 방식인 만큼, 소규모의 정보 전달이 다수 발생할 경우 계속해서 연결을 생성하고 해제하는 과정을 거쳐야 하기 때문에, 이는 굉장한 낭비를 하게 됩니다. 또한, 클라이언트의 요청 없이 서버 쪽에서 먼저 클라이언트의 문을 두드리는 것이 불가능해진다고 말할 수 있습니다. 한마디로, 일반적인 HTTP 통신 프로토콜을 따른다면, 모든 정보의 송수신의 시작은 클라이언트 쪽이 행동을 취함으로써 이루어질 수 밖에 없다는 것입니다.

HTTP_Image


-HTTP의 작동 방식-



소켓 통신이란?

이러한 HTTP의 단점을 보완하기 위해 소켓 통신이 사용됩니다. 소켓 통신은 이름만으로는 쉽게 그 기능이 연상되지는 않을 것이지만, 간단하게 클라이언트와 서버, 두 컴퓨터가 특정한 Port를 통해 실시간으로, 양방향 통신을 가능하게 만든 통신을 의미합니다. 정확히는 두 컴퓨터가 서로에게 단방향 통신을 주고 받음으로써 양방향 통신과 동일하게 구동하는 것이지만, 기능적으로 구 컴퓨터는 거의 동등한 위치에서 통신을 주고받게 되는 것입니다. 이는 클라이언트만이 통신을 시작할 수 있었던 HTTP 통신과는 큰 차이점을 보여줍니다.

소켓 통신은 HTTP 요청에서 연결을 Socket으로 업그레이드 하고자 한다는 요청을 주고 받음으로써 시작됩니다. 두 컴퓨터간의 상호 동의가 있다는 가정 하에 진행되는 연결로써, 소켓을 '여는' 작업과 그 열린 소켓에 '연결' 하는 작업을 동반하게 됩니다. 최초의 Handshake 방식의 요청과 응답이 한 차례 오가면, 자유로운 Websocket 통신이 열리게 되는 것입니다.

WebSocket_Image


-WebSocket의 작동 방식-



소켓 통신의 이점

소켓 통신의 이점은 HTTP 통신 만으로는 불가능했던 것을 가능하게 만드는 것에 있습니다. HTTP로는 불가능했던 지속, 반복 적인 통신을 더 적은 리소스로 가능하게 만들며 무엇보다도 서버 쪽에서 클라이언트의 방향으로 먼저 정보를 전송하며 통신하는 것이 가능해집니다. 이는 흔하게 접할 수 있는 서비스인 채팅, 온라인 게임 등 클라이언트와 무관하게 발생하는 이벤트에 대한 정보를 정확한 시점에 클라이언트에게 전달하는 것이 가능하게 됩니다.



사용 방법

웹 소켓이라는 개념은 엄밀한 의미에서 라이브러리가 아닙니다. HTTP와 같은 통신 프로토콜이기 때문에, 이를 구현하기 위해서는 Socket.io 라는 라이브러리를 사용할 것입니다.

프론트 엔드 Home_page.tsx

import { io } from "socket.io-client";

useEffect(() => {
const socket = io("http://localhost:8080", {
transports: ["websocket"],
});
socket.emit("recieveMsg_Server", "Hello Server");

socket.on("recieveMsg_Client", (i: string) => {
console.log(i);
});
}, []);

코드를 실행시킬 수 있는 방법을 마련한다면, 굳이 useEffect를 사용하지 않아도 됩니다.

백 엔드 index.js

const express = require("express");
const http = require("http");

const app = express();
app.use(express.json());

const server = http.createServer(app);
const io = Socket(server);

io.on("connection", (socket) => {
socket.on("recieveMsg_Server", (i: string) => {
console.log(i);
});
socket.emit("recieveMsg_Client", "Hello Client");
});

직관적으로 이해할 수 있다시피, 이 코드는 클라이언트/서버에서 각각 서버/클라이언트로 전달한 "Hello Server" 와 "Hello Client"라는 string을 콘솔 로그를 통해 출력합니다. 이 웹 소켓은 문자열 뿐만 아니라 함수가 사용할 수 있는 어떤 방식으로든 정보를 전달할 수 있을 것입니다.

하지만 Socket.io를 이용한 통신도 무적은 아닙니다. 웹소켓 통신은 한번 클라이언트와 서버가 연결된 이상 중단을 선언하기 전까지는 연결이 지속되기 때문에, 그만큼 정보 전달에 리소스를 많이 소모하게 됩니다. 의도적으로 조정하는 것이 가능하기는 하지만, 한번 전송하는 메세지의 한계가 1MB 인 것 또한 감안해야할 것입니다. 이에 관한 더 자세한 정보는 Socket.io의 서버 옵션 문서를 참고하면 도움이 될 것입니다.

본론으로 돌아와서, 보다시피, 프론트 엔드와 백 엔드는 동등한 방식으로 서로에게 통신을 하며, 전달 받은 정보를 가공하여 사용하는 것이 가능하게 됩니다. 웹 소켓 방식을 사용하면, 통신이 전달된 순간에 익명 함수가 실행되는 방식이므로, 개발자의 목적에 따라 다양한 활용이 가능해집니다.



마무리하며

이번 글에서는 WebSocket에 관한 설명과 그 사용 예시에 관하여 다루어보았습니다. 다양한 개발 경험을 가진 개발자라면 이 내용이 아주 쉽게 느껴질 수 있겠지만, 아직 경험을 쌓아가는 단계의 분들에게는 다양한 가능성을 가진, 굉장히 매력적인 기능으로 다가올 것이라고 생각합니다.

참고

https://socket.io/docs/v3/client-initialization/

https://fred16157.github.io/node.js/nodejs-socketio-communication-basic/

https://stackoverflow.com/questions/12977719/how-much-data-can-i-send-through-a-socket-emit

https://socket.io/docs/v4/server-options/

다사다난했던 AWS Amplify 배포 : 과연 옳은 선택일까?

· 약 8분
김승재 (prion)
개발새발 코드써가면 안되는디...

서비스 코드를 갈아엎으면서 어떻게 최적화할지도 같이 고민을 좀 해봤습니다. 기존에는 EC2를 VPC처럼 이용하던 PHP 기반 서비스였는데, 이를 Frontend-backend 구조로 분리해서 API 지원, 앱 개발 등 추후 다양한 확장을 할 수 있도록 설계를 했었습니다.

물론, 그건 설계의 이야기고 현실은 시ㄱ...

어쨌던, React단 코드와 Express 코드가 한 repo 폴더 안에 담겨있는 흔히 보는 구조가 나왔습니다. 이제 이걸 어떻게 배포해야할까요?

  1. 하던대로 서버에 빌드를 올려서 돌린다
  2. CI/CD를 빡세게 만들어서 매주 새 빌드를 받고 로그 분석하고..
  3. 아니면 다른 방법이...?

개발론적으로는 CI/CD를 빡세게 잡는게 맞겠죠. 하지만 현실은 프로그래밍도 잘 모르는 새내기 두명 앉혀다가 관리를 맡겨야 하는데, 그런 애들에게 개발 프로세스를 전부 설명해줄 여력이 없다는 겁니다.

그리고 개발 과정 동안 개발팀을 지켜본 결과, 사람은 편한 길로 간다는 교훈을 얻을 수 있었습니다.

뭔 말이냐고요? 아무리 CI/CD를 빡세게 만들어 물리 서버에 자동 배포되게 해도, 결국엔 서버에서 수정을 해서 다 꼬이게 만든다는 겁니다. 그게 편하니까요.

그래서 Serverless Deployment로 가기로 했습니다. 일단 Frontend만이라도요(Backend를 서버에서 건드릴 일이 없길 빕니다). 기존에 Serverless 백엔드를 구성해 본 적이 있었기에, 상당히 편할거라고 기대를 했었죠.

물론 일반적 환경이라면 그랬을 겁니다.

Amplify도 다른 서비스처럼 원격 저장소의 내용이 변하면 가져와서 빌드 후 배포하는 과정을 거치게 됩니다. 특히, 개발 중인 브랜치를 테스트할 수 있다는 기능이 있다는 것은 마음에 들었어요. 인증서를 만들어준다는 것도 좋았고.

근데 인증서를 만들어 줘야’만’ 한답니다. 즉, 인증이 잘 안되거나 해서 발급이 안되면 바로 에러를 뿜습니다.

Chapter 1. The Certificate

이론적으로는 인증이 굉장히 쉽습니다.

서버로 향하는 CNAME에 하나 더 추가하면 그 정보로 인증을 하겠다.

하지만 이 인증 과정이 문제가 **많습니다(물론 Route 53을 쓰면 되긴 하지만, 저희가 서브도메인을 받아오는 입장이라 쉽지 않습니다). 예를 들자면, 서버 CNAME 기록 추가시 오타가 났다고 합시다.

그러면 “서버 CNAME 기록이 일치하지 않습니다” 라고 해줍니다. 참 친절하기도 해라. 그래서 수정을 해주면... 타임아웃이 나서 다시 인증을 시도합니다.

근데... 어라? 인증용 CNAME 기록이 바뀌었네요? 그래서 다시 바꾸러 가줍니다. 그러면 “인증용 CNAME 기록이 기존과 충돌합니다.” 라는 에러로 바뀌어 있더라고요. 자세히 보니, 서버 CNAME 기록도 바뀌었어요.

삭제하고 다시 만들면 편할겁니다. 근데 전 이번에 도메인을 받아서 했어요. 서브도메인을. 새 서브도메인을 만드는 것은 별 문제가 없는데, 기존 서브도메인은 사람이 승인을 해줘야 변경처리가 됩니다.

이 관료주의적 시스템과 실패하면 재시작해야하는 인증 시스템이 합쳐지면 고구마가 탄생합니다.

Chapter 2. Lost in Documents

CORS 관련 보안이 최근 몇년 사이 상당히 강화되었죠. 그래서 reverse proxy를 이용해서 다른 서버지만 같은 도메인으로 콘텐츠를 제공하시는 분들이 많을겁니다.

이 기능을 지원하기 때문에 ‘오, 좋다’, 이러면서 바로 implement 했었는데... 생각보다 문제가 많더라고요?

일단, React가 기본적으로 SPA라는 것을 이해해야 합니다. 근데 hard link를 잘 걸어놓는 웹사이트 특성상 그 링크를 타고 들어가면 바로 콘텐츠가 나와야죠.

이걸 지원하기 위해 기본으로 redirect rule이 하나 설정되어 있습니다. 그리고 index.html용 리다이렉트가 하나 더 있고요.

근데 왜 이게 있는지 설명하는 문서가 하나도 없습니다. 왜 있는지 모르니, 일단 삭제해 봅시다. 그러니 앱이 안돌아갑니다. 빈 화면만 나오더라고요.

대체 왜 그런가 3시간을 찾아 헤메니 그 줄을 다시 추가하게 됐습니다.

고마워요, AWS. 당신의 유명한 지원 정책 덕분에 한 줄로 해소될 문제를 3시간동안 돌아왔네요.

이거 말고도 리다이렉트 관련 문제가 몇가지 더 있었지만, 나중에 이야기할거리인 것 같네요.

Chapter 3. Verdict

만약 여러분 목적이 scalable frontend deployment라면 굳이 Amplify를 쓸 필요가 없어 보입니다. 애초에 그 정도로 신경쓰셔야 한다면 좀 더 전문적인 서비스가 나아 보이고요.

그리고 react를 막 배워 배포할 생각인데, AWS free tier라서 쓰실 분도 추천하진 않습니다. 지원이 많이 부족하거든요.

저는 개인적으로 3~5인의 개발팀으로 구성된 작은 단체가 쓸만하다고 생각됩니다. 데이터 비용은 그렇게 싸지 않지만, CI/CD 환경에 상당히 잘 맞아 떨어지고 설정 후 이용에 큰 지식이 필요 없다고 느꼈습니다.

여러분의 배포가 평탄하길 빌며,

TypeORM과 함께 살아가기

· 약 7분
최상아 (retro)
백엔드 개발자

Disclaimer

본문에 앞서, 필자가 사용한 TypeORM은 v0.2x라는 점을 미리 말씀드린다. TypeORM v0.3x의 경우 문법이 크게 달라진 것으로 알고 있고 실제로 사용해보지 않아 이 글의 내용과 다를 수 있다.

다만 본문에서 언급한 버그들은 현재의 이슈 보드에도 Open되어 있다.


Into.

TypeORM은 NodeJS 플랫폼에서 가장 많이 사용되는 ORM 중 하나이지만, 프로덕션 환경에서 사용하기에는 조금 불편한 면이 있다.

2022년 10월 31일 현재, GitHub 이슈 보드에 bug 라벨이 달린 이슈만 1천개가 넘는다.

2022년 10월 31일 현재, GitHub 이슈 보드에 bug 라벨이 달린 이슈만 1천개가 넘는다.

9개월 동안 현업에서 TypeORM을 사용하며 느꼈던 불편함, 그럼에도 TypeORM과 함께 살아가기 위한 팁들을 소개한다.

TypeORM의 문제점

황당한 버그

상식적으로 이해가 안되는 치명적인 버그가 여럿 존재한다.

마이그레이션 스크립트 자동 생성 기능은 엔티티(모델)의 변경사항을 실제 테이블에 적용하는 스크립트를 만들어주는 기능이다. 그런데 컬럼의 타입을 변경하는 등 일부 ALTER 문에 대해 TypeORM은 해당 컬럼을 DROP(!)하고 다시 CREATE해버리는 스크립트를 만든다.

이 이슈는 1천개가 넘는 TypeORM의 버그 중 가장 좋아요👍를 많이 받았고, 2019년 1월에 제보되었지만 아직 고쳐지지 않았다.

성능 버그도 여럿 존재한다. 성능 버그는 개발 환경에서는 잘 동작하다가, 실 서비스 환경에 배포해서 스케일이 커지면 대참사가 발생한다는 점에서 치명적이다. 서비스 규모가 작을 때에는 괜찮을지 몰라도, 서비스가 성장하면서 대대적인 리팩토링이 필요해질 것이다.

이외에 find operation에서 id에 해당하는 첫 번째 인자를 undefined로 넣으면 아무 relation도 찾아오지 못하는 대신 첫 번째 relation을 받아오는 버그도 굉장히 인상적이었다.

불완전한 타입 검사

완벽한 타입 안전성을 기대했다면 실망할 수 있다. 특정 컬럼만 가져오거나 연결된 relation이 존재하는 상황에서, 가져온 객체의 타입이 본래 객체의 타입과 같다고 간주해버린다. post.chat.author.profile 과 여러 join하여 모든 정보를 객체로 가져오는 상황이 많다면 런타임에서 타입 에러로 고충을 겪을 가능성이 높다.

자세한 내용은 또다른 ORM인 Prisma의 공식 블로그를 참고 바란다.

Typeorm과 함께 살아가기 위한 팁

TypeORM이 버그의 원인일 수 있다는 사실을 기억하자

내가 작성한 React 웹 앱이 제대로 작동하지 않는다면 React의 문제일 가능성보다는 내 코드의 문제일 가능성이 훨씬 높다. 그러나 TypeORM을 사용한 서버가 제대로 작동하지 않는다면 TypeORM의 문제일 가능성이 꽤나 있다.

익숙하지 않은 문법을 사용할 때는 사후에 디버깅하는 대신 코드 작성 전부터 TypeORM에 대한 멘탈 모델을 검증해봐야한다. 또한 GitHub 이슈 보드에서 해당 feature가 문제가 없는지, 문제가 있다면 대안은 무엇인지 꼭 확인하자.

때로는 Raw Query로만 해결할 수 있는 문제도 있다

“이것도 안 된다고?” 싶은 순간이 있을 수 있다. 침착하게 Raw Query를 쓰도록 하자. 0.x 버전의 프로젝트이므로 부족한 기능이 있을 수 있다는 것을 받아들이자..

어디에서도 답을 찾을 수 없는 문제가 있다면 Slack에서 질문하자

TypeORM를 사용하는 개발자들이 충분히 많기 때문에, 보통은 GitHub 이슈 보드, SOF, 블로그 등에서 답을 찾을 수 있을 것이다. 그러나 설계 상의 특수한 제약으로 인해 검색되지 않는 문제를 해결해야 한다면, **질문용 Slack**에 도움을 구해보자. 메인 컨트리뷰터 pleerock이 거의 하루 만에 답변을 달아주고 계신다.

Conclusion

만약 토이 프로젝트에서 TypeORM을 만족스럽게 사용하다가 좀 더 본격적인 서비스에 도입하려 하는 독자라면, TypeORM의 이슈 보드를 찬찬히 읽어보고 이 버그를 견딜 수 있는지 다시 생각해보면 좋겠다.

이미 TypeORM을 사용하고 계시다면, 행운을 빈다. 불편함을 견딜 수 없다면 TypeORM을 fork해서 자체적으로 개선하거나, 아예 다른 ORM을 사용하는 방법도 있겠으나 그러기 어려운 상황도 분명 있을 것이다. 이 글의 작은 팁들—아마 이미 알고 계시겠지만—이 도움이 되었기를 바란다.

GPU와 GPGPU 알아보기

· 약 7분
김현민 (scotch)
두드리기를 잘해요

안녕하세요! SPARCS에서 활동중인 scotch 입니다.

컴퓨터나 프로그래밍(특히 머신러닝)에 관심 가져보신 분들은 GPU라는 용어를 한 번쯤 들어보셨을 겁니다. 단순히 컴퓨터 부품 중 하나로 알고계신 분들도 있을거고, 머신러닝 등에 활용해신 분들도 있을 겁니다. 그리하여 이번 글에서는 GPU에 대해 자세히 알아보고자 합니다.

초창기의 그래픽카드

극초창기의 컴퓨터는 CPU(Central Processing Unit, 중앙처리장치)가 화면 출력까지 담당했습니다. 이후 해상도와 색상 수가 증가하며 화면 출력에 필요한 부하가 커졌고, 화면 출력을 담당하는 독립적인 장치인 그래픽카드가 탄생하였습니다. 다만 당시의 그래픽카드는 메모리의 저장된 화면 정보를 디스플레이로 옮기는 역할에 그쳤습니다.

그래픽카드의 역할이 커지기 시작한것은 3D 그래픽이 나온 이후입니다. 단순히 선과 면으로 이루어진 3차원 공간을 구현하는걸로 시작하여, 텍스쳐와 광원 등 현실과 가까운 그래픽을 구현하기 위해 화면 출력에 엄청난 연산량을 필요로 하게 됩니다. 이러한 연산은 단순한 벡터 계산 정도였지만 그 양이 엄청나 단순히 그래픽카드의 처리속도를 올리는 것만으로 처리하기는 힘들었습니다.

GPU의 탄생

그리하여 그래픽카드 제조사들은 단순한 연산 유닛을 여러개 탑재해 병렬로 그래픽 연산을 처리하기 시작하였고, 기존의 그래픽카드와 차별화된 용어로 제시된것이 GPU입니다. 이는 Graphic Processing Unit의 약자로, 용어 그대로 컴퓨터 그래픽 처리에 특화된 장치입니다.

CPUGPU GPU는 CPU에 비해 훨씬 많은 수의 ALU(Arithmetic and Logical Unit, 산술 논리 장치)를 탑재하고 있어 그래픽 출력과 같은 대량의 병렬 계산에 특화되어 있습니다. 다만 CPU에 비해 ALU의 기능이 단순하고 처리 속도(Clock, 클럭)가 느리기 때문에 복잡한 직렬 계산에는 부적합하며, 단독으로 작업을 처리할 수 없습니다.

GPGPU

GPU가 그래픽 처리에 사용되기 시작한 후, 몇몇 개발자들은 이를 그래픽처리뿐 아니라 범용적인 계산에도 사용할 수 있음을 알게 됩니다. 이후 그래픽카드 제조사도 이러한 범용 계산 기능을 공식적으로 지원하기 시작했으며 이를 GPGPU(General-Purpose computing on GPU)라고 칭하기 시작했습니다.

GPGPU를 활용하기 위해서는 이를 지원하는 GPU뿐 아니라 GPU의 명령어셋을 사용할 수 있게 해주는 소프트웨어 레이어가 필요한데, 대표적으로 NVIDIA의 CUDA와 범용으로 사용 가능한 OpenCL이 있습니다.

GPGPU의 강력함

여러분들이 가장 흔하게 접해보셨을 GPGPU는 바로 머신러닝입니다. CPU보다 GPU가 머신러닝 학습 과정에서 훨씬 빨랐던 경험을 한 번씩 해보셨을텐데요. ML은 기본적으로 엄청난 양의 단순 연산(행렬곱)에 기반을 두고 있습니다. 즉, GPU가 병렬로 처리하기 아주 적합한 것입니다. 이외에도 렌더링, 영상 인코딩/디코딩, 유체 시뮬레이션, 암호화폐 채굴 등 다양한 분야에서 GPGPU가 활용되고 있습니다.

머신러닝을 포함한 다양한 범용 계산은 부동소수점을 활용합니다. 연산장치의 부동소수점 연산 성능을 FLOPS(FLoating point Operations Per Second)로 표현하는데요, 이 수치를 통해 GPU의 연산력이 얼마나 강력한지 알 수 있습니다.

최신 고성능 CPU인 5950X(80만원 내외)의 FP32(단정밀도 부동소수점) 연산 성능은 1.9 TFLOPS 정도인데에 반해, 최신 GPU인 RTX 3080(100만원 내외)의 FP32 성능은 30 TFLOPS에 가깝습니다. 항상 GPU의 성능을 100% 활용할 수 있는 것은 아니지만 이 수치를 통해 GPGPU가 얼마나 강력한 성능을 보여주는지 짐작할 수 있습니다.

마무리

상향평준화된 컴퓨터 시장에서 GPU는 급격한 성장을 하며 대량의 연산을 필요로 하는 과학, 공학 분야 전반의 발전에 큰 기여를 하고 있습니다. 최근 머신러닝의 급격한 부상도 GPU의 발전과 동반되었습니다. 그만큼 GPU의 중요성은 더욱 대두될 것이며, 본 글이 조금이나마 이해를 도왔으면 합니다. 감사합니다.

Next.js: 더 빠른 페이지를 위해서

· 약 11분
이진우 (jaydub)
필요하면 만들어 쓴다!

안녕하세요, SPARCS에서 개발자로 활동하고 있는 jaydub입니다!

최근 많은 기업과 서비스들이 Server Side Rendering을 도입하고 있습니다. SSR이란 무엇인지, 그리고 SSR을 위한 프레임워크 중 하나인 Next.js는 내부적으로 어떤 순서로 동작하는지 알아보도록 하겠습니다.

SSR이란?

React의 등장 이후 클라이언트는 많은 일들을 처리하게 되었습니다. 기존에는 PHP 등을 사용해 서버에서 완성된 HTML 문서를 전달하고 있었습니다. React는 이와 달리 서버에서는 root element만이 포함된 HTML 문서를 전달하고 나머지 작업들은 JavaScript에서 처리하는 방식을 사용하게 됩니다. 바로 Client Side Rendering(CSR)이죠. 이 둘을 비교한 간단한 다이어그램을 그려보았습니다.

CSR

image-20221024221254055

SSR

image-20221024221327137

보시다시피 CSR은 클라이언트에서, SSR은 서버에서 더 많은 작업을 하고 있죠. 둘 중 어떤 것이 "정답이다"라는 것은 없습니다. 하지만 처리해야 하는 데이터의 양이 점점 증가하는 요즘, SSR이 사용자에게 더 빨리 의미있는 화면을 전달할 수 있다는 장점이 부각되고 있습니다.

SSR의 또다른 장점은 SEO(검색 엔진 최적화, Search Engine Optimization)에서 유리한 측면을 보여주는데요, 그 이유는 웹 크롤러의 특성에 있습니다. 대다수의 크롤러들은 JavaScript를 인식하지 못합니다. 단순히 HTML의 내용을 파싱하는데, CSR의 경우 화면에 표시될 데이터를 표시하는 많은 부분을 JavaScript에 의존하기 때문에 크롤러가 의미를 파악하기 힘들다는 말입니다. 예를 들어 내가 멋있는 맛집 사이트를 만들었는데 CSR로 구현되었다면 웹 크롤러는 식당에 대한 정보(JavaScript가 불러오고 있음)를 알 수 없습니다. 반대로 SSR로 구현되었다면 크롤러가 반환받은 페이지에 맛집에 관한 정보들이 담겨있겠죠. (이는 비즈니스 측면에서 매우 중요합니다)

이런 이유들로 많은 서비스들이 SSR 방식으로 구현되고 있습니다. SSR을 위한 프레임워크! 당연히 없을 리가 없겠죠? 수많은 SSR 프레임워크 중 1등은...

image-20221024215004860

바로 Next.js입니다! Next.js는 내부적으로 어떻게 동작하길래 SSR이 가능한 것인지 알아보도록 하겠습니다.

Next.js의 동작 순서

이하 글은 Next.js의 공식 document를 번역, 참고한 글입니다.

1. 컴파일링

보통 컴파일링은 코드를 어셈블리 언어로 변환하는 과정을 의미하곤 하는데요, 여기에서 컴파일링은 이보다 넓은 의미를 가집니다. 공식 문서에서는 다음과 같이 설명하고 있네요.

컴파일은 한 언어로 된 코드를 다른 언어 또는 같은 언어의 다른 버전으로 출력하는 과정을 말합니다.

프론트엔드를 개발할 때는 사용되는 언어가 한정적이긴 하지만 JavaScript (jsx), TypeScript (tsx)와 같은 옵션들이 있죠. 컴파일링을 통해 저희가 어떠한 언어로 개발하더라도 컴파일 과정을 통해 "브라우저"가 이해할 수 있는 코드로 변환됩니다.

2. Minifying

저희가 작성한 코드에는 사실 불필요한 부분들이 있습니다. 주석도 실행에 있어서 불필요하고 줄바뀜이나 공백도 사실 필요 없습니다. 이러한 부분들은 페이지의 로딩 시간을 지연시킵니다. 파일의 크기가 증가하면서 네트워크에 오가는 패킷의 크기가 커지면 결국 클라이언트에 다다르는 데 더 많은 시간이 걸리는 것이죠. 피카츄 배구는 클릭과 동시에 실행되지만 배틀그라운드는 로딩에 꽤 오랜 시간이 걸리는 것을 생각해보세요.

그렇다고 모든 컴포넌트를 한 줄에 작성할 수는 없겠죠? 다행히도 Next.js에서는 minifying을 수행합니다. 위에서 말한 불필요한 charater들을 제거해줘 데이터를 효율적으로 서빙할 수 있게 해줍니다.

img

3. 번들링

프론트엔드를 개발하다보면 자주 마주치는 단어가 바로 "컴포넌트"이죠. 프론트엔드는 결국 이 컴포넌트를 어떻게 설계하고 사용할 것인가에 관한 부분이라 말할 수도 있겠습니다. 각 컴포넌트는 서로 다른 파일에서 정의되고 또다른 파일에서 불러와서 사용합니다. 서로 다른 파일에 있는 코드가 어떻게 한 화면에 보여질 수 있는 것일까요?

바로 이것을 가능하게 하는 것이 번들링입니다. Next.js는 서로의 종속성을 파악해 브라우저에 최적화된 번들로 병합(패키징)합니다. 여기에는 저희가 작성한 컴포넌트 뿐만 아니라 (npm, yarn 등을 이용해 추가한) 외부 패키지도 포함됩니다.

4. 코드 스플리팅

스플리팅(splitting)은 나눈다는 뜻이죠. 왜 방금 합쳐놓고 다시 나누냐는 의문을 가질 수도 있습니다.

사실 한 페이지를 보여주는 데 모든 컴포넌트가 필요한 것은 아닙니다. 예를 들어 로그인 버튼은 로그인 페이지에서만 사용되겠죠? 그 말은 검색 페이지에서는 로그인 버튼을 필요로 하지 않는다는 것입니다. 최적화가 가능해 보입니다.

Next.js에서는 특정 페이지를 실행하는 데 필요한 코드만 로드하도록 해 초기 로드 시간을 감소시켜줍니다. 특히 Next.js에서는 pages/라는 특수한 폴더가 존재하는데 각 페이지는 각각 번들로 분할됩니다. 또한 여러 페이지에 걸쳐 사용되는 컴포넌트의 경우 별도로 번들링합니다. 이 경우 다른 페이지로 이동하더라도 이미 가지고 있기 때문에 로드 시간을 줄일 수 있습니다.


지금까지의 과정은 Next.js의 빌드 단계였습니다. 이 과정을 통해 생성되는 파일들은

  • 정적 HTML 파일
  • 서버에서 렌더링하기 위한 JavaScript 코드
  • 클라이언트에서 interaction을 하기 위한 JavaScript 코드
  • CSS 파일

이 파일들은 서버에 배포하면 드디어 전세계 수많은 유저들이 저희 서비스를 이용할 수 있게 됩니다. 마지막으로 렌더링은 어떻게 이뤄지는지 살펴볼까요?


5. 렌더링

Next.js에서는 다음 세 가지 타입의 렌더링 방식을 지원합니다.

  • CSR
  • SSR
  • Static Site Generation

이 중 CSR은 저희가 보통 생각하는 React의 렌더링 방식입니다. 이를 제외한 나머지 두 가지 방식에 대해 좀 더 자세히 알아볼게요.

  • SSR

    SSR을 사용하면 페이지의 HTML이 "매 요청마다" 생성됩니다. 그리고 생성된 HTML 파일과 JSON 파일, 그리고 페이지의 인터랙션을 위한 JavaScript 파일이 클라이언트에게 전송됩니다.

  • Static Site Generation

    이 렌더링 방식은 HTML 파일이 빌드시 단 "한 번"만 생성됩니다. 데이터의 변경이 없는 페이지에 적용할 수 있는 방식입니다.

마무리

지금까지 SSR의 기본적인 개념과 Next.js의 동작 순서에 대해 알아보았습니다. Next.js가 더 궁금해지셨다면 공식 문서를 통해 깊이 알아보는 것도 좋을 것 같네요. 긴 글 읽어주셔서 감사합니다 :)