-
[SpringBoot] 5주차 JWT 인증/인가(회원가입 및 로그인) API 구현Study/SpringBoot 2026. 4. 30. 16:23
이번 주차에는 기존 게시판의 글을 작성할 때 단순 문자열로 비밀번호를 받고, 수정/삭제 시 그 문자열을 대조하는 형태를 수정했다. 작동은 하지만 게시글의 진짜 주인을 식별할 수는 없는 불안정한 구조였다. 이를 해결하기 위해 Spring Security와 JWT 라이브러리를 의존성에 추가하고 본격적인 회원(Member) 도메인을 설계했다.
이번 주차 공부 내용
- 회원 가입, 로그인 API 구현
- 비밀번호 암호화 저장
- JWT 기반 인증 토큰 발급
- JWT 필터 기반 암호화 API 구현
기능 구현 Flow(코드 로직)
Step 1. 회원가입 & 비밀번호 암호화 저장 (BCrypt)
회원가입 시 클라이언트가 보낸 비밀번호(예: "1234")를 그대로 DB에 저장하는 것은 심각한 보안 결함이다. Spring Security의 BCryptPasswordEncoder를 Bean으로 등록해 단방향 암호화를 적용했다.- Client Request: 클라이언트가 이메일, 비밀번호, 이름이 담긴 JSON 데이터를 POST /api/members/join 엔드포인트로 전송한다.
- Controller Layer: MemberController가 클라이언트의 요청을 MemberJoinRequestDto 객체로 바인딩하고 형식 유효성을 검증(@Valid)한 뒤 MemberService로 전달한다.
- Duplicate Check (Service): MemberRepository.existsByEmail()을 호출하여 이메일 중복 여부를 검사한다. 중복 시 MEMBER409 예외를 발생시키고 로직을 중단한다.
- Password Encryption (Service): Spring Security 설정에 Bean으로 등록해 둔 BCryptPasswordEncoder.encode() 메서드를 호출하여 사용자의 평문 비밀번호를 복호화가 불가능한 단방향 해시 문자열로 암호화한다.
- Entity Persistence: 암호화된 비밀번호와 이메일 정보를 바탕으로 Member 엔티티를 빌드한 후 데이터베이스에 영속화(save)하고, 생성된 회원의 고유 ID를 클라이언트에게 반환한다.
// MemberService.java 중 일부 Member member = Member.builder() .email(request.getEmail()) .password(passwordEncoder.encode(request.getPassword())) // BCrypt 암호화 적용 .name(request.getName()) .build();
이제 DB를 열어봐도 사용자의 진짜 비밀번호 대신 복잡한 해시값만 남아있게 된다.
Step 2. 로그인 & JWT 발급: 위조 불가능한 출입증
로그인 API를 통해 이메일과 비밀번호가 일치함이 확인되면, 서버는 JwtTokenProvider를 통해 서명된 JWT(JSON Web Token)를 발급한다.- Client Request: 클라이언트가 이메일과 비밀번호를 POST /api/members/login 엔드포인트로 전송한다.
- User Lookup (Service): 전달받은 이메일을 기반으로 MemberRepository에서 엔티티를 조회합니다. 가입되지 않은 이메일일 경우 MEMBER404 예외를 발생시킨다.
- Password Matching (Service): DB에 저장된 암호화된 해시값과 클라이언트가 보낸 비밀번호를 BCryptPasswordEncoder.matches() 메서드로 안전하게 비교한다. 일치하지 않으면 AUTH401 예외를 발생시킨다.
- JWT Generation: 자격 증명이 완료되면 JwtTokenProvider.createToken(email)을 호출한다. 서버의 application.yml에 등록된 Secret Key를 사용하여 사용자의 이메일(Subject), 발행일, 만료일 정보가 포함된 서명된 JWT를 생성한다.
- Response: 생성된 JWT 문자열을 클라이언트에게 성공 응답과 함께 반환한다.
// JwtTokenProvider.java 중 일부 public String createToken(String email) { Date now = new Date(); return Jwts.builder() .subject(email) // 토큰 주체에 이메일 저장 .issuedAt(now) .expiration(new Date(now.getTime() + expirationTime)) .signWith(key) // SecretKey로 서명 (위조 방지) .compact(); }
클라이언트는 응답받은 이 문자열을 안전하게 보관해 두었다가, 이후 API를 호출할 때마다 HTTP Header에 담아 보내게 된다.
Step 3. 암호화 API 구현 (인가): JWT 필터와 권한 검증
가장 중요한 인가(Authorization) 단계다. 모든 HTTP 요청을 가로채는 JwtAuthenticationFilter를 만들어 SecurityFilterChain에 등록했다.- 토큰 해독: 헤더에서 토큰을 추출해 유효성을 검사한다.
- Context 저장: 토큰이 정상이라면 내부에 들어있는 이메일을 꺼내 Spring의 SecurityContextHolder에 보관한다.
- Controller 단의 처리: 이제 컨트롤러는 비밀번호를 직접 파라미터로 받을 필요가 없다. @AuthenticationPrincipal (또는 Principal)을 통해 로그인한 회원의 정보를 즉시 꺼내 쓸 수 있다.
- Authenticated Request: 클라이언트가 권한이 필요한 보호된 API(예: PUT /api/posts/{id})를 호출할 때, HTTP 헤더(Authorization: Bearer <토큰>)에 발급받은 JWT를 담아 전송한다.
- Filter Interception: 클라이언트의 요청이 Controller에 도달하기 전, SecurityFilterChain에 등록된 커스텀 JwtAuthenticationFilter가 요청을 가장 먼저 가로챈다.
- Token Validation: 헤더에서 토큰 문자열을 추출(resolveToken)한 뒤, JwtTokenProvider.validateToken()을 통해 서명 위변조 및 토큰 만료 여부를 엄격하게 검사한다.
- Security Context Registration: 토큰이 유효하다고 판별되면 페이로드에서 사용자 이메일을 추출합니다. 이후 UsernamePasswordAuthenticationToken 인증 객체를 생성하여 Spring의 전역 SecurityContextHolder에 저장한다.
- Controller Processing: 컨트롤러는 파라미터에 선언된 Principal 객체를 통해 시큐리티 컨텍스트에 보관된 사용자 이메일을 즉시 주입받아 비즈니스 계층(PostService)으로 전달한다.
- Ownership Verification (Service): 타겟 게시글을 DB에서 조회한 후, 엔티티 내부의 validateAuthor() 메서드를 호출한다. 게시글과 연관관계가 맺어진 작성자의 이메일과 토큰에서 추출한 요청자의 이메일이 일치하는지 대조한다. 일치하지 않을 경우 AUTH403 예외를 반환하며, 일치할 경우에만 실제 데이터 수정 및 삭제 로직을 수행한다.
// PostController.java - 수정 API @PutMapping("/{id}") public ApiResponse<PostResponseDto> updatePost(@PathVariable Long id, @RequestBody @Valid PostRequestDto request, Principal principal) { // 필터가 검증한 정보 // principal.getName()으로 글 작성자 본인이 맞는지 서비스 단에서 최종 대조 PostResponseDto response = postService.updatePost(id, request, principal.getName()); return ApiResponse.onSuccess(response); }
코드에 사용된 문법(어노테이션) 및 객체 정리
Step 1. 회원가입 & 비밀번호 암호화 저장 (BCrypt)
핵심 코드: MemberService의 join 메서드 & SecurityConfig// SecurityConfig.java @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } // MemberService.java @Transactional public Long join(MemberJoinRequestDto request) { if (memberRepository.existsByEmail(request.getEmail())) { throw new GeneralException("MEMBER409", "이미 존재하는 이메일입니다."); } Member member = Member.builder() .email(request.getEmail()) .password(passwordEncoder.encode(request.getPassword())) // 평문 -> 해시 암호화 .name(request.getName()) .build(); return memberRepository.save(member).getId(); }
🔍 주요 어노테이션 분석- @Bean: 개발자가 직접 제어할 수 없는 외부 라이브러리(BCryptPasswordEncoder)를 Spring의 빈(Bean, 관리 객체)으로 등록할 때 사용한다. 이를 통해 프로젝트 전역에서 의존성 주입(DI)을 받아 사용할 수 있게 된다.
- @Transactional: 데이터베이스의 상태를 변경하는 작업(Insert, Update, Delete)을 하나의 논리적 단위로 묶어준다. 로직 수행 중 하나라도 실패하면 모든 작업을 원상 복구(Rollback)하여 데이터의 정합성을 보장한다.
- @Valid (Controller 단 사용): 클라이언트로부터 넘어온 DTO 객체 내부에 선언된 검증 조건들(@NotBlank, @Email 등)을 실행시켜, 조건에 맞지 않으면 즉시 예외를 발생시키는 방어막 역할을 한다.
Step 2. 로그인 & JWT 발급: 위조 불가능한 출입증
핵심 코드: MemberService의 login 메서드 & JwtTokenProvider// MemberService.java public String login(MemberLoginRequestDto request) { Member member = memberRepository.findByEmail(request.getEmail()) .orElseThrow(() -> new GeneralException("MEMBER404", "가입되지 않은 이메일입니다.")); // DB의 암호화된 비밀번호와 입력된 평문 비밀번호 대조 if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { throw new GeneralException("AUTH401", "비밀번호가 일치하지 않습니다."); } return jwtTokenProvider.createToken(member.getEmail()); // JWT 생성 및 반환 } // JwtTokenProvider.java public JwtTokenProvider(@Value("${jwt.secret}") String secretKey, ...) { ... }
🔍 주요 어노테이션 분석- @RequiredArgsConstructor (클래스 상단): final이 붙은 필드나 @NonNull이 붙은 필드에 대해 생성자를 자동으로 만들어준다. Spring 핵심 원칙인 '생성자 주입(Constructor Injection)'을 코드를 줄이면서 안전하게 구현할 수 있게 해준다.
- @Value: Spring Environment에 등록된 프로퍼티 값(주로 application.yml에 작성된 설정값)을 자바 코드의 변수에 주입할 때 사용한다. 보안상 소스코드에 직접 노출되면 안 되는 JWT Secret Key 등을 불러올 때 필수적이다.
- @Component: 개발자가 직접 작성한 클래스(JwtTokenProvider)를 Spring Container가 관리하는 빈(Bean)으로 등록해 달라고 선언하는 어노테이션이다.
Step 3. 암호화 API 구현 (인가): JWT 필터와 권한 검증
핵심 코드: PostController의 권한 주입 & Post 엔티티의 검증 로직// PostController.java @PutMapping("/{id}") public ApiResponse<PostResponseDto> updatePost(@PathVariable Long id, @RequestBody @Valid PostRequestDto request, Principal principal) { // principal.getName()을 통해 필터를 통과한 사용자의 이메일을 Service로 전달 PostResponseDto response = postService.updatePost(id, request, principal.getName()); return ApiResponse.onSuccess(response); } // Post.java (Entity) public void validateAuthor(String currentUserEmail) { // 게시글 작성자의 이메일과 토큰에서 추출한 요청자의 이메일 대조 if (!this.member.getEmail().equals(currentUserEmail)) { throw new GeneralException("AUTH403", "해당 게시글에 대한 권한이 없습니다."); } }
🔍 주요 어노테이션 및 객체 분석- @PutMapping / @DeleteMapping: HTTP의 특정 메서드(PUT, DELETE) 요청을 해당 컨트롤러 메서드와 매핑한다. RESTful API 설계 원칙에 따라 자원의 수정과 삭제를 명확히 구분한다.
- @PathVariable: URI 경로에 포함된 변수 값을 추출하여 매개변수로 전달받는다. (/api/posts/1 에서 1을 추출)
- Principal 객체 (어노테이션은 아니지만 핵심): Spring Security가 앞선 필터 단에서 인증을 성공적으로 마친 사용자의 정보를 담아두는 표준 객체이다. 컨트롤러의 파라미터로 선언해 두기만 하면, 스프링이 알아서 토큰 내부의 정보(이메일 등)를 채워 넣어 주므로 매우 편리하고 안전하게 현재 로그인한 사용자를 식별할 수 있습니다.
실행 결과
🟢 신규 유저 회원가입

