안녕하세요! 그로스로그 입니다!😊 🌱
인증 하나에 아홉 개의 부품이 붙는 이유, JWT 서명의 주체가 왜 서버뿐인가, 그리고 추상화만으로는 OCP를 못 지키는 이유. 3기 6회차 세 편입니다.
3기 6회차, 2025년 6월 중순입니다. 이번 회차에는 역할을 누구에게 맡길 것인가를 다룬 세 편이 모였어요.
01
🔐 Servlet 인증 구조
최태형 님
Spring Security의 인증 구성 요소 아홉 개를 정리한 글입니다.
먼저 표로 전체 목록을 보여주고, 그다음 하나씩 풀어서 설명해요.
SecurityContextHolder부터 AbstractAuthenticationProcessingFilter까지, 각각이 무슨 역할인지를 짚습니다.이분의 Spring Security 연재는 이번이 다섯 번째 편이에요.
조금 더 들어가 보면
📋 부품 목록을 먼저 편다
이 글의 시작이 표입니다. 아홉 개 구성 요소와 한 줄 설명이요.
SecurityContext — 현재 인증된 사용자의 인증과 SecurityContextHolder로 얻을 수 있다
Authentication — 자격증명을 제공하는 AuthenticationManager 입력
GrantedAuthority — 인증 객체의 권한 부여
AuthenticationManager — Filter가 인증 수행하는 방법을 정의하는 API
ProviderManager — 대부분 AuthenticationManager의 공통 요소
AuthenticationProvider — ProviderManager를 사용해 특별한 유형의 인증을 수행
이름이 서로 비슷해서 처음 보면 구분이 안 갑니다. Holder와 Context, Manager와 Provider요.
그런데 표로 나란히 놓으면 짝이 보입니다. SecurityContextHolder는 담는 그릇이고 SecurityContext는 담기는 것, AuthenticationManager는 규격이고 ProviderManager는 그 구현이에요.
Spring Security가 어렵다고들 하는 이유가 여기 있습니다. 인증이라는 한 가지 일을 아홉 조각으로 나눠놨거든요. 그런데 그렇게 나눈 덕에 아이디·비밀번호든 OAuth든 JWT든 같은 틀 안에 끼워 넣을 수 있습니다.
🧵 스레드마다 하나씩
SecurityContextHolder 설명에서 가장 중요한 대목이 저장 방식입니다.
메소드를 명시적으로 전달하지 않아도 같은 Thread의 메소드로 SecurityContext를 사용
ThreadLocal이 핵심입니다.
웹 서버는 요청 하나를 스레드 하나가 처리합니다. ThreadLocal에 넣어두면 그 스레드 안에서는 어디서든 꺼낼 수 있어요. 컨트롤러에서 서비스로, 서비스에서 그 아래로 "지금 누가 로그인했는지"를 인자로 넘기지 않아도 됩니다.
그런데 대가가 있습니다.
Spring Security의 FilterChainProxy는 SecurityContext가 항상 비워짐을 보장
비우지 않으면 큰일 납니다.
톰캣 같은 서버는 스레드를 재사용하거든요. A 사용자의 요청을 처리한 스레드가 B 사용자의 요청에 다시 쓰입니다. 안 비우면 B가 A로 로그인된 상태가 돼요.
그래서 FilterChainProxy가 요청이 끝날 때 반드시 비웁니다. 프레임워크가 그 책임을 대신 지는 구조고요. 그런데 스레드를 직접 만들어 쓰면 이 보장이 안 통합니다. 그래서 그다음 이야기가 나와요.
단일 응용프로그램의 경우
MODE_GLOBAL (…) 보안 Thread로 생성된 Thread가 같은 보안 식별임을 가정할 수 있는데 MODE_INHERITABLETHREADLOCAL을 사용해 달성세 가지 모드를 상황별로 갈라둔 것입니다. 기본은 요청마다 따로, INHERITABLE은 자식 스레드까지 물려주기, GLOBAL은 JVM 전체가 하나요.
🧪 비어 있는 것부터 만든다
코드 예제에 붙은 주석이 좋습니다.
// 빈 SecurityContext를 생성 // 여러 스레드의 조건을 피하기 위해 // SecurityContext.getContext.setAuthentication(authentication); 대신 // 새 SecurityContext 요소를 생성한다. SecurityContext context = SecurityContextHolder.createEmptyContext();
"여러 스레드의 조건을 피하기 위해"라는 이유가 적혀 있어요.
기존 컨텍스트를 꺼내서 고치면, 다른 스레드가 같은 객체를 보고 있을 수 있습니다. 새로 만들어 통째로 갈아끼우면 그 위험이 없죠.
이건 공식 문서에 있는 권장 방식인데, 왜 그런지가 주석에 남아 있는 게 이 정리의 값어치입니다. 코드만 옮겨두면 다음에 "그냥 getContext 쓰면 안 되나" 하고 되돌아가게 되거든요.
그리고 TestingAuthenticationToken을 쓴 이유도 밝혔습니다.
TestingAuthenticationToken을 사용한다.일반적인 시나리오는
UsernamePasswordAuthenticationToken(userDetails, password, authorities);예제용과 실제용을 구분해둔 것입니다. 예제를 그대로 복사해서 운영에 쓰는 사고를 막아주는 한 줄이에요.
📝 원문에서는 이렇게 씁니다
Authentication 인터페이스 설명에서 담기는 것 셋을 정리했습니다.
자격 증명 : 대부분 패스워드로 사용자 인증 후 유출되지 않도록 지운다
권한 : 사용자에게 허용된 권한으로 역할과 범위를 지정
두 번째 줄이 중요합니다. 인증이 끝나면 비밀번호를 지운다는 것요.
인증에 성공한 뒤에도 비밀번호가 객체에 남아 있으면, 로그에 찍히거나 직렬화될 때 새어 나갈 수 있습니다. 필요 없어진 순간 지우는 게 원칙이에요.
그리고 isAuthenticated()가 처음엔 false라는 것도 짚었습니다. 같은 Authentication 객체가 인증 전에는 요청서, 인증 후에는 증명서로 쓰이는 구조거든요.
이 글의 문체는 짧게 끊어집니다. "~수행", "~저장", "~설정" 식으로요. 설명문이 아니라 정리 노트에 가깝습니다.
큐레이터 노트
연재를 다섯 편째 이어온 것이 이 글을 고른 이유입니다.
블로그의 카테고리 목록을 보면 Spring Security (1)부터 (4), 그리고 인증 구조 2·3편까지 이어집니다. 한 주제를 반년 가까이 파고 있어요.
Spring Security는 처음 만나면 벽처럼 느껴지는 영역입니다. 설정 한 줄이 무슨 일을 하는지 모르는 채로 복사해 쓰게 되죠. 그런데 이 연재는 구성 요소를 하나씩 이름부터 짚고 갑니다.
그리고 이번 편이 특히 그렇습니다. 사용법이 아니라 누가 무엇을 담당하는지를 정리했어요. 그 지도가 있어야 에러가 났을 때 어디를 봐야 할지 알 수 있습니다.
02
🎫 JwtUtil 관련 기록
이민경 님
JWT 토큰을 다루는 유틸 클래스의 함수 다섯 개를 하나씩 뜯어본 기록입니다.
각 함수마다 역할 · 입력 · 출력 · 내부 동작을 적고, 관련 보안 개념을 붙였어요.
뒷부분은 자문자답 다섯 개입니다. "HMAC-SHA256은 전자서명인가?" "서명의 주체는 누구?" 같은 것요.
시작이 솔직합니다. "작년까지만 해도 그냥 복붙하고 실행되면 일단 되는구나 했는데"
조금 더 들어가 보면
🔁 복붙에서 이해로
첫 문단이 이 기록의 출발점을 말해줍니다.
그리고 다시 돌아보니 내가 이전에 만든 서버는 보안이 엉망진창이었다.
"엉망진창이었다"를 그대로 적었습니다.
그리고 그다음 문장이 좋아요.
질문하는 법이 달라졌다는 것. 모를 때는 "이거 왜 안 돼요"밖에 물을 수 없는데, 개념이 잡히면 "이게 대칭키인가요 비대칭키인가요"처럼 답이 갈리는 질문을 할 수 있게 됩니다.
🔑 서명이 무엇을 지키나
첫 함수가 서명용 키를 만드는 부분입니다.
private Key getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
그리고 개념을 붙였어요.
대칭키 방식 : 서명 생성과 검증에 같은 키(secret)를 사용
HMAC-SHA256 : 해시 알고리즘(SHA-256)과 키를 결합한 서명 방식
"위조되지 않았음"이 정확한 표현입니다.
JWT를 처음 보면 흔히 오해하는 게 있어요. 토큰이 암호화되어 있다고 생각하는 것요. 아닙니다. Payload는 그냥 Base64로 인코딩돼 있어서 누구나 열어볼 수 있습니다.
서명이 하는 일은 감추는 게 아니라 바꾸지 못하게 하는 것입니다. 사용자가 Payload의 권한을 admin으로 고쳐도, 서명이 안 맞으니 서버가 거부해요.
그래서 이런 자문자답이 나옵니다.
서버가 secret key를 가지고 있고, 토큰을 만들고 서명하며, 클라이언트가 보낸 JWT를 다시 서명 검증할 수 있는 주체도 서버뿐
대칭키라서 그렇습니다. 같은 열쇠로 만들고 확인하니, 그 열쇠를 가진 서버만 양쪽을 다 할 수 있어요. 그래서 secret이 새면 누구나 토큰을 위조할 수 있게 됩니다.
그리고 Q1의 구분도 정확합니다.
비대칭키를 쓰면 검증은 아무나, 서명은 개인키 가진 쪽만 할 수 있습니다. 서버가 여러 대로 나뉘거나 다른 서비스가 토큰을 확인해야 할 때 그쪽이 맞아요.
🧩 검증이 자동으로 일어나는 자리
extractClaims에 붙은 설명이 짧지만 중요합니다.
public Claims extractClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
서명이 위조되었거나 변조되면 예외 발생
꺼내는 함수인데 검증도 같이 합니다.
이게 라이브러리 설계에서 중요한 지점이에요. 만약 "꺼내기"와 "검증하기"가 따로 있으면, 검증을 빠뜨린 코드가 나올 수 있거든요. 하나로 묶어두면 그 실수가 원천적으로 안 생깁니다.
parseClaimsJws의 Jws가 그 뜻입니다. 서명된 것만 받는다는 표시예요. 서명 없는 토큰을 파싱하는 함수는 이름이 다릅니다.
그리고 유효성 검사가 둘로 나뉘어 있습니다.
public boolean isTokenValid(String token, String email) {
final String tokenEmail = extractClaims(token).getSubject();
return (tokenEmail.equals(email) && !isTokenExpired(token));
}
서명 검증은 이미 extractClaims에서 끝났고, 여기서는 내용을 봅니다. 서명이 맞아도 만료됐으면 안 되니까요.
📝 원문에서는 이렇게 정리합니다
마지막에 표 하나와 자문자답 다섯 개가 붙습니다. 그중 Q4가 JWT의 구조를 요약해요.
HEADER.PAYLOAD.SIGNATUREHeader : 어떤 알고리즘으로 서명했는지
Payload (Claims) : 이메일, 생성시간, 만료시간 등
Signature : 위 두 개를 서버의 secret key로 HMAC-SHA256 처리한 결과
"위 두 개를"이 핵심입니다. 서명은 헤더와 페이로드 둘 다를 재료로 만들어져요. 그래서 어느 한 글자만 바뀌어도 서명이 안 맞습니다.
헤더까지 포함하는 이유가 있습니다. 헤더에 알고리즘이 적혀 있는데, 그것마저 바꿀 수 있으면 "서명 안 함"으로 위조하는 공격이 가능하거든요.
그리고 마지막 Q5의 답이 담백합니다.
exp)과 현재 시간을 비교해서복잡할 줄 알았는데 아니라는 걸 확인한 대목이에요.
큐레이터 노트
자문자답 형식이 이 기록의 특징입니다.
함수 설명은 문서를 보고도 쓸 수 있습니다. 그런데 "HMAC-SHA256은 전자서명인가?", "서명의 주체는 누구?" 같은 질문은 직접 코드를 보다가 걸린 것들이에요.
그리고 그 질문들이 좋은 질문입니다. 다섯 개 다 답이 갈리는 질문이고, 그 답을 알면 JWT를 쓸 때 판단이 달라집니다. 대칭키인 걸 알면 secret 관리가 왜 중요한지 알게 되고, 페이로드가 열린다는 걸 알면 거기에 비밀을 안 담게 되죠.
무엇보다 "보안이 엉망진창이었다"로 시작한 것. 지난 코드를 다시 보고 문제를 발견한 상태에서 쓴 기록이라, 정리 하나하나에 이유가 붙어 있습니다.
03
🧩 OCP와 Dependency Injection
최준성 님
"추상화만으로는 OCP를 못 지킨다"는 걸 코드로 증명한 글입니다.
결제 처리기를 예로 들어 두 단계로 실패시켜요. 먼저 추상화 없는 코드, 그다음 추상화는 했지만 DI가 없는 코드요.
두 번째가 이 글의 핵심입니다. 인터페이스를 도입했는데도 여전히 OCP 위반이라는 것이요.
예제 언어가 Dart입니다.
조금 더 들어가 보면
📖 원칙을 먼저 세운다
시작이 정의입니다.
쉽게 말해, 새로운 기능을 추가할 때는 기존 코드를 변경하지 않고 확장할 수 있어야 한다
"개방"과 "폐쇄"라는 말이 헷갈리는데, 아래 줄에서 풀어 적었습니다. 새 기능은 붙일 수 있고, 있던 코드는 안 건드린다요.
그리고 왜 그래야 하는지 세 가지를 들었습니다. 유지보수성, 재사용성, 유연성이요. 그중 첫 번째 설명이 실질적입니다.
이미 돌아가는 코드를 건드리는 게 위험하기 때문입니다. 새 코드는 테스트하면 되는데, 고친 코드는 원래 되던 것까지 다시 확인해야 하니까요.
❌ 첫 번째 실패 — 직접 만들면
처음 코드는 이렇습니다.
class PaymentProcessor {
final CreditCardPayment _creditCardPayment = CreditCardPayment();
void processOrderPayment(double amount) {
_creditCardPayment.processPayment(amount);
}
}
문제를 이렇게 짚었어요.
PaymentProcessor 클래스 내부에서 새로운 if/else를 추가하거나, (…) PaymentProcessor 자체를 수정해야 한다.클래스 안에서 new를 했다는 게 문제입니다. 무엇을 쓸지가 코드에 박혀 있으니, 다른 걸 쓰려면 그 코드를 고쳐야 해요.
5회차에서 소개해드린 박상훈 님의 Flutter 테스트 글과 정확히 같은 지점입니다. 그쪽도 서비스 안에서 API를 직접 만들면 테스트에 가짜를 못 넣는다는 이야기였죠. 한쪽은 확장을, 한쪽은 테스트를 막습니다. 원인은 하나고요.
⚠️ 두 번째 실패 — 추상화를 해도
여기서부터가 이 글의 진짜 내용입니다. 인터페이스를 도입합니다.
abstract interface class Payment {
void processPayment(double amount);
}
class PaymentProcessor {
const PaymentProcessor(this._payment); // 생성자로 받는다
final Payment _payment;
}
PaymentProcessor는 이제 완벽해 보입니다. 인터페이스만 알고, 생성자로 받고요.
그런데 부르는 쪽을 봅니다.
void main() {
String selectedMethod = 'creditCard';
Payment selectedPayment;
if (selectedMethod == 'creditCard') {
selectedPayment = creditCard;
} else (selectedMethod == 'kakaoPay') {
selectedPayment = kakaoPay;
}
final PaymentProcessor paymentProcessor = PaymentProcessor(selectedPayment);
// 만약 ApplePay가 추가된다면, 이 main 함수 내의 'if/else' 로직을 수정해야 한다
}
PaymentProcessor 자체는 (…) OCP를 지키는 것처럼 보인다. 그러나 main 함수와 같이 PaymentProcessor를 사용하는 클라이언트 코드에서 어떤 결제 수단을 사용할지 직접 if/else나 다른 분기 로직으로 선택하고 생성하고 있다.분기가 사라진 게 아니라 자리를 옮긴 것입니다.
이게 이 글에서 가장 볼 만한 대목이에요. 의존성 주입을 배울 때 흔히 여기서 멈춥니다. "생성자로 받으면 DI다"라고요. 그런데 넣어주는 쪽 코드도 누군가는 써야 하고, 그 코드가 여전히 구현체를 알고 있으면 문제가 그대로 남습니다.
🔗 그래서 결론
마지막이 두 개념을 잇습니다.
따라서, OCP를 실질적으로 구현하고 유지하기 위해서는 의존성 주입이 선택이 아닌 필수적인 설계 원칙이 된다.
"필수적인"이라고 못박았습니다.
여기서 한 걸음 더 나가면 DI 컨테이너가 나옵니다. main의 if/else마저 없애려면, 무엇을 쓸지를 코드가 아니라 설정에서 읽어야 하거든요. Spring의 @Autowired나 Flutter의 get_it이 그 자리를 채웁니다.
앞의 최태형 님 글에 나온 AuthenticationProvider도 같은 구조입니다. ProviderManager가 여러 Provider를 들고 있다가 맞는 걸 골라 쓰죠. 새 인증 방식이 생기면 Provider를 하나 더 등록하면 되고, 기존 코드는 안 바뀝니다.
📝 원문에서는 이렇게 씁니다
이 글에는 성공하는 코드가 안 나옵니다.
실패 두 개를 보여주고, 왜 실패인지 짚고, 원칙으로 마무리해요. 완성 코드를 안 준 게 오히려 이 글의 성격을 만듭니다. 읽는 사람이 "그럼 어떻게 해야 하지"를 들고 나가게 되니까요.
그리고 두 실패의 층위가 다릅니다. 첫 번째는 클래스 안의 문제고, 두 번째는 클래스 밖의 문제예요. 같은 원칙 위반인데 위반이 일어나는 자리가 한 칸씩 밖으로 밀려납니다.
이 구조 덕에 "그럼 세 번째는 어디일까"가 자연스럽게 따라옵니다.
큐레이터 노트
두 번째 실패를 보여준 게 이 글을 고른 이유입니다.
SOLID 원칙 글은 많습니다. 대부분 나쁜 예 하나와 좋은 예 하나를 나란히 놓고 끝나요. 그러면 "인터페이스를 쓰면 된다"가 남습니다.
그런데 실무에서 막히는 자리는 그다음입니다. 인터페이스를 다 만들어놨는데도 새 기능을 추가할 때마다 어딘가를 고치고 있는 상황이요. 이 글이 정확히 그 지점을 코드로 보여줍니다.
Dart로 쓴 것도 눈에 띕니다. SOLID 예제는 대개 자바로 나오는데, Flutter를 하면서 만난 문제를 그 언어로 적었어요. 자기가 쓰는 자리에서 나온 정리라는 게 드러납니다.
5회차 박상훈 님의 테스트 글과 나란히 읽으면 더 좋습니다. 같은 구조가 한쪽에서는 확장을, 한쪽에서는 검증을 가능하게 만듭니다.
💡 세 편을 겹쳐 읽으며
세 편의 주제가 다릅니다. 인증 프레임워크, 토큰 서명, 설계 원칙이요.
그런데 셋 다 누가 그 일을 맡는가를 다룹니다.
역할을 나누면 이름이 늘어난다
최태형 님의 Spring Security는 인증 하나를 아홉 조각으로 나눴습니다. Holder, Context, Authentication, Manager, Provider, Filter요.
최준성 님의 OCP는 결제를 고르는 일을 어디에 둘지 묻습니다. 클래스 안인가, 부르는 쪽인가, 아니면 그보다 더 밖인가요.
이민경 님의 JWT는 서명의 주체를 서버로 못박습니다. 대칭키라서 다른 선택지가 없거든요.
이름이 늘어나는 건 일이 늘어난 게 아니라 책임이 나뉜 것입니다. 그리고 나뉘어 있어야 갈아끼울 수 있어요.
나눈 뒤에 남는 문제
그런데 나누면 새 문제가 생깁니다. 누가 조립할 것인가요.
최준성 님의 글이 정확히 이 지점입니다. 인터페이스로 나눴는데, 그럼 어느 구현체를 쓸지는 누가 정하나요. 그 결정이 main에 남아 있으면 문제가 그대로입니다.
최태형 님의 ProviderManager가 그 답의 한 형태예요. 여러 Provider를 들고 있다가 맞는 걸 고르는 역할을 따로 둔 것이니까요.
SecurityContextHolder도 마찬가지입니다. 인증 정보를 어디에 둘지의 문제인데, ThreadLocal이라는 답을 프레임워크가 정해뒀어요. 그리고 비우는 책임까지 FilterChainProxy가 집니다.
나누는 것이 절반이고, 조립과 뒷정리를 누가 맡을지 정하는 게 나머지 절반입니다.
감추는 것과 못 바꾸게 하는 것
이민경 님의 글에서 나온 구분도 세 편에 걸쳐 유용합니다.
JWT 서명은 감추지 않습니다. 페이로드는 누구나 열어볼 수 있어요. 대신 바꾸면 들킵니다.
Spring Security의 Authentication은 반대입니다. 인증이 끝나면 비밀번호를 지웁니다. 이쪽은 감추는 쪽이에요.
둘은 다른 문제이고 해법도 다릅니다. 보안을 공부할 때 이 둘을 섞으면, 토큰에 개인정보를 담아놓고 안전하다고 생각하는 실수가 나옵니다.
이번 회차에서 가져갈 것
1. ThreadLocal에 담았으면 비우기.
서버는 스레드를 재사용합니다. 안 비우면 다음 요청이 앞 사람의 인증 정보를 물려받습니다. 직접 만든 스레드에서는 프레임워크의 보장이 안 통합니다.
2. JWT 페이로드에 비밀을 담지 않기.
서명은 위조를 막을 뿐 내용을 감추지 않습니다. Base64라 누구나 열어봅니다.
3. 인증 후에는 자격증명 지우기.
비밀번호가 객체에 남아 있으면 로그나 직렬화로 새어 나갈 수 있습니다. 필요 없어진 순간이 지울 때입니다.
4. 생성자 주입까지 했으면 부르는 쪽도 보기.
클래스는 인터페이스만 아는데 main의 if/else가 구현체를 고르고 있으면 OCP는 여전히 깨져 있습니다.
5. 클래스 안에서 new 하지 않기.
확장도 막히고 테스트도 막힙니다. 원인이 하나라서 고치면 둘 다 풀립니다.
3기 6회차는 2025년 6월 중순이었습니다.
Spring Security 연재와 JWT 유틸 정리와 설계 원칙이라는 서로 다른 자리에서, 세 분이 나란히 역할을 나누는 이야기를 기록했습니다.
성장일지는 멤버들이 2주에 한 번씩 자신의 성장을 기록하는 활동이에요. 지난 기수의 성장일지를 다시 읽으면서, 그때 소개해드리지 못한 글을 하나씩 꺼내고 있습니다.
좋은 글 남겨주신 최태형 님, 이민경 님, 최준성 님께 감사드립니다. 그리고 3기를 함께 채워주신 모든 멤버분들께도요! 😊
다음 큐레이션으로 다시 찾아오겠습니다! 🌱
- 에디터 · 성장일지 큐레이터 -
※ 본 큐레이션은 각 저자가 공개한 글을 소개하는 것이며, 모든 원문의 저작권은 저자에게 있습니다. 저자 본인의 요청이 있을 경우 즉시 수정 또는 삭제합니다.