글 목록

비밀번호는 어떻게 저장돼야 안전할까? 해시, 솔트, 느린 해시

비밀번호를 안전하게 저장하는 방안을 자물쇠와 솔트 개념에 비유하여 정리하였습니다. 해시와 솔트, 그리고 bcrypt 및 Argon2와 같은 저속 해시 알고리즘이 필수적인 이유를 예시와 함께 설명합니다.

목차

    도서관 사물함을 떠올려 보세요. 관리인이 사물함 열쇠 사본을 전부 벽에 걸어 두면 편하지만, 도둑이 한 번 들어오면 모든 사물함이 열립니다. 비밀번호를 평문으로 저장하는 것이 바로 이 상황입니다. 그렇다면 열쇠 대신 "열쇠 모양의 흔적"만 보관하면 어떨까요? 서버는 비밀번호 자체가 아니라 비밀번호에서 만든 지문만 기억합니다. 이 지문이 해시입니다. 이 글은 그 지문을 어떻게 만들고, 왜 "느리게" 만들어야 하는지를 다룹니다.

    해시: 데이터의 복구가 불가능한 고유 식별값.

    해시 함수는 임의의 입력값을 고정된 길이의 문자열로 변환하는 함수입니다. 동일한 입력에 대해 항상 일관된 결과값을 도출하며, 결과값으로부터 원본 데이터를 역산하는 것은 사실상 불가능합니다. 암호화와 달리 복호화 키가 존재하지 않는다는 점이 주요한 특징입니다.

    로그인 프로세스는 간소화되어 있습니다. 가입 시 입력값을 해싱하여 저장하고, 로그인 시도 시 동일한 방식으로 해싱하여 저장된 값과 대조하는 구조입니다.

    flowchart LR
        A[가입: 비밀번호 입력] --> B[해시 계산]
        B --> C[(DB에는 해시만 저장)]
        D[로그인: 비밀번호 입력] --> E[해시 계산]
        E --> F{저장된 해시와 같은가}
        C --> F
        F -->|같음| G[로그인 성공]
        F -->|다름| H[로그인 실패]

    이러한 방식을 적용하면 데이터베이스 유출 시에도 비밀번호 원문이 즉각적으로 노출되지는 않습니다. 다만, 이것이 전부라면 논의가 여기서 마무리되었을 것이나, 해시 방식만으로는 두 가지 취약점이 잔존합니다.

    구멍 1. 같은 비밀번호는 같은 해시가 된다

    해시 알고리즘은 동일한 입력값에 대해 항상 동일한 결과값을 산출하며, 이러한 특성은 공격자에게 악용될 소지가 있습니다. 공격자는 다수의 일반적인 비밀번호를 사전에 해시화하여 데이터베이스화한 뒤, 유출된 정보와 대조하는 방식을 취하는데, 이를 레인보우 테이블 공격이라 합니다.

    또한 동일한 비밀번호를 사용하는 사용자들은 데이터베이스상에서도 동일한 해시값이 저장됩니다. 따라서 특정 계정의 보안이 침해될 경우, 동일한 해시값을 가진 타 계정까지 동시다발적인 피해를 입을 위험이 있습니다.

    솔트. 사용자마다 다른 소금뿌리기

    해결책은 솔트(Salt)를 활용하는 것입니다. 솔트는 사용자별로 개별 생성되는 무작위 문자열을 비밀번호와 결합하여 해싱하는 방식입니다. 이는 동일한 요리에 사용자마다 다른 소금을 가미하여 결과적인 맛(해시값)을 모두 다르게 만드는 것과 동일한 원리입니다.

    flowchart LR
        P[비밀번호] --> M[섞기]
        S[사용자별 무작위 솔트] --> M
        M --> H[해시 계산]
        H --> R[솔트 + 해시를 함께 저장]

    솔트는 비밀이 아닙니다. 해시 옆에 그대로 저장해도 됩니다. 목적은 숨기기가 아니라 미리 계산한 표를 쓸모없게 만드는 것입니다. 솔트가 사용자마다 다르면 공격자는 계정마다 처음부터 다시 계산해야 합니다.

    간단한 예제를 통해 살펴보겠습니다. 아래는 Python을 활용하여 솔트를 적용한 해싱 구현 사례입니다. 이는 학습 목적의 예시이므로, 실제 서비스 환경에서는 뒤에서 설명할 전용 라이브러리를 사용하시기 바랍니다.

    import hashlib, os
    
    def hash_with_salt(password: str):
        salt = os.urandom(16)  # 사용자마다 새로 만드는 16바이트 무작위 값
        digest = hashlib.sha256(salt + password.encode()).hexdigest()
        return salt.hex(), digest
    
    print(hash_with_salt("hello1234"))
    print(hash_with_salt("hello1234"))

    같은 비밀번호인데도 두 번의 출력이 전혀 다릅니다. 솔트가 매번 달라서입니다. 여기까지는 확인됐습니다.

    구멍 2. 해시가 너무 빠르다

    SHA-256과 같은 범용 해시 함수는 고속 연산을 목적으로 설계되었습니다. 이는 파일 무결성 검증에는 효율적이나, 비밀번호 보안 측면에서는 취약점을 가집니다. 데이터베이스 유출 시 공격자는 보유한 컴퓨팅 자원을 활용해 오프라인에서 무제한 대입 공격을 수행할 수 있으며, 특히 GPU의 초당 수십억 회에 달하는 연산 성능을 고려할 때 단순하거나 흔한 비밀번호는 신속하게 무력화될 위험이 있습니다.

    OWASP 가이드도 이 점을 분명히 합니다. SHA-256 같은 빠른 해시 알고리즘은 공격자가 대량의 대입을 수행할 수 있어 비밀번호 저장에 적합하지 않다고 설명합니다. 방어하는 쪽이 할 수 있는 일은 가능한 한 자원을 많이 쓰는 해시 알고리즘을 골라 오프라인 공격을 늦추는 것뿐입니다.

    여기서 발상을 전환해 보겠습니다. 일반 사용자가 로그인 시 해시를 1회 연산하는 데 소요되는 0.1초는 체감하기 어려운 수준입니다. 반면, 공격자는 수십억 회의 연산이 필요하므로 1회당 0.1초의 지연은 치명적인 비용 부담으로 작용하게 됩니다.

    의도적으로 연산 속도를 지연시키도록 설계된 해시 함수

    슬로우 해시(Slow Hash)는 비밀번호의 계산 비용을 유연하게 조정할 수 있도록 설계된 전용 해시 함수이며, 대표적인 알고리즘으로 bcrypt와 Argon2가 있습니다. 현재 보안 업계의 표준 권장 사항에 따르면, OWASP는 Argon2id, bcrypt, PBKDF2와 같은 강력한 슬로우 해시 알고리즘의 사용을 지침으로 삼고 있습니다.

    알고리즘비용을 늘리는 방법특징
    bcrypt작업 계수(work factor)오래 검증된 방식. 입력 길이 제한(72바이트)이 있음
    scryptCPU·메모리 비용메모리를 많이 쓰도록 설계됨
    Argon2id메모리, 반복 횟수, 병렬도2015년 해시 대회 우승 계열. 신규 서비스의 1순위
    PBKDF2반복 횟수FIPS-140 인증이 필요할 때 선택

    Argon2id가 특별한 이유는 메모리를 많이 쓰게 만든다는 점입니다. 공격자가 GPU 수천 개 코어를 돌려도 코어마다 메모리가 필요해서 병렬화 이점이 줄어듭니다. 비밀번호 크래킹 장비의 효율을 떨어뜨리는 구조입니다.

    설정 값은 어느 정도로 잡을까

    OWASP의 Argon2id 최소 설정은 메모리 19 MiB, 반복 2회, 병렬도 1입니다. 같은 수준의 방어력을 주는 대안으로 m=47104(46 MiB), t=1, p=1 같은 조합도 제시됩니다. bcrypt를 쓰는 레거시 환경이라면 작업 계수 10 이상, 비밀번호 길이 72바이트 제한을 지키라고 합니다.

    해당 수치는 기준점이며, OWASP에서도 보안 파라미터 설정 시 실제 서버 환경에서의 벤치마크 수행을 권장하고 있습니다. 로그인 속도 저하로 인한 서버 과부하가 발생할 경우, 자칫 서비스 거부 공격의 원인이 될 수 있기 때문입니다.

    작은 예제, 직접 확인해 보자

    실제 서비스에서는 해시 함수를 직접 조립하지 말고 검증된 라이브러리를 쓰세요. Python의 argon2-cffi 예시입니다.

    from argon2 import PasswordHasher
    
    ph = PasswordHasher()  # 기본값이 안전하게 잡혀 있음
    
    stored = ph.hash("hello1234")
    print(stored)
    # $argon2id$v=19$m=...,t=...,p=...$솔트$해시
    
    ph.verify(stored, "hello1234")   # 맞으면 True
    # 틀리면 VerifyMismatchError 발생

    출력 문자열 하나에 알고리즘, 버전, 비용 설정, 솔트, 해시가 모두 들어 있습니다. 솔트를 따로 저장하거나 관리할 필요가 없습니다. 라이브러리가 알아서 만들고 검증할 때 꺼내 씁니다.

    비용 설정은 주기적인 상향 조정이 필요합니다. 이때 사용자가 원문을 인지하는 시점이 로그인 단계뿐임을 고려하여, 로그인 성공 시 새로운 설정으로 재해시하여 저장하는 방식을 적용하는 것이 일반적입니다. 이를 통해 사용자의 강제적인 비밀번호 재설정 없이도 운영이 가능합니다.

    전체 그림 한 번에 보기

    지금까지의 흐름을 가입과 로그인으로 묶으면 다음과 같습니다.

    flowchart TD
        A[비밀번호 입력] --> B[라이브러리가 무작위 솔트 생성]
        B --> C[느린 해시 계산: Argon2id 또는 bcrypt]
        C --> D[(솔트 + 비용 설정 + 해시 저장)]
        D --> E[로그인 시 같은 설정으로 다시 계산]
        E --> F{일치?}
        F -->|예| G[성공 + 필요하면 새 설정으로 재해시]
        F -->|아니오| H[실패]

    정리하면 역할은 이렇습니다. 해시는 원문을 숨기고, 솔트는 미리 계산한 표와 중복 노출을 막고, 느린 해시는 대량 대입 비용을 키웁니다. 셋 중 하나라도 빠지면 구멍이 남습니다.

    한 가지 첨언합니다. 아무리 보안 조치를 철저히 하더라도 '1234'와 같은 단순한 비밀번호는 신속하게 탈취될 위험이 있습니다. 데이터 저장 방식은 유출 시 피해를 최소화하는 사후 보완책일 뿐이므로, 사용자가 복잡하고 고유한 비밀번호를 설정하도록 유도하는 것 또한 서비스 측의 중요한 역할입니다. 데이터 보호를 위한 저장 방식은 최후의 방어선이며, 선행되어야 할 예방 조치에 대해서는 후속 글을 통해 상세히 다루겠습니다.

    암호화해서 저장하면 안 되나요?

    암호화는 키가 있으면 원문으로 되돌릴 수 있습니다. 서버나 키가 털리면 모든 비밀번호가 한꺼번에 드러납니다. 서버가 원문을 알 필요가 없으므로 되돌릴 수 없는 해시가 맞습니다.

    이미 SHA-256이나 MD5로 저장 중이라면요?

    새 방식으로 옮기는 것이 좋습니다. 원문을 모르니 일괄 변환은 어렵고, 사용자가 로그인에 성공할 때 새 알고리즘으로 다시 해시해 교체하는 방식이 현실적입니다. 전환 기간에는 해시 앞에 알고리즘 표시를 붙여 구분하세요.

    솔트를 비밀로 해야 하나요?

    아닙니다. 솔트는 해시와 함께 저장해도 됩니다. 공격자가 솔트를 알아도 사용자마다 다르기 때문에 표를 재사용할 수 없다는 점이 핵심입니다. 별도로 비밀 값을 하나 더 두는 것은 페퍼라고 부르며, 보조 수단일 뿐입니다.

    댓글 0

    1. 댓글을 불러오는 중이에요.