[예약시스템 개발 일지] #1. C++ 개발 환경 설정

예약 시스템 개발 계기

우리 학교 2학년 1학기 과정인 객체지향 프로그래밍 과목에서, 전설처럼 내려오는 과제가 하나 있다. C++을 이용한 통합 예약 프로그램인데, C++ 좋아하는 나로썬 참을 수 없는 과제였다. 이 교수님의 객체 과목을 수강한 이들은 이 과제를 해야 객체 과목을 수강했다고 인정하는 분위기다. 바로 시작했다.

실제 과제는 2주라는 시간이 주어졌는데, 이 과제에만 시간을 쏟는다면 모르겠으나 다른 일들이 많아서.. 이거는 완전 사이드 of 사이드라서 얼마나 오래 걸릴 지는 모르겠다.

C++ 개발 환경 설정

IDE는 CLion으로

IDE는 별 고민없이 CLion으로 선택했다. 애초에 이 프로젝트를 시작한 이유 중 하나가 CLion에 적응해보려고 한 목적도 있다. 윈도우에서는 주로 Visual Studio를 썼었는데, 일단 C++를 개발하는데 있어서 무겁다는 단점 제외하고는 흠잡을 때 없는, 훌륭한 IDE이다. MSVC 컴파일러 역시 정말 최고의 컴파일러이다.

그러나 문제는 맥에서는 Visual Studio에서 C++ 개발 환경을 지원하지 않는다는 것이고, XCode는 ... 쓰는 사람이 있나 싶다. 그래서 맥에서는 VS Code를 이용했는데, 아무래도 규모가 큰 개발을 할 것이라면 VS Code보다는 IDE에 대한 니즈가 있기는 했었다. 그래서 JetBrain 사의 C/C++ 개발 IDE인 CLion을 선택했다. 내가 그리고 JetBrain의 팬이기도 하다.

CMake 설정

현대의 고수준 언어, 예를 들어서 파이썬과 같은 언어는 코드를 작성하고 바로 실행이 가능하다. 스크립트같은 언어가 아니더라도, 현대의 컴파일 언어들은 빌드 과정을 크게 문제 없이 수행할 수 있다.

그런데 C++는 그런 거 없다. 파일 하나하나 링크해가면서 빌드해야 한다. 예를 들어서 main.cpp, hello.cpp/hello.hpp로 구성된 프로그램을 빌드하려면 다음 과정을 수행해야 한다.

  1. hello.cpp와 main.cpp를 컴파일하여 오브젝트 파일(hello.o, main.o) 만들기
  2. $ g++ -c hello.cpp -o hello.o $ g++ -c main.cpp -o main.o
  3. 오브젝트 파일들을 링크하기
  4. g++ hello.o main.o -o myProgram

위에처럼 하는 거는 파일 한 두 개만 있는게 아닌 이상 결코 추천하지는 않는다... 파일들이 많아지고, 또한 여러 가지 컴파일 옵션들을 지정할 때 굉장히 불편해지기 때문이다(예를 들어서 -std=c++20-Wall -Wextra 같은 옵션들은 꽤 자주 쓰인다.).

위 단점을 보완하기 위해서 나온 것이 makefile이다. makefile은 저렇게 명령어를 하나하나 치는 과정을 단축해서 크게 사랑받고 있다. 그런데 makefile 자체도 여러 단점이 존재했고, 이를 관리하는 것도 문제였기에, 이 makefile을 관리하고 생성해주기 위한 빌드 시스템이 바로 CMake이다.

CLion은 이 CMake를 기본 빌드 시스템으로 채택하고 있다. 뿐만 아니라 Visual Studio의 빌드 시스템을 제외한 절대 다수의 C++ 프로젝트들은 CMake를 채택하고 있다. Jetbrain 사의 조사에 따르면 57%의 C++ 프로젝트가 CMake를 빌드 시스템으로 채택하고 있고, 이는 Visual Studio Project보다 훨씬 많은 수치이다. 사실상 표준이라고 보면 된다.

