코드를 볼 때마다 가장 거슬리고 불쾌한 부분이 있습니다. 바로 하드코딩입니다.

하드코딩된 문자열, 실수, 정수 등의 리터럴은 그 의미를 파악하기 위해 위아래 수십 줄을 읽어 문맥을 파악해야 하는 번거로움이 잦습니다. 심지어 잘 짜인 변수 네이밍으로도 그 의미 파악이 쉽지 않은 경우가 많습니다. 의미뿐만 아니라 try로 도배된 프로덕션 코드에서 흔히 볼 수 있는 로그용 텍스트 리터럴은 보면 볼수록 난잡하고 끔찍합니다.

그렇다고 해서 하드코딩 없이 코드를 짜는 건 불가능에 가깝습니다. 이 글에서는 조금이나마 하드코딩의 불쾌함을 덜어낼 여러 방법들을 소개하고 장단점을 알아보겠습니다!

주석

주석 설명 그림

가장 간단한 방법, 바로 주석입니다. 한 곳에서만 사용되는 리터럴은 별도의 상수 없이 잘 작성된 주석만으로 그 의도를 명확하게 전달할 수 있습니다. 지역적으로만 사용된다면 주석을 사용하는 게 가장 좋은 방법일 수 있습니다. 또 리터럴이 그 자체로 너무나도 명확하다면 주석이 필요 없기도 합니다. (ex 쉐이더 코드, 인라인 어셈블리, 정규식 등등)

사례

다음은 LLVM LTO.cpp에서 가져온 스니펫입니다. "_imp"에 대한 설명을 위의 주석으로 자세하게 설명하고 있습니다.

// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception

// Strip the __imp_ prefix from COFF dllimport symbols (similar to the
// way they are handled by lld), otherwise we can end up with two
// global resolutions (one with and one for a copy of the symbol without).
if (TT.isOSBinFormatCOFF() && Name.startswith("__imp_"))

상수

상수 설명 그림

다음과 같은 코드는 100명에게 커피를 나눠준다는 것이 명확하지만, 앞뒤의 문맥 없이는 정작 저 숫자 100의 의미를 알 방법이 없습니다. 저 100이 아무 맥락 없이 코드 여기저기에 뿌려져 있으면 더더욱 파악하기 힘듭니다. 또 갑자기 100이 아닌 300이 끌려서 코드를 수정하고 싶어도 코드에 존재하는 모든 100의 의미를 하나하나 해석한 뒤에 300으로 바꿔야 하는 번거로움이 있습니다.

for(int people_count = 1; people_count <= 100; ++people_count) // 100명에게 커피를 나눠줌
  give_coffee(people_count);

C에서는 이런 문제를 해결하기 위해 #define이나, const를 활용해 다음과 같이 상수를 정의해 사용합니다. 물론 C++에서는 constexpr을 사용할 수 있겠죠. 이제 명확하게 참석자 100명에게 커피를 나눠주는 코드라는 것을 알 수 있습니다. 참석자가 300명으로 늘어도 단순히 상수 하나만 수정해주면 끝나죠!

#defineconst의 성능차는 컴파일러와 컴파일러 플레그, 타입에 따라 있을 수도 없을 수도 있습니다. 최악의 경우는 #define이 크기가 매우 큰 타입을 역참조가 아닌 리터럴로 치환한다던가, const가 크기가 매우 작은 타입을 .rodata 섹션에서 역참조하는 경우죠. 하지만 진공관 컴퓨터가 아닌 이상 이런 성능 차이는 측정 불가능 할정도로 작으니 끌리는 걸 사용합시다!
여담으로 함수 시그니처의 const가 최적화에 도움이 된다고 잘못 알려져 있는 경우가 많은데, 정의로 사용되는 const가 아닌 모든 const는 실제로 최적화에 도움이 되지 않습니다. 임시 객체에 대한 참조나, 참조 또는 포인터에 사이드 이펙트를 방지할 목적이 아니라면 대부분은 불필요합니다.
//CPP
constexpr auto attendee_count = 100;
//C
#define ATTENDEE_COUNT 100
//or
const int attendee_count = 100;

// ...

for(int people_count = 1; people_count <= ATTENDEE_COUNT; ++people_count)
  give_coffee(people_count);

