모두의 코드
씹어먹는 C++ - <22. 구글에서는 C++ 을 어떻게 쓰는가?>

작성일 : 2022-02-15 이 글은 28174 번 읽혔습니다.

안녕하세요! 여러분. 마지막으로 글을 쓴지 시간이 많이 지났네요. 그럼에도 불구하고 모두의 코드를 방문해주시는 여러분들께 감사하다는 말을 전하고 싶습니다 :)

저는 현재 구글에서 소프트웨어 엔지니어로 일하고 있습니다. 지금 하는 일은 YouTube 의 채널과 플레이리스트 서버 인프라를 담당하고 있습니다. 쉽게 말해 백엔드 개발자라고 보시면 됩니다. 우리나라에서는 주로 서버를 개발할 때 자바 스프링을 많이 쓴다고 하는데, 구글의 경우 거의 대부분의 서버는 C++ 로 개발이 됩니다. 아무래도 구글의 역사가 꽤나 오래 되었으니까 (90년도 후반에 시작했으니까요), 자연스럽게 C++ 을 초기에 사용하면서 지금까지 온 것 같습니다. 이 때문에 구글에 전 세계에서 가장 큰 C++ repository 가 있다고 해도 과언이 아닙니다. 구글에는 최소 수 십만개의 C++ 파일들이 있고, 매일 수 만명의 개발자들이 C++ 코드들을 제출합니다. 저도 마찬가지로 매일 C++ 코드를 읽고 또 쓰고 있죠.

C++ 의 장점으로 언어 차원에서의 넓은 자유도가 있겠지만, 단점은 너무나 자유로운 탓에 같은 동작을 수행하는 코드를 10 가지 다른 방법으로 작성 할 수 있다는 점입니다. C++ 의 창시자 스트로스트럽 아저씨의 말을 빌리자면

C 에서는 실수들을 하기 쉽습니다. C++ 은 C 에 비해 실수할 가능성을 줄여주지만, 만일 실수를 하게될 경우 ㅈ되게 됩니다. (C makes it easy to shoot yourself in the foot; C++ makes it harder, but when you do it blows your whole leg off) - Bjarne Stroustrup

따라서 구글 처럼 큰 스케일로 C++ 를 사용하는 곳에서는 언어 차원에서 올바른 가이드라인을 제공하는 것이 중요합니다. 더군다나 구글은 회사 전체에서 단 하나의 Repository 만을 사용하기 때문에 서로가 서로의 C++ 코드를 모두 볼 수 있습니다.

따라서 회사 차원에서 적절한 가이드라인을 제시하지 않는다면 정말로 똑같은 작업을 10 가지 다른 방법으로 작성한 것을 볼 수 있겠죠!

이번 글에서는 구글 뿐만이 아니라, C++ 개발자라면 알면 좋을 가이드라인들과, 또 조금 특이하다고 생각될 만할 것들을 이야기 해보고자 합니다.

전체적인 코딩 스타일

기본적으로 구글의 모든 C++ 코드는 Google C++ Style Guide 를 따르고 있습니다. 링크를 들어가 보시면 알겠지만 꽤나 방대하지만, 그래도 코드 몇 번 쓰다 보면 쉽게 익혀지는 내용들 입니다. (특히 코드 리뷰에서 코멘트 폭탄을 맞아보면 쉽게 기억이 됩니다 ㅎㅎ)

clang-format 사용

구글의 모든 C++ 코드는 clang-format 을 사용해서 (GoogleStyle 옵션이 있습니다!) 정렬됩니다. formatter 가 탭 크기, 개행 방식 등 모든 것들을 알아서 맞춰주기 때문에 개발자가 포맷에 신경쓸 일은 거의 없습니다. 또한 이를 통해 회사 차원에서 모든 코드들을 일관된 스타일로 관리할 수 있죠.

이름 정하기

