개발 기록/개발 요약

[toy-project] JWT 기반 인증/인가 구현 (Spring Boot)

dev.jelee 2025. 7. 4. 18:59

📌 프로젝트 개요

JWT를 활용해 로그인을 구현하고, Spring Security로 인가 기능을 구현. 비밀번호는 BCrypt로 암호화.
  • 프로젝트 명: JWT 기반 인증/인가 구현 (Spring Boot)
  • 개발 기간: 25.06.23 ~ 25.06.27
  • 목적:
    • JWT를 활용한 로그인 및 인가 기능을 직접 구현하여 인증 방식의 원리를 이해하고 실습
    • Spring Boot로 기능별 API 설계 연습
  • 사용 기술: Java 17, Spring Boot 3.5.4, JPA & H2, VS Code, Git & GitHub

🧩 구현 기능

기능명 상세 설명
회원가입 비밀번호는 BCrypt로 암호화하여 저장
로그인 로그인 시 JWT 토큰이 발급, HttpOnly 쿠키에 저장
로그아웃 로그아웃 시 HttpOnly 쿠키의 JWT 토큰 삭제
인가 페이지 접근 시 JWT 토큰을 검증해 권한 확인

💜 GitHub 저장소 & 데모

🔗 GitHub Repository

🎬 데모 영상 보러가기

🧪 API 테스트 하기


📁 디렉토리 구조

📂 src/main/java/com/jelee/toyauthjwtjpa
├── ToyauthjwtjpaApplication.java       // 애플리케이션 진입점
├── config/
│   └── SecurityConfig.java             // 스프링 시큐리티 설정
├── security/
│   ├── JwtAuthenticationFilter.java    // JWT 인증 필터 (요청 시 토큰 검사)
│   └── JwtProvider.java                // JWT 생성 및 검증, 사용자 조회
├── entity/
│   ├── Role.java                       // 권한 정보 엔티티
│   └── User.java                       // 사용자 엔티티 (UserDetails 구현)
├── repository/
│   └── UserRepository.java             // DB와 접근 처리 레포지토리
├── controller/
│   └── AuthController.java             // API 엔드포인트 정의
├── service/
│   └── AuthService.java               // 인증 관련 비즈니스 로직 처리
├── dto/
│   ├── JoinRequest.java               // 회원가입 데이터 전송 객체
│   └── LoginRequest.java              // 로그인 데이터 전송 객체
└── README.md

