프로젝트
약 13분 분량 학습 프로젝트/결제 시스템

비밀번호 암호화: 더 강한 해시가 더 빨랐고, 서버는 위태로워졌다

Payment보안인증성능결제 시스템
목차

개요

요청마다 돌던 BCrypt 해싱을 JWT로 걷어내고(p95 567.84 → 37.09ms), 남은 로그인 1회의 해싱을 BCrypt에서 Argon2id로 옮겼습니다. 더 강한 쪽이 더 빨랐고(87ms → 32ms), 대신 서버 메모리라는 새 축이 생겨 로그인 경로의 DoS 표면이 오히려 커졌습니다.

비밀번호 해싱 비용은 한 번은 걷어내야 하고 한 번은 남겨야 합니다. 요청마다 하는 재검증은 없앨 수 있지만, 로그인 1회의 해싱은 비밀번호를 실제로 대조하는 자리라 없앨 수 없습니다.

회원 도메인을 붙일 때 비밀번호 해싱은 BCryptPasswordEncoder로 잡았습니다. Spring Security가 기본으로 주고 추가 의존이 없어서 손이 제일 덜 갔습니다. 널리 쓰이는 선택이기도 합니다.

그 선택이 지금도 맞는지 확인해 봤습니다. 반은 틀렸습니다.

상황다룬 것
상황 1요청마다 하던 BCrypt를 JWT로 걷어냈다
상황 2남은 한 번을 Argon2id로 옮겼다
상황 3강화가 새 공격 표면을 열었다
상황 4끝낼 수 없는 이관, 대신 끝을 판정한다

개인 프로젝트로 만든 결제 시스템 pay의 개발 기록입니다. 실무 운영 경험이 아닙니다.

1. 요청마다 하던 BCrypt를 JWT로 걷어냈다

확장 모듈을 다 얹고 인증을 건 상태로 k6(부하 테스트 도구)로 가상 사용자(VU) 50명이 동시에 두드리게 했더니, 느린 쪽 5%가 걸린 시간(p95)이 567.84ms였습니다. 요청당 최소
지연이 110ms로 올라 있었고, 원인은 앱 로직이 아니라 인증 방식이었습니다. 무상태 HTTP Basic은
요청마다 비밀번호를 BCrypt로 재검증하는데, BCrypt는 의도적으로 느립니다.

그래서 JWT로 바꿨습니다. 로그인 때 한 번만 해싱하고 이후엔 서명만 검증합니다.

세션도 같은 문제를 풉니다. 모놀리스라면 로그아웃·강제 만료가 오히려 단순합니다. 무상태를 유지해 나중에 인스턴스를 늘릴 때 세션 저장소를 따로 두지 않으려고 짧은 수명의 JWT를 골랐습니다. 대신 탈취·폐기 문제가 새로 생겨 denylist를 따로 만들어야 했습니다(4편).

지표Basic + 요청당 BCryptJWT
min110ms (BCrypt 바닥)4.17ms
p95567.84ms37.09ms
p99712.27ms58.47ms
오류율0%0%

같은 머신·같은 부하(50 VU)에서 k6/checkout-load.js 재실행. 체크아웃 한 번이 주문과 승인으로
인증을 두 번 타서 개선폭이 특히 컸습니다.

여기서 남는 게 있습니다. 같은 BCrypt 비용이 성능에서는 제거 대상이고 보안에서는 유지 대상입니다.
요청마다 하던 재검증은 없앴지만, 로그인 1회의 해싱은 비밀번호를 실제로 대조하는 자리라
없앨 수 없습니다. 아래는 전부 그 남은 한 번에 관한 이야기입니다.

2. 남은 한 번을 Argon2id로 옮겼다

요청당 비용은 없앴습니다. 남은 건 로그인 1회의 해싱이고, 그건 없앨 수 없습니다. 그러면 그 한 번을 더 잘하는 것이 남습니다.

BCrypt는 깨지지 않았다. 다만 1순위가 아니다

OWASP Password Storage Cheat Sheet의 순서는 이렇습니다.

순위알고리즘
1순위Argon2id (메모리 19MiB, iterations 2, parallelism 1 이상)
2순위scrypt
3순위bcrypt, work factor 10 이상
특수PBKDF2 (FIPS 준수가 필요할 때)

bcrypt 항목의 표현이 인상적이었습니다. “레거시 시스템을 위해”. 안전하지 않다는 말은 아니지만, 새로 만드는 것에 권하는 말도 아닙니다.

