교내 동아리 도서 관리 프로젝트 회고

간단한 거라도 끝까지 배포해보자

교내 동아리 도서 관리 프로젝트 회고

https://github.com/ilcm96/dku-aegis-library-system

개요

교내 프로그래밍 동아리에서 예산으로 구매한 책이 점점 쌓여가서 이를 관리하는 웹사이트를 개발하는 동아리내 공모전이 개최되었다.

요구사항을 단순화 하면 다음과 같았다.

  • 유저

    • 회원가입, 로그인, 회원탈퇴

    • 도서 대출, 반납

    • 도서 요청

  • 관리자

    • 회원승인, 반려, 삭제

    • 도서 등록, 업데이트, 삭제

    • 도서 요청 승인, 반려, 삭제

아키텍처

서버

Django로 시작했다, 그러나

프론트와의 협업 없이 혼자서 개발하는 프로젝트였기 때문에 처음에는 Django로 시작했다. 그러나 Django는 DRF를 살짝만 맛 본 상태였고 충분한 학습이 없는 상태에서 Django 특유의 Magical 한 부분때문에 오히려 개발 속도가 떨어졌다.

늘 먹던걸로, Golang

그래서 가장 익숙하기도 하고 어짜피 컨테이너로 배포할 생각이었기 때문에 Golang으로 선회했다. 단일 바이너리로 컴파일되기 때문에 컨테이너 이미지를 만들때 정말 편리하고 용량도 20MB 미만으로 빌드할 수 있다. 또한 이번 프로젝트는 CGO가 필요없는 것들에만 의존하기 때문에 배포도 정말 빨랐다.

문서화의 끝, Fiber

다음으로 웹 프레임워크는 Fiber를 선택했다. Fiber는 2대장인 echo와 gin에 비해 문서화가 매우 잘 되어 있고, 템플릿 렌더링 설정이 편리해서 선택했다. 다만 net/http가 아니라 fasthttp를 사용하면서까지 성능에 집중한 Fiber의 템플릿 렌더링 성능은 설정이 편리한 만큼 뛰어나진 않았다. 이는 부분은 성능 테스트 부분서 다뤘다.

One and only, Entgo

현재 Golang의 데이터베이스 관련 기술은 타 진영에 비하면 아직 많이 부족하다. 그래도 현재 Golang에서 ORM을 쓰려면 반드시 ent를 써야한다고 생각한다. 현재 어느 정도 사용자가 있는 ORM 중에서 ent를 제외하면 모두 자동완성이 충분하지도 Type-safe하지도 않다.

Raw SQL을 쓴다면 어쩔 수 없이 Type-safe를 어느 정도 포기해야할 수 밖에 없지만 이외에 ORM을 쓰면 Type-safe는 반드시 보장되어야 한다고 생각한다. 물론 ORM의 역할이 Type-safe를 보장하는건 아니다. 이는 테스트를 통해 걸러내야 하는 것이 맞다. 그러나 하나의 계층을 추가했음에도 Raw SQL 처럼 문자열 기반의 쿼리를 쓸거면 굳이 쓸 필요가 있나 싶다.

Postgresql

RDB와 NoSQL중 뭘 써야할 지 모르겠다만 RDB를 쓰면 된다

도서 스키마가 이미 정해져있고 자주 바뀔 일이(사실 전혀 없다) 없고, 동아리 내에서만 사용할 예정이라 DB의 성능이 크게 중요한 프로젝트는 아니라 RDB를 선택했다. 여러 RDB중에서 Postgresql을 선택한 이유는 다음과 같다.

  • 도서 스키마 중 태그 부분을 Array 타입으로 편리하게 사용 가능

  • 검색 기능 구현 시 Like 대신 형태소 분석기 사용하는 경우 타 DB 대비 편하게 확장 설치 가능

Redis

세션 기반 인증을 사용할 예정이어서 세션 저장소로 사용하기 위해 도입했다. 서버의 가용성을 위해서 서버 컨테이너를 2개 띄웠기 때문에 공통 세션 저장소가 필요했고, 현재 VM이 디스크 성능은 부족하지만 RAM은 24GB로 충분하기 때문에 최적이라고 판단했다. 또한 세션 정보는 영속성이 반드시 필요한 정보는 아니지만 매 요청마다 확인 해야하기 때문에 이 부분도 Redis와 잘 맞다고 생각해 선택했다.

