일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | |
7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 |
28 | 29 | 30 |
- 오블완
- 디자인패턴
- database/sql
- replication lag
- go
- 통합 로깅 시스템
- Infra
- AWS
- sqs fifo queue
- elasticsearch
- context7
- MSA
- GIT
- goland
- Intellij
- javascript
- 구조체
- 관측 가능성
- 티스토리챌린지
- AI
- typescript
- logging
- GoF
- blank import
- golang
- RDS
- esbuild
- Kubernetes
- go-sql-driver
- 캡슐화
- Today
- Total
Fall in IT.
개발자라면 꼭 알아야 할 비밀번호 암호화 동작 방식 본문
오늘은 많은 개발자들이 매일 사용하지만, 그 내부 동작까지는 깊이 생각해보지 않았을 '비밀번호 암호화'에 대해 이야기해보려고 합니다. bcrypt, rainbow table, salt 같은 키워드들, 어렴풋이 알고만 계셨나요? 이 글을 통해 개념을 확실히 정리하고 가시죠!
지금 우리 서비스는 안전할까? - BCrypt와 Argon2
2025년 현재, 우리가 만드는 대부분의 서비스는 BCrypt 알고리즘으로 사용자의 비밀번호를 해시(hash)하고 있을 겁니다. 1999년에 개발되어 오랜 기간 안정성을 검증받은, 아주 훌륭한 '국민 알고리즘'이죠.
하지만 기술은 계속 발전합니다. 2015년 열린 암호 해싱 대회에서 우승을 차지한 Argon2라는 새로운 강자가 등장했습니다. Argon2의 가장 큰 장점은 무엇일까요? 바로 GPU를 사용한 무차별 대입 공격(Brute-force Attack)에 대한 강력한 방어력입니다.
- BCrypt: CPU 계산량을 의도적으로 늘려 해시 생성 속도를 늦춥니다.
- Argon2: CPU 계산량뿐만 아니라, 메모리 사용량까지 강제하여 GPU의 약점을 파고듭니다.
물론, 국가 기밀을 다루는 수준의 보안이 아니라면 BCrypt만으로도 충분히 안전합니다. 그래서 여전히 많은 서비스에서 널리 쓰이고 있죠. 중요한 것은 우리가 왜 이런 '느린' 해시 함수를 써야만 하는지 이해하는 것입니다.
내 비밀번호는 어떻게 뚫리는가: '레인보우 테이블'의 습격
"비밀번호는 해시해서 저장하니까 안전해!"라고 생각하셨나요? 만약 SHA-256 같은 빠른 해시 함수를 사용했다면 큰 착각입니다.
해커들은 이미 흔하게 사용되는 비밀번호 수억 개를 미리 해시해서 거대한 테이블을 만들어 두었습니다. 이 테이블이 바로 레인보우 테이블(Rainbow Table)입니다. 만약 우리 회사의 DB가 유출되면, 해커는 유출된 해시값을 이 테이블에서 순식간에 조회하여 원래 비밀번호를 찾아낼 수 있습니다.
해커를 막는 마법의 가루, 'Salt' 🧂
이 무시무시한 레인보우 테이블을 무력화하기 위해 등장한 개념이 바로 **Salt(솔트)**입니다. 이름처럼 원본 비밀번호에 '소금'을 뿌려 전혀 다른 맛을 내는 원리죠.
Salt는 사용자별로 생성되는 고유한 랜덤 문자열입니다. 이 Salt를 원본 비밀번호에 합쳐서 해시하면, 설령 두 사용자가 같은 비밀번호를 쓰더라도 DB에 저장되는 해시값은 전혀 달라집니다.
"잠깐, Salt도 DB에 같이 저장되는데, DB 털리면 결국 소용없는 거 아니야?"
반은 맞고 반은 틀립니다. 해커는 Salt 값을 알 수 있지만, 이제 레인보우 테이블 공격은 불가능해집니다. 사용자 A를 뚫기 위해선 '사용자 A의 Salt'를 적용한 레인보우 테이블을 새로 만들어야 하고, 사용자 B를 뚫기 위해선 또 '사용자 B의 Salt'를 적용한 테이블을 만들어야 합니다. 모든 사용자를 뚫기 위해 수억 개의 레인보우 테이블을 만드는 것은 현실적으로 불가능하죠.
"어? 나는 Salt를 쓴 적이 없는데?"
아마 대부분의 개발자분들은 비밀번호를 암호화하면서 Salt를 직접 생성하고 관리한 기억이 없을 겁니다. 걱정 마세요. 여러분은 이미 Salt를 아주 잘 사용하고 계십니다!
최신 BCrypt 라이브리들은 encode() 함수를 호출할 때마다 내부적으로 새로운 랜덤 Salt를 생성하고, 그 Salt를 최종 해시값에 포함시켜서 돌려줍니다.
$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
위와 같은 BCrypt 해시 문자열을 자세히 들여다보면, 알고리즘 정보($2a$), 계산 비용($10$), 그리고 Salt(N9qo8uLOickgx2ZMRZoMye)와 순수 해시값이 모두 한곳에 담겨있는 것을 볼 수 있습니다.
라이브러리는 비밀번호를 비교할 때 이 문자열에서 Salt 부분을 알아서 추출한 뒤, 입력된 비밀번호와 조합하여 해시값을 계산하고 비교해줍니다.
실제 라이브러리는 어떻게 동작할까? (Go 코드 예시)
말로만 듣는 것보다 실제 코드를 보면 더 명확하게 이해할 수 있습니다. 아래는 Go 언어의 표준 bcrypt 라이브러리에서 비밀번호를 비교하는 CompareHashAndPassword 함수의 일부를 간략화한 것입니다.
// CompareHashAndPassword는 bcrypt 해시값과 사용자가 입력한 비밀번호를 비교합니다.
func CompareHashAndPassword(hashedPassword, password []byte) error {
// 1. 저장된 해시 문자열을 파싱하여 구조체로 변환합니다.
p, err := newFromHash(hashedPassword)
if err != nil {
return err
}
// 2. 입력된 비밀번호와 '파싱해서 얻은 salt, cost'를 사용해 해시를 다시 계산합니다.
otherHash, err := bcrypt(password, p.cost, p.salt)
if err != nil {
return err
}
// 3. 새로 계산된 해시와 기존 해시를 안전하게 비교합니다.
if subtle.ConstantTimeCompare(p.hash, otherHash) == 1 {
return nil // 일치하면 성공
}
return ErrMismatchedHashAndPassword // 불일치하면 에러
}
// newFromHash는 해시 문자열을 파싱하여 버전, 비용, salt, 순수 해시로 분리합니다.
func newFromHash(hashedSecret []byte) (*hashed, error) {
// ... 에러 처리 및 길이 검사 ...
p := new(hashed) // 파싱된 정보를 담을 구조체
// '$2a$' 같은 버전 정보를 디코딩
p.decodeVersion(hashedSecret)
// '$10$' 같은 cost 정보를 디코딩
p.decodeCost(hashedSecret)
// 약속된 길이(22바이트)만큼 잘라서 salt를 복사
p.salt = make([]byte, encodedSaltSize)
copy(p.salt, hashedSecret[:encodedSaltSize])
// 나머지 부분을 순수 해시로 복사
hashedSecret = hashedSecret[encodedSaltSize:]
p.hash = make([]byte, len(hashedSecret))
copy(p.hash, hashedSecret)
return p, nil
}
코드에서 볼 수 있듯이, newFromHash 함수는 우리가 앞에서 살펴본 구조(알고리즘$비용$Salt$해시)를 정확히 인지하고, 약속된 길이에 따라 문자열을 잘라 각 구성 요소를 추출합니다. 그리고 CompareHashAndPassword 함수는 이렇게 추출된 Salt와 Cost를 사용하여 입력된 비밀번호를 다시 해시한 후, 최종 결과물을 비교합니다.
마지막 질문: "구조를 다 아는데, 파싱해서 공격하면 되지 않나?"
이것이 오늘 이야기의 핵심입니다. "해시 구조가 (알고리즘$비용$Salt$해시)로 정해져 있다면, 해커가 이 구조를 파싱해서 공격의 지름길로 삼을 수 있지 않을까?"
결론부터 말하면, 불가능합니다.
BCrypt의 보안은 구조를 감추는 것(Security by Obscurity)에 의존하지 않습니다. 오히려 구조를 모두에게 공개하고, 오직 암호학적인 계산의 어려움에만 의존합니다(Security by Design).
- 완성된 빵을 보고 "여기서 설탕의 효과는 무시하고 밀가루만 분석하겠다"고 할 수 없듯이, 최종 해시값은 Salt와 완벽하게 결합된 결과물입니다. Salt 없이는 그 어떤 의미도 갖지 못합니다.
- 해커가 공격하려면 당연히 이 구조를 파싱해서 Salt를 알아내야만 합니다. 하지만 그건 공격의 필수적인 첫 단계일 뿐, 보안을 우회하는 지름길이 될 수는 없습니다. 해커는 결국 알아낸 Salt와 추측한 비밀번호를 가지고 수천 번의 반복 계산을 수행해야만 하죠.
오늘 우리는 비밀번호 암호화의 기본 원리부터 실제 라이브러리 코드까지 살펴보았습니다. 이제 여러분의 서비스에 로그인 기능을 구현할 때, 코드 한 줄 뒤에 숨겨진 든든한 보안 원리를 자신 있게 떠올리실 수 있을 겁니다!