Posts [회고] 42서울 C++ 기반 nginx 샘플링 프로젝트 회고
Post
Cancel

[회고] 42서울 C++ 기반 nginx 샘플링 프로젝트 회고

들어가며

Nginx처럼 작동하는 웹 서버를 만드는 webserv 과제를 드디어 마쳤다. 약 두 달간 팀원들과 치열하게 머리를 맞대며 고민하고, 오류를 잡기 위해 늦은 시간까지 모니터 앞에서 보낸 시간들이 아득하게 떠오른다.

이전에 진행했던 Inception 과제에서는 Nginx를 사용했지만, 정작 Nginx가 어떻게 작동하는지, 설정 파일을 어떻게 다뤄야 하는지는 제대로 이해하지 못해 남았다. 그래서 이번 과제를 통해 웹 서버의 작동 원리를 제대로 이해하기 위해 소켓 프로그래밍부터 HTTP 관련 RFC 문서(7230 ~ 7235)까지 읽으며 바닥부터 웹 서버를 직접 구현했다.

Minishell 과제를 하며 Bash처럼 작동하는 Shell을 구현한 덕분에 Shell Script 사용이 능숙해졌듯, 이번 Webserv 과제를 통해서도 Nginx의 작동 원리를 깊이 이해할 수 있었고, 앞으로 어떤 웹 서버를 사용하더라도 잘 다룰 수 있겠다는 자신감이 생겼다.

이 글을 과제를 마치며 우리가 어떤 문제를 마주했는지, 어떻게 해결했는지, 그리고 협업을 어떻게 진행했는지를 기록한 회고록이다.

트러블슈팅

메모리 누수와 자원 회수

kqueue의 사용자 정의 데이터 메모리 누수

I/O 멀티플렉싱을 이용한 소켓 통신을 구현하기 위해 kqueue를 사용했다. kevent 함수를 통해 kqueue가 감지할 파일 디스크립터, 이벤트 종류, 사용자 정의 데이터(udata)를 설정해서 이벤트를 등록할 수 있었다.

클라이언트와의 TCP 연결 맺기, 데이터 수신, 데이터 전송 순서로 이벤트가 발생하는 과정에서, 우리는 각 이벤트마다 udata를 동적으로 새롭게 할당해주었다.

예를 들어, 클라이언트 A의 소켓 fd가 7번으로 할당되었다고 가정하자. 전송할 데이터가 남아 있어 데이터 전송 이벤트가 발생해야 하는 상황에서, 소켓 연결 종료 이벤트가 먼저 발생하면 전송 이벤트가 무시되었다. 이로 인해 클라이언트 A와 연결된 이벤트들의 udata를 적절히 해제하지 못했고, 이는 곧 메모리 누수로 이어졌다.

게다가 클라이언트 B가 다시 7번 fd를 할당받게 되면, 이전 클라이언트 A의 udata가 남아 있는 상태에서 잘못된 데이터가 덮어씌워지는 문제가 발생했다. 결과적으로 udata 주소를 잃어 해제할 수 없는 상황이 반복되며, 요청이 늘어날수록 메모리 누수가 급격하게 증가했다.

소켓 종료로 인한 자원 회수 처리

또한, 특정 소켓이 닫히면 해당 클라이언트와 관련된 모든 자원을 정리해야 했다. 할당된 udata는 물론이고, CGI 실행 후 무한 루프에 빠진 자식 프로세스, 파일 입출력에 사용된 파일 디스크립트, 파이프 등 모두 회수해야 했다.

하지만 udata를 이벤트마다 따로 생성하다 보니, 하나의 클라이언트에 속한 모든 정보(파일 디스크립터, udata 등)를 한 번에 관리하기 어려웠다. 이로 인해, 클라이언트 연결이 종료된 이후에도 CGI로 생성된 자식 프로세스가 종료되지 않고 남는 문제가 발생했다.

이를 해결하기 위해 클라이언트 요청마다 하나의 udata만 할당하고, 해당 요청에 관련된 모든 이벤트를 이 udata를 통해 추적하도록 설계했다. 그리고 각 fd에 대한 udata를 map에 저장해 전체적으로 관리했다.

1
std::map<int, std::set<t_event_udata*> > m_udata_map;

소켓 연결이 끊어지거나 오류가 발생하면, 정리해야 할 파일 디스크립터 번호를 저장해두고 이벤트 루프가 끝날 때 map의 value에 저장된 모든 udata와 내부 자원을 해제하도록 구현했다.