구글의 경우 기본적으로 클래스와 함수는 CamelCase (대소문자로 띄어쓰기를 구분), 변수나 클래스의 필드 이름들은 모두 snake_case (띄어쓰기를 _ 로 구분. 모두 소문자) 를 사용합니다. 그리고 enum 필드의 경우 KEBAB_CASE (띄어쓰기를 _ 로 구분. 모두 대문자) 로 정의합니다.

위 규칙들은 꽤나 엄격하게 지켜지는데, 이름을 바꿀 수 없는 외부 라이브러리나 std 의 함수들의 경우 말고는 항상 규칙을 따른다고 보시면 됩니다. 덕분에 대충 심볼 형태만 보고도 이게 함수 이름인지, 변수 이름인지는 쉽게 알 수 있습니다.

참고로 이 규칙이 우월하다 라고 주장하는 것은 아닙니다. 예를 들어서 LLVM 프로젝트의 경우 변수와 함수 이름 모두 CamelCase 를 쓰지만 함수의 경우 맨 앞글자가 소문자여야 합니다.

하지만 중요한 점은 적어도 같은 프로젝트 내에선, 아니면 회사 차원에서 한 가지 스타일을 고수하는 것이 눈이 안아픕니다.

한 줄에 최대 80자

구글의 모든 C++ 코드는 한 줄에 최대 80 자 까지 올 수 있습니다. 옛날에서야 모니터가 해상도가 낮아서 한 줄에 막 100 자씩 오면은 한 모니터에 코드를 두 개 못띄웠겠지만, 왜 지금도 이러한 제한을 두냐고 물을 수 있는데,

  1. 대부분의 경우 formatter 가 코드를 정렬해줘서 80 자로 제한을 둬도 깔끔하게 보이고

  2. 만일 indentation 이 너무 깊어서 80 자로 정렬이 제대로 안된다면 애초에 코드 설계가 잘못 된 것이고 (해당 부분을 다른 함수 같은 걸로 빼야겠죠)

  3. QHD 모니터의 경우 한 화면에 코드를 최대 4 개 까지 띄울 수 있습니다.

아무튼 이러한 연유로 2022 현재에도 최대 80 자를 고집하고 있습니다.

네임 스페이스

기본적으로 모든 코드는 적절한 네임스페이스 안에 들어가 있어야 합니다. 예를 들어서 유트브에서 작성한 모든 코드는 youtube 네임스페이스안에 들어가 있죠. 그리고 팀들 마다 각자 자기 팀이름의 네임스페이스 를 가집니다. 예를 들어 저희 팀의 경우 playlistschannel 안에 들어 있죠. 즉, youtube::channel::... 이런 식으로요.

그리고 네임스페이스와 관련해 몇 가지 중요한 규칙들이 더 있는데,

  1. 헤더 파일에는 절대로 using ::..::SomeFunction선언하지 않는다. 이는 헤더파일이 다른 소스 코드에 include 되었을 때 심볼들 끼리 충돌이 나는 것을 방지하기 위함입니다. 예를 들어서 헤더 파일에 using youtube::NiceFunction; 이 선언되어 있고, 다른 헤더 파일에 using google::NiceFunction; 이 되있다면, 두 헤더파일을 include 하는 소스 파일은 (심지어 include 되지 않더라도 여러 다리 건너서(trasitively) include 되더라도!) NiceFunction 이란 심볼이 충돌이 나게 됩니다.

  2. 헤더든 소스든 절대로 using namespace ... 를 하지 않는다. 구글에서 using namespace 를 통해서 해당 네임스페이스 안에 모든 심볼들을 불러오는 것은 금지되어 있습니다. 네임스페이스 안에 필요한 애들이 있다면 따로 불러와야 합니다.

  3. using ::SomeFunction 같이 소스 안에서 alias 를 하려면 반드시 fully qualify 를 해야 합니다. Fully qualify 라는 말은 마치 절대 경로와 상대 경로를 생각하면 되는데, ::a::b::c 와 같이 네임스페이스 를 지정할 경우 반드시 최상단의 a 로 부터 시작한다는 의미로 fully qualify (절대 경로) 하는 것이고, 그냥 a::b::c 를 하면 namespace a 가 어디 다른 네임스페이스 안에 정의된 공간이여도 인정이 되는 것으로 partially qualify 한다고 합니다 (상대 경로). Alias 를 할 때 fully qualify 를 해야 나중에 충돌될 여지를 없앨 수 있습니다.

  4. 헤더 파일에서는 partially qualify 해도 된다.