CLion은 CMakeLists.txt 파일을 통해서 빌드 대상 파일들을 관리한다. CMake 기반의 프로젝트 구조는 보통 이런 식이다.

.
├── CMakeLists.txt
├── src/
│   ├── CMakeLists.txt
│   ├── main.cpp
│   ├── hello.hpp
│   └── hello.cpp
├── tests/
│   ├── CMakeLists.txt
│   ├── hello_test.cpp
│   └── main.cpp
├── build/
├── .github/
├── .gitignore
├── .clang-format
├── LICENSE
├── CODE_OF_CONDUCT.md
├── vcpkg.json
└── README.md

CMakeLists.txt는 기본적으로 프로젝트 최상단의 루트 디렉토리에 존재한다. 그리고 소스가 포함되는 src와 tests 디렉토리에 각각 CMakeLists.txt 파일이 따로 존재한다. CMakeLists는 이런 식으로 재귀적으로 구성된다.

프로젝트 최상단의 CMakeLists.txt는 다음과 같이 구성된다.

cmake_minimum_required(VERSION 3.24)

set(CMAKE_TOOLCHAIN_FILE "/Users/nx006/Documents/vscode/vcpkg/scripts/buildsystems/vcpkg.cmake" CACHE STRING "vcpkg toolchain file")

project(
        integrated_reservation_system
        VERSION 0.1
        DESCRIPTION "Integrated Reservation System"
        LANGUAGES CXX
)

set(CMAKE_CXX_STANDARD 20)

enable_testing()

add_subdirectory(src)
add_subdirectory(tests)

set(CMAKE_TOOLCHAIN_FILE)은 후술할 vcpkg를 위한 설정이다. project()는 프로젝트의 이름, 버전, 설명, 사용할 언어를 설정한다. set(CMAKE_CXX_STANDARD)는 C++ 표준을 설정한다. 이 프로젝트에서는 C++20를 사용할 것이므로 20으로 맞춰준다. enable_testing()은 테스트를 위한 설정이다. add_subdirectory()는 하위 디렉토리에 있는 CMakeLists.txt를 불러온다.

src 디렉토리의 CMakeLists.txt는 다음과 같이 구성된다.

cmake_minimum_required(VERSION 3.24)

add_compile_options(-Werror -Wall -Wextra -Wpedantic -Wconversion -Wsign-conversion -Wshadow -Wundef -Wunreachable-code -Wstrict-aliasing -Wnull-dereference -Wdouble-promotion -Wformat=2 -Wcast-qual -Wcast-align)

if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "GNU")
    # Additional flags for GCC
    add_compile_options(-Wuseless-cast)
elseif ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
    # Additional flags for Clang
    add_compile_options(-Weverything -Wno-c++98-compat -Wno-c++98-compat-pedantic -Wno-unused-macros -Wno-padded)
endif ()

find_package(fmt REQUIRED)
find_package(Boost REQUIRED)
find_package(unofficial-argon2 CONFIG REQUIRED)
find_package(unofficial-sqlite3 CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED)

add_executable(integrated_reservation_system main.cpp user.cpp user.hpp user_builder.cpp user_builder.hpp reservation_manager.cpp reservation_manager.hpp reservation.cpp reservation.hpp reservation_builder.cpp reservation_builder.hpp auth_manager.cpp auth_manager.hpp restaurant/table.cpp restaurant/table.hpp restaurant/table_manager.cpp restaurant/table_manager.hpp restaurant/restaurant_reservation_manager.cpp restaurant/restaurant_reservation_manager.hpp)

target_link_libraries(integrated_reservation_system PRIVATE
        fmt::fmt
        Boost::boost
        unofficial::argon2::libargon2
        unofficial::sqlite3::sqlite3
        nlohmann_json::nlohmann_json)

조금 복잡한데, 핵심적인 기능은 바로 add_executable이다. 이곳에 빌드 대상의 cpp/hpp 파일들을 추가한다. 참고로 CLion과 같은 IDE를 쓴다면, 수동으로 추가할 필요 없이 자동으로 CMakeLists.txt에 추가된다.