🧱 클래스/설계 구조 설명

  • SecurityConfig:
    • SecurityFilterChain 빈 등록
      •  .authorizeHttpRequests 설정
        -> index.html파일과 /css, /js, /h2-console, /auth 해당 경로로 시작하는 건 모두 접근 가능하게 permitAll() 지정
      • JWT 인증 필터 (JwtAuthenticationFilter)를
        UsernamePasswordAuthenticationFilter 이전에 실행되도록 등록
      • H2 콘솔에서 iframe을 사용 허용 설정 추가
    • PasswordEncoder 빈 등록
      • BCryptPassowrdEncoder 객체 생성 후 리턴.
  • JwtAuthenticationFilter
    • doFilterInternal 재정의
      • 파라미터로 사용자한테 요청받은 HttpServletRequest, 응답용인 빈 객체 HttpServletResponse, 그리고 요청과 응답을 다음 필터로 보내기 위해 FilterChain을 받는다.
      • token에 사용자한테 요청받은 request에서 Cookies가 있는지 확인하고 token 변수에 요청받은 데이터에서 쿠키에 JWT 글자가 있는지 확인하고 쿠키에 JWT가 있으면 쿠키의 값을 꺼낸다. 만약에 없으면 null을 반환한다.
    • token에 담은 JWT가 유효한지 검사하기
      • JwtProvider.validateToken()을 사용해 token이 유효한지 먼저 검사한다.
      • 유효하다면 user 객체에 토큰에서 추출한 username을 User 객체를 생성하여 담는다.
      • UsernamePasswordAuthenticationToken을 사용해 생성한 User 객체와 credentials는 null, 그리고 User 객체에서 권한을 담아 인증 객체를 생성한다.
      • 생성한 인증 객체는 SecurityContext에 저장한다.
    • 마지막으로 filterChain.doFilter()를 사용하여 클라이언트한테 요청받은 request와 빈 응답 객체인 response를 다음 체인으로 보낸다.
  • JwtProvider:
    • 토큰을 생성하기 전에 토큰에 사용할 시크릿 키를 생성하기 위해 Keys.secretKeyFor(SignatureAlgorithm.HS256); 을 key에 상수로 담는다.
    • 그리고 EXPIRATION 변수를 상수로 선언하여 1000 * 60 * 60 * 24 의 값을 초기화한다. (24시간을 밀리초 단위로 환산한 값)
    • 토큰 생성하기
      • username과 role을 파라미터로 받는다.
      • Date를 사용하여 생성된 현재 시간을 now 변수에 담고, expiry 변수에는 생성된 시간에 1일을 더한 값을 저장한다.
      • 그리고 Jwts를 사용해 username, 권한, 생성 시간, 유효 시간, key(시크릿 키)을 .compact() 하나의 JWT 토큰 문자열로 만들어서 빌더 객체를 반환한다.
    • 토큰 검증하기
      • 클라이언트로 받은 token을 파라미터로 받아서 Jwts를 사용해 .parserBuilder()를 한다. 이때 클라이언트한테 받은 token을 parseClaimsJws()를 사용해 분해하여 palyload 정보를 읽어 처음에 token 생성할 때 서명된 key인지 확인한다.
      • JWT 검증 실패하거나 잘못된 인자가 전달된 경우 JwtException과 IllegalArgumentException 예외를 발생시킨다.
    • 토큰으로 사용자 정보 얻기
      • 클라이언트로 요청받은 데이터에서 token을 Jwts를 사용해 파싱(빌더)한다. 이 과정에서 서명 검증을 한 다음 서명이 유효하면 token을 분해하여 토큰의 body를 가져온다. 그리고나서 Subject를 꺼내어 리턴한다.
  • Role/User: 
    • Role 엔티티는 사용자 권한을 enum으로 관리. ROLE_USER, RULE_MANAGER, ROLE_ADMIN 으로 구성
    • User 엔티티는 UserDetails를 구현한다.
      • 필드는 id, username(사용자 아이디), password, role로 구성되어 있다.
      • 메서드는 UserDetails를 구현하기 때문에 반드시 오버라이드해야하는 메서드들을 재정의 해준다.
      • getAuthorities, getUsername, getPassword, isAccountNonExpired, isAccountNonLocked, isCredentialsNonExpired, isEnabled
  • UserRepository
    • JpaRepository를 상속 받는다.
    • 로그인 시 아이디로 사용자를 찾을 때 사용할 메서드 findByUsername은 Optional 제너릭을 사용해 username으로 파라미터를 받아서 DB에서 찾아서 User객체를 반환한다.
    • 회원가입 시 아이디가 중복되었는지를 확인하기 위해 existsByUsername()을 만들어 username을 파라미터로 받아서 중복되는지 체크하여 boolean값을 반환한다.
  • AuthController
    • /auth를 기본 mapping으로 두기
    • 회원가입은 /register, 로그인은 /login, 사용자 인증은 /me, 로그아웃은 /logout 각 API 엔드포인트를 갖는다.
    • 회원가입은
      • RequestBody로 요청값을 받아 AuthService의 register()를 통해 회원가입을 처리한다.
    • 로그인은
      • RequestBody로 클라이언트 요청과 HttpServletResponse 빈 객체를 파라미터로 받는다.
      • 클라이언트 요청은 AuthService의 logn()을 통해 로그인을 하는데 이때 생성되는 token을 token 변수에 담는다.
      • 그리고 ResponseCookie 인스턴스 cookie를 생성해 JWT이름과 값은 token을 담고 httpOnly에 저장, secure(), path()는 기본 /, maxAge()는 24시간, samSite()는 Strict로 설정해주고 .build()를 해준다.
      • 이렇게 생성한 cookie는 Set-Cookie로 응답해주는데 JWT 토큰을 로그인한 브라우저에 보내어 저장해준다.
    • 로그아웃은
      • HttpServletResponse 빈 객체 response를 파라미터로 받는다.
      • ResponseCookie 인스턴스 deleteCookie를 생성하는데 로그인과 다르게 JWT 속성에 값은 "" 빈 문자열로 설정한다. 그리고 maxAge를 0으로 하여 쿠키를 즉시 만료시킨다. 
      • response 헤더에 deleteCookie를 담아 보내어 로그아웃 시 쿠키에 JWT가 삭제되도록 한다.
    • 사용자 인증은
      • 페이지마다 이동할 때 유효한지 검증을 해야한다.
      • Authentication 인스턴스 authentication을 파라미터로 받아서 User 인스턴스인 user에 요청 데이터인 authentication에서 getPrincipal()하여 담는다.
      • user.getUsername()을 반환하여 로그인한 사용자를 확인할 수 있다.
  • AuthService
    • 회원가입과 로그인 로직이 작성되어 있다.
    • 회원가입은
      • Controller로부터 요청받은 request를 먼저 아이디 중복 체크를 한 다음에 중복이 아니라면 User 인스턴스 user를 생성하여 builder()를 한다. 이때 username은 request.getUsername(), password는 passwordEncoder를 사용해 암호화하여 비밀번호를 저장, role은 기본 ROLE_USER로 한다.
      • 마지막에 UserRepository.save()를 통해 user를 저장한다.
    • 로그인은
      • Controller로부터 요청받은 request를 User 인스턴스 user에 DB에 해당 username이 있는지 찾아서 데이터를 저장한다.
      • 만약에 request.getPassword()와 user.getPassword()가 일치하지 않으면 IllegalArugementException을 throw하고, 일치하면 jwtProvider를 통해 token을 생성해 반환한다.
  • JoinRequest/LoginRequest
    • 두 객체는 username과 password 필드를 갖고 있다.
    • 두개로 분류한 이유는 DTO는 하나의 요청에만 사용되도록 설계되어야 역할이 명확해져 코드를 이해하기 쉽고 재사용도 용이하다는 내용을 봤다. 그리고 유지보수에도 용이하기에 각각 나누었다.