한계는 둘입니다.

하나는 72바이트 절단입니다. BCrypt는 그보다 긴 입력을 조용히 자릅니다. 문서로 읽고 넘어갈 수도 있었는데, 실제로 그런지 재현해 봤습니다.

String base = "a".repeat(72);
String longer = base + "DIFFERENT-TAIL";
bcrypt.matches(longer, bcrypt.encode(base)); // true

앞 72바이트가 같으면 뒤가 아무리 달라도 같은 비밀번호로 판정합니다. 긴 패스프레이즈를 쓰는 사용자에게 실질 엔트로피 상한이 생긴다는 뜻입니다.

다른 하나는 메모리 하드가 약하다는 것입니다. BCrypt가 쓰는 메모리는 4KB 수준입니다. 공격자가 GPU나 전용 하드웨어로 병렬화할 때 메모리가 병목이 안 됩니다. Argon2id는 파라미터로 메모리 비용을 강제해서 그 이점을 줄입니다.

바꾸기 전에 바꿀 수 있는 상태부터 만들었다

곧바로 Argon2로 갈 수 없었습니다. Spring Security의 Argon2PasswordEncoder는 BouncyCastle을 요구하는데 이 프로젝트에 그 의존이 없었습니다.

그런데 의존을 추가하기 전에 먼저 해야 할 게 있다는 걸 알았습니다. 지금 저장된 해시가 어떤 알고리즘으로 만들어졌는지 알 수 없다는 것.

$2a$10$N9qo8uLOickgx2ZMRZoMye...

이게 전부입니다. bcrypt처럼 생겼지만 코드가 그걸 알 방법이 없습니다. 이 상태에서 알고리즘을 바꾸면 모든 사용자에게 비밀번호를 재설정하게 하는 것 말고는 방법이 없습니다.

그래서 알고리즘은 그대로 두고 접두사만 붙였습니다.

DelegatingPasswordEncoder delegating = new DelegatingPasswordEncoder(
"bcrypt", Map.of("bcrypt", new BCryptPasswordEncoder()));
delegating.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder());

새로 만드는 해시에는 {bcrypt} 접두사가 붙고, 이미 저장된 접두사 없는 해시는 setDefaultPasswordEncoderForMatches가 계속 검증합니다. 강도는 하나도 안 올라갑니다. 커밋 메시지에도 그렇게 적었습니다. 이건 개선이 아니라 준비입니다.

그리고 이 준비가 며칠 뒤에 값을 했습니다. 실제 교체는 맵에 한 줄 추가하고 기본 id를 바꾼 게 전부였습니다.

String encodingId = "argon2";
PasswordEncoder argon2 = new Argon2PasswordEncoder(16, 32, 1, 19456, 2);
DelegatingPasswordEncoder delegating = new DelegatingPasswordEncoder(
encodingId, Map.of(encodingId, argon2, "bcrypt", bcrypt));
delegating.setDefaultPasswordEncoderForMatches(bcrypt);

파라미터는 OWASP 최소 권고인 19MiB, iterations 2, parallelism 1로 잡았습니다. Spring이 제공하는 defaultsForSpringSecurity_v5_8()은 메모리가 16MiB라 권고에 못 미쳐서 직접 구성했습니다.

같은 전환을 정리한 국내 글도 찾아 대조했습니다. 토이토 님의 「아직도 Bcrypt만 쓰시나요? 이제는
Argon2id로 전환해야 할 때」
new Argon2PasswordEncoder(16, 32, 1, 65536, 3),
그러니까 메모리 64MiB에 iterations 3을 씁니다. OWASP 최소 권고의 세 배가 넘습니다.

어느 쪽이 맞는지는 한 대에서 동시에 몇 명이 로그인하느냐에 달렸습니다. 이 메모리는 해시 1건당
잡히는 값이라 동시 요청 수만큼 곱해집니다. 19MiB 설정에서 동시 100건이면 Argon2 몫만 2GB인 걸
뒤에서 실측했는데(상황 2), 같은 비례라면 64MiB 설정은 같은 조건에서 6GB를 넘깁니다.

그래서 최소 권고에서 시작하고, 힙이 얼마나 남는지 재고 나서 올리기로 했습니다. 파라미터를 높게
잡는 건 언제든 할 수 있지만, 그 대가는 재 보기 전에는 모릅니다.

이제 세 세대의 해시를 모두 받습니다.

