암호를 올바르게 저장하기: bcrypt와 argon2

1. 개요

암호를 잘못된 방식으로 저장하는 것은 데이터베이스가 공격받을 때 사용자 데이터가 노출되도록 하는 가장 흔한 보안 취약점 중 하나입니다. 이 글에서는 암호 해싱에 순수한 MD5/SHA256을 사용하면 안 되는 이유와 Spring Security에서 BCryptPasswordEncoder와 Argon2PasswordEncoder를 사용하는 방법을 배우게 됩니다. 이 글을 마치면 안전한 PasswordEncoder를 구성하고, 동작을 검증하는 테스트를 작성하며, 프로젝트에 어떤 알고리즘을 선택할지 알 수 있을 것입니다.

2. SHA-256 순수 해싱을 사용하면 안 되는 이유

SHA-256, SHA-512과 같은 암호 해시 함수는 가능한 한 빠르게 실행되도록 설계되었습니다. 이는 파일 무결성을 검증할 때는 장점이지만, 암호에 사용할 때는 재앙입니다.

import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;

public class NaiveHasher {

    public static String sha256(String password) throws Exception {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
        return java.util.HexFormat.of().formatHex(hash);
    }
}

위의 함수는 중급 GPU에서 초당 수십억 개의 암호를 해시할 수 있습니다. 공격자는 레인보우 테이블이나 무작위 대입(brute-force)을 사용하여 원본 암호를 찾을 수 있으며, 특히 123456, password1과 같은 일반적인 암호는 더 쉽게 찾을 수 있습니다.

두 가지 핵심 문제가 있습니다:

  • Salt이 없음: 같은 암호를 가진 두 사용자는 같은 해시값을 가지므로 레인보우 테이블 공격에 취약합니다.
  • 너무 빠름: 공격자를 ‘느리게’ 만들 방법이 없습니다.

주의: SHA-256에 수동으로 salt를 추가하고 연결하지 마세요. 이것은 여전히 약한 솔루션인데, “work factor”가 없기 때문입니다 — 계산 난이도가 시간이 지나면서 하드웨어가 강해져도 증가하지 않습니다.

3. Password hashing function이란 무엇인가

bcrypt, scrypt, argon2와 같은 알고리즘은 암호 해싱을 위해 특별히 설계되었으며, 세 가지 특징이 있습니다:

  1. 자동 Salt: 해싱할 때마다 임의의 salt를 생성하고 해시 출력 문자열에 포함시킵니다.
  2. 의도적으로 느림 (work factor): 난이도를 높이는 파라미터가 있어 하드웨어가 강해져도 brute-force 공격에 대항합니다.
  3. 메모리 집약적 (argon2/scrypt 포함): 많은 RAM을 소모하므로 GPU/ASIC에서 병렬 처리가 비효율적입니다.

bcrypt 해시 문자열의 결과는 다음과 같이 보입니다:

$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW

구조는 $<version>$<cost>$<salt+hash base64> 형태입니다. 여기서 10은 cost factor (2^10번의 반복)입니다.

4. Spring Security에서 bcrypt 사용하기

4.1. Dependency

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

BCryptPasswordEncoder는 spring-security-crypto에 이미 포함되어 있으므로 추가 설정이 필요하지 않습니다.

4.2. Bean 선언

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder(12); // cost factor = 12
    }
}

파라미터 12는 반복 횟수를 2^12로 결정합니다. Cost factor가 높을수록 → 느릴수록 → 더 안전하지만, 로그인할 때 더 많은 CPU를 소비합니다. 10–12의 값은 2026년의 대부분의 애플리케이션에 적합합니다.

4.3. 회원가입 및 로그인 시 사용

import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

@Service
public class UserRegistrationService {

    private final PasswordEncoder passwordEncoder;
    private final UserRepository userRepository;

    public UserRegistrationService(PasswordEncoder passwordEncoder, UserRepository userRepository) {
        this.passwordEncoder = passwordEncoder;
        this.userRepository = userRepository;
    }

    public void register(String email, String rawPassword) {
        String hashed = passwordEncoder.encode(rawPassword);
        userRepository.save(new Customer(email, hashed));
    }