하지만 이 방법 또한 한계가 명확합니다. 상수가 많아지면 많아질수록 네임스페이스 오염이 심해진다는 것이죠. 만약 컨퍼런스 참석자와 시상식 참석자, 공연 참석자 수가 모두 필요하다면 어떻게 해야 할까요? 단순히 변수명을 convention_attendee_count로 바꾸는 것 만으로는 충분하지 않습니다. 그래서 다음과 같이 struct나, enum, namespace를 사용해서 오염을 방지해주곤 합니다.

enum은 상태 리턴 외에, 이런 용도로는 사용이 적합하지 않을 수 있습니다. 어떤 한 카테고리의 것들을 열거하여 모아놓는다는 의미가 더 강하기 때문에, 코드를 해석하는 입장에서 오해할 여지가 있기 때문입니다.
하지만 enum class가 아닌 enum은 열거자 스코프가 네임스페이스라는 점을 이용해 전역적으로 사용되는 데이터를 중복 없이 저장하는데 꽤나 유용하게 활용할 수 있습니다.
이전에 const int &i = convention_data::attendee_count;와 같이 참조되는 경우 ODR에 위배되기 때문에 외부에 따로 정의해줘야 했지만, C++17부터는 static constexpr인 멤버 데이터가 암시적으로 inline이므로 그럴 필요가 없습니다. 또 이로 인해 static constexpr auto attendee_count = 100;과 같이 auto를 어색함 없이 사용할 수 있게 되었습니다.
//C
struct convention{
  int attendee_count;
} convention_data = { .attendee_count = 100 };

// ...

for(int people_count = 1; people_count <= convention_data.attendee_count; ++people_count)
  give_coffee(people_count);

//C++

struct convention_data{
  static constexpr int attendee_count = 100;
};

constexpr int convention_data::attendee_count; // cpp17에서는 제거 가능

// ...

for(auto people_count = 1; people_count <= convention_data::attendee_count; ++people_count)
  give_coffee(people_count);

여기까지는 대부분의 사람들이 잘 알고 있는 방법입니다. 당연하게도 위의 방법은 아직도 문제가 남아있습니다. 상수를 이용했다 하더라도 여전히 코드에 데이터를 쓰고 있기 때문이죠.

예시를 정수로 들긴 했지만, 사실 스칼라 타입들은 상수가 가장 좋은 해결책이라고 생각합니다. 런타임에 빠르게 접근할 수 있으며 사용 시 접근성도 높기 때문입니다. 오히려 상수가 아닌 다른 복잡한 방법을 사용하면 어떤 코드에서는 attendee_count을 사용하지만, 다른 코드에서는 100을 사용하는 등의 일관성이 깨지는 문제가 발생할 수 있습니다. 단순한 상수는 IDE의 리펙토링 기능으로 쉽게 변경할 수 있지만 복잡한 방법은 변경이 어려울 수 있기 때문이죠.

하지만 문자열과 같은 복잡한 타입, 특히나 로그 등의 자주 접근되지 않는 문자열은 상수가 좋은 해결책이 아닐 수 있습니다. Git과 같은 VCS를 사용하면 코드와 상수가, 정확히 말해 코드와 데이터가 한 파일에 같이 커밋되어 리버트가 곤란하거나, 데이터에 대한 변경사항 추적이 어려운 경우가 많습니다. 또 코드가 너무 장황해지며, 별도의 헤더에 상수를 옮겨놓아도 상수가 필요한 모든 파일에 인클루드 해줘야 한다는 점도 상당히 번거롭습니다. 라이브러리라면 배포 방식에 따라 헤더가 아닌 소스 파일에서 정의해줘야 하는 경우도 생기는데, make나 CMake 같은 빌드 자동화 도구를 사용한다면 관리가 필요한 소스 파일이 늘어난다는 점이 불편하기도 합니다.

사례

다음은 리눅스 fork.c에서 가져온 스니펫입니다. 크기가 작은 int#define으로 크기가 큰 char* 배열은 const로 정의해주는 것을 확인할 수 있습니다. 위에서는 끌리는 걸 사용하라고 했지만, 커널은 작은 성능차가 중요하고, 다양한 컴파일러로 빌드돼야 하기 때문에 다른 리눅스 커널 코드들도 비슷한 선택을 한 것을 확인할 수 있습니다.

// SPDX-License-Identifier: GPL-2.0-only