Minio

보다 현실에 가까운 아키텍처를 구현하기 위해서 도입했다. 어떻게 보면 오버 엔지니어링이긴 한데 S3를 안 쓰는 기업이 없는 상황에서 S3 호환 인터페이스를 가지고 있는 Minio는 학습 목적으로는 충분히 도입할 만하다고 생각했다. 또한 도입함으로서 정적 자료와 서버 컨테이너 간의 의존성을 분리할 있었다.

Nginx

Nginx 컨테이너는 총 2개가 존재한다. 외부 컨테이너는 VM이 받는 모든 요청의 앞 단에 위치해 있다. 리버스 프록시로서 도메인에 따라서 컨테이너에 요청을 분배하며 TLS 인증서를 적용해 민감한 정보가 담긴 요청을 암호화한다. 추가적으로 캐시 정책을 적용해서 자주 바뀔 일이 없는 정적 에셋을 캐싱해서 뒤에 서비스 컨테이너들의 부담을 줄인다.

내부 컨테이너는 Blue Green 배포 전략을 적용하기 위해서 사용했다. Nginx와 서버 2개를 하나의 Compose 파일로 묶어서 배포했다.

개발 내용

Layered Architecture와 의존성 역전의 법칙

개발 초기에는 하나의 Handler 함수 안에 사용자 요청 처리, 비즈니스 로직과 데이터 영속화 과정을 모두 넣었지만 그다지 크지 않은 프로젝트임에도 불구하고 코드가 늘어남에 따라 코드 재사용도 어렵고 함수 하나 하나의 길이가 너무 길어지기 시작했다. 그래서 Layered Architecture를 도입했다.

Controller, Service, Repository 레이어로 나눠 각각 클라이언트와의 상호작용, 비즈니스 로직, DB 접근과 관련된 코드로 나눴다. "구조체가 아닌 인터페이스에 의존해라", 즉 의존성 역전의 법칙을 함께 도입하면서 추후 확장에 유리한 구조로 변경했다.

물론 이 과정도 뭔가 더 개선할 여지가 있다고 생각한다. DI 컨테이너 없이 수동으로 struct와 interfac를 기반으로 DI를 적용하다 보니 Golang의 특성때문에 boilerplate 코드가 너무 많아서 함수 하나를 추가할 때 마다 코드 작성이 늘어났다. 아마 이 부분은 NestJS 처럼 프레임워크단에서 CLI를 지원하면 편할 것 같은데, 마이크로 프레임워크 특성 상 공식적으로 만들어지진 않을 것 같고 추후 Fiber를 더 많이 사용하게 된다면 한번 CLI를 만들어보고 싶고, DI 컨테이너도 사용해보고 싶다.

Golang의 에러 핸들링

Golnag 설문 조사를 진행하면 어려움을 겪는 부분으로 항상 에러 헨들링이 최상위로 꼽힌다. try-catch 없이 if 문으로 에러를 확인하고 헨들링하기 때문에 이에 대한 정말 많은 글을 찾아볼 수 있다. 나 또한 Layered Architecture를 도입하면서 레이어간 에러의 전달을 어떻게 처리할 것인지, 어디서 로깅할 것인지에 대해 정말 많은 고민을 했다.

이 과정에서 Naver D2 블로그의 Golang, 그대들은 어떻게 할 것인가" 에서 정말 많은 영감을 얻을 수 있었다. 해당 글과 비슷하게 Golang의 Runtime 패키지를 활용해서 Stack Trace를 직접 만들어서 에러 메세지와 함께 감싸 Controller 레이어까지 올리는 방법을 적용했고 이 과정에서 Runtime 패키지에 대해 배울 수 있었다. 이렇게 변경함으로서 Golang 프로그래머라면 누구나 알고 있는 if err != nil {return err} 코드의 중복을 줄일 수 있었다.

로깅은 최근 Standard Library에 추가된 slog을 사용했다. 성능적인 부분에서는 uber에서 만든 zap이 훨씬 더 나았지만 성능이 크게 중요한 프로젝트가 아닌 만큼 표준 라이브러리에서 해결했다.