    public boolean authenticate(String email, String rawPassword) {
        Customer customer = userRepository.findByEmail(email)
                .orElseThrow(() -> new IllegalArgumentException("Email이 존재하지 않습니다"));
        return passwordEncoder.matches(rawPassword, customer.getPasswordHash());
    }
}
  • encode()는 호출할 때마다 임의의 salt를 생성하므로 같은 rawPassword는 매번 다른 해시가 생성됩니다 — 이것은 올바른 동작이지, 버그가 아닙니다.
  • matches()는 DB에 저장된 해시에서 salt를 자동으로 추출하고, 입력된 암호를 다시 해싱한 후 비교하므로 salt를 직접 관리할 필요가 없습니다.

4.4. 동작 검증 테스트

import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

import static org.assertj.core.api.Assertions.assertThat;

class BcryptPasswordEncoderTest {

    private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);

    @Test
    void shouldGenerateDifferentHashesForSamePassword() {
        String hash1 = encoder.encode("S3cretPass!");
        String hash2 = encoder.encode("S3cretPass!");

        assertThat(hash1).isNotEqualTo(hash2);
        assertThat(encoder.matches("S3cretPass!", hash1)).isTrue();
        assertThat(encoder.matches("S3cretPass!", hash2)).isTrue();
    }

    @Test
    void shouldRejectWrongPassword() {
        String hash = encoder.encode("S3cretPass!");

        assertThat(encoder.matches("WrongPass!", hash)).isFalse();
    }
}

이 테스트는 두 가지 중요한 점을 확인합니다: salt가 임의이므로 해시가 다르고, matches()는 해시가 다르더라도 여전히 원본 암호를 올바르게 인식합니다.

주의: bcrypt는 입력 길이가 72바이트로 제한됩니다. 암호가 더 길면 초과 부분이 자동으로 잘립니다. 일반적인 암호(72자 미만)에는 문제가 없지만, 매우 긴 암호나 암호 구절을 허용하려면 argon2를 고려하세요.

5. Argon2 — 더 현대적인 선택

Argon2는 2015년 Password Hashing Competition에서 우승했으며, GPU 크래킹에 대한 저항력이 bcrypt보다 뛰어나기 때문에(메모리 집약적) OWASP에서 현재 첫 번째 선택지로 권장됩니다.

5.1. Dependency

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
    <groupId>org.bouncycastle</groupId>
    <artifactId>bcprov-jdk18on</artifactId>
    <version>1.78.1</version>
</dependency>

Spring Security는 Argon2PasswordEncoder의 provider로 Bouncy Castle을 사용하므로 이 의존성을 추가해야 합니다.

5.2. Bean 선언

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        // saltLength=16, hashLength=32, parallelism=1, memory=1<<16 (64MB), iterations=3
        return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
    }
}

Factory 메서드 defaultsForSpringSecurity_v5_8()는 Spring Security 팀에서 대부분의 애플리케이션에 안전하다고 검증한 파라미터 설정을 반환하므로 memory/iterations를 직접 계산할 필요가 없습니다.

5.3. 출력 비교

$argon2id$v=19$m=65536,t=3,p=1$c29tZXNhbHQ$5t1h3u7K3iC2rT0...

구조는 변형(argon2id), 버전, 메모리 파라미터(m), 반복(t), 병렬성(p)으로 구성되고, 그 다음에 salt와 해시가 따릅니다 — bcrypt보다 투명하며 cost를 명확히 노출합니다.

5.4. 두 encoder를 비교하는 테스트

import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;

import static org.assertj.core.api.Assertions.assertThat;

class Argon2PasswordEncoderTest {

    private final Argon2PasswordEncoder encoder =
            Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();

    @Test
    void shouldVerifyCorrectPassword() {
        String hash = encoder.encode("MyP@ssw0rd123");

        assertThat(encoder.matches("MyP@ssw0rd123", hash)).isTrue();
        assertThat(encoder.matches("wrong", hash)).isFalse();
    }
}

6. DelegatingPasswordEncoder를 사용한 여러 encoder 동시 지원

