[예약시스템 개발 일지] #2. User Model, Auth 기능 구현, Argon2 Encrypt 사용하기

전편

#1. C++ 개발 환경 설정

대략적인 클래스 설계

integrated-reservation-system repository

리드미에 구현해야 할 사항들이 상세하게 나와있다. 이 프로그램에서 원하는 요구 사항은

  1. 식당 예약, 항공편 예약, 독서실 예약이라는 각기 다른 세 개의 서비스를 동작시킬 것
  2. 각기 다른 서비스에 독립적인 Auth 기능, 예약 기능들이 존재할 것. 또한 각기 다른 예약 기능에는 각 서비스에서 요구하는 조건들을 만족할 것
  3. 각기 다른 세 개의 독립된 서비스에 대해 독립된 통계 기록을 구현할 것
  4. UI를 구현할 것

다행히 각기 다른 세 개의 서비스에 대해서 통합된 통계 기록을 제공해야 하는 것이 아니라서, 독립된 객체로 서비스를 동작시키는 것이 가능하다. 무지막지한 클래스 설계를 요구하지는 않을 것 같다.

그리고 auth 기능은, 개발 과정에서 변경될 수는 있으나, 현 시점에서는 각 세 개의 서비스에 대해서 별도의 클래스를 정의할 필요 없이, 하나의 auth 기능을 담당하는 클래스를 만들어서, 이 클래스를 각 예약 시스템 클래스의 속성으로 동작시키면 될 것 같다. 만약 특수한 기능이 필요할 경우, Decorator 클래스를 구현해서 특수한 기능을 재정의한 후 구성하면 될 것 같다.

디자인 패턴을 우선적으로 적용시키는 것을 선호하지는 않지만, 몇몇 디자인 패턴들은 우선적으로 고려해볼만 하다. 먼저 AuthManager라는 클래스를 따로 만들어서, 이 클래스에 로그인/로그아웃/회원가입 기능을 정의하는 방법을 생각할 수 있다. 이때 그러면 AuthManager라는 클래스를 싱글톤(Singleton) 객체로 만드는 방법을 고려할 수 있다. 실제로 Firebase의 auth 관련 객체 역시 싱글톤 객체로 초기화되며 관리되며, 전체 프로세스에서 하나의 싱글톤 객체만 존재한다. 이 방법을 적용할 수 있을까?

나의 답은 아니요였다. 여기에는 구현하고자 하는 프로그램이 다음 조건을 포함하고 있다는 것을 상기해야 한다.

"각기 다른 독립적인 Auth 기능"

그렇다. 세 개의 서비스에 대해 auth에 대한 각기 다른 독립적인 객체를 요구하고 있다. 즉 전체 프로세스에서 AuthManager라는 객체는 최소한 세 개가 필요하다. IAuthManager라는 인터페이스 객체를 만든 후에, 이를 상속받는 세 개의 RestaurantAuthManager, AirplaneAuthManager, StudyRoomAuthManager라는 객체를 따로 만들어서 각각 세 개를 싱글톤 객체로 관리할 수는 있으나, 굳이 Dipendency Inversion(의존성 역전)이 필요한 경우도 아니고, AuthManager에 각기 다른 기능이 필요한 것도 아니다. 그렇기 때문에 그냥 Private 변수로 각 시스템 클래스에서 구성 요소로 들고 있는게 더 편리할 것이다.

class RestaurantReservationManager
{
public:
    void login();
    // ...
private:
    std::unique_ptr<AuthManager> auth_manager_;

이때 RestaurantReservationManager는 식당 예약 프로그램에 관한 전반적인 인터페이스를 제공하는 일종의 '파사드(Facade)' 클래스로써 동작한다. 이 클래스에서는 클라이언트에 제공되는 UI로 추상화된 동작 혹은 명시적인 UI만 제공하고, 내부적으로 구현을 숨긴다. 더 나아가서 최종 main 함수 단에서 동작하는 클래스에서는 이를 더 추상화하여 run()과 같은 인터페이스만 제공하는 파사드 클래스로, 구현을 더 추상화할 수 있다.

구성 vs 상속

한편 클래스에 기능을 추가하는 방법 중에서, 객체의 인스턴스를 구성으로 소유하는 방식 외에, '상속'의 방식이 존재한다. 그러나 대부분의 경우 이는 잘못된 선택이다. 상속은 피할 수 있으면 최대한 피하는 게 좋다. 상속으로 연결된 클래스들의 경우 의존 관계가 강하게 발생하게 되고, 클래스들의 관계를 복잡하게 만들어서, 결과적으로 코드의 복잡성을 증가시키고 수정을 불편하게 만들기 때문이다. 상속 관계를 지양하는 것은 내 개인적인 사견이 아닌, 구글의 권장 코딩 스탠다드이기도 하다. 상속 관계는 오직 이른바 Is-A 관계일 때만 사용한다.

