Skip to main content
로그인은 화면 하나가 아니라, 비밀번호를 확인하는 입구와 token을 검증하는 입구, 데이터 저장 규칙, 실패 응답, 테스트가 함께 움직이는 기능이에요.
서비스에 회원가입 화면과 로그인 화면을 붙이는 일은 겉으로 보면 단순해 보여요. 가입 정보를 저장하고, email과 password가 맞으면 token을 돌려주면 끝인 것 같죠. 근데요, 실제 요청이 오가기 시작하면 이야기가 달라져요. 로그인할 때 받은 credential을 보호 API에도 계속 보내야 하는지, 만료된 access token은 어떻게 다시 발급할지, 실패 응답은 누가 같은 모양으로 맞출지까지 결정해야 해요. 그러다 보면 이런 질문이 생겨요.
“그래서 실제 프로젝트에서는 파일을 어디에 두죠?”
“HTTP Basic과 JWT를 한 애플리케이션에서 같이 써도 되나요?”
“refresh token은 DB에 그대로 저장해도 되나요?”
“성공하는 curl 하나만 보면 인증 구현이 끝난 걸까요?”
앞선 글에서는 Spring Security의 filter chain, JWT Resource Server, method security를 각각 떼어 살펴봤어요. 이번에는 그 조각들을 하나의 실행 가능한 auth-api 안에 모아볼 차례예요. 빈 Spring Boot shell에서 시작해 H2에 회원과 역할을 저장하고, HTTP Basic은 email/password를 token으로 바꾸는 한 경로에만 허용해요. 나머지 /api/**는 JWT Bearer token으로 보호하고, refresh token은 원문 대신 HMAC-SHA-256 결과를 저장한 뒤 한 번 사용할 때마다 회전시켜요. 처음부터 파일 이름을 모두 기억할 필요는 없어요. 이 글은 긴 실습이라 다섯 checkpoint로 끊어 읽을 수 있어요. 한 번에 완독하기보다 checkpoint마다 코드를 실행해 보세요. 이미 익숙한 부분은 표를 보고 해당 절로 바로 이동해도 흐름이 끊기지 않아요.
이 글은 SOURCE-BACKED PRACTICE예요. 실습 코드는 Auth API 실습 프로젝트 저장소main 브랜치, auth-api-first-commit 태그를 기준으로 확인할 수 있어요.저장소를 clone한 뒤 git switch --detach auth-api-first-commit으로 글과 같은 snapshot을 열고, ./gradlew test로 15개 test를 재현할 수 있어요. 직접 만드는 흐름을 보고 싶다면 아래 생성 명령부터 차례대로 따라오면 돼요.
여기서 실무처럼 만든다는 말은 인증 서버의 모든 운영 요구를 완성한다는 뜻이 아니에요. HTTP 입구, application 유스케이스, domain port, JDBC와 Security adapter, test 경계를 실제 프로젝트에서 다시 찾을 수 있는 모양으로 나눈다는 뜻이에요.그래서 이 글은 한 요청이 어느 경계를 지나고, 어느 값이 DB에 남고, 어떤 test가 그 약속을 고정하는지에 집중해요. 계정 비활성화와 token 전체 폐기, refresh token 탈취 탐지, TLS, rate limit, 영구 key 관리처럼 별도의 운영 설계가 필요한 항목은 소스를 더 키우지 않고 마지막 절에서 현재 한계와 확장 방향을 정확히 밝힐게요.
curl 예제의 http://localhost:8080은 한 컴퓨터 안에서 흐름을 관찰하기 위한 주소예요. Basic credential, access token, refresh token은 그 자체로 민감한 값이므로 localhost 밖으로 옮길 때는 반드시 TLS로 보호해야 해요. HTTP Basic은 password를 암호화하는 방식이 아니라 HTTP header에 실어 보내는 인증 방식이에요.

1. Spring 프로젝트 shell부터 실행해 봐요

완성된 저장소부터 열면 어떤 파일을 Spring Boot가 만들어줬고, 어떤 파일을 인증 기능 때문에 추가했는지 구분하기 어려워요. 그래서 먼저 아무 인증 규칙도 없는 project shell을 만들고, 이 출발점이 정상인지 확인할게요. 필요한 것은 Java 21, Spring CLI, curl, JSON을 읽을 jq예요. Gradle은 따로 설치하지 않아요. 생성된 Gradle wrapper만 사용해요.
Spring Boot 4.0.7과 현재 프로젝트가 쓰는 starter를 지정해 shell을 만들어요.
마지막 명령은 생성된 shell 자체가 정상인지 보는 기준선이에요. 아직 인증 기능을 검사하는 test는 아니에요. 이 명령이 실패하면 source를 더 얹기 전에 Java 21과 wrapper 다운로드부터 해결해야 해요. 기준선 test가 통과했어요. 인증 API에 필요한 부품을 올리기 위해 생성된 build.gradle을 현재 프로젝트와 똑같이 맞춰요. Boot 4에서는 기능별 main/test starter 이름이 세분화되어 있으므로 아래 파일 전체를 사용해요.
build.gradle
main class에는 나중에 만들 TokenProperties를 자동으로 찾도록 설정 속성 스캔(configuration properties scan)을 켜요.
src/main/java/me/nvim/blog/auth/AuthApiApplication.java
생성된 빈 properties 설정 파일과 기본 context test는 지우고, 이 글에서 만드는 YAML 설정과 세 test class로 바꿀 거예요.

2. 코드보다 앞서 API 계약을 고정해요

project shell은 생겼지만 아직 어떤 요청을 받을지는 정하지 않았어요. class부터 만들면 Basic, Bearer, refresh token의 책임이 뒤섞이기 쉬우니 클라이언트가 볼 계약부터 고정할게요. Basic은 email/password를 token으로 교환하는 한 경로에서만 쓰고, Bearer는 발급된 access token으로 보호 API에 들어갈 때 써요. refresh 경로에는 별도의 HTTP 인증 filter가 없지만, body의 token을 application layer가 HMAC hash·만료·폐기 상태로 검증해요. 이 그림은 호출 순서가 아니라 코드가 의존하는 방향이에요. controller는 IdentityFacade를 호출하고, application은 저장과 token 발급을 Java interface에 요청해요. 이 글에서는 그 interface를 port, JDBC나 Spring Security로 만든 구현 class를 adapter라고 부를게요. IdentityFacade는 SQL이나 JWT library를 직접 부르지 않아요. userAccountRepository.save(...), tokenIssuer.issue(...)처럼 안쪽이 정한 규격만 사용해요. 안쪽은 원하는 일을 말하고, 바깥은 기술로 그 일을 구현한다. port 설명은 여기까지만 기억하면 충분해요. 프로젝트 package는 다음 모양으로 자라요.
me.nvim.blog.auth
AuthApiApplication.java
identity
domain
UserAccount.java
RefreshToken.java
UserAccountRepository.java
application
IdentityFacade.java
RegisterAccountCommand.java
AccountResult.java
infrastructure
config
jdbc
security
presentation
AccountController.java
RegisterRequest.java
UserSummaryResponse.java
common
config
exception
기능 이름인 identity를 먼저 두고 그 안을 계층으로 나눴어요. 계정 기능을 고칠 때 관련 파일은 함께 찾되, 변경 이유는 package로 구분하기 위해서예요. 표는 framework의 절대 규칙이 아니라 코드 리뷰 기준이에요. applicationRegisterRequest를 import하면 JSON 계약이 안쪽으로 샌 것이고, controller가 JdbcUserAccountRepository를 바로 부르면 HTTP가 저장 기술까지 알게 된 거예요. 여기서 흔히 헷갈리는 Request → Command → Result → Response도 한 문장으로 정리할 수 있어요. HTTP 모양은 presentation이, 유스케이스의 입력과 출력은 application이 소유해요. 값이 같아 보여도 바뀌는 이유가 다르면 분리하고, 단순 조회처럼 의미가 분명하면 모든 값을 억지로 wrapper에 넣지 않아요. 실제 변환 코드는 6절과 11절에서 바로 볼게요.
어떤 팀은 port를 application/port/out에 두고 domain에는 model만 둬요. 이 글은 작은 프로젝트라 domain을 “핵심 model과 바깥에 요구하는 계약”까지 넓게 잡았어요. package 이름보다 중요한 것은 안쪽 interface가 JDBC 구현을 import하지 않는 의존 방향이에요.

3. 설정, schema, 초기 역할을 만들어요

HTTP 계약과 package 경계가 잡혔어요. domain object나 repository 구현에 앞서 설정과 schema를 고정하면, 뒤에서 등장하는 expiresAt, tokenHash, ROLE_USER가 어디에 저장되는지 연결해서 볼 수 있어요. 애플리케이션이 사용할 값과 DB 모양은 다음과 같아요.
src/main/resources
+ application.yml
+ schema.sql
+ data.sql
src/main/resources/application.yml
H2는 메모리에서 뜨고, 실행할 때마다 schema.sqldata.sql을 적용해요. access token은 15분, refresh token은 7일이에요. 환경 변수가 없으면 학습용 값과 임시 RSA 키를 사용하지만, 이 기본값은 운영 secret이 아니에요.
src/main/resources/schema.sql
src/main/resources/data.sql
회원과 역할을 다대다 표로 나누었고, refresh token table에는 원문 열이 없어요. HMAC-SHA-256 결과는 32 byte, 16진수로 64글자라서 char(64)에 들어가요. 아직 repository와 security bean이 없으므로 여기서 애플리케이션 전체를 띄우는 checkpoint를 만들지는 않을게요. 파일 모양만 확인해요.

4. Spring 없는 domain으로 규칙을 잠가요

DB 표를 만들었으니 곧바로 JDBC 코드를 쓰고 싶을 수 있어요. 하지만 저장 기술부터 시작하면 회원의 기본 역할이나 token의 만료·폐기 규칙까지 SQL 모양에 끌려가기 쉬워요. 그래서 안쪽 규칙을 먼저 만들고, JDBC는 그 규칙에 맞춰 나중에 연결할게요. domain에는 Spring import가 하나도 없어야 해요. 계정과 token이라는 업무 개념, 그리고 바깥 구현에 요구할 port를 만들어요. 전체 파일은 실습 저장소의 domain package에서 볼 수 있고, 본문에서는 규칙이 들어 있는 UserAccount와 저장 계약에 집중할게요.
src/main/java/me/nvim/blog/auth/identity/domain
+ IssuedToken.java
+ PasswordHasher.java
+ RefreshToken.java
+ RefreshTokenRepository.java
+ TokenIssuer.java
+ UserAccount.java
+ UserAccountRepository.java
src/main/java/me/nvim/blog/auth/identity/domain/UserAccount.java
register는 email을 소문자로 정규화하고 공개 가입자의 역할을 무조건 ROLE_USER로 정해요. 전달받은 role 목록은 방어적으로 복사해서 바깥 코드가 계정 권한을 몰래 바꾸지 못하게 해요. IssuedTokenRefreshToken은 값만 담는 record예요. PasswordHasherTokenIssuer도 각각 hash(...), issue(...) method 하나를 선언해요. 이 네 파일에는 정책을 설명하는 추가 코드가 없으므로 저장소에서 확인하고, 저장 port 두 개는 method 모양을 함께 볼게요.
src/main/java/me/nvim/blog/auth/identity/domain/UserAccountRepository.java
src/main/java/me/nvim/blog/auth/identity/domain/RefreshTokenRepository.java
잠깐 이름만 살펴볼게요. port는 findActive(rawToken, now)처럼 원하는 일을 말하고, HMAC이나 column 이름은 드러내지 않아요. 실제 hash와 SQL은 8절의 JDBC adapter가 맡아요.
IdentityFacade는 이 interface만 호출하고, Spring은 실행할 때 JDBC 구현 bean을 연결해요. 저장 기술이 바뀌어도 application의 회원가입·token 회전 순서를 건드리지 않기 위한 경계예요. 잠깐 멈춰서 Spring context 없이 domain 규칙 두 개를 검사해요.
src/test/java/me/nvim/blog/auth/identity/domain/UserAccountTests.java
여기서는 정확히 이 test만 실행할 수 있어요. main source에는 아직 구현되지 않은 class 참조가 없으므로 실제로 통과하는 첫 checkpoint예요.

5. 실패는 HTTP 경계에서 ProblemDetail로 번역해요

domain의 정상 규칙을 만들었지만 인증 API는 실패하는 장면이 더 많아요. 이미 가입한 email, 잘못된 가입 입력, 만료되거나 재사용된 refresh token을 각 class가 제각각 예외로 던지면 클라이언트가 보는 응답도 흔들려요. 그렇다고 domain이 401이나 409 같은 HTTP 숫자를 알게 만들면 안쪽 규칙이 웹 기술에 묶여요. 안쪽에서는 EMAIL_ALREADY_USED, INVALID_REFRESH_TOKEN 같은 업무 오류로 말하고, HTTP 경계에서만 이를 ProblemDetail로 번역하는 공통 어휘를 만들어요.
src/main/java/me/nvim/blog/auth/common/exception
+ BusinessException.java
+ ErrorCode.java
+ GlobalExceptionHandler.java
src/main/java/me/nvim/blog/auth/common/exception/ErrorCode.java
src/main/java/me/nvim/blog/auth/common/exception/BusinessException.java
src/main/java/me/nvim/blog/auth/common/exception/GlobalExceptionHandler.java
Spring MVC가 validation 실패를 MethodArgumentNotValidException으로 전달하면 advice가 400 Problem Detail과 필드별 errors를 만들어요. 중복 email, 이미 쓴 refresh token처럼 controller까지 들어온 뒤 발생한 업무 실패도 같은 응답 형식으로 번역돼요. 여기에는 중요한 경계가 하나 있어요. 틀린 Basic password, 없거나 잘못된 Bearer token, 권한 부족은 controller보다 앞선 Spring Security filter에서 거절돼요. 이런 실패는 @RestControllerAdvice를 지나지 않기 때문에 현재 source에서는 업무 오류와 같은 ProblemDetail body를 보장하지 않고 401 또는 403 status만 고정해요. 모든 실패 body를 하나의 계약으로 맞춰야 하는 API라면 AuthenticationEntryPointAccessDeniedHandler에서도 같은 Problem Detail을 쓰고, body까지 통합 test로 확인해야 해요. 이번 실습은 controller 안쪽의 오류 번역Security filter의 인증·인가 거절이 서로 다른 경계라는 점까지만 보여줘요. 또 하나, 예제의 https://blog.nvim.me/problems/... 값은 problem type을 구분하는 식별자로 사용하지만 현재 해당 설명 page까지 제공하지는 않아요. HTTP URL을 type으로 쓴다면 운영에서는 사람이 읽을 수 있는 안정적인 설명 page를 함께 게시하는 편이 좋아요. 아직 문서를 운영하지 않는 API라면 준비되지 않은 URL을 약속하기보다 about:blank나 실제로 관리할 수 있는 type 체계를 선택해야 해요.

6. 유스케이스는 IdentityFacade에 모아요

domain이 할 수 있는 일과 실패할 때 쓸 언어가 준비됐어요. 하지만 회원가입 하나만 해도 “password hash → 계정 저장 → DB unique constraint로 중복 확정”이라는 순서가 필요하고, refresh에는 “활성 token 조회 → 폐기 → 새 token 발급 → 저장” 순서가 필요해요. 이 흐름을 controller나 repository에 흩어놓지 않고 application layer의 한 입구에 모을게요. 중복 email을 저장 전에 한 번 조회하는 방식은 동시에 들어온 두 요청을 완전히 막지 못해요. 그래서 이 source는 사전 조회보다 DB의 unique constraint를 최종 기준으로 삼고, 저장 중 발생한 DuplicateKeyExceptionEMAIL_ALREADY_USED 업무 오류로 바꿔요. HTTP request를 domain에 바로 넘기지 않아요. presentation의 입력은 command로, domain의 출력은 result로 바뀌어요. 2절에서 길게 설명하는 대신 실제 코드에서 역할을 확인해 볼게요.
src/main/java/me/nvim/blog/auth/identity/application
+ AccountResult.java
+ IdentityFacade.java
+ IssueTokenCommand.java
+ RefreshTokenCommand.java
+ RegisterAccountCommand.java
+ TokenResult.java
command 세 개는 유스케이스 입력만 담고, result 두 개는 공개 가능한 출력만 담는 record예요. 반복되는 field 선언과 from(...) 변환은 실습 저장소의 application package에서 확인할 수 있어요. 여기서는 이 객체들을 실제로 조합하는 facade 전체를 봐요. facade는 presentation이 동기 방식으로 들어오는 유일한 입구예요. 가입, 조회, token 발급, refresh 회전의 transaction 경계도 여기 있어요.
src/main/java/me/nvim/blog/auth/identity/application/IdentityFacade.java
issueToken이 비밀번호를 다시 받지 않는 게 처음엔 이상해 보이죠? /api/auth/token/basic 앞의 Spring Security filter가 이미 비밀번호를 확인하고 Authentication을 만들어요. controller는 검증을 통과한 email만 facade에 넘겨요. 이 경계를 뒤에서 두 filter chain으로 고정할 거예요. refresh에서는 “조회 후 폐기”만 믿지 않고 revokeIfActive의 update 결과도 검사해요. 같은 token을 동시에 두 요청이 쓰더라도 먼저 폐기한 요청만 다음 token을 받을 수 있게 하는 핵심 조건이에요. 원문은 client와 발급 순간에만 보이고 DB 비교는 HMAC hash로 해요. 이전 token의 폐기가 성공한 뒤에만 새 token을 저장하므로 rotation이 “새 token도 주고 옛 token도 살려두는” 동작이 되지 않아요.
현재 구현은 같은 refresh token으로 두 요청이 둘 다 성공하는 일을 막아요. 하지만 탈취한 token을 공격자가 먼저 사용했을 때 새로 발급된 공격자 쪽 token까지 찾아 폐기하는 replay 탐지는 아니에요. 피해자가 뒤늦게 옛 token을 보내면 401을 받지만, 먼저 성공한 요청이 받은 새 token은 계속 유효해요.운영에서 탈취 재사용까지 대응하려면 refresh token에 family나 parent 관계를 남기고, 이미 폐기된 token이 다시 제시되면 그 family의 활성 token을 함께 폐기해야 해요. 이 글은 그 단계까지 구현하지 않고 원자적인 1회 사용과 교체만 다뤄요.

7. JWT 서명 재료를 준비해요

IdentityFacadeTokenIssuer port에 “token을 발급해 달라”고 요청해요. 바깥쪽 adapter가 실제 JWT를 만들려면 만료 시간, 발급자, 대상자, RSA key가 필요하죠. 문자열 설정을 필요한 곳마다 직접 읽지 않고, 시작할 때 한 번 검증되는 설정 객체와 key bean으로 준비할게요. application.ymlauth.token을 타입 있는 record로 묶어요. TTL은 양수여야 하고, private/public PEM은 반드시 쌍으로 들어와야 해요.
src/main/java/me/nvim/blog/auth/identity/infrastructure/config
+ RsaKeyConfig.java
+ TokenProperties.java
src/main/java/me/nvim/blog/auth/identity/infrastructure/config/TokenProperties.java
RSA 설정은 PEM이 있으면 PKCS#8 private key와 X.509 public key를 읽고, 없으면 학습용 2048-bit key pair를 실행할 때 만들어요. 같은 pair에서 encoder와 decoder를 만들어요.
src/main/java/me/nvim/blog/auth/identity/infrastructure/config/RsaKeyConfig.java
설정한 PEM을 같은 key pair로 복원하는지는 RsaKeyConfigTests가 byte 배열까지 비교해요. 전체 test 코드는 저장소에서 보고, 여기서는 domain test와 함께 checkpoint만 실행할게요.

8. domain port를 JDBC adapter로 구현해요

여기까지 안쪽 코드는 “회원을 저장해 달라”, “활성 refresh token을 찾아 달라”고 요청만 했어요. 이 절에서는 준비된 설정과 schema를 사용해 그 요청을 실제 DB 작업으로 바꿔요. UserAccountRepositoryRefreshTokenRepository의 실제 DB 구현을 만들어요. Spring Data가 interface를 대신 구현하게 하지 않고 JdbcClient로 SQL을 눈에 보이게 적어요.
src/main/java/me/nvim/blog/auth/identity/infrastructure/jdbc
+ JdbcInstantReader.java
+ JdbcRefreshTokenRepository.java
+ JdbcUserAccountRepository.java
회원 adapter는 계정과 role row를 함께 저장하고, unique email 위반을 EMAIL_ALREADY_USED 업무 오류로 바꿔요. 조회·mapping과 H2 timestamp 차이를 처리하는 코드는 JDBC package 전체에서 확인할 수 있어요. 여기서 자세히 볼 쪽은 refresh token이에요. 원문에 application secret을 섞어 HMAC-SHA-256을 계산하고, DB에는 16진수 hash만 저장해요. 활성 token 조회와 폐기의 핵심 SQL은 다음 두 조건을 공유해요.
src/main/java/me/nvim/blog/auth/identity/infrastructure/jdbc/JdbcRefreshTokenRepository.java
findActive로 읽은 뒤에도 revokeIfActive의 update 결과를 다시 확인하는 이유는 동시 요청 때문이에요. 같은 token을 두 요청이 함께 읽더라도 updated == 1을 얻은 한 요청만 새 token으로 진행할 수 있어요. HMAC 생성과 저장 코드는 본질적으로 hash(rawToken)을 양쪽에서 동일하게 적용하는 구현이라 저장소 링크로 넘겼어요.

9. password와 token 발급 adapter를 연결해요

DB adapter가 저장 port를 채웠다면, 아직 비어 있는 기술 경계는 password hash와 인증이에요. domain은 password를 어떤 algorithm으로 hash하는지, JWT를 어떤 library로 서명하는지 몰라야 하죠. 이번에는 그 port들을 Spring Security 구현과 연결할게요. domain port와 Spring Security 사이를 잇는 네 class를 만들어요.
src/main/java/me/nvim/blog/auth/identity/infrastructure/security
+ DatabaseUserDetailsService.java
+ JwtTokenIssuer.java
+ SpringPasswordHasher.java
+ UserPrincipal.java
Basic 쪽 세 class는 번역 역할이 분명해요. UserPrincipalUserAccountUserDetails로 바꾸고 role 문자열을 GrantedAuthority로 옮겨요. DatabaseUserDetailsService는 email을 소문자로 정규화해 계정을 찾고, SpringPasswordHasher는 domain의 PasswordHasherPasswordEncoder로 구현해요. 짧지만 반복적인 위임 코드는 security package 전체에서 확인할 수 있어요. 실제로 token 모양을 결정하는 JwtTokenIssuer는 본문에서 볼게요. JWT adapter는 RSA private key로 access token을 서명하고 32 byte 무작위 refresh token을 만들어요. JWT에는 iss, sub, aud, iat, exp와 우리 claim인 email, roles가 들어가요.
src/main/java/me/nvim/blog/auth/identity/infrastructure/security/JwtTokenIssuer.java
access token은 서명된 JWT라서 resource server가 자체 검증할 수 있어요. refresh token은 의미 없는 opaque random 문자열이고, 우리 DB와 application만 상태를 판단해요.

10. 두 SecurityFilterChain의 책임을 갈라요

password를 확인할 provider와 JWT를 만들 adapter는 준비됐어요. 하지만 이것만으로는 Spring Security가 어느 URL에서 어느 인증 방식을 사용할지 알 수 없어요. 그 선택을 filter chain의 matcher와 순서로 명시해야 해요. 이 프로젝트의 핵심은 인증 방식을 섞지 않는 두 chain이에요.
src/main/java/me/nvim/blog/auth/common/config
+ SecurityConfig.java
Spring Security는 먼저 matcher가 맞는 chain 하나를 고른 뒤 그 chain의 filter만 적용해요. 그래서 /api/me에는 Basic filter가 없고, email/password가 맞아도 Basic header만으로는 통과할 수 없어요.
src/main/java/me/nvim/blog/auth/common/config/SecurityConfig.java
두 chain 모두 server-side session을 만들지 않는 STATELESS예요. 이 실습은 browser가 자동으로 credential을 붙이는 화면이 아니라, curl 같은 비브라우저 client가 Authorization header를 직접 넣는 localhost API를 전제로 CSRF를 꺼요. STATELESS이거나 API라는 이유만으로 CSRF가 자동으로 안전해지는 것은 아니에요. browser가 Basic credential이나 cookie를 자동 전송하는 구성이라면 CSRF 보호를 유지하거나, 별도의 token 전달 방식과 origin 정책을 함께 설계해야 해요. JwtAuthenticationConverter는 principal 이름으로 email claim을 쓰고, 기본 scope 권한과 roles claim을 합쳐 method security가 ROLE_ADMIN을 볼 수 있게 해요.
RsaKeyConfig의 decoder는 RSA 공개키로 서명과 표준 시간 claim을 검증하지만, 현재 source는 issueraudience validator를 명시적으로 연결하지 않았어요. token을 만들 때 두 claim을 넣는 것과, 받을 때 기대값을 강제하는 것은 별개의 일이라는 점을 기억해야 해요.

11. HTTP 계약을 controller로 열어요

두 filter chain이 현관에서 credential을 검사하도록 했지만, 아직 그 뒤에서 JSON을 받을 controller가 없어요. 2절에서 약속한 HTTP 계약을 실제 URL과 DTO로 열어볼게요. 바깥 요청이 들어올 presentation layer를 만들어요. request는 Bean Validation으로 “HTTP 입력 모양”을 검사하고 command로 바뀌어요. response는 result에서 필요한 필드만 내보내므로 password hash는 API에 노출되지 않아요.
src/main/java/me/nvim/blog/auth/identity/presentation
+ AccountController.java
+ AdminController.java
+ AdminUsersResponse.java
+ AuthTokenController.java
+ RefreshTokenRequest.java
+ RegisterRequest.java
+ TokenResponse.java
+ UserSummaryResponse.java
src/main/java/me/nvim/blog/auth/identity/presentation/RegisterRequest.java
@Size(max = 72)는 Java 문자열의 문자 수를 검사하지만, 기본 DelegatingPasswordEncoder가 새 password에 사용하는 bcrypt의 입력 한계는 UTF-8 기준 72 byte예요. 그래서 ASCII 72자는 처리할 수 있어도 한글처럼 한 글자가 여러 byte인 password는 72자보다 짧아도 encoder에서 거절될 수 있어요. 현재 source는 이 예외를 validation ProblemDetail로 바꾸지 않으므로 그런 입력은 500이 될 수 있어요.운영 API에서는 hash 전에 UTF-8 byte 길이를 검증해 400으로 돌려주거나, 선택한 password hash algorithm에 맞는 별도 입력 정책을 둬야 해요. 이 글에서는 DTO와 encoder의 경계가 서로 다른 단위를 볼 수 있다는 한계만 명시하고 source는 그대로 유지해요.
RefreshTokenRequest는 빈 token을 거르고 RefreshTokenCommand로 바꿔요. 세 response record는 application result에서 공개 필드만 복사해요. 같은 모양의 선언을 모두 싣기보다 presentation package 전체에서 확인하도록 맡길게요. 계정 controller는 가입 request를 command로 바꿔 IdentityFacade에 넘기고 201 Created를 만들어요. /api/me는 Bearer filter가 검증해 둔 Authentication.getName()으로 계정을 조회해요. 두 method 모두 저장 adapter를 직접 알지 않는다는 점만 확인하면 돼요.
가입 응답의 Location/api/users/{id}를 가리키지만 현재 프로젝트에는 그 경로의 GET controller가 없어요. header 형식을 만들었다고 실제 resource 조회 API까지 구현된 것은 아니며, 운영 계약에서는 route를 추가하거나 Location 정책을 바꿔야 해요.
token controller의 Basic method는 password를 직접 읽지 않아요. 앞선 filter가 인증을 끝낸 Authentication의 이름을 사용해 token을 발급해요. refresh method는 body를 application command로 넘겨요.
src/main/java/me/nvim/blog/auth/identity/presentation/AuthTokenController.java
admin controller는 URL 인증을 통과한 뒤 @PreAuthorize("hasRole('ADMIN')")로 역할을 한 번 더 확인해요. 공개 가입은 항상 ROLE_USER만 만들기 때문에, 뒤의 통합 test는 관리자를 repository에 직접 준비해서 method security를 검사해요.

12. 전체 경계를 통합 test로 잠가요

필요한 class는 모두 생겼어요. 그래도 개별 코드를 읽는 것만으로는 Spring이 올바른 bean을 연결했는지, 두 filter chain 중 맞는 하나를 골랐는지, 예외가 약속한 JSON으로 번역됐는지 알 수 없어요. 여기부터는 여러 경계를 한꺼번에 통과하는 요청으로 확인해야 해요. domain test 2개와 RSA 설정 test 1개에 Spring context, H2, MockMvc, 두 filter chain을 함께 띄우는 통합 test 12개를 더해 총 15개를 만들어요. test profile에서는 refresh HMAC secret만 test 전용 값으로 덮어써요.
src/test/resources/application-test.yml
12개를 모두 펼치면 test 목록만으로 흐름이 다시 늘어져요. 그래서 본문에는 회원가입→Basic 발급→Bearer 조회, Bearer API의 Basic 거절, refresh rotation을 대표로 남겼어요. 통합 test 전체에는 validation, Problem Detail, JWT kid, admin 권한 같은 나머지 경계도 들어 있어요.
src/test/java/me/nvim/blog/auth/identity/presentation/AuthApiIntegrationTests.java
@SpringBootTest는 전체 bean graph를 만들고, @AutoConfigureMockMvc는 실제 servlet/filter 흐름을 process 안에서 호출해요. 각 test 뒤 context를 다시 만들어 H2 상태와 임시 RSA key가 test 사이에 섞이지 않게 해요. 15개 test가 고정하는 경계는 다음과 같아요.

13. 전체 tree에서 build와 test를 확인해요

지금까지는 역할별로 파일을 하나씩 추가했어요. 여기서 전체 tree를 다시 보면 처음에 그린 presentation → application → domain, 그리고 바깥의 infrastructure가 실제 package에 어떻게 놓였는지 한눈에 연결할 수 있어요. 실행에 필요한 project tree를 펼치면 다음과 같아요. Gradle이 생성하는 .gradle/, build/는 제외했어요.
auth-api
build.gradle
settings.gradle
gradlew
gradlew.bat
gradle
wrapper
gradle-wrapper.jar
gradle-wrapper.properties
src
main
java/me/nvim/blog/auth
AuthApiApplication.java
common
config
SecurityConfig.java
exception
BusinessException.java
ErrorCode.java
GlobalExceptionHandler.java
identity
domain
IssuedToken.java
PasswordHasher.java
RefreshToken.java
RefreshTokenRepository.java
TokenIssuer.java
UserAccount.java
UserAccountRepository.java
application
AccountResult.java
IdentityFacade.java
IssueTokenCommand.java
RefreshTokenCommand.java
RegisterAccountCommand.java
TokenResult.java
infrastructure
config
RsaKeyConfig.java
TokenProperties.java
jdbc
JdbcInstantReader.java
JdbcRefreshTokenRepository.java
JdbcUserAccountRepository.java
security
DatabaseUserDetailsService.java
JwtTokenIssuer.java
SpringPasswordHasher.java
UserPrincipal.java
presentation
AccountController.java
AdminController.java
AdminUsersResponse.java
AuthTokenController.java
RefreshTokenRequest.java
RegisterRequest.java
TokenResponse.java
UserSummaryResponse.java
resources
application.yml
data.sql
schema.sql
test
java/me/nvim/blog/auth/identity
domain
UserAccountTests.java
infrastructure/config
RsaKeyConfigTests.java
presentation
AuthApiIntegrationTests.java
resources
application-test.yml
모든 production bean과 test가 모였으니 전체 checkpoint를 실행해요.
auth-api-first-commit snapshot에서 ./gradlew clean test --console=plain을 다시 실행한 결과는 다음과 같았어요.
생성된 XML test report를 합산하면 tests=15, failures=0, errors=0, skipped=0이에요. 실행 시간처럼 환경마다 달라지는 값은 증거에서 제외하고, 재현할 때 확인할 결과만 남겼어요. 두 번째 명령도 test task를 포함해요. 첫 번째 명령 뒤 HTML report의 class별 결과를 보고 싶다면 다음 파일을 browser로 열면 돼요.
report에서 UserAccountTests 2개, RsaKeyConfigTests 1개, AuthApiIntegrationTests 12개, 합계 15개가 모두 통과했는지 확인해요. 숫자만 맞는 것보다 실패 경계의 이름을 함께 읽는 게 중요해요.

14. 실제 server를 curljq로 끝까지 사용해요

통합 test는 애플리케이션 안쪽의 servlet과 filter 흐름을 통과했어요. 마지막 확인은 실제 server port를 열고 외부 client로 요청하는 일이에요. 회원가입부터 refresh token 재사용 거절까지 같은 status가 나오는지 볼게요. “Basic은 발급 창구, Bearer는 보호 API의 출입증”이라는 경계가 여기서 눈에 보여요. Security filter가 만든 401 body까지 ProblemDetail로 통일됐다는 뜻은 아니에요. 첫 번째 terminal에서 server를 띄워요.
두 번째 terminal은 auth-api directory에서 시작해요. 응답 body는 임시 directory에 두고, HTTP status와 JSON 필드를 따로 검사해요.
가입은 201이고 가입 응답에는 ROLE_USER가 있어야 해요.
이번에는 Basic credential을 token 쌍으로 교환해요. TokenResponse의 실제 필드인 tokenType, accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt을 그대로 사용해요.
Bearer access token으로 /api/me를 부르면 200이에요.
같은 email/password가 맞더라도 /api/me에 Basic을 보내면 401이어야 해요. 두 chain의 경계가 실제로 보이는 요청이에요.
refresh token을 한 번 사용하면 새 access/refresh token 쌍이 200으로 와요. 새 응답에서도 실제 필드명을 사용해 값을 꺼내요.
마지막으로 refresh token을 다시 보내요. 이미 revoked_at이 기록됐으므로 401이어야 해요.
이 흐름에서 확인한 status는 차례대로 health 200, register 201, Basic token 200, Bearer /api/me 200, Basic /api/me 401, refresh 200, 이전 refresh token 재사용 401이에요. success path만이 아니라 “맞는 credential을 틀린 입구에 보냈을 때”와 “이미 쓴 token을 다시 보냈을 때”까지 관찰했어요.

지금 구현의 운영 경계를 숨기지 않을게요

여기까지 실제 요청이 모두 예상대로 움직였어요. 그렇다고 “로컬에서 실행된다”와 “운영에 배포할 준비가 됐다”가 같은 말은 아니에요. 오히려 지금 빠진 것을 분명히 적어야 이 실습 코드를 출발점으로 안전하게 확장할 수 있어요. 이 프로젝트는 구조와 보안 경계를 학습하기 위한 실행 가능한 기준점이지, 그대로 배포할 완제품은 아니에요.
  • http://localhost:8080은 로컬 관찰용이에요. 외부 환경에서는 Basic password와 두 token을 모두 TLS로 보호해야 하고, token 발급 경로에는 rate limit, 반복 실패 지연, credential stuffing 탐지와 audit log 같은 abuse control이 필요해요.
  • base application.yml에서 H2 console이 켜져 있고, 두 SecurityFilterChain/api/auth/**/api/**에만 맞아요. 따라서 H2 console과 그 밖의 경로는 현재 Spring Security filter chain 밖에 있어요. 운영에서는 console을 dev profile로 옮기거나 끄고, 마지막 catch-all chain으로 의도하지 않은 경로를 거절해야 해요. H2 in-memory 대신 실제 DB와 migration 도구도 필요해요.
  • RSA PEM 환경 변수가 없으면 실행할 때 임시 key pair를 만들어요. 재시작하면 기존 access token의 서명을 더는 검증할 수 없고, 여러 instance가 서로의 token을 검증할 수도 없어요. 운영에서는 secret store의 고정 key와 rotation 전략이 필요해요.
  • JWT를 만들 때 issueraudience를 넣지만 현재 JwtDecoder에는 두 claim의 기대값 validator가 명시되어 있지 않아요. 공개키가 맞는 token이라도 의도한 발급자와 수신자인지 별도 검증하도록 강화해야 해요.
  • 현재 계정의 enabled 값은 Basic 로그인에서는 확인하지만 refresh 유스케이스에서는 다시 확인하지 않아요. 이 프로젝트에는 계정 비활성화 API가 없지만, 그 기능을 추가한다면 비활성 계정의 refresh를 거절하고 해당 계정의 refresh token도 함께 폐기해야 해요. 이미 발급된 stateless access token은 별도 폐기 수단이 없다면 만료 시점까지 유효하다는 점도 운영 정책에 포함해야 해요.
  • 현재 rotation은 같은 refresh token의 동시·순차 재사용이 두 번 성공하지 않게 만들 뿐, 탈취 재사용을 탐지해 token family 전체를 폐기하지는 않아요. 운영에서 replay 대응이 필요하면 family나 parent 관계와 전체 폐기 정책을 추가해야 해요.
  • @Size(max = 72)는 문자 수를 보고 bcrypt는 UTF-8 byte 수를 봐요. 다국어 password를 받는 운영 API는 hash 전에 byte 경계를 검증하고 validation 오류로 돌려줘야 해요.
  • GlobalExceptionHandler가 만드는 Problem Detail은 controller 안쪽의 validation과 업무 예외에 적용돼요. Security filter에서 먼저 끝나는 Basic/Bearer 인증 실패와 403까지 같은 body로 맞추려면 별도의 AuthenticationEntryPointAccessDeniedHandler가 필요해요.
  • 가입 응답의 Location: /api/users/{id}가 가리키는 GET route는 아직 없어요. API 계약을 완성할 때 route나 header 중 하나를 맞춰야 해요.
  • 공개 가입은 ROLE_USER만 만들어요. admin provisioning API, 운영자 승인, audit trail은 없어요. 통합 test의 admin은 repository로 직접 준비한 test fixture예요.
  • refresh token HMAC secret에 학습용 기본값이 있어요. 운영에서는 충분히 긴 무작위 secret을 외부 secret store에서 주입하고 교체 계획을 세워야 해요.

참고한 링크

이 글에서 사용한 설정과 Security 동작을 더 깊게 확인하고 싶다면 아래 공식 문서를 이어서 보면 돼요.

자, 정리해볼까요?

  • 인증 API는 controller 하나가 아니라 credential 확인, token 발급·검증, 저장, 실패 계약, 권한 검사를 각각의 경계로 나눠야 해요.
  • presentation -> application -> domain으로 의존하고, JDBC와 Spring Security는 domain port를 구현하는 infrastructure adapter로 두었어요.
  • HTTP Basic은 token 교환 경로에만, JWT Bearer는 보호 API에만 적용하도록 두 SecurityFilterChain을 분리했어요.
  • refresh token 원문 대신 HMAC-SHA-256 결과를 저장하고, 조건부 폐기 뒤 새 token을 발급해 원자적인 1회 사용과 교체를 만들었어요. token family 전체를 폐기하는 replay 탐지는 다음 운영 단계예요.
  • domain 2개, RSA 설정 1개, HTTP 통합 12개, 모두 15개 test와 실제 curl 흐름으로 성공과 거절 경계를 함께 확인했어요.
  • source-backed라는 말은 운영 준비가 끝났다는 뜻이 아니에요. TLS와 abuse control, 비활성 계정과 token 폐기, 임시 RSA key, H2 console, issuer/audience validator, 불완전한 Location, admin provisioning 같은 남은 경계를 사실대로 기록해야 해요.
다음 글에서는 이 프로젝트의 test를 unit, slice, full-context로 다시 나눠 보면서, 빠른 test와 실제 경계를 증명하는 test가 왜 서로를 대신할 수 없는지 살펴볼게요.