저장된 형태누가 검증하나
{argon2}$argon2id$...맵의 argon2 (신규 가입)
{bcrypt}$2a$...맵의 bcrypt (이관 중간 세대)
$2a$...setDefaultPasswordEncoderForMatches (가장 오래된 것)

기존 회원이 비밀번호를 재설정하지 않아도 됩니다. 이 조건을 테스트 다섯 개로 못박았습니다. 하나라도 깨지면 그 세대의 사용자 전부가 로그인하지 못합니다.

재 봤더니 예상과 반대였다

비밀번호 해싱은 느린 것이 목적이라 “빨라졌다”가 개선은 아닙니다. 그래도 재야 했습니다. 로그인 응답에 얼마가 붙는지 알아야 하고, 무엇보다 이 교체가 /auth/login에 무슨 부담을 주는지 알아야 했습니다.

로컬(Apple Silicon), 워밍업 후 7회 중앙값.

알고리즘소요메모리
BCrypt (strength 10)87ms~4KB
Argon2id (19MiB, t=2, p=1)32ms19MiB

더 강한 쪽이 더 빨랐습니다. 처음엔 파라미터를 잘못 넣은 줄 알았습니다.

해시 비용 비교: BCrypt는 87ms에 4KB, Argon2id(19MiB·t=2)는 32ms에 19MiB. 메모리는 해시 1건당이라 동시 요청 수만큼 곱해진다

이유는 비용을 어디서 가져오느냐가 다르기 때문으로 보입니다. BCrypt는 비용이 반복 횟수에서 나옵니다. work factor를 올리면 시간이 그만큼 늘어납니다. Argon2id는 상당 부분을 메모리에서 가져옵니다. 그래서 같은 방어력을 시간을 덜 쓰고 얻을 수 있습니다.

메모리 하드가 노리는 게 그거입니다. 다만 여기서 확인한 건 이 머신에서의 지연뿐입니다. “같은 방어력을 더 싸게”까지 말하려면 GPU·전용 하드웨어의 실제 크래킹 비용 비교가 있어야 하는데, 그건 안 쟀습니다. 정확히는 현재 우선 권고되는 Argon2id의 최소 설정이 이 환경에서 BCrypt cost 10보다 빨랐다까지입니다.

3. 강화가 새 표면을 열었다

시간은 줄었는데 공짜가 아니었습니다. 메모리라는 축이 새로 생겼고, 그게 인증 없이 누구나 부를 수 있는 경로에 얹혔습니다.

대신 서버 메모리를 쓴다

여기가 이 교체의 진짜 대가입니다. 메모리 하드는 공격자만 메모리를 쓰게 하는 게 아닙니다. 우리 서버도 씁니다.

로그인 1건 19MiB
동시 로그인 100건 약 1.9GB

그리고 /auth/login인증 없이 누구나 부를 수 있는 경로입니다. 비밀번호가 틀려도 해싱은 돕니다. 맞는지 확인하려면 해싱을 해야 하니까. 그래서 공격자는 계정을 몰라도 됩니다.

즉 이렇게 됩니다.

알고리즘을 강화했는데 그 경로의 DoS 표면은 오히려 커졌습니다.

전에는 CPU 87ms를 태웠습니다. 이제는 32ms에 19MiB를 잡습니다. 시간은 줄었지만 메모리라는 새 축이 생겼습니다. 동시 접속이 많아지면 CPU보다 메모리가 먼저 아플 수 있습니다.

19MiB는 해시 1건당이었다

앞에서 Argon2id가 BCrypt보다 빠르다고(32ms vs 87ms) 쓰고, 대신 메모리를 19MiB 쓴다고 적었습니다. 그때 안 잰 게 있었습니다.

19MiB는 해시 1건당입니다. 동시 로그인 수만큼 곱해집니다.

힙 상한만 바꿔가며 같은 스크립트를 돌렸습니다. 아래는 각 조건 1회 실행이고, 동시 시작을 배리어로 맞췄습니다. “실패”는 JVM이 죽은 게 아니라 해당 요청이 OutOfMemoryError로 끝난 건수입니다. 실행마다 스케줄링이 달라 실패 건수는 흔들립니다.

동시 25동시 50동시 100
512MB성공 (696ms)50건 전부 OOM75건 실패
1GB성공성공12건 실패
2GB성공성공성공 (1,984MB)

지정한 메모리 비용에 거의 선형으로 늘었습니다. 동시 1건당 약 20MB. Tomcat 스레드 상한이 100이니 Argon2 몫만 2GB가 필요합니다.