/*
 * Minimum number of threads to boot the kernel
 */
#define MIN_THREADS 20

// ...

static const char * const resident_page_types[] = {
	NAMED_ARRAY_INDEX(MM_FILEPAGES),
	NAMED_ARRAY_INDEX(MM_ANONPAGES),
	NAMED_ARRAY_INDEX(MM_SWAPENTS),
	NAMED_ARRAY_INDEX(MM_SHMEMPAGES),
};

외부 파일

외부 파일 설명 그림

다음과 같이 하드코딩된 로그는 수정과 변경사항 추적이 힘들고, 국제화가 매우 제한적이라는 단점이 있습니다. 상수는 위에서 설명한 문제점들 때문에 적합하지 않습니다. 그렇다면 외부 파일에 데이터를 저장하는 방법은 어떨까요?

외부 파일의 구현은 이 글의 범위를 넘어서므로, 대신 몇 가지 아이디어와 장단점만 소개하겠습니다.

clang은 에러 메세지를 그대로 하드코딩했지만, 러스트가 진행 중인 국제화를 보면 clang도 조만간 비슷한 선택을 하지 않을까 싶네요. (실제로 몇몇 관련 토론도 보입니다.)
std::clog << "에러: 혈액 속 카페인이 충분하지 않습니다!"

외부 파일은 여러 방식으로 구현될 수 있습니다. 가장 간단한 방식은 텍스트 파일의 라인에 기반한 읽기입니다. 예를 들어 get_data(1)는 첫 번째 줄의 텍스트를 반환할 수 있겠죠. 하지만 이대로 쓰기에는 위의 100과 같은 문제가 생깁니다. 숫자 1의 의미를 알기 힘들다는 것이죠.

std::clog << get_data(1);

//file ex)
//에러: 혈액 속 카페인이 충분하지 않습니다!
//경고: 무슨 메세지 쓸지 5분이나 고민했습니다! 그만 고민하세요!

따라서 이 방식은 열거형과 결합해 사용하는 것이 좋습니다. 이 방식은 가독성이 좋고, 아래의 check_caffeine()와 같이 에러 메세지도 깔끔하게 출력할 수 있다는 장점이 있지만, 짧은 데이터는 상수를 사용하는 것보다 코드가 장황해질 가능성이 있습니다.

enum class error {
  none = -1,
  caffeine_shortage = 1
};

std::clog << get_data(error::caffeine_shortage);

//check_caffeine()
if(auto e = check_caffeine(); e != error::none)
  std::clog << get_data(e);

문자열을 이용해 원하는 데이터를 가져올 수도 있겠죠. 이 방법은 유지보수가 쉽고 확장성이 좋다는 장점이 있습니다. 이밖에도 JSON, XML, SQLite와 같은 데이터베이스 등등 방법은 무궁무진합니다.

std::clog << get_data("caffeine_shortage_error");

//file ex)
//caffeine_shortage_error,에러: 혈액 속 카페인이 충분하지 않습니다!
//overthinking_waring,경고: 무슨 메세지 쓸지 5분이나 고민했습니다! 그만 고민하세요!

외부 파일은 코드와 데이터를 분리하는 좋은 방법이지만, 위처럼 간단한 형식이 아닌 JSON과 같은 복잡한 형식은 파셔를 직접 작성하지 않는 이상 의존성이 필연적으로 발생하게 되고, 이는 유지보수 복잡성을 증가시키며 소규모 프로젝트에서는 오버 엔지니어링일 수 있습니다.

또 외부 파일은 데이터에 대한 접근 속도가 느리며, 링크 타임 최적화를 활용하지 못한다는 단점이 있습니다. 특히나 임베디드에서는 외부 파일을 위해 공간을 따로 할당하고, 그 파일을 접근하는 코드까지 포함한다는 것은 너무 큰 사치죠. 이럴 때는 빌드 단계에 외부 파일을 상수로 만들어주는 과정을 추가하면 빌드 속도가 느려진다는 단점은 있지만, 외부 파일과 상수의 장점을 적절히 활용할 수 있습니다.

사례