이렇게 구조를 단순화하면서 한 요청당 최대 5개의 udata를 할당하던 복잡성을 줄일 수 있었고, 메모리 누수 발생 지점을 파악하는 데도 큰 도움이 되었다. 해당 요청에서 사용한 모든 파일 디스크립터도 한 번에 닫을 수 있었다.

이 문제를 해결하는 과정에서 과제를 먼저 마친 분에게 정말 큰 도움을 받았다. 팀원과 아무리 코드를 봐도 원인을 알 수 없었지만, 도움을 요청한 덕분에 문제의 핵심을 짚어낼 수 있었다. 게다가 평가 때 만나게 되어서 더 꼼꼼하게 리뷰를 받을 수 있었고, 클라이언트가 보낸 데이터가 클 때 느려지는 응답 속도를 높이기 위한 최적화 방법에 대해서도 조언을 들을 수 있었다.

이번 경험을 통해 메모리 누수의 심각성과 동적 메모리 관리의 중요성을 깊이 체감했다. 그리고 Nginx 이전에 httpd에서 발생했던 C10K 문제의 원인과 이를 해결하기 위해 Nginx 가 왜 I/O 멀티플렉싱을 채택한 이유도 명확히 이해하게 되었다.

kqueue 사용법 이해하기

kqueue는 FreeBSD 계열에서 사용하는 시스템 콜이다. 하지만, kqueue 관련 예제나 문서가 너무 부족했다. 그나마 있는 자료들은 이미 과제를 하신 분들이 남긴 자료 몇 개가 전부였다.

man 페이지에는 다양한 옵션들과 설명이 있었지만, 실제 사용 방법을 이해하기엔 부족했다. 특히 소켓 관련 함수들이 추상화된 상태라 사용법 자체가 복잡하게 느껴졌다.

스마트폰도 사용 설명서를 보면 기능이 많고 복잡해서 다 이해하기 어려운 것처럼, kqueue도 그랬다. 특히 등록하는 파일의 종류에 따라 작동하는 방식이 다르다는 설명이 있었지만, man 페이지만으로는 구체적인 차이를 알기 어려웠다.

결국 하나하나 실험하며, 가설 → 테스트 → 결론의 과정을 반복했다. 덕분에 man 페이지 사용에도 익숙해지고, kqueue의 다양한 옵션을 작동 방식을 자연스럽게 이해할 수 있었다.

물론 여전히 이해되지 않는 부분이 있을 때마다 팀원들과 “그것이 I/O 멀티플렉싱 이니까요.”라는 농담을 하며 웃고 넘기기도 했다. 이 말은 결국, 정답은 직접 실험하고 부딪혀보는 것뿐이라는 의미였다.

Nginx 작동 방식 이해하고 구현하기

Nginx처럼 작동하는 웹 서버를 만들기 위해서 Nginx의 작동 원리를 먼저 이해하는 것이 중요했다.

특히, Nginx는 설정 파일에 따라 작동 방식이 달라지기 때문에, 우리는 다양한 설정을 직접 실험하며 기능을 분석했따. 그리고 어떤 수준까지 Nginx를 따라 구현할 것인지에 대한 고민도 필요했다.

시간은 부족했고, 과제 요구 사항도 애매한 부분이 많았다. 그래서 모호한 요구사항에 대해서는 충분한 실험과 의견 교환을 거쳤다. 의견이 하나로 합쳐질 때까지 계속 생각을 나누었다. 이 과정에서 우리는 기능의 완성도보다 에러 없는 안정적인 동작을 우선시했다.

대표적으로 CGI 기능은 클라이언트가 요청한 파일을 실행하는 방식이 아닌 서버가 사전에 정한 하나의 파일만 실행하도록 제한했다.

완벽한 구현을 하지 못한 아쉬움도 있지만, 우리가 가진 시간과 실력 내에서 최선을 다했다는 자부심은 있다.

협업 방식

데일리 스크럼

1.png

평일마다 15분 정도 오프라인 스크럼을 진행하며, 각자의 작업 현황을 공유하고 궁금한 점이나 함께 고민할 주제를 나누었다. 오프라인 참석이 어려운 날에는 온라인으로 대체했지만, 매일 같은 시간에 팀원들과 긴밀하게 소통하기 위해 노력했다.