숫자보다 나쁜 건 실패하는 방식입니다. 성공했을 때 0.7초인 작업이 힙이 모자라면 12.1초를 쓰고 죽습니다. 느려지면서 죽으니 클라이언트가 재시도하고, 재시도가 다시 메모리를 잡습니다. 스스로 증폭됩니다.

앞에서 “알고리즘 강화가 로그인 경로의 DoS 표면을 키웠다”고 썼는데, 그 표면의 크기가 이제 숫자로 나온 셈입니다. 유입 제어가 1차 방어이긴 한데, 고정 윈도우라 100건이 같은 순간에 도달할 수 있습니다(8편). 전역 상한값 자체가 위험 구간과 겹칩니다.

기동 직후 스레드 수 × 20MB와 힙 상한을 비교해 경고하게 했습니다. 부팅을 막지는 않았습니다. 전 스레드 동시 해시는 최악의 경우고, 힙이 빠듯하다고 부팅을 거부하면 그게 더 큰 사고입니다. 판단은 배포하는 사람이 하되 모르고 지나가지는 않게.


유입 제한이 먼저 있었던 건 다행이었다

이 경로에는 이미 유입 제한이 걸려 있었습니다. 그것도 우연이 아니라, 얼마 전에 비어 있던 걸 발견해서 채운 것이었습니다.

/auth/login/members/signup인증 전이라 principal이 없습니다. 그래서 사용자별 유입 제한 필터를 그냥 통과하고 있었습니다. 나머지 경로는 다 막혀 있는데 이 둘만 열려 있던 셈입니다.

그 상태가 왜 나쁜지는 BCrypt 비용이 그대로 말해줍니다. 두 경로는 요청마다 BCrypt(~110ms CPU)를 태우니 미인증 공격자에게 비대칭 DoS가 됩니다. 요청 한 번으로 서버 CPU 110ms를 강제로 태울 수 있고, 알려진 이메일에 비밀번호를 무제한 시도하는 크리덴셜 브루트포스도 열려 있었습니다.

IP 하나만으로는 부족합니다. 공유 NAT 뒤의 정상 사용자가 같이 막히고, 분산 IP 공격에는 약합니다. 계정별 제한과 반복 실패 지연이 함께 있어야 크리덴셜 스터핑을 막는데, 지금은 IP와 전역 상한뿐입니다. 이것도 한계로 적어둡니다.

IP 기준으로 막되, X-Forwarded-For는 신뢰하지 않습니다. 클라이언트가 위조·회전할 수 있어서 그걸 믿으면 헤더만 바꿔 가며 제한을 우회할 수 있습니다. 소켓 피어(getRemoteAddr)만 씁니다. 프록시 뒤 배포에서는 신뢰된 프록시에서 온 경우에만 프레임워크가 실 IP를 대입하게 설정하고, 코드는 그 결과를 읽습니다. 분산 IP 공격은 global 한도가 backstop합니다.

방어 코드를 짤 때도 신뢰 경계를 착각하면 그게 곧 구멍이 됩니다. rate limit을 붙이면서 “클라이언트가 준 IP”를 믿으면, 막으려던 우회를 스스로 열어주는 셈입니다.

이게 없었다면 Argon2 교체는 더 위험했습니다. 다만 이것만으로 안전해진 건 아니었습니다.

그런데 유입 제어로는 못 막는 자리였다

외부 리뷰가 짚은 지점입니다. 그리고 맞습니다.

RPS 제한은 단위 시간당 시작하는 요청 수를 묶습니다. 그런데 Argon2가 잡는 메모리는
같은 순간에 실행 중인 해시 수에 비례합니다.
둘은 다른 자원입니다.

고정 윈도우(매초 시작마다 카운터가 0으로 돌아가는 방식이라 초 경계에서 두 배까지 몰릴 수 있습니다)라 초당 100건 상한이어도 매초 초입에 100건이 겹칠 수 있습니다.
그러면 해시당 약 19.84MB × 100 ≈ 1.9GB를 한꺼번에 요구합니다.
바로 뒤에서 재는 표가 그 상태를 그대로 보여줍니다, 힙 1GB에 동시 100건이면 12건이 OOM으로 실패했습니다.

내 실험이 이미 그 방어로도 OOM이 난다는 걸 보여주고 있었는데, 결론은 “안전한 변경이 됐다”였습니다.