JWT보단 Session

요즘 인터넷을 보다 보면 정말 많은 글에서 JWT를 사용자 인증에 활용한다. Stateless 한 HTTP의 특징과 DB에 접근할 필요 없이 인증인가를 진행할 수 있다는 것은 분명한 장점이다. 그러나 MSA나 처리량이 높은 서비스가 아닌 이상 일반적인 모놀리틱 어플리케이션에선 세션을 사용하는 것이 더 좋다고 생각한다. 예를 들면 로그아웃을 구현할 때 세션의 경우 세션 ID를 세션 저장소에서 삭제하면 되지만 JWT는 블랙리스트를 구현해야 하는 등, 결국 요구사항이 늘어나면 늘어날 수록 과연 JWT가 진정으로 Stateless 한건지 의문이 드는 상황에 늘어난다.

따라서 이번 프로젝트에선 Redis를 저장소로 사용하는 세션 기반 인증을 구현했다. Fiber에서 제공하는 미들웨어는 프로젝트 요구사항을 완벽히 반영하지 못해서 세션 미들웨어와 Basic Auth 미들웨어 코드를 참고해서 새로운 미들웨어를 작성해서 적용했다.

세션 기반 인증에서 세션을 쿠기에 저장하기 때문에 여러 보안 문제에 노출될 수 있다.

  • 세션 하이재킹

  • 크로스 사이트 스크립팅(XSS)

  • 크로스 사이트 요청 위조(CSRF)

세션 하이재킹은 쿠키에서 Secure 옵션을 사용해서 HTTPS 상황에서만 쿠키의 전송을 허용함으로서 쿠키를 전달할 때 암호화해서 방지했다.
XSS 공격은 쿠키에 HttpOnly 옵션을 사용해서 JS에서의 쿠키 접근을 원천적으로 차단함으로서 방지했다.
CSRF 공격은 템플릿 렌더링을 사용해서 웹사이트와 API 서버의 주소가 같기 때문에 SameSite 옵션을 Strict 로 적용함으로서 같은 도메인 안에서만 쿠키가 전송되도록 해 방지했다.

비밀번호를 Hashing 하는 보다 나은 방법, bcrypt

bcrypt는 SHA와 같은 Hash 함수지만 애초부터 비밀번호를 해싱하기 위해 탄생한 만큼 보다 편리하고 안전하다.

bcrypt는 레인보우 테이블 공격을 막기 위해 생성하는 Salt를 생성하는 로직이 알고리즘 자체에 포함되어 있기 때문에 개발자가 직접 Salt를 생성할 필요도 빼먹고 해싱할 수도 없다.

또한 bcrypt는 해싱 속도가 SHA에 비해 매우 느리다. 느린 알고리즘은 일반적으로 좋지 않지만 비밀번호를 해싱하는 경우 브루트포스 공격때문에 빠른 알고리즘이 항상 좋은 것은 아니다. bcrypt는 해싱 속도를 직접 조정할 수도 있기 때문에 발전하는 컴퓨팅 파워에 맞춰 Cost 값을 올려 해싱 속도를 일정하게 유지할 수 있다.

소감

사실 많은 기능이 있는 프로젝트는 아니였다. 대부분은 기본적인 CRUD였고 연관관계도 그렇게 복잡하지는 않았다. 그럼에도 불구하고 꽤 많은 것들을 배울 수 있던 프로젝트였다. 특히 이번 프로젝트에선 기존에는 잘 알지 못했던 세션에 대해 공부할 수 있었고, Fiber의 미들웨어도 직접 만들어서 적용하고, Nginx 기반의 Blue-Green 배포도 자동화 해보는 등 꽤나 재미있었던 경험이었다.

out: New version deployed to blue. Checking health...
out: Waiting for the new version to become healthy...
out: New version is healthy. Switching traffic to blue...
Notice: 4/05/31 03:55:49 [notice] 177#177: signal process started
out: Traffic switched to blue. Stopping old version...
err:  Container dku-app-green-2  Stopping
err:  Container dku-app-green-1  Stopping
err:  Container dku-app-green-1  Stopped
err:  Container dku-app-green-2  Stopped
out: Deployment complete. Now serving blue.