스크럼을 통해 팀원 간의 작업 상황을 빠르게 파악할 수 있었고, 프로젝트의 전체적인 흐름을 모두가 이해할 수 있었다. 어느 날에는 함께 고민하고 결정해야 하는 주제로 1시간 넘게 진행한 적이 있는데, 팀원들 모두 적극적으로 나서서 의견을 나누었다. 이런 과정이 있었기에 협업의 효율성이 훨씬 높아졌다.

팀 프로젝트를 하면서 가장 중요하게 생각하는 것은 서로의 작업 현황을 인지하고 속도를 맞추는 것이다. 각자 할 일을 나누고 본인이 담당하는 부분만 집중하는 것도 좋지만, 프로젝트의 전체적인 흐름을 팀원 모두 이해하고, 공동의 목표를 잊지 않는 것이 프로젝트 성공의 핵심이라 생각한다. 또한, 매일 정해진 시간에 스크럼을 진행한 덕분에 긴장감을 늦추지 않고 프로젝트를 목표 기간 안에 마칠 수 있었다고 생각한다.

C++ 포맷터 적용

코드를 작성하기 전에 우리 팀만의 코드 컨벤션을 정했다.

전체적으로는 Google C++ 스타일 가이드를 따르고, 일부는 팀 스타일에 맞게 수정했다.

Visual studio code의 clang-format 익스텐션을 이용하면 파일 저장 버튼을 눌렀을 때 자동으로 설정한 포맷에 맞게 코드를 바꾸어주었다. 코드 컨벤션에 맞게 모든 것을 수정해주지는 않지만, 들여쓰기나 중괄호의 개행 정도는 자동으로 바꿔준 덕분에 코드 정리에 들어가는 시간을 많이 줄일 수 있었다.

간단하게 사용 방법을 정리하자면 아래와 같다.

  1. 프로젝트 루트 폴더에 .clang-format 파일을 생성한다.
  2. https://clang.llvm.org/docs/ClangFormatStyleOptions.html 링크를 참고해서 사용할 옵션들을 사용한다.

우리 팀이 사용한 파일 내용은 아래와 같다.

1
2
3
4
BasedOnStyle: Google
BreakBeforeBraces: Allman
Standard: Cpp03
UseTab: Never
  • BasedOnStyle: 포맷 기준이 되는 스타일 지정 (Google, llvm, mozila 등)
  • BreakBeforeBraces: 중괄호 앞에 줄바꿈 여부 (Allman: 항상 줄바꿈)
  • Standard: 사용할 C++ 표준 지정. 버전에 따른 포맷팅을 한다. 각 버전마다 조금씩 달라지는 문법을 적용할 때 사용하는 옵션이다. 예를 들면, c++11 과 c++03 의 차이는 아래와 같다.

    1
    2
    3
    4
    5
    
      // c++11
      vector<set<int>> vec;
    
      // c++03
      vector<set<int> > vec;
    
  • UseTab : 들여쓰기에 Tab 사용 여부. (Never: Space 사용)

Github + Slack 연동

Github Repository에서 Issue, Pull Request, main 브랜치 커밋 등의 이벤트가 발생할 때마다 Slack 알림을 받아 작업 현황을 실시간으로 공유했다.

우리 팀은 main 브랜치의 하위 브랜치에서 작업을 하고 main 브랜치에 PR을 올려 작업 내용을 merge 하도록 했다. 그러다보니 main 브랜치에 PR을 올리기 전에 main 브랜치에 추가된 commit이 존재하면 최신 상태로 업데이트를 해주어야 충돌이 일어나지 않았다.

2.png

처음에는 직접 메시지를 보내며 알렸지만, 번거로워서 Slack과 GitHub를 연동했다. 덕분에 main 브랜치의 최신 상태를 확인하고 PR 전 충돌을 예방할 수 있었다.

3.png

Figma로 프로그램 설계도 작성

4.png

이전 프로젝트들과 달리 프로그램의 규모가 크고, 고려할 요소가 많았다. 그래서 코드를 작성하기 전에 Figma로 프로그램 설계도를 작성했다. 설계도가 곧 코드를 작성하는 기준이 되었기 때문에 큰 흐름을 놓치지 않으면서 프로젝트를 진행할 수 있었다.