add_compile_options는 컴파일 옵션을 추가한다. 컴파일 옵션은 주로 컴파일 경고를 표시하는 용도이다. 일반적으로 프로그래밍을 할 때, 특히 C++를 개발할 때는 코드의 부정확성을 방지하고 메모리 누수, 잘못된 읽기 쓰기 등을 방지하기 위해 컴파일 경고 옵션은 전부 켜주는 게 좋다. 이렇게 컴파일 경고 옵션을 이용하는 것을 코드의 정적 분석(Static Analysis)라 하는데, 컴파일 옵션 외에도 cppcheck같은 정적 분석 툴들도 사용할 수 있다. CLion IDE에서는 이렇게 컴파일 옵션을 추가해놓으면, 굳이 컴파일을 하지 않아도 IDE에서 자동으로 경고를 표시해준다. Flutter-Dart의 flutter lint같은 개념이라고 보면 된다.

참고로 이 경고는 무시하면 안 된다. 귀찮아도 모두 해결하고 넘어가야 한다. 모든 경고를 에러로 취급하도록, 무조건 해결하고 넘어갈 수 있도록 -Werror 옵션을 추가해주었다. 컴파일러에 따라서 옵션이 달라지기도 하는데, 본인은 로컬에서는 Clang, 원격에서는 gcc를 사용하므로, 각각의 컴파일러에 맞는 옵션을 더 추가해주었다. 참고로 이렇게 서로 다른 컴파일러를 이용하는 것도 좋은 방법이다. 컴파일러에 따라서 서로 다른 결과를 내거나, 서로 다른 경고를 내보낼 수 있으므로, 두 컴파일러를 사용해서 교차 검증을 함으로써 코드 품질을 높일 수 있다. 실제로 한 프로젝트에 대해 두 개 이상의 컴파일러를 사용하는 것은 자주 사용되는 방법이다.

프로젝트 구조

.
├── CMakeLists.txt
├── src/
│   ├── CMakeLists.txt
│   ├── main.cpp
│   ├── hello.hpp
│   └── hello.cpp
├── tests/
│   ├── CMakeLists.txt
│   ├── hello_test.cpp
│   └── main.cpp
├── build/
├── .github/
├── .gitignore
├── .clang-format
├── LICENSE
├── CODE_OF_CONDUCT.md
├── vcpkg.json
└── README.md

위에서 이미 프로젝트의 구조에 대해서 설명하기는 했는데, 이것이 보통의 C++ 어플리케이션을 제작할 때 사용하는 구조라고 한다. 그런데 이건 어플리케이션일 때의 이야기이지, 라이브러리를 만드는 경우는 달라진다고 한다. 어플리케이션을 만들 때에는 의외라고 느껴질 수도 있지만, 헤더 파일인 hpp와 소스 파일인 cpp 파일을 같은 폴더 안에 위치시킨다.

hpp와 cpp를 서로 다른 파일로 분리시키는 것은 라이브러리를 만들 때이다. 이떄는 src/에서 모두 관리하는 것이 아니라, 헤더 파일은 include/ 폴더에, 소스 파일은 src/ 폴더에 위치시킨다.

./         Makefile and configure scripts.
./src      General sources
./include  Header files that expose the public interface and are to be installed
./test     Test suites that should be run during a `make test`

아마 이런 식으로 될 것이다. 여기서는 C++로 라이브러리를 만드는 게 아니므로 hpp와 cpp를 분리하지는 않았다.

의존성 관리 툴 설치 - vcpkg

현대 소프트웨어 개발에서는 나 혼자 처음부터 끝까지 개발하기보단, 외부 라이브러리들을 많이 사용하게 되는데, 이 때문에 많은 언어에서는 외부 라이브러리나 의존성들을 관리하기 위한 툴들이 기본 제공된다. javascript에는 npm이나 yarn이 있고, python에는 pip가 있고, dart를 기반으로 한 flutter에는 pub이 있다.

 