테스트

구글에서 작성하는 모든 소스 파일(.cc)에는 반드시 테스트 코드가 딸려와야 합니다. 예를 들어서

class A {
 public:
  int DoSomething(int x) {
    if (x == 1) {
      return 2;
    }
    return 3;
  }
};

위와 같은 클래스 A 를 정의하였다면 반드시 A 클래스의 모든 public 인터페이스 (위 경우 DoSomething 이겠죠) 확인하는 유닛 테스트를 작성해야 합니다. 예를 들어서 아래와 같이요.

TEST(ATest, Success) {
  A a;
  EXPECT_EQ(a.DoSomething(1), 2);
  EXPECT_EQ(a.DoSomething(2), 3);
}

참고로 구글의 모든 C++ 테스트 코드는 GoogleTest 라는 테스트 프레임워크를 사용하는데, 이를 사용하는 방법은 다른 글에서 다루어보도록 하겠습니다.

때로는 어떠한 기능을 구현하는데 걸리는 시간 보다, 해당 기능의 테스트 코드를 짜는데 더 많은 시간이 걸리기는 하지만 적절한 유닛 테스트의 중요성을 이루 말할 수 없는데:

  1. 안전한 리팩토링. 코드 퀄리티를 개선할 때 리팩토링된 코드가 기존의 코드와 동일하게 작동함을 보장하는 최소한의 안전장치. 특히 구글에서는 내 팀 뿐만이 아니라, 구글에서 사용하는 라이브러리들에 대한 리팩토링도 진행하기 때문에 상당히 중요한 문제 입니다.

  2. 적절한 예제로 만들어진 유닛 테스트를 통해 남의 코드를 이해하기 쉽다.

  3. 내 코드가 적어도 내가 생각한대로 작동한다는 신뢰도를 올려줌.

와 같은 장점이 있습니다.

소프트웨어 개발에서 테스팅이라 하면 크게 두 가지 종류로 나눌 수 있는데 앞서 이야기한 개개의 클래스나 함수의 동작을 확인하는 유닛 테스트(Unit test) 와 여러 시스템의 상호작용을 테스트 하는 Integration test 가 있습니다.

보통 구글에서는 유닛 테스트는 해당 코드를 작성한 사람이 쓰는 편이고, Integration testing 의 경우 시스템이 워낙 거대하기 때문에 아예 integraion 테스팅만을 전문적으로 담당하는 팀이 따로 있습니다.

클래스 관련

C++ 에서 struct 키워드와 class 키워드는 차이가 없지만 (디폴트가 private 이냐 public 이냐 말고는), 구글의 경우 이 두 키워드의 사용 경우를 명확하게 구분합니다.

struct 는 간단한 데이터들의 모음을 나타날 때 사용됩니다. 흔히 C++ 에서 말하는 Plain Old Data (POD) 가 그 예지요.

struct Data {
  std::string s;
  int k;
};

이런 식으로 말이죠. struct 로 표현되는 데이터에는 그 어떠한 로직도 구현하지 않습니다. 만일, 특별한 로직이 필요한 경우에는 무조건 class 를 사용합니다.