  • Is-A 관계
class Animal
{
public:
    virtual void speak() = 0;
    virtual ~Animal();
};

class Dog : Animal // Dog is an Animal, 즉 is-a 관계
{
public:
    virtual void speak() override;
};

class Cat : Animal // Cat is an Animal, 즉 is-a 관계
{
public:
    virtual void speak() override;
};

위 방식은 괜찮다. Is-A 관계일 때만 클래스의 상속 관계를 유지하고 있기 때문이다.

  • Has-A 관계
class AuthManager
{
public:
    virtual ~AuthManager();
    virtual login();
    // ...
};

class ReservationManager
{
public:
    virtual ~ReservationManager();
    // ...
};

// Restaurant System class has an auth and a reservation class, 즉 has-a 관계
class RestaurantReservationSystem : AuthManager, ReservationManager
{
};

위 방식은 추천되지 않는다. Has-A 관계를 상속시키는 것은 코드의 복잡성을 증가시키고, 유지보수를 어렵게 한다. RestautrantReservationSystem은 다음과 같이 구성의 방식을 채택하는게 유리하다.

class RestautrantReservationSystem
{
public:
    // ...
private:
    std::unique_ptr<AuthManager> auth_manager_;
    std::unique_ptr<ReservationManager> reservation_manager_;
};

실제 코드가 이렇게 구현될 지는 모르겠으나, 하위 기능 집합을 포함할 때는 상속이 아닌, 내부 객체를 구성하고 소유함으로써 이를 통해서 기능을 독립적으로 작동시키는 편이 훨씬 좋다. 상속 관계보다 의존성을 훨씬 낮출 수 있다.

이처럼 이번 프로젝트에서는 꼭 필요한 경우가 아니면 상속 관계를 피하는 방향으로 프로그램을 설계하고자 한다.

User Model 구현

Auth 기능을 구현하기 전에, 먼저 User의 모델부터 구현하고 가자. User 모델에는 어떤 정보가 들어가는지 고민해볼 수 있다. 과제의 요구사항을 고려할 때 다음 네 가지 정보가 들어가면 좋겠다.

  • name : string
  • user id (uid) : string
  • age : int
  • gender : int

참고로 password는 User 모델에서 저장하지 않는다. 유저 모델은 오직 유저의 정보만 담고 있지, 패스워드와 같은 정보는 AuthManager에게 위임한다. 보안상의 이유도 있고, 사실 로그인 기능은 유저 모델의 책임이 아니기 때문이다.

또한 gender는 여기서는 int라 표기했지만, 실제로는 enum으로 구현하는 게 좋다. 그럼 위 정보를 담아서 user model을 구현해보면 아래 코드와 같이 된다.

enum class Gender
{
    MALE,
    FEMALE
};

class User
{
  public:
    User(std::string name, int age, Gender gender);
    User(std::string name, int age, Gender gender, std::string uid);
    User(const User &other);
    User(User &&other) noexcept;

    User &operator=(const User &other);
    User &operator=(User &&other) noexcept;
    bool operator==(const User &other) const;
    bool operator!=(const User &other) const;

    [[nodiscard]] std::string getName() const;
    [[nodiscard]] Gender getGender() const;
    [[nodiscard]] int getAge() const;
    [[nodiscard]] std::string getId() const;
    void setName(const std::string &name);
    void setGender(Gender gender);
    void setAge(int age);