C++에는? 그런 거 없다. 공식적으로 언어 자체에서 지원하는 패키지 매니저가 없다. 대신 공식은 아니지만 여러 패키지 매니저 툴들이 존재한다. 일단 패키지 매니저 툴을 쓰지 않고서, 직접 라이브러리를 다운받아서 프로젝트에 포함시킬 수도 있지만.. 별로 추천되는 방법은 아니다. 대신 CMake를 통해서 직접 패키지를 인스톨하기도 하고, conan이나 vcpkg를 이용하기도 한다. 이 프로젝트에서는 vcpkg를 이용하기로 했다.

여담으로 사실은 conan을 사용하려고 했다. 근데 로컬 환경에서는 이 conan 사용이 크게 문제가 되지는 않았는데, 원격 환경에서, github actions 워크플로우를 사용하는 과정에서 이 conan이 문제가 됐다. 코난이 최근 conan 2.0으로 버전이 올라가면서 뭔가가 많이 바꼈는데, 그 때문인지 github actions에서 계속해서 문제가 발생했다... 사실 이 conan 때문에 엄청 길게 고생했다.

사진에서 느껴지는 분노의 워크플로우들... 이게 다 conan 때문이었다. 그래서 그냥 때려치우고 vcpkg로 갈아탔다. 한 C++ 프로젝트 모델에서는 이 vcpkg를 프로젝트 내부의 submodule로 관리하라고 하는데, 여기서는 복잡성을 줄이기 위해 그냥 전역으로 설치했다.

vcpkg 설치 방법

vcpkg의 설치 방법은 무척 간단하다. 먼저 git을 클론한다.

$ git clone https://github.com/microsoft/vcpkg

그리고 다음 과정을 거쳐서 설치하면 된다.

$ cd vcpkg
$ ./bootstrap-vcpkg.sh

(윈도우에서는 ./vcpkg/bootstrap-vcpkg.bat이며, permission denied가 될 경우 chmod +x bootstrap-vcpkg.sh를 입력하고 ./bootstrap-vcpkg.sh를 시도한다.)

~/.zshrc 파일을 열고 아래 환경 변수를 추가한다.

export PATH="/Users/nx006/Documents/vscode/vcpkg:$PATH"

나같은 경우 vcpkg를 저 폴더에 추가해놨는데, 실제로는 which vcpkg 등의 커맨드를 통해서 vcpkg가 설치된 경로를 추가해놓으면 된다. 아마 윈도우는 자동으로 환경 변수가 등록이 되거나, 마찬가지로 환경 변수를 저렇게 추가해주면 된다. 이제 제대로 설치되었는지 vcpkg를 터미널에 입력한다. 그러면 vcpkg에서 사용 가능한 명령어들의 리스트가 나올 것이다. 그러면 제대로 설치된 것이다.

vcpkg 사용 방법

설치하고자 하는 라이브러리를 검색해서 설치하면 된다. 예를 들어서 외부 라이브러리 중에서 gtest라는 라이브러리를 설치한다고 해보자. gtest는 C++에서 유닛 테스트 등 테스트를 진행하기 위한 라이브러리이다. vcpkg에서 gtest를 검색하면 다음과 같이 나온다.

$ vcpkg search gtest

gtest                    1.13.0           GoogleTest and GoogleMock testing frameworks
libsndfile[regtest]                       Build regtest
The result may be outdated. Run `git pull` to get the latest results.
If your port is not listed, please open an issue at and/or consider making a pull request.    -  https://github.com/Microsoft/vcpkg/issues

왼쪽 열에 gtest가 보이고, 가운데에 버전, 맨 오른쪽에는 라이브러리에 대한 간단한 설명이 나온다. 그러면 install을 통해서 설치하면 된다.

$ vcpkg install gtest

Computing installation plan...
The following packages are already installed:
    gtest[core]:arm64-osx -> 1.13.0
gtest:arm64-osx is already installed
Restored 0 package(s) from /Users/nx006/.cache/vcpkg/archives in 1.29 us. Use --debug to see more details.
Total install time: 161 us
The package gtest is compatible with built-in CMake targets:

    enable_testing()

    find_package(GTest CONFIG REQUIRED)
    target_link_libraries(main PRIVATE GTest::gtest GTest::gtest_main GTest::gmock GTest::gmock_main)

    add_test(AllTestsInMain main)