🔢 개발 순서

순서 내용 상세 설명
1 요구사항 분석 - 기능 요약, 기술 명세, 사용자 역할 정리
2 ERD 설계 - User 테이블
- 권한 Enum으로 관리
3 프로젝트 구조 설계 - config, security, dto, entity, controller, repository, service 로 구성
4 개발 환경 세팅 - build.gradle 의존성 추가
- application.properties 설정
5 Entity & Enum 구현 - User, Role Enum 클래스 만들기
6 SecurityConfig 기본 설정 - H2 콘솔 접근 허용, H2에서 iframe 허용, H2 콘솔은 CSRF 무시 설정
- 기본 로그인 폼 사용 설정
7 UserRepository 생성
(JpaRepository 상속)
- 회원가입 시 중복 아이디 체크 기능
- 로그인 시 아이디로 사용자 찾기 기능
8 회원가입 구현 - AuthService 생성 (중복 아이디 체크 후 회원가입)
- JoinRequest 생성 (회원가입에 필요한 DTO 생성)
- SecurityConfig에 PasswordEncoder 빈으로 등록
- AuthServiceTest 생성 (회원가입 단위 테스트를 위해 생성)
- SecurityConfig에서 테스트를 위해 .csrf() 잠시 꺼두기
- AuthController 생성 (회원가입 API 작성)
9 로그인 구현 - LoginRequestDTO 생성 (로그인에 사용할 DTO 생성)
- JWT 토큰 생성, 검증, 정보 추출을 위해 JwtProvider 생성
- AuthService에 로그인에 사용될 비즈니스 로직 구현
- AuthController에 로그인 API 설계 (JWT를 HttpOnly 쿠키에 저장)
10 JWT 인증 필터 생성 - JwtAuthenticationFilter 만들기 (쿠키에서 JWT 찾고 유효성 검사)
- SecurityConfig에 .addFilterBefore() 추가
(UsernamePasswordAuthenticationFilter 앞에 JWT 두기)
- AuthController에 인증을 필요로하는 API 설계
11 로그아웃 구현 - AuthController에 로그아웃 API 설계 (쿠키에서 JWT 삭제)

🐞 문제 해결 & 트러블 슈팅

✅ 이슈 1 - 엔티티 생성 후 서버 실행 시 403 에러코드 발생

  • 문제: H2 Console에서 403에러 발생
  • 원인: Spring Security 설정에서 /h2-console 추가 안함
  • 해결: SecurityFilterChain에서 .authorizeHttpRequests에 /h2-console 추가
.authorizeHttpRequests(auth -> auth
        .requestMatchers("/", "/h2-console/**").permitAll()
        .anyRequest().authenticated()
      )

✅ 이슈 2 - 엔티티 생성 후 서버 실행 시 403 에러코드 발생

  • 문제: h2-console에 로그인 안됨
  • 원인: application.properties에 spring.datasource.username과 password를 임의로 설정함
  • 해결: 기본값으로 설정함
spring.datasource.username=sa
spring.datasource.password=
***
Spring Boot는 기본적으로 
H2 in-memory DB를 사용하는 경우 
DB를 서버 실행 시점에 메모리에 생성하는데
이때 DB에 아무런 데이터가 없다.
그런데 username과 password를 설정하면
DB에서 username과 password를 찾아서 비교해야하는데
DB에는 데이터가 없기 때문에 접근할 수 없게 되는 거다.

✅ 이슈 3 - h2 콘솔에 db가 보이지 않는 현상

  • 문제: Postman에서 회원가입 API를 테스트하는데, 200OK가 뜨는데도 h2에 데이터가 생성이 안됨
  • 원인: 애플리케이션과 H2 콘솔이 서로 다른 커넥션을 사용
  • 해결: application.properties에서 datasource.url 수정 및 h2 콘솔 접속 시 같은 DB URL로 접속
// application.properties에서 설정하기
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1

JDBC URL에 jdbc:h2:mem:testdb 설정


✍ 느낀 점 / 회고

  • JWT의 사용 방법과 흐름을 알게 되었다. 그렇다고 전체다 이해하는 건 아니지만 전체적인 흐름은 이해하는데 도움이 되었다.
  • 처음부터 끝까지 코드를 작성한다면 못할 거 같다. 여러번 반복하며 연습해야할 거 같다.
  • dto, entity의 차이를 알게 되었고 repository와 service의 차이점도 알게 되었다. dto는 계층 간 데이터 전달용 객체이고 entity는 db와 연결된 객체로 저장, 수정, 삭제 등을 할 수 있다. service는 controller로부터 요청을 받아 필요한 작업을 하고 repository는 db에 접근하는 클래스다. Mybatis에서 Mapper와 비슷한 역할을 하는 거 같다.