그래서 동시 실행 수를 따로 묶었습니다. 기다리게 하지 않고 즉시 거절합니다.

if (!permits.tryAcquire()) {
throw new HashCapacityExceededException(); // 429
}
try {
return delegate.matches(raw, encoded);
} finally {
permits.release();
}

대기열을 안 만든 이유는 이 경로에서 지연이 방어가 아니라 증폭이기 때문입니다.
기다리는 동안 스레드가 묶이고, 그 스레드가 다시 메모리를 잡습니다.

한 가지 예외를 뒀습니다. upgradeEncoding()은 저장된 해시의 접두사만 보는 문자열 검사
메모리를 쓰지 않습니다. 이걸 막으면 점진 이관만 조용히 멈춥니다.

상한값은 아직 측정 기반이 아닙니다. 기본 8로 뒀고(약 160MB), 힙 예산과 평상시 사용량을
재고 나서 조정해야 합니다. 그 전까지는 보수적인 값입니다.

하마터면 가입이 터질 뻔했다

교체하고 테스트를 돌렸습니다. 553개 전부 초록불이었습니다.

그런데 해시 길이를 재 봤습니다.

{bcrypt}$2a$10$... 68자
{argon2}$argon2id$v=19$m=19456,t=2,p=1$... 105자

그리고 컬럼은 이랬습니다.

password_hash varchar(100) not null

들어가지 않습니다. 실제 가입에서 Data too long으로 터졌을 것입니다.

테스트가 왜 못 잡았냐면, 인코더 테스트는 DB를 타지 않기 때문입니다. 인코딩과 검증만 보니까 문자열이 얼마나 긴지는 관심 밖이었습니다. 알고리즘 교체가 스키마가 따라와야 하는 변경이라는 걸 길이를 재 보고서야 알았습니다.

255로 넓혔습니다. 나중에 파라미터를 올리면 인코딩 문자열이 더 길어지므로, 그때마다 마이그레이션을 다시 쓰지 않으려고 여유를 뒀습니다.

이런 게 처음이 아닙니다. 이 프로젝트에서 테스트가 전부 초록불인데 문제가 있던 게 벌써 여러 번입니다. 취소 경로가 새 결제수단을 몰라 환불이 증발했을 때, 정산 집계 기준일이 틀려 지급이 통째로 빠졌을 때, 그리고 이번. 공통점은 테스트가 내가 만든 조건 안에서만 돌았다는 것입니다.

4. 끝낼 수 없는 이관, 대신 끝을 판정한다

새 가입은 Argon2로 갑니다. 그런데 이미 BCrypt 해시를 가진 회원은 그대로입니다. 배치로 옮길 수 없습니다.

기존 회원은 어떻게 옮기나

새 가입은 Argon2로 갑니다. 그런데 이미 BCrypt 해시를 가진 회원은 그대로입니다.

배치로 옮길 수 없습니다. 저장된 건 해시뿐이고, 해시에서 원문을 되돌릴 수 없습니다. 원문 비밀번호는 로그인 순간에만 존재합니다.

그래서 그 순간을 잡아야 합니다. Spring Security가 이 흐름을 이미 갖고 있었습니다.

로그인 성공
→ DaoAuthenticationProvider가 PasswordEncoder.upgradeEncoding(저장된해시) 질의
→ 참이면 UserDetailsPasswordService.updatePassword(user, 새로_인코딩된_해시)

DelegatingPasswordEncoder는 저장된 접두사가 현재 인코딩 id와 다르면 참을 반환합니다. 그래서 {bcrypt}와 접두사 없는 레거시가 모두 대상이 됩니다. Argon2 인코더는 메모리나 iterations 파라미터가 바뀔 때도 참을 반환하므로, 나중에 파라미터를 올려도 같은 경로로 이관됩니다.

인터페이스만 구현하면 되는데, 왜 프레임워크가 기본 구현을 안 주는지가 궁금했습니다. 찾아보니 의도된 것이었습니다. Spring의 JdbcUserDetailsManager는 이 인터페이스를 일부러 구현하지 않습니다. 자동 갱신이 기존 운영 코드를 깨뜨릴 수 있어 옵트인으로 남겨 둔 설계입니다.

구현하면서 원칙을 하나 세웠습니다.

이관 실패가 로그인을 막지 않습니다.