나는 이미 설치됐기에 따로 추가적인 설치가 진행되지는 않는다. vcpkg에서는 친절하게 CMake에서 어떻게 GTest를 추가할 수 있는지 밑에 설명까지 보여준다. 밑에 예시를 복사해서 CMakeLists.txt에 그대로 붙여넣으면 된다.

    enable_testing()

    find_package(GTest CONFIG REQUIRED)
    target_link_libraries(main PRIVATE GTest::gtest GTest::gtest_main GTest::gmock GTest::gmock_main)

    add_test(AllTestsInMain main)

한편 이렇게 라이브러리를 추가하기 전에, 최상단 루트 디렉토리의 CMakeLists.txt에서는 다음 설정을 추가해서, 라이브러리를 관리하는 툴로 vcpkg를 사용할 것임을 CMake한테 알려준다.

set(CMAKE_TOOLCHAIN_FILE "${vcpkg_path}/scripts/buildsystems/vcpkg.cmake" CACHE STRING "vcpkg toolchain file")

${vcpkg_path}에는 실제 vcpkg가 설치된 경로를 입력하면 된다.

 

이 vcpkg는 프로젝트 최상단 루트 디렉토리에서 vcpkg.json이라는 파일을 만들어서 관리하기도 하는데, 이는 CMakeLists.txt와는 별개로 추가를 해줘야 한다. 나는 다음과 같이 추가해놨다.

{
  "name": "integrated-reservation-system",
  "version-string": "0.1",
  "dependencies": [
    "boost",
    "gtest",
    "fmt",
    "argon2",
    "nlohmann-json"
  ]
}

대충 설명을 하자면, boost는 C++에서 정말 유명한, 온갖 유용한 라이브러리들을 함께 모아놓은 집합체이다. STL은 이제 C++에서 기본 라이브러리로 편입됐는데, boost도 STL 수준으로 방대한 생태계를 갖고 있는 라이브러리이다. gtest는 위에서 말한 테스트를 위한 것, fmt 라이브러리는 문자열 포맷팅을 위해서, argon2는 비밀번호 암호화를 위해서, nlohmann-json은 json을 다루기 위한 라이브러리이다. 이건 필요할 때 언제든 더 추가해서 사용하면 된다.

Unit Test

테스트는 이제 기본 사양이 되고 있는데, C++에서도 여러 테스트를 위한 라이브러리가 존재한다. 그중에서 사실 제일 유명하고 표준처럼 쓰이는게 구글에서 만든 GTest이다. 이 프로젝트에서도 GTest를 사용했다.

Clang-format

코드의 품질을 높이는 방법 중 하나가 바로 일관성을 유지하는 것이다. 공통된 작명 스타일, 코딩 스타일 뿐만 아니라 코드의 포맷 역시 일관적으로 맞춰주는 게 좋다. 그런데 언제 이렇게 하나하나 코드 포매팅을 관리할까. 그래서 C++에서는 Clang-format과 같은 툴을 통해서 이 코딩 스타일을 관리한다.

CLion에서는 처음 Clang-format을 세팅해두면 IDE 자체의 Clang-format 스타일이 적용되는데, 나는 마음에 들지 않으므로 더 마음에 드는 Microsoft의 포맷 스타일을 사용하겠다. Clang-format은 자체 커스텀 세팅도 가능하지만, Google, Microsoft, LLVM, Chromium 등 유명한 IT 회사들의 코딩 스타일을 직접 지원하기도 한다.

프로젝트 루트 디렉토리에 있는 .clang-format 파일을 열고 다음과 같이 간단하게 작성해주면, 알아서 Microsoft의 코딩 스타일로 포맷을 맞춰준다.

---
BasedOnStyle: Microsoft

...