실제로, 시스템은 오래된 해시를 손상시키지 않으면서 시간이 지남에 따라 알고리즘을 변경해야 할 수 있습니다(예: bcrypt에서 argon2로 마이그레이션). Spring Security는 이미 이 솔루션을 제공합니다.

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

createDelegatingPasswordEncoder()는 해시 문자열의 {bcrypt}, {argon2}, {noop}… 접두사를 읽어 올바른 알고리즘을 선택할 수 있는 encoder를 생성합니다. 기본적으로, 새 암호를 인코딩할 때는 bcrypt를 사용하지만 다른 알고리즘으로 생성된 오래된 해시에 대해서도 matches()가 올바르게 작동합니다.

{bcrypt}$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
{argon2}$argon2id$v=19$m=65536,t=3,p=1$...

이 접두사 덕분에 점진적으로 마이그레이션할 수 있습니다: 기존 사용자는 계속 로그인할 수 있고, 새로 가입하거나 암호를 변경한 사용자는 최신 알고리즘으로 다시 해싱됩니다.

7. bcrypt와 argon2 빠른 비교

기준 bcrypt argon2
출시 연도 1999 2015 (PHC 우승)
GPU/ASIC 저항성 양호 우수 (메모리 집약적)
입력 제한 72 바이트 실질적 제한 없음
보급도 매우 높음, 모든 곳에서 지원 점점 더 보급되는 중
OWASP 권장 두 번째 선택 첫 번째 선택

새로운 프로젝트이고 레거시 시스템의 제약이 없다면 argon2id를 선택하세요(argon2id 변형은 Spring Security의 기본값이며, side-channel 공격과 GPU 공격 방지 사이의 균형을 제공합니다). 시스템이 여러 해 동안 bcrypt를 안정적으로 사용해왔다면, 즉시 마이그레이션할 필요가 없습니다 — bcrypt의 cost factor를 12–14로 높이는 것으로도 대부분의 애플리케이션에 충분히 안전합니다.

8. 흔한 실수들

  • 직접 구현한 salt + SHA-256: 섹션 2에서 이미 말했듯이 work factor가 없어서 현대의 brute-force 공격에 여전히 취약합니다.
  • Cost factor가 너무 낮음: BCryptPasswordEncoder()의 기본값은 cost 10이지만, 테스트 중에 ‘속도를 높이기’ 위해 4–5로 낮춘다면 프로덕션 배포 시 변경하는 것을 잊기 쉽습니다.
  • String.equals()로 해시를 비교함: 해시 문자열을 직접 비교하지 마세요 — 항상 PasswordEncoder의 matches()를 사용하세요. 이 라이브러리들은 timing attack을 피하기 위해 constant-time 비교를 사용하기 때문입니다.
  • DB의 별도 열에 salt를 저장함: 불필요합니다. bcrypt와 argon2 모두 salt를 해시 출력 문자열에 포함하고 있습니다.
  • 로그인 시도 제한이 없음: 해시가 아무리 강해도 rate limiting이나 계정 잠금이 없으면 온라인 brute-force 공격이 여전히 가능합니다.

주의: 프로덕션에서는 절대 {noop} encoder(평문)를 사용하지 마세요. 이것은 로컬 테스트나 데모에서만 나타나야 합니다.

9. 결론

암호를 올바르게 해싱한다는 것은 SHA-256과 같은 빠른 해시 함수 대신 이 목적을 위해 특별히 설계된 알고리즘 — bcrypt 또는 argon2를 사용하는 것입니다. Spring Security는 이를 간단하게 만듭니다: PasswordEncoder bean을 선언하고 저장할 때는 encode()를 호출하고 인증할 때는 matches()를 호출하면 되므로 salt를 직접 관리할 필요가 없습니다. 알고리즘 간에 유연하게 마이그레이션해야 한다면 DelegatingPasswordEncoder는 안전하고 하위 호환성이 있는 선택입니다. bcrypt를 선택하든 argon2를 선택하든 강력한 해싱은 한 겹의 방어일 뿐입니다 — rate limiting과 MFA를 추가하여 진정으로 안전한 인증 시스템을 구축해야 합니다.

web-securityspring-securitybcryptargon2authentication