참고로 구글은 protobuf 라는 데이터 직렬화(serialization) 라이브러리를 광범히 하기 사용하기 때문에 조금 복잡한 형태의 데이터나 중요한 데이터 형식의 경우에는 struct 를 직접 정의하기 보다는 무조건 protobuf 를 사용해 정의한 후, 이 라이브버리가 생성하는 코드들을 사용하기 때문에 struct 를 사용하는 경우는 드뭅니다. (보통 protobuf 로 정의하기 너무 사소한 것들을 쓰죠.)

Inheritance vs Composition

C++ 에서 클래스를 디자인하는 방법으로 크게 두 가지로 나눌 수 있는데 바로 상속(Inheritance) 와 구성(Composition) 입니다. 예를 들어서 자동차라는 클래스를 구현한다고 했을 때 상속과 구성을 사용할 경우 어떻게 다른지 살펴봅시다.

먼저 상속의 경우

class Engine {
 public:
  bool Accelerate();
  bool Stop();
};

class Sensor {
 public:
  bool IsCarFront();
};

class Car : public Engine, public Sensor {
 public:
  bool MoveFront() {
    if (!IsCarFront()) {
      Accelerate();
    } else {
      Stop();
    }
  }
};

위와 같이 클래스를 구성하는데 필요한 요소들을 부모 클래스로 부터 상속 받아서 파생 클래스로 부터 기능들을 구현하는 것일테고, 구성 방식을 사용하였을 경우

class Car {
 public:
  Car(Engine* engine, Sensor* sensor) : engine_(engine), sensor_(sensor) {}

  bool MoveFront() {
    if (sensor_->IsCarFront()) {
      engine->Accelerate();
    } else {
      engine->Stop();
    }
  }

 private:
  Engine* const engine_;
  Sensor* const sensor_;
}

위와 같이 표현할 수 있겠죠. 그렇다면 위 둘 중에서 어느 방식이 나은 것일까요? 두 개 방식 모두 Car 이라는 클래스를 구현하는데 사용될 수 있지만, 적어도 구글에서는 구성 방식을 되도록이면 사용하도록 장려하고 있습니다.

일단 클래스의 상속을 사용했을 때 발생하는 문제가 몇 가지 있습니다.

  1. 다중 상속의 경우 심볼의 충돌이 발생할 수 도 있고, 또 해당 함수에 어떠한 클래스에 구현이 되어 있는지 찾기도 힘듧니다.

  2. 구성을 사용했을 때와 비교해서 클래스를 테스트 하는게 힘들어집니다. 예를 들어서 Enginemock 하고 싶다면 구성을 사용했을 경우 Engine* engineMockEngine 을 전달하면 되지만, 상속의 경우에는 아예 Engine 을 받는 걸로 명시되어 있기 때문에 불가능 합니다.

구성 방식으로 구현된 클래스는 테스팅이 훨씬 용이하기 때문에 대부분의 경우 구성 방식을 취하라고 권장하고 있습니다. 물론 그렇다고 해서 상속을 하는 것이 금지되어 있는 것은 아니고, 명확한 is-a 관계일 때에만 상속을 하는 것이 가능합니다. 예를 들어서

class Engine {
 public:
  virtual bool Accelerate() { /* .. */
  }
};

class V8Engine : public Engine {
 public:
  bool Accelerate() override { /* .. */
  }
};
class HybridEngine : public Engine {
 public:
  bool Accelerate() override { /* .. */
  }
};

이와 같이 말이죠.

함수 관련

Output parameter 는 지양하자.

C++ 의 함수에서 계산된 결과값을 돌려주는 방법은 두 가지가 있습니다.

void DoSomething(int input, std::string* output);  // 1
std::string DoSomething(int input);                // 2

예를 들어서 1 번 처럼, 돌려줄 결과값을 보관하는 곳의 주소값을 전달해서 해당 함수 안에서 이를 설정하는 방법이 있고, 아니면 2 번 처럼 그냥 함수의 리턴을 통해 연산 결과를 돌려주는 방법이 있습니다.