CLion에서는 단축키 option + cmd + l을 통해서 포맷을 맞춰줄 수 있는데, 이때 자동적으로 clang-format에 맞춰서 코드 정렬을 해준다. 또한 IDE 자체적으로 commit과 동시에 clang-format을 적용해주는 기능도 지원한다.

코드의 일관적인 구성을 위해서, 그리고 하나하나 포매팅을 해주는 번거로움을 덜기 위해서 이 clang-format은 매우 유용한 옵션이다.

github actions

원격으로 레포지토리에 올라갈 때, 자동으로 테스트를 하고, clang-format 등을 맞추는 등, 깃허브 레포지토리에 이벤트가 발생했을 때 자동으로 어떠한 일련의 명령들을 수행하도록 설정할 수가 있다. github actions를 이용하면 된다. github actions는 .github/workflows/cmake.yml에 워크 플로우들을 정리해놓으면 된다.

나는 많은 시행 착오 끝에 다음과 같이 설정해두었다. 개발 과정에서는 이 워크플로우는 계속해서 바뀐다.

name: CMake

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main, develop ]

env:
  # Customize the CMake build type here (Release, Debug, RelWithDebInfo, etc.)
  BUILD_TYPE: Release
  VCPKG_ROOT: ${{ github.workspace }}/vcpkg

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Set up vcpkg
        run: |
          git clone https://github.com/microsoft/vcpkg.git
          cd vcpkg
          ./bootstrap-vcpkg.sh
          ./vcpkg integrate install

      - name: Cache vcpkg dependencies
        uses: actions/cache@v2
        with:
          path: ${{ env.VCPKG_ROOT }}/installed
          key: ${{ runner.os }}-vcpkg-${{ hashFiles('**/vcpkg.json') }}
          restore-keys: |
            ${{ runner.os }}-vcpkg-

      - name: Install dependencies
        run: |
          cd ${{ env.VCPKG_ROOT }}
          ./vcpkg install --triplet x64-linux

      - name: Configure
        run: |
          cmake -B build -DCMAKE_BUILD_TYPE=${{env.BUILD_TYPE}} -DCMAKE_TOOLCHAIN_FILE=${{env.VCPKG_ROOT}}/scripts/buildsystems/vcpkg.cmake

      - name: Build Project
        working-directory: ${{github.workspace}}/build
        run: cmake --build . --config ${{env.BUILD_TYPE}} -j 2

      - name: Run Test
        working-directory: ${{github.workspace}}/build
        run: ctest -V -C ${{env.BUILD_TYPE}} --output-on-failure

  Check-Clang-Format:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Run clang-format-lint
        uses: DoozyX/clang-format-lint-action@v0.16.1

        # These are optional (defaults displayed)
        with:
          source: './src'
          exclude: './third_party ./external ./cmake-build-debug ./tests'
          extensions: 'h,cpp,c,hpp,cc'
          clangFormatVersion: 14
          style: microsoft

위 워크플로우를 요약하면, main과 develop 브랜치에 push가 들어오거나 PR이 요청됐을 때 다음 사항을 명령들을 수행한다.

  1. 빌드 및 테스트: vcpkg를 이용해 dependency들을 설치하고, cmake를 통해 프로젝트를 빌드하고 테스트를 수행한다.
  2. clang-format 검사: 소스 코드가 clang-format을 만족하는 지 검사한다. 여기서는 microsoft 스타일을 쓰니깐 style에 microsoft라 맞춰놨는데, 프로젝트 내 clang-format 파일을 쓰고 싶다면 microsoft 대신 'file'이라 쓰면 아마 될 것이다.

이런 식으로 actions에서 결과물을 받아볼 수 있다.

자동적으로 유닛 테스트가 수행됐고, 모두 통과했음을 확인할 수 있다. 만약 이 워크플로우가 실패한다면 (test가 실패하거나 빌드가 안 되는 등) 이메일이 도착해서 실패했음을 알린다.


개발 시작

프로젝트는 이 정도로 구성하면 될 것 같다. 나머지는 실제로 개발을 하면서 수정하거나 추가하면 되겠다. 이제 열심히 개발을 하기만 하면 된다.