외부 파일에 대한 적당한 사례를 찾기 힘들어서 그냥 제가 예전에 작성한 간단한 클래스 일부를 가져왔습니다. 아래와 같이 외부 파일에는 커서의 (x, y)값들이 일렬로 있으며, 값들은 특정 블록으로 나눠져 있습니다. 이 데이터는 마우스의 제스처를 식별하는 용도로 사용됩니다. (아래는 아무 의미 없는 더미 데이터입니다.)

[(1,2),(3,5),(4,6)],
[(45,436),(254,35),(343,465),(756,364),(123,34),(54,6)],
[(545,55),(42,54),(35,64),(446,66)]

void deserialize(const std::string &records)는 위의 형식으로 된 문자열 데이터를 역직렬화해 데이터를 편하게 처리할 수도 있도록 도와줍니다. 만약 이 값이 상수였다면, 수천 줄의 커서 데이터가 코드에 적혀있는 끔찍한 일이 일어났을 겁니다.

C++20부터 std::regexstd::views::split로 대체해 더 빠르고 깔끔하게 바꿀 수 있습니다. 아쉽게도 libc++은 아직 지원하지 않습니다. 사실 이런 단순한 직렬화는 파일을 읽는 과정만 제외한다면 constexpr 함수, 즉 컴파일 타임에 처리할 수도 있습니다.
template<typename ...Data>
using Record = std::vector<std::tuple<Data...>>;

template<typename ...Data>
using Records = std::vector<Record<Data...>>;

template<typename ...Data>
struct CursorRecords : public Records<Data...> {
public:
  void deserialize(const std::string &records) {
    // std::views::split - libc++ not support
    std::regex regexLine{R"([\s\n]+)"};
    std::regex regexComma{R"([\s),(]+)"};

    const std::sregex_token_iterator end;

    constexpr auto number = std::tuple_size<std::tuple<Data...>>{};
    std::array<int, number> temp;

    for (std::sregex_token_iterator
             lines{records.begin(), records.end(), regexLine, -1};
         lines != end; ++lines) {
      const std::string &line = *lines;
      std::sregex_token_iterator
          // remove "[("(+2) and ")]"(-2)
          elements{line.begin() + 2, line.end() - 2, regexComma, -1};
      std::vector<std::string> element{elements, {}};

      Record<Data...> record{};

      for (auto i = 0u; i < element.size(); i += number) {
        for (auto j = 0u; j < number; ++j) {
          temp[j] = std::stoi(element[i + j]);
        }
        record.emplace_back
            (makeDataTuple(temp, std::make_index_sequence<number>{}));
      }

      this->emplace_back(record);
    }
  }

private:
  template<auto N, auto... I>
  auto makeDataTuple(const std::array<int, N> &a, std::index_sequence<I...>) {
    return std::make_tuple(a[I]...);
  }
};

부록

이대로 끝내면 너무 날로 먹는 것 같아서 몇 가지 더 적어봤습니다!

ELF 파일에서 상수 읽는 법

먼저 상수가 어디에 저장되는지 알아야 합니다. 상수는 크기에 따라 .text 또는 .rodata에 저장되는 게 일반적입니다. 아래는 clang15에서 생성된 어셈블리입니다. 위에서 #defineconst의 성능차는 무의미하다고 말했듯이 크기가 크면 .rodata에 저장되고 크기가 작으면 .text에 저장되는 것을 확인할 수 있습니다. 물론 gcc도 같은 최적화를 수행합니다.

.L (gcc에서는 .LC0등 0으로 끝나는 섹션)은 .rodata에 들어가며, 64비트 어셈블리기 때문에 rax에 데이터 오프셋을 저장하고, 스택에 넣는 것을 확인할 수 있습니다.

#define TEST "Lorem ipsum/.../"

void foo() {
  char *text = TEST;
}
.L.str:
        .asciz  "Lorem ipsum/.../"
foo():
        push    rbp
        mov     rbp, rsp
        lea     rax, [rip + .L.str]
        mov     qword ptr [rbp - 8], rax
        pop     rbp
        ret

마찬가지로 이번에는 오프셋 대신 100을 스택에 넣습니다.

const int TEST = 100;

void foo() {
  int test = TEST;
}
foo():
        push    rbp
        mov     rbp, rsp
        mov     dword ptr [rbp - 4], 100
        pop     rbp
        ret