물론 처음부터 완벽한 설계도를 만든 것은 아니었다. 또한, 설계한 대로 프로그램을 구현하기 위해 노력했지만, 구현 하다보니 설계가 잘못된 부분이 있어서 설계를 바꾼 부분도 많았다.

우리 팀은 소켓 프로그래밍http response 메세지 생성 크게 두 팀으로 나누어 작업을 진행했는데, 설계도 덕분에 각 팀에서 주고 받아야 하는 데이터를 구체화할 수 있었다. 즉, 공통된 인터페이스를 잘 만든 덕분에 구현 과정에서도 의사소통 비용이 많이 감소했다.

KPT 회고

Keep

  • 스크럼을 통해 팀원들과 지속적으로 의견을 교환하고, 서로 하는 일을 공유하는 시간을 갖자.
  • 자동화 도구를 적극적으로 활용하자. github 알림 봇이나 포맷터 같은 도구로 생산성을 높이는 것은 중요하다.
  • 완벽하지 않더라도 작동하는 프로그램을 먼저 만들자. 처음부터 완벽한 프로그램은 존재하지 않는다.

Problem

  • 모든 문제를 혼자서 해결하려고 했다. 나는 에러를 해결하기 위해 혼자서 최대한 많이 시도해본 다음에 다른 분들에게 도움을 요청하는 편인데, 이로 인해 프로젝트 진행 속도가 더뎌진 경우가 종종 있었다.
  • 코드 컨벤션을 잘 지키지 못해서 아쉽다. 구현을 하는데 집중하느라 변수명이나 함수명을 컨벤션에 맞지 않게 지은 경우가 많았다. 이로 인해 내가 작성한 코드를 이해하느라 다른 팀원들에게 코드를 설명하는 시간이 오래 걸리는 경우가 많았다.
  • 기간별 목표를 세우지 않다보니 시간이 흐를 수록 목표 의식이 희미해지는 경험을 했다.

Try

  • 아무리 코드를 봐도 문제가 뭔지 모르겠으면 주변 동료들에게 적극적으로 도움을 청하자. 다른 사람들도 동일하거나 비슷한 문제를 겪었을 가능성이 높다.
  • 코드 컨벤션을 더 명확하게 정하자. 프로젝트 초반에 컨벤션을 정하기는 했으나, 코드 양이 많아지기 시작하면서 컨벤션에 맞지 않는 부분들이 많아졌다. 예를 들어, 변수명은 스네이크 케이스로 정했지만, 카멜 케이스로 변수명을 작성하는 경우가 있었다. 컨벤션이 지켜지면 다른 사람의 코드를 이해하는데 걸리는 시간이 단축된다.
  • 1주 또는 2주 정도의 스프린트마다 목표를 정해서 계획을 세우고, 각 스프린트가 끝나면 팀원들과 회고를 하며 점검하는 시간을 꼭 가져야겠다.

마치며

소켓 프로그래밍도 처음이었고, RFC 문서도 처음 읽어봤기에 시작 당시에는 모든 것이 두렵고 막막했다. “우리가 이걸 정말 끝낼 수 있을까?”라는 생각이 들기도 했다.

하지만 결국 우리는 끝까지 포기하지 않고 해냈다. 결과물의 퀄리티가 굉장히 좋다고 할 수는 없지만, 프로젝트를 하는 과정 속에서 무수히 깨지고 단단해지기를 반복하면서 큰 성장을 했다. 팀원들과 점심, 저녁을 같이 먹고, 배가 출출해지면 간식을 나누어 먹던 시간들이 그립다.

프로젝트가 끝나고 뒷풀이까지 함께 하며 팀원들에게 정이 많이 쌓였다. 다음 프로젝트는 이번에 함께한 팀원들이 아닌 다른 팀원들을 찾아서 할 예정이다. 이미 합이 잘 맞는 사람들과 함께 작업을 한다는 것은 정말 편한 일이지만, 사회에서 일을 시작하게 되면 알던 사람보다는 새로운 사람과 일을 하는 경우가 대부분이기 때문에 새로운 사람들과 일하는 환경에 익숙해지기로 했다.

우리 팀원들은 다른 프로젝트나 다른 스터디에서 각자 지금보다 더 성장한 모습으로 다시 만날 수 있으면 좋겠다. 포기하고 싶은 순간이 많았지만, 끝까지 함께 해준 팀원들에게 진심으로 감사하다.

This post is licensed under CC BY 4.0 by the author.