  private:
    std::string name_;
    Gender gender_;
    int age_;
    std::string uid_ ;
};

기본적인 getter와 setter, 그리고 copy/move assignment 함수들로만 구성되어 있으므로 내부 구현은 생략하겠다.

user builder 구현

user 클래스를 생성할 때 사용할 user builder를 구현해보자. 비록 user 모델의 정보가 그다지 많지는 않지만, uid를 생성하는 것도 위임할 수 있고, 일종의 factory 클래스여서 유용하다.

#pragma once

#include "user.hpp"
#include <string>

class UserBuilder
{
  public:
    UserBuilder &setName(std::string_view name);
    UserBuilder &setGender(Gender gender);
    UserBuilder &setAge(int age);
    UserBuilder &setUid(std::string_view uid);
    UserBuilder &setUid();
    User build();

  private:
    std::string name_;
    Gender gender_ = Gender::FEMALE;
    int age_ = 0;
    std::string uid_;

    static std::string generateUuid();
};

간단한 함수들이여서 크게 신경쓸 필요는 없는데, 살펴볼 사항은 두 가지가 존재한다.

먼저 이건 팁인데, builder 함수들은 다음과 같이 setter 함수에 자기 자신을 리턴해주는 게 좋다.

UserBuilder &UserBuilder::setName(std::string_view name)
{
    this->name_ = name;
    return *this;
}

위와 같은 형식으로 setter 함수들을 구현한다면, 나중에 이런 식으로 함수들을 체이닝해서 사용할 수 있다.

UserBuilder user_builder;
const User user = user_builder
                  .setName("홍길동")
                  .setAge(21)
                  .setGender(Gender::Male)
                  .setUid()
                  .build();

build 함수만 다음과 같이 정의해주면 된다.

User UserBuilder::build()
{
    return {name_, age_, gender_, uid_};
}

두 번째는 uid의 생성 방식에 관한 사항이다. uid를 어떻게 생성할 것인지에 대해서 고민해야 한다. 단순히 0부터 시작해서 uid : int를 하나씩 늘려갈까? 그래도 문제는 없겠으나, uid같은 경우 integer로 구현하게 될 경우 회원 탈퇴 등의 경우로 중간 정수가 빌 수도 있고, uid의 길이 또한 일정하지 않다. 그래서 string으로 관리하는게 편리하고, 이때 string으로 uid를 생성하기 위해서, boost 라이브러리의 uuid_generator 헤더와 uuid_io 헤더가 사용된다. 두 라이브러리는 uid를 편리하게 생성해주는 함수들이다.

먼저 스태틱 함수인 static std::string generateUuid(); 클래스를 만들어준 뒤, 이곳에서 userid를 생성한다. 참고로 C++에서 static 함수는 일종의 pure 함수로 봐도 좋다.

아무튼...

#include <boost/uuid/uuid_generators.hpp>
#include <boost/uuid/uuid_io.hpp>

std::string UserBuilder::generateUuid()
{
    boost::uuids::random_generator generator;
    auto uuid = generator();
    return boost::uuids::to_string(uuid);
}

UserBuilder &UserBuilder::setUid()
{
    this->uid_ = generateUuid();
    return *this;
}

이런 식으로 uuid를 생성하면 된다! 이러면 매 번 새로운 유저를 생성할 때마다 uid 역시 바뀌게 될 것이다. 참고로 이렇게 생성된 uid는 8fd7282d-662e-4c85-a984-6b49e02428a4 이런 형식으로 되어 있다.

물론 setUid 함수에 임의의 uid를 배정하는 경우도 고려해야 하므로 (database에서 원래 있던 uid를 긁어올 때) 그에 대한 setter만 따로 정의해주면 된다.

AuthManger 구현

대망의 AuthManager 클래스만 남아있다. 여기서 유저의 회원가입, 로그인, 로그아웃을 담당한다. 이때 비밀번호를 암호화하는 것이 중요한데, 단순하게 비밀번호를 암호화하여 저장했다간 큰일날 수 있기 때문이다. 먼저 클래스 헤더를 보자.

class AuthManager
{
  public:
    AuthManager();
    virtual ~AuthManager();
    virtual bool registerWithUsernameAndPassword(std::string_view username, std::string_view password);
    virtual bool loginWithUsernameAndPassword(std::string_view username, std::string_view password);
    virtual bool logout();
    virtual bool isLoggedIn();
    virtual bool isRegistered(std::string_view username);
    virtual std::optional<User> getCurrentUser();

