Skip to main content
로그인에서는 비밀번호 확인, token 검증, 데이터 저장, 실패 응답과 test가 맞물려요.
서비스에 회원가입 화면과 로그인 화면을 붙이는 일은 겉으로 보면 단순해 보여요. 가입 정보를 저장해요. email과 password가 맞으면 token을 돌려줘요. 그게 전부인 것 같죠. 근데요, 실제 요청이 오가기 시작하면 이야기가 달라져요. 로그인 뒤 credential을 어느 요청에 보낼지 정해야 해요. 만료된 access token을 어떻게 다시 발급할지, 실패 응답은 누가 같은 모양으로 맞출지도 결정해야 해요. 그러다 보면 이런 질문이 생겨요.
“그래서 실제 프로젝트에서는 파일을 어디에 두죠?”
“HTTP Basic과 JWT를 한 애플리케이션에서 같이 써도 되나요?”
“refresh token은 DB에 그대로 저장해도 되나요?”
“성공하는 curl 하나만 보면 인증 구현이 끝난 걸까요?”
앞선 글에서는 Spring Security의 filter chain, JWT Resource Server, method security를 각각 떼어 살펴봤어요. 이번 실습은 그 조각들을 하나의 실행 가능한 auth-api에 모아요. 분량이 길어 두 편으로 나눴고, 이 1편에서는 빈 Spring Boot shell부터 domain port, IdentityFacade, JWT 설정까지 만들어요. 2편에서는 JDBC와 Security adapter, controller, 통합 test와 실제 HTTP 요청을 연결해요. 처음부터 파일 이름을 모두 기억할 필요는 없어요. 이 글은 세 checkpoint로 끊어 읽을 수 있어요. 처음 따라 한다면 절을 건너뛰지 마세요. 각 절의 파일은 뒤 절의 compile 입력이 되고, 실행 가능한 checkpoint에서만 Gradle을 호출해요.
이 글은 SOURCE-BACKED PRACTICE예요. 실습 코드는 Auth API 실습 프로젝트 저장소의 auth-api-first-commit tag, commit 9af34bd를 기준으로 확인했어요.완성본을 확인하려면 저장소를 clone한 뒤 git switch --detach auth-api-first-commit으로 snapshot을 고정하고 ./gradlew test를 실행해요. 직접 만들려면 아래 생성 명령부터 순서대로 진행해요.
여기서 실무처럼 만든다는 말은 인증 서버의 모든 운영 요구를 완성한다는 뜻이 아니에요. HTTP 입구, application 유스케이스, domain port, JDBC와 Security adapter, test 경계를 실제 프로젝트에서 다시 찾을 수 있는 모양으로 나눈다는 뜻이에요.그래서 이 실습은 한 요청이 어느 경계를 지나고, 어느 값이 DB에 남고, 어떤 test가 그 약속을 고정하는지에 집중해요. 계정 비활성화와 token 전체 폐기, refresh token 탈취 탐지, TLS, rate limit, 영구 key 관리처럼 별도의 운영 설계가 필요한 항목은 2편 마지막에서 현재 한계와 확장 방향을 밝혀요.

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가 통과했어요. Initializr가 만든 build.gradle에는 필요한 starter가 이미 들어 있어요. 이 snapshot은 직접 사용하지 않는 Actuator test starter 한 줄만 빼요.
build.gradle
빨간 줄은 Initializr 결과에서 지울 코드예요. 초록 줄은 기존 파일에 더할 코드예요. 이 뒤에 만드는 파일은 파일 전체가 새 코드라서 모든 줄을 초록색으로 칠하지 않아요.
한 줄을 제거한 뒤 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의 절대 규칙이 아니라 코드 리뷰 기준이에요. application이 RegisterRequest를 import하면 JSON 계약이 안쪽으로 샌 것이고, controller가 JdbcUserAccountRepository를 바로 부르면 HTTP가 저장 기술까지 알게 된 거예요. 여기서 흔히 헷갈리는 Request → Command → Result → Response도 한 문장으로 정리할 수 있어요. HTTP 모양은 presentation이, 유스케이스의 입력과 출력은 application이 소유해요. 값이 같아 보여도 바뀌는 이유가 다르면 분리하고, 단순 조회처럼 의미가 분명하면 모든 값을 억지로 wrapper에 넣지 않아요. application 쪽 변환은 6절에서, HTTP 쪽 변환은 2편에서 이어서 볼게요.
어떤 팀은 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.sql과 data.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를 만들어요. 먼저 main과 test package를 만들어요.
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 목록은 방어적으로 복사해서 바깥 코드가 계정 권한을 몰래 바꾸지 못하게 해요. IssuedToken과 RefreshToken은 값만 담는 record예요. PasswordHasher와 TokenIssuer는 각각 hash(...), issue(...) method를 선언해요. 아래 여섯 파일도 모두 만들어야 해요. 하나라도 빠지면 뒤의 IdentityFacade가 compile되지 않아요.
src/main/java/me/nvim/blog/auth/identity/domain/UserAccountRepository.java
src/main/java/me/nvim/blog/auth/identity/domain/RefreshToken.java
src/main/java/me/nvim/blog/auth/identity/domain/RefreshTokenRepository.java
src/main/java/me/nvim/blog/auth/identity/domain/IssuedToken.java
src/main/java/me/nvim/blog/auth/identity/domain/PasswordHasher.java
src/main/java/me/nvim/blog/auth/identity/domain/TokenIssuer.java
잠깐 이름만 살펴볼게요. port는 findActive(rawToken, now)처럼 원하는 일을 말하고, HMAC이나 column 이름은 드러내지 않아요. 실제 hash와 SQL은 2편의 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라면 AuthenticationEntryPoint와 AccessDeniedHandler에서도 같은 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를 최종 기준으로 삼고, 저장 중 발생한 DuplicateKeyException을 EMAIL_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예요. 여섯 파일을 모두 만든 뒤 IdentityFacade의 transaction 순서를 살펴볼게요. facade는 presentation이 동기 방식으로 들어오는 유일한 입구예요. 가입, 조회, token 발급, refresh 회전의 transaction 경계도 여기 있어요.
src/main/java/me/nvim/blog/auth/identity/application/AccountResult.java
src/main/java/me/nvim/blog/auth/identity/application/IssueTokenCommand.java
src/main/java/me/nvim/blog/auth/identity/application/RefreshTokenCommand.java
src/main/java/me/nvim/blog/auth/identity/application/RegisterAccountCommand.java
src/main/java/me/nvim/blog/auth/identity/application/TokenResult.java
src/main/java/me/nvim/blog/auth/identity/application/IdentityFacade.java
issueToken이 비밀번호를 다시 받지 않는 게 처음엔 이상해 보이죠? /api/auth/token/basic 앞의 Spring Security filter가 이미 비밀번호를 확인하고 Authentication을 만들어요. controller는 검증을 통과한 email만 facade에 넘겨요. 이 경계는 2편의 두 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 서명 재료를 준비해요

IdentityFacade는 TokenIssuer port에 “token을 발급해 달라”고 요청해요. 바깥쪽 adapter가 실제 JWT를 만들려면 만료 시간, 발급자, 대상자, RSA key가 필요하죠. 문자열 설정을 필요한 곳마다 직접 읽지 않고, 시작할 때 한 번 검증되는 설정 객체와 key bean으로 준비할게요. application.yml의 auth.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
src/test/java/me/nvim/blog/auth/identity/infrastructure/config
RsaKeyConfigTests.java
src/test/java/me/nvim/blog/auth/identity/infrastructure/config/RsaKeyConfigTests.java
RsaKeyConfigTests는 설정한 PEM이 같은 key pair로 복원되는지 byte 배열까지 비교해요. 이제 domain test와 함께 checkpoint를 실행해요.
아래 두 test가 통과하면 domain 규칙과 RSA 설정까지 준비된 상태예요. 아직 JDBC bean과 Security 설정을 만들지 않았으므로 전체 애플리케이션은 실행하지 않아요.

2편: JDBC, Security, HTTP 검증으로 이어가기

지금 만든 port에 adapter를 연결하고, 실제 서버 요청까지 검증해요.