회원을 못 찾거나 데모 계정이면 넘어가고 인증은 통과시킵니다. 다만 완전히 조용하면 안 됩니다, DB 장애나 버그로 이관이 계속 실패하면 BCrypt가 영구히 남는데 아무도 모릅니다. 실패를 세는 카운터가 아직 없습니다. 후속으로 남깁니다. 이관은 편의이고 인증은 기능입니다. 이관 로직의 버그 하나가 전체 로그인을 막는 건 말이 안 됩니다. 이것도 테스트로 고정했습니다.

그런데 이건 끝낼 수 없는 이관이다

여기까지 하고 나서 남은 걸 봤습니다. 로그인하지 않는 계정은 영원히 옛 해시로 남습니다.

처음엔 이걸 더 짜서 닫으려고 했습니다. 그런데 닫을 수가 없습니다. 원문 비밀번호 없이는 재인코딩이 불가능하고, 원문은 로그인 순간에만 존재합니다. 노력이 부족해서가 아니라 해시가 단방향이라서입니다.

남은 방법은 두 가지뿐입니다.

  1. 그 사람이 로그인합니다
  2. 비밀번호를 재설정합니다

레거시 인코더를 제거하면 2번이 강제됩니다. 옛 해시가 검증되지 않으니 로그인이 막히고, 재설정할 수밖에 없습니다. 그런데 이 프로젝트에는 비밀번호 재설정 플로우가 없습니다. 그 상태에서 인코더를 제거하면 그건 이관이 아니라 계정 잠금입니다.

그래서 끝낼 수 없는 대신, 끝을 판정할 수 있게 했다

남은 레거시 해시 수를 게이지로 올렸습니다.

password_hash_legacy_count

이 값이 0이 되면 레거시 인코더를 제거할 수 있습니다. 그 시점이 이관의 진짜 종료입니다. 판정을 사람의 기억이 아니라 이 값으로 합니다.

7일 이상 남아 있으면 알림이 뜨는데, 심각도를 info로 뒀습니다. 경보가 아니라 언제 끝나는지 보이게 하는 용도라서입니다. 값이 줄지 않으면 휴면 계정인지 확인하고 재설정 유도를 검토하면 됩니다.

이건 이 프로젝트에서 반복해 온 방식입니다. 미확정 결제를 상태로 남기면서 가장 오래된 미확정 건의 경과 시간을 게이지로 올렸고, 대사(내 결제 기록과 PG가 보내주는 정산 파일을 하루치씩 대조해 안 맞는 건을 찾는 작업) 예외 큐를 만들면서 미해결 건수를 올렸습니다. 상태를 만들었으면 그 상태의 재고를 보는 게 세트입니다. 재고가 안 보이면 줄고 있는지 늘고 있는지 알 수 없습니다.

남는 생각

같은 BCrypt 비용을 두 방향으로 다뤘습니다. 요청마다 하던 재검증은 걷어냈고(p95 567.84 → 37.09ms),
로그인 1회의 해싱은 오히려 더 비싸게 만들었습니다(BCrypt 87ms → Argon2id 32ms에 19MiB).
앞은 없앨 수 있는 비용이었고 뒤는 비밀번호를 실제로 대조하는 자리라 없앨 수 없는 비용이었습니다.

그리고 강화가 공짜가 아니었습니다. 시간은 줄었는데 메모리라는 축이 새로 생겼고, 그 축이
로그인 경로의 DoS 표면을 키웠습니다.

여기서 한 번 틀렸습니다. 처음엔 “유입 제한이 먼저 있었기 때문에 안전한 변경이 됐다”고 적었습니다.
그런데 바로 그 아래 표가 힙 1GB에 동시 100건이면 12건이 OOM으로 실패한다는 걸 보여주고 있었습니다.
RPS 제한은 시작하는 요청 수를 묶을 뿐이고, 메모리는 같은 순간에 실행 중인 해시 수에 비례합니다.
자기 실험이 반증하는 결론을 적어둔 셈입니다. 외부 리뷰가 짚어줘서 알았고, 동시 실행 수를 따로 묶었습니다.

막을 자원을 잘못 짚으면 방어가 있어도 안 막힙니다. 이 편에서 제일 크게 배운 게 그거입니다.

이관은 끝내지 못했습니다. 원문 비밀번호 없이는 재인코딩이 불가능하고, 원문은 로그인 순간에만 존재합니다.
그래서 끝내는 대신 끝을 판정할 수 있게 남은 레거시 해시 수를 지표로 올렸습니다.
이 프로젝트에서 반복해 온 방식입니다. 상태를 만들었으면 그 상태의 재고를 보는 게 세트입니다.

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.