  private:
    std::unique_ptr<User> currentUser;
    std::unordered_map<std::string, std::string> usernameToUid;
    std::unordered_map<std::string, std::string> uidToPassword;
    static constexpr std::size_t saltLength = 16;
    using Salt = std::array<uint8_t, saltLength>;

    std::string getUserIdByUsername(std::string_view username);

    static std::optional<std::string> encryptPassword(std::string_view password, const Salt &salt);
    static bool verifyPassword(std::string_view password, std::string_view encryptedPassword);
    static Salt generateSalt();
};

상속을 받을 것 같지는 않은데 혹시 몰라서 일단은 모두 virtual로 선언해놓기는 했다. 나중에 상속하지 않는다면 virtual은 빼두면 된다. register 기능, login, logout 기능이 포함되어 있다. 그리고 private의 객체로는 현재 로그인된 유저 정보를 담고 있는 currentUser, 유저 이름과 uid를 매칭시키는 userNameToUid, 그리고 password 암호화를 위한 salt가 존재한다.

회원가입을 할 때 제일 가장 중요한 것은 비밀번호를 안전하게 암호화(Encrypt)하고, 로그인 시 이를 적절하게 인증(Verify)하는 것이다. 직관적으로 생각했을 때 우리는 이를 해시함수로 처리할 수 있다. 정답이다. 해시는 단방향성을 보장하기에 해시화된 결과를 보고서 원본 비밀번호를 유추할 수 없단 장점이 있고, 서로 다른 입력에 대해서 결과가 완전히 달라지는 성질도 보장된다. 그렇다면 C++에서 제공하는 std::hash 함수를 사용해서 다음과 같이 저장하면 될까?

auto encryptPassword(const std::string& password)
{
    const auto encrypt_password = std::hash<std::string>{}(password);
    return encrypt_password;
}

답은 아니요이다. 물론 이렇게 하면 해시화가 되는 것은 맞다. encrypt_password의 타입은 std::size_t로, unsingned long 형태 그대로 사용하거나 std::string 등으로 변환해서 저장하면 된다. 그러나 이 방식의 문제점은 C++에서 제공하는 std::hash가 안전하지 않다는데 있다. std::hash는 애초에 목적이 해시맵, 해시셋 등과 같은 자료구조에서 빠른 키값 생성을 위해 사용되기 위해 설계된 함수이다. 그렇기 때문에 빠르게 해시를 생성할 수 있다는 장점이 있지만, 애초에 빠르게 키값을 생성하는데 목적을 두고 있기에 안전성이 확보되지 않고, 또한 해시 충돌의 가능성도 상대적으로 높다. 마지막으로 무차별 대입 공격(Brute force)에 취약하다. 그렇기 때문에 보안용으로 설계된 특수한 해시 함수를 사용해야 한다.

비밀번호를 암호화하기 위해서 설계된 함수는 무엇이 있을까? 아쉽게도 C++의 STL에서 기본 제공하는 함수는 없고, 심지어 방대한 양의 라이브러리 생태계를 제공하는 boost 라이브러리에서도 없다. 대신 argon2, BCrypt, scrypt 등의 알고리즘을 사용할 수 있다. 여기서는 2015 Password Hashing Competition (PHC)에서 우승을 차지하였고, 현재까지도 알려진 매우 안전한 암호화 알고리즘인 argon2를 사용할 것이다.

argon2 Encryption

위 레퍼런스를 찾아가보면, argon2는 argon2i, argon2d, argon2id 세 개의 암호화 방식을 제공하고 있단 것을 알 수 있다. 동시에 비밀번호 암호화를 위해선 i와 d의 혼합 방식인 id를 사용하는 것이 권장된다고 설명한다. 아래 세 개의 방식을 비교한 정리표인데, 나도 뭔지 모른다. 그냥 그렇다고 한다. PHC가 세계적인 대회라고 하던데, 거기서 우승했으면 그냥 안전하고 효율적일 것이라 믿고 써도 되지 않을까?

argon 설명
argon2d 버전은 데이터 종속 메모리 액세스에 최적화되어 있으며 GPU 기반 공격에 대해 가장 높은 저항력을 제공함
암호화폐 채굴과 같이 외부 공격의 위협이 없는 애플리케이션에 가장 적합함
비밀번호 해시화 등에는 부적합
argon2i 데이터 독립적 메모리 액세스에 중점을 둔 Argon2i는 외부 공격에 저항하도록 설계됨
하드웨어에 물리적으로 액세스할 수 있는 공격자로부터 비밀 값을 보호해야 하는 암호 해싱 및 기타 애플리케이션에 이상적
그러나 외부 공격에 대비하도록 설계되었기에 속도가 느림
argon2id 두 가지 장점을 결합한 Argon2id는 데이터 종속형과 데이터 독립형 메모리 액세스를 혼합함
GPU 기반 공격과 사이드 채널 공격 모두에 대해 강력한 보안을 제공함

뭔 소리인지 이해는 안 되지만 i와 d의 하이브리드 방식인 argon2id를 사용하면 될 것 같다. argon2id의 C++ 인터페이스를 사용하기 위해서 깃허브를 찾아보자.
phc-winner-argon2 레포지토리
를 확인할 수 있다.

README를 읽어보면 argon2id에는 세 가지 파라미터가 필요하다고 한다.