이제 본격적으로 ELF 파일에서 상수를 찾아봅시다. 디버그 기호가 제거되지 않은 바이너리에, 심볼을 알고 있다면 objdump만으로 생각보다 쉽게 찾을 수 있습니다. 디버그 기호가 제거되어있다면 기드라나 IDA를 쓰는 게 정신건강에 이롭습니다.

clang이나 gcc는 -s 옵션이 없는 이상 릴리즈 빌드에도 디버그 기호가 포함됩니다. 크레쉬 리포트 등의 이유로 이런 기호를 제거하지 않는 경우도 많습니다.

7줄짜리 매우 간단한 main.c를 빌드해 실습용으로 사용해보겠습니다. objdump로 main을 디어셈블리하면 다음과 같이 출력됩니다. main()가 리턴하는 int는 4바이트 크기고 rbp-0x4rbp-0x8 사이에 위치하므로 rbp-0x10rbp-0x8사이에 8바이트 크기의 지역변수가 있다는 것을 알 수 있습니다. 0x402004가 가상 메모리 주소 영역 내고, 64비트 바이너리이므로 정확한 타입은 아직 모르겠지만 일단 포인터겠네요! 포인터가 가리키는 값을 찾아봅시다.

$clang main.c -o main
$objdump -d main | grep -A10 main

/.../
0000000000401110 <main>:
  401110:       55                      push   %rbp
  401111:       48 89 e5                mov    %rsp,%rbp
  401114:       c7 45 fc 00 00 00 00    movl   $0x0,-0x4(%rbp)
  40111b:       48 b8 04 20 40 00 00    movabs $0x402004,%rax
  401122:       00 00 00 
  401125:       48 89 45 f0             mov    %rax,-0x10(%rbp)
  401129:       31 c0                   xor    %eax,%eax
  40112b:       5d                      pop    %rbp
  40112c:       c3                      retq   
  40112d:       0f 1f 00                nopl   (%rax)
/.../

.rodata에 위치해있을게 뻔하지만, 명확히 하기 위해 ELF 파일의 헤더를 보면, 0x402000에 .rodata 섹션이 위치한 것을 확인할 수 있습니다.

$objdump -x main

/.../
 12 .rodata       00000026  0000000000402000  0000000000402000  00002000  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
/.../

.rodata 섹션을 읽으면 이제 포인터가 가리키는 값 0x402004, 즉 상수의 정체를 알 수 있습니다. 간단하죠? 또 어셈블리를 토대로 원래 코드도 유추할 수 있습니다. 디컴파일링은 여러분들의 몫으로 남겨두겠습니다.

$objdump -sj.rodata main

/.../
Contents of section .rodata:
 402000 01000200 6b666165 6b6c6a61 77656b64  ....kfaekljawekd
/.../

상수 보안

상수는 위처럼 대놓고 저장되기 때문에 읽기가 너무 쉽습니다. 따라서 상수를 보호해야 할 필요가 있습니다. 당연히 중요한 정보는 상수로 저장하면 안 됩니다. 여기서 설명하는 방법은 리버싱을 조금 더 불편하게 만드는 임시조치죠.

구현하기 가장 간단한 방법은 XOR 연산입니다. 이 방법은 예전부터 전통적으로 많이 사용되어왔고 구현도 아래와 같이 매우 쉽습니다.

#include <iostream>

int main() {
  const char key{'7'};
  std::string text{"phruse"};
  
  for (auto& c : text)
    c = c ^ key;

//or
//std::for_each(text.begin(), text.end(), [&](auto& c){ c = c ^ key; });

  std::cout << text;    // print "G_EBDR"
  return 0;
}

조금 더 복잡하게는 상수 별로 임의의 코드를 부여하고 네트워크에서 해당 코드를 조회해서 실제 상수를 얻도록 만들 수 있습니다. 물론 조회에 특수한 암호나 키가 필요하게 만들어야 합니다. 매 빌드 별로 모든 상수와 키가 초기화되고, 난독화까지 곁들이면 더욱 좋죠. 이 방법은 상용 솔루션으로 나와있을 만큼 널리 알려져 있으니 궁금하시다면 찾아보는 것도 좋을 것 같습니다.

끝까지 읽어주셔서 감사합니다. 🙂

글쓴이

phruse

쉬운 길보다는 어려운 길을 즐깁니다. 다양한 분야에 관심이 많으며 언젠가 많은 사람이 사용하는 기반 기술을 개발하는 것이 목표입니다.