구글에서는 되도록이면 2번 방법을 사용하도록 권장하고 있습니다. 왜냐하면 일단 2번 형태의 함수를 사용하는 것이 더 자연스럽습니다. 예를 들어서

std::string result;
DoSomething(3, &result);

std::string result = DoSomething(3);

위 둘을 비교했을 때 후자가 훨씬 자연스럽지 않나요? 그리고 C++ 11 에서 강제된 리턴값 최적화(return value optimization) 덕분에 대부분의 함수 리턴 시 불필요한 복사가 일어나지 않습니다.

구글 특이적인 것들.

absl 라이브러리

Abeseil 라이브러리는 구글에서 만든 C++ 라이브러리로 C++ 의 표준 라이브러리에서 부족한 부분을 보완하고 있습니다. 문자열 처리나, C++ 표준 보다 훨씬 빠른 해시맵

, time 등등을 제공하고 있습니다. 예를 들어서

// StrCat 은 문자열을 합쳐주는 함수
std::string s = absl::StrCat(x, y, z, w);

std::string s_2 = x + y + z + w;

Abeseil 에서 제공하는 문자열들을 합치는 함수인 StrCat 의 경우 std::stringoperator+ 를 사용하는 것 보다 훨씬 빠릅니다. 왜냐하면 operator+ 와는 다르게 불필요한 문자열의 할당/해제를 사용하지 않기 때문이죠.

Abeseil 은 누구나 사용할 수 있도록 공개되어 있기에 관심있는 분들은 여기를 참조하시면 됩니다. 참고로 구글은 빌드 시스템으로 bazel 을 사용하고 있어서 bazel 을 쓰지 않는 시스템이라면 구글에서 제공하는 라이브러리를 같이 사용하기 힘들 수 도 있는데, absl 의 경우 CMake 를 통해서 빌드할 수 있도록 CMake 파일을 제공하고 있어서 큰 문제가 없습니다.

C++ Exception 을 사용하지 않는다.

한 가지 재미있는 사실로 구글에서는 C++ exception 을 사용하고 있지 않습니다. 그 대신 예외 적인 상황을 나타내는 객체로 absl 라이브러리의 Status 를 사용하는데, 어떠한 데이터 처리 중에 예외가 발생할 경우 throw 를 하는 대신에 그냥 적절한 Status 를 리턴합니다. 예를 들어서

absl::Status ReadSomethingAt(int i) {
  if (i >= some_vec.size()) {
    return absl::InternalError("Oops");
  }

  // Do something;
}

위와 같이 함수에서 Status 를 리턴합니다. 참고로 Status[[no_discard]] 로 마크되어 있기 때문에 Status 를 받는 함수는 반드시 리턴된 Status 를 읽어야 합니다. 이를 통해서 리턴된 Status 가 무시되는 경우를 막고 있습니다.

그런데 그러면 C++ 표준 라이브러리에서 발생하는 예외는 어떻게 하나요?

네. 이 경우에는 그냥 프로그램은 그냥 세그폴트를 내면서 죽습니다. 사실 그래도 큰 문제가 되지는 않는게 구글에서 운영하는 서버들은 서버가 죽을 시에 자동으로 재시작을 해주기 때문입니다. 물론 예외 처리를 하면서 graceful 하게 처리할 수 도 있지만, 이미 너무나 많은 코드들이 예외를 가정하기 않고 쓰여졌고, exception 을 사용하게 되면 런타임 시 약간의 오버헤드가 있기 때문에, 빠른 시일 이내에 바뀔 일은 없을 것 같습니다.

이것으로 구글에서 어떠한 방식으로 C++ 을 사용하는지 간단히 살펴보았습니다. 사실 여기에 말한 내용들 말고 훨씬 많은 principle 들과 guideline 들이 있는데, 일부 내용들은 여기 에 공개되어 있으니 읽어보는 것을 추천합니다.

댓글이 16 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요

    댓글을 불러오는 중입니다..