  • A time cost, which defines the amount of computation realized and therefore the execution time, given in number of iterations
  • A memory cost, which defines the memory usage, given in kibibytes
  • A parallelism degree, which defines the number of parallel threads

솔직히 잘 모르겠어서 그냥 적절하게 iteration (time cost)는 4, memory cost는 256 * 256, 병렬 처리는 사용하고 있는 환경의 쓰레드 개수로 맞춰놓기로 했다.

그리고서 salt를 생성해야 한다. salt는 사용자의 비밀번호 또는 비밀 값과 결합하여 생성된 임의의 데이터 조각으로, 비밀번호의 보안을 강화시키는 용도이다. salt를 사용하기 위해서는 Randomness, Enough Length, Uniqueness 함이 보장되어야 한다고 한다. Length같은 경우는 보통 128 bits 이상이 권장된다고 한다.

이를 바탕으로 encryptPassword 함수를 완성해보자. 먼저 salt를 생성하는 static 함수 generateSalt를 완성해보자.

AuthManager::Salt AuthManager::generateSalt()
{
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_int_distribution<uint8_t> dist(0, 255);

    Salt salt;
    for (auto &s : salt)
    {
        s = dist(gen);
    }

    return salt;
}

위 함수는 256 비트 길이의 랜덤화된 salt를 생성한다. 랜덤 엔진은 random 헤더의 mt19937 엔진을 사용했다.

참고로 mt19937 랜덤 엔진에 대해서 더 알아보고 싶다면 [모던 C++] C++에서 랜덤값 얻기/ rand() 함수 쓰면 안 되는 이유 포스트를 참고해보라.

다음은 encryptPassword를 만들 차례이다.

std::optional<std::string> AuthManager::encryptPassword(std::string_view password, const Salt &salt)
{
    constexpr auto iterations = 4;
    constexpr auto memoryUsage = 256 * 256;
    const auto threads = std::thread::hardware_concurrency();
    const auto saltLen = saltLength;
    const auto hashLength = 32;

    const auto encodedLen = argon2_encodedlen(iterations, memoryUsage, threads, saltLen, hashLength, Argon2_id);

    std::string encryptedPassword(encodedLen, '\0');

    auto resultStatus =
        argon2id_hash_encoded(iterations, memoryUsage, threads, password.data(), password.length(), salt.data(), saltLen,
                             hashLength, encryptedPassword.data(), encryptedPassword.size());

    if (resultStatus != ARGON2_OK)
    {
        return std::nullopt;
    }

    return encryptedPassword;
}

argon2id_hash_encoded 함수는 다음 파라미터를 받는다.

ARGON2_PUBLIC int argon2id_hash_encoded(const uint32_t t_cost,
                                        const uint32_t m_cost,
                                        const uint32_t parallelism,
                                        const void *pwd, const size_t pwdlen,
                                        const void *salt, const size_t saltlen,
                                        const size_t hashlen, char *encoded,
                                        const size_t encodedlen);

이에 맞추어서 iterations = 4, memoryUsage = 256 * 256, threads = 하드웨어에 맞춰서, saltLen, hashLength, Argon2_id 이렇게 집어넣었다. 아웃풋 해시 길이는 32바이트로 저장하기로 했다. 32바이트면 충분하지 않을까? 나중에 상황 보고 바꿔봐야지.

참고로 하드웨어에 맞춰서 병렬 처리할 스레드를 결정하려면 std::thread::hardware_concurrency() 함수를 사용하면 된다. 이러면 프로그램이 돌아가는 환경에서 사용가능한 스레드 최대 개수가 지정된다. 예를 들어서 16스레드 CPU를 쓰고 있다면 16이 반환된다.

argon2id 해시 암호화 함수에서 암호화에 성공해서 ARGON2_OK 상태를 리턴하면 암호화된 메시지를 전달하고, 문제가 발생한다면 std::nullopt를 리턴한다.

이 함수를 Register 함수에서 다음과 같이 사용했다.

bool AuthManager::registerWithUsernameAndPassword(std::string_view username, std::string_view password)
{
    if (isRegistered(username))
    {
        return false;
    }
    UserBuilder builder;
    const auto user = builder.setName(username).setUid().build();
    const auto uid = user.getId();
    const auto salt = generateSalt();
    const auto encryptedPassword = encryptPassword(password, salt);

    if (!encryptedPassword.has_value())
    {
        throw std::runtime_error("Failed to encrypt password");
    }

    usernameToUid.emplace(std::string(username), uid);

    // salt도 함께 보관한다
    uidToPassword.emplace(uid, encryptedPassword.value() + std::string(salt.begin(), salt.end()));

    return true;
}

이때 잊지 말아야 할 것은, salt도 함께 보관해야 한다는 것이다. salt는 랜덤으로 생성되기에, 한 번 잊어버리면 다시는 복구할 수 없다. salt는 암호화된 해시 뒤에다가 원본을 붙여넣어야 한다. 그래서 const auto encryptedPassword = encryptPassword(password, salt);에서 생성된 암호화 스트링 뒤에다가 솔트의 원본을 이어 붙였다. 이 정보를 비밀번호를 인증하는데 사용하면 된다.

마지막 과정이다! 이제 verifyPassword 함수만 만들면 끝이 난다!

bool AuthManager::verifyPassword(std::string_view password, std::string_view encryptedPassword)
{
    return argon2id_verify(encryptedPassword.data(), password.data(), password.size()) == ARGON2_OK;
}

위와 같이 implementation하면 모든 결과가 끝이 난다! 만약 파라미터로 넘어온 password와 encryptedPassword가 같다면, argon2id_verify 함수는 ARGON2_OK을 리턴할 것이고, 아니라면 ARGON2_OK가 아니게 될 것이다*(참고로 ARGON2_OK는 enum으로 정의되어 있다). 이에 대한 true, false 값을 리턴하면 끝이다.

이제 verifyPassword 함수도 준비되었으니, 로그인 함수만 구현하면 끝이다.

bool AuthManager::loginWithUsernameAndPassword(std::string_view username, std::string_view password)
{
    if (!isRegistered(username))
    {
        return false;
    }
    const auto uid = getUserIdByUsername(username);
    const auto storedPassword = uidToPassword[uid];
    const auto encryptedPassword = storedPassword.substr(0, storedPassword.size() - saltLength);

    if (!verifyPassword(password, encryptedPassword))
    {
        return false;
    }
    UserBuilder userBuilder;
    const auto user = userBuilder.setName(username).setUid(uid).build();
    currentUser = std::make_unique<User>(user);
    return true;
}

참고로 여기서는 계속해서 login, register 함수가 성공했는지 실패했는지를 true, false의 불리언으로 반환하고 있다. 그러나 나중 가서는 정확한 상태를 반환하도록 status 클래스나 enum을 사용하는 것이 더 좋을 것이다. 일단은 true, false로 구현하겠다.

이제 테스트를 돌려보자.

test

TEST_F(AuthManagerTest, loginWithUsernameAndPassword)
{
    EXPECT_TRUE(authManager->registerWithUsernameAndPassword("tester", "password1234"));
    EXPECT_TRUE(authManager->loginWithUsernameAndPassword("tester", "password1234"));
    EXPECT_TRUE(authManager->isLoggedIn());
    const auto currentUser = authManager->getCurrentUser();
    EXPECT_TRUE(currentUser.has_value());
    EXPECT_EQ(currentUser.value().getName(), "tester");
}

위 테스트를 정의하고, 그 외 여러가지 상황을 가정해서 테스트를 작성하고.. 실행해보자. 결과는?

모두 성공이다! 좋다!

argon2 사용 후기

사실 여기서는 굉장히 간단하게, 입력값을 딱딱 주고 끝낸 것으로 표현했지만 실제로 적용하는데는 많은 어려움이 있었다. 정확히 어떤 인자가 왜 들어가야 하는지 모르기도 했었고, 특히 salt를 어떻게 생성할 지를 몰라서 ChatGPT와 많은 대화를 하였다. ChatGPT 조차도 혼란스러워하더라. 특히 encodedLenargon2_encodedlen 함수로 따로 구해야 한다는 것을 놓쳐서, 꽤 멀리 돌아갔다.

그리고 테스트 결과를 확인해보면 알겠지만, 암호화와 복호화 과정이 생각보다 오래 걸리고 있음을 알 수 있다. 암호화하는데 200ms, 이를 인증하는데 또 200ms가 걸리는 듯 하다. iteration을 4번이나 돌려서 그런 걸까. 혹은 메모리 용량이 부족해서일까. 고민해봐야겠다.

그리고 여기서는 argon2id 함수를 직접 사용했지만, 한 단계 더 추상화된 라이브러리로 libsodium 라이브러리가 있다고 한다. 이 라이브러리에서는 기본적으로 argon2를 지원하여 비밀번호를 안전하게 보관할 수 있도록 지원한다고 하니, 나중에는 하나하나 파라미터를 하드코딩해가면서 집어넣을 필요 없이 libsodium 라이브러리를 이용하면 될 것 같다.

기타 함수의 구현

나머지는 크게 중요하진 않고 기본적인 함수들인데, 다만 살펴볼 한 가지 사항은 있다.

std::optional<User> AuthManager::getCurrentUser()
{
    if (!isLoggedIn())
    {
        return std::nullopt;
    }
    return *currentUser;
}

현재 로그인된 유저를 반환하는 함수인데, 만약 현재 아무도 로그인되어 있지 않다면, std::nullopt를 반환한다. std::nullopt는 해당 값이 있을 수도 있고, 없을 수도 있을때 사용하는 유용한 자료형이다. 그냥 nullptr을 리턴할 수도 있기는 한데, nullptr의 경우 항상 nullcheck를 명시적으로 해야 하는 등의 불편함이 있고, 그리고 널 포인터를 사용하는 게 null-safe 관점에서 보면 그리 좋은 방식은 아니다. 대신 non-nullable한 optional 자료형을 사용하여 이 문제를 해결한다. 클라이턴트에서는 다음과 같이 std::optional을 사용할 수 있다.

const auto user = authManager->getCurrentUser;
if(user.hasValue())
{
    // do something
}
else
{
    // do something
}

std::optionalvalue_or() 함수도 지원하는데, 이는 dart의 ?? 연산자와 비슷한 역할을 한다.

int? getNumber() {
    if (something()) return _number;
    else return null;
}

var number = getNumber() ?? 0; // getNumber에서 null이 리턴되면 0으로 초기화

위 dart 코드를 cpp 버전으로 다시 쓰면 아래와 같은 느낌이다.

std::optional<int> getNumber() {
    if (something()) return _number;
    else return std::nullopt;
}

auto number = getNumber().value_or(0); // getNumber에서 nullopt가 리턴되면 0으로 초기화

다음 목표

이제 회원가입, 로그인, 로그아웃 기능이 마련되었으니 각 식당, 공항, 독서실 간의 예약 시스템을 본격적으로 구현하면 된다.