위와 같은 이메일, 패스워드, 이름으로 회원가입 
회원가입 성공 
같은 이메일이 존재할 시 에러코드
🟢 유저 로그인
로그인 실행 
로그인 성공(아래는 토큰) 🟡 스웨거에 JWT 등록하기

로그인 시 받은 토큰 입력 
정상 적용된 모습 🔵 게시글 작성 및 수정 (정상 작동 테스트)

게시글 작성(post) 
1번 게시글 생성 완료 
1번 게시글 수정 실행 결과 
로그아웃 이후 put 
로그아웃 시 실행 결과 트러블 슈팅 (NullPointerException)
Issue1: 레거시 데이터로 인한 NullPointerException과 Null-Safe 로직 방어
[현상]
새롭게 추가한 비밀번호 검증 로직을 테스트하기 위해 PUT /api/posts/{id} API를 호출했으나 정상적인 요청임에도 500 Internal Server Error가 발생했다. 서버 로그를 확인한 결과 아래와 같은 NPE가 찍혀 있었다.java.lang.NullPointerException: Cannot invoke "String.equals(Object)" because "this.password" is null at com.study.springbootstudy.domain.Post.validatePassword(Post.java:45)
[원인 분석]
자바 코드의 논리적 결함이 아닌, 데이터 정합성(Data Integrity) 문제였다. 저번 주까지만 하더라도 비밀번호 필드가 존재하지 않거나 필수 값이 아니었을 때 생성된 오염된 구형 데이터가 로컬 DB에 남아있었다. 이 상태에서 새로운 검증 로직인 this.password.equals(inputPassword)가 실행되니, 비교 대상인 this.password 자체가 null이어서 자바의 .equals() 메서드를 호출할 수 없어 터진 것이다.
[해결 방안 및 배운 점]- 로컬 DB 완전 초기화: Docker 볼륨에 남아있는 찌꺼기 데이터를 날리기 위해 docker-compose down -v를 실행하여 DB를 백지화하고 클린한 환경을 구축했다.
- Null-Safe 코드 리팩토링: 데이터가 null이더라도 서버가 뻗는 것은 막아야 한다. 엔티티 내부의 검증 로직을 아래와 같이 수정하여 안정성을 높였다.
// 수정 전 (위험한 방식) if (!this.password.equals(inputPassword)) { ... } // 수정 후 (Null-Safe 방식) public void validatePassword(String inputPassword) { if (this.password == null || !this.password.equals(inputPassword)) { throw new GeneralException("AUTH403", "비밀번호가 일치하지 않거나 정보가 없습니다."); } }
단순히 코드를 잘 짜는 것을 넘어, 실제 DB에 적재된 데이터의 상태(State)를 함께 고려해야 시스템 장애를 막을 수 있음을 체감했다.
Issue 2: DTO 필드 "한 번도 대입되지 않았습니다" IDE 경고와 Reflection
[현상]
클라이언트의 요청을 받는 여러 클래스를 작성한 후, 인텔리제이(IntelliJ)에서 private 필드 'email'이(가) 한 번도 대입되지 않았습니다라는 노란색 경고(Warning)가 무더기로 발생했다. 코드 어디에도 request.setEmail(...)이나 생성자를 통한 대입 코드가 없었기 때문이었다.
[원인 분석]
초기에는 값을 넣는 로직을 빼먹은 줄 알았으나 이는 IDE의 정적 분석기가 Spring Boot 내부의 동작 방식을 완전히 추적하지 못해 발생하는 현상이었다.
Spring Boot는 클라이언트로부터 JSON 데이터가 넘어오면 내부적으로 Jackson 라이브러리와 자바의 리플렉션(Reflection) API를 사용한다. 즉 개발자가 직접 코드로 값을 대입하지 않아도 런타임(실행 시점)에 기본 생성자로 객체를 만들고 필드에 강제로 값을 꽂아 넣는다.
[해결 방안 및 배운 점]
해당 경고는 무시하고 진행해도 서버 동작에 전혀 문제가 없음을 확인했다.
프론트엔드에서 상태(State)를 명시적으로 다루는 것에 익숙했는데, 백엔드 프레임워크는 리플렉션과 같은 메타 프로그래밍을 통해 런타임에 동적으로 객체를 조작한다는 점을 이해하게 되었다. 정적 분석 도구(IDE)의 경고를 맹신하기보다 프레임워크의 라이프사이클과 동작 원리를 정확히 파악하는 것이 중요함을 깨달았다.
프론트엔드 프로젝트 때 이해가 잘 안 갔던 스웨거 로직과 JWT 발급 과정을 직접 구현해보니 전체적인 아키텍처에 직관적인 관점이 생기고 현재 진행하고 있는 프로젝트 이해에 도움이 되었다. 스웨거의 자물쇠 버튼(Authorize)을 풀고 게시글 수정을 시도했을 때 서버가 403 Forbidden 코드를 뱉는 걸 보고 제대로 로직이 적용됐다는 안도감이 들었다. 미니멀한 코드 컨벤션을 유지하면서도 핵심적인 인증 인가 흐름을 직접 통제해보니 클라이언트와 서버 간의 통신 규약이 한층 더 입체적으로 다가온다.'Study > SpringBoot' 카테고리의 다른 글
[SpringBoot] 7주차 게시글 + 댓글 동시 저장 서비스 작성, 예외 케이스 적용, 인덱스 생성 (0) 2026.05.20 [SpringBoot] 6주차 페이지네이션(Pagination), N+1 문제 해결 (0) 2026.05.07 [SpringBoot] 4주차 게시판(수정, 삭제), 댓글(생성, 삭제) API 구현 및 Swagger 명세화 (1) 2026.04.02 [SpringBoot] 3주차 ERD 설계, Docker 연결 및 생성 조회 API 구현 (0) 2026.03.26 [SpringBoot] 2주차 예외 처리 및 Vaildation 적용 (0) 2026.03.19