2편 - 로그인 API + JWT 발급 구현하기

Do Hyun ·2026년 4월 24일

commerce-project

목록 보기
2/6

로그인 API + JWT 발급 구현하기

JWT란?

JWT(JSON Web Token)는 로그인 인증에 사용되는 토큰 방식이야.

핵심 포인트: 서버는 토큰을 저장하지 않는다.

일반적인 세션 방식은 서버가 로그인 상태를 저장하지만, JWT는 다르다.
서버는 Secret Key로 서명만 해서 클라이언트에게 토큰을 발급하고,
이후 요청마다 그 서명이 유효한지만 검증한다. DB 조회 없이.

그래서 JWT의 장점은 Stateless(무상태) — 서버가 상태를 저장하지 않아도 된다.

JWT 동작 흐름

① 클라이언트 → 서버: email + password 전송
② 서버 → DB: 유저 조회
③ DB → 서버: 유저 정보 반환
④ 서버: 비밀번호 검증 + JWT 생성 (서명)
⑤ 서버 → 클라이언트: JWT 반환
⑥ 이후 API 요청 시: Authorization: Bearer {token} 헤더에 포함
⑦ 서버: 서명 검증 (DB 조회 없이!)

JWT 구조

JWT는 3개 파트로 구성된다:

header.payload.signature

eyJhbGc... . eyJ1c2VySWQiOjF9 . SflKxwRJSMeKKF2QT4...
  • header — 알고리즘 정보 (HS256)
  • payload — 담을 데이터 (userId, email, 만료시간)
  • signature — 위 두 개를 Secret Key로 서명한 값

서명이란?

서명은 "내가 만든 토큰이 맞다"는 증명이다.

누군가 payload를 { userId: 99 }로 바꾸면? Secret Key가 없으니 signature를 다시 못 만든다.
서버가 검증할 때 "이 서명 이상한데?" 하고 바로 거부한다.

"payload가 중간에 변조되지 않았다는 걸 Secret Key로 보증하는 것"


의존성 추가

build.gradle에 JWT 라이브러리 추가:

implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

application.yml에 Secret Key 설정:

jwt:
  secret: mysecretkey12345678901234567890123456789012
  expiration: 86400000  # 24시간 (ms)

JwtProvider 구현

JWT 생성, 검증, userId 추출 3가지 기능을 담당하는 유틸 클래스:

@Component
public class JwtProvider {

    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.expiration}")
    private long expiration;

    // 토큰 생성
    public String generateToken(Long userId, String email) {
        return Jwts.builder()
                .setSubject(String.valueOf(userId))
                .claim("email", email)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(Keys.hmacShaKeyFor(secretKey.getBytes()), SignatureAlgorithm.HS256)
                .compact();
    }

    // 토큰 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    // userId 추출
    public Long getUserId(String token) {
        String subject = Jwts.parserBuilder()
                .setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
        return Long.parseLong(subject);
    }
}

왜 try-catch로 검증하나?
토큰이 만료되거나 서명이 틀리면 Jwts가 예외를 던진다.
그걸 잡아서 false를 반환하는 방식으로 유효성을 검증한다.


로그인 API 구현

MemberRepository

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    boolean existsMemberByEmail(String email);
    Optional<Member> findByEmail(String email);  // 로그인용 추가
}

MemberService

@Override
public LoginResponseDTO login(LoginRequestDTO loginRequestDTO) {
    // 1. 이메일로 유저 조회
    Member member = memberRepository.findByEmail(loginRequestDTO.getEmail())
            .orElseThrow(() -> new IllegalArgumentException("가입되지 않은 회원입니다."));

    // 2. 비밀번호 확인
    if (!passwordEncoder.matches(loginRequestDTO.getPassword(), member.getPassword())) {
        throw new IllegalArgumentException("비밀번호가 틀렸습니다.");
    }

    // 3. JWT 발급
    String token = jwtProvider.generateToken(member.getId(), member.getEmail());

    return new LoginResponseDTO(member.getId(), token);
}

포인트 1: orElseThrow() 활용
Optional.isPresent() 체크보다 훨씬 깔끔하다. 값이 없으면 바로 예외를 던진다.

포인트 2: passwordEncoder.matches() 사용
비밀번호는 BCrypt로 암호화되어 저장되므로 평문과 직접 비교하면 안 된다.
matches(평문, 암호화된값)으로 비교해야 한다.

LoginResponseDTO

@Getter
@AllArgsConstructor
public class LoginResponseDTO {
    private Long memberId;
    private String token;
}

MemberController

@PostMapping("/login")
public ResponseEntity<LoginResponseDTO> login(@RequestBody LoginRequestDTO loginRequestDTO) {
    LoginResponseDTO response = memberService.login(loginRequestDTO);
    return ResponseEntity.ok(response);
}

JwtInterceptor 설정

로그인, 회원가입 URI는 토큰 검증 없이 통과시키도록 설정:

@Component
@RequiredArgsConstructor
public class JwtInterceptor implements HandlerInterceptor {

    private final JwtProvider jwtProvider;

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) throws Exception {
        String uri = request.getRequestURI();

        // 로그인, 회원가입은 토큰 검증 스킵
        if (uri.startsWith("/member/login") || uri.startsWith("/member/signUp")) {
            return true;
        }

        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            response.setStatus(401);
            return false;
        }

        String token = authHeader.substring(7);
        if (!jwtProvider.validateToken(token)) {
            response.setStatus(401);
            return false;
        }

        Long memberId = jwtProvider.getUserId(token);
        request.setAttribute("memberId", memberId);
        return true;
    }
}

테스트 결과

POST http://localhost:8080/member/login

{
    "email": "test@test.com",
    "password": "1234"
}

응답:

{
    "memberId": 1,
    "token": "eyJhbGciOiJIUzI1NiJ9.eyJ..."
}

주의사항: ddl-auto 설정

개발 중 ddl-auto: create로 설정하면 서버 재시작 시 테이블이 드롭되고 데이터가 날아간다.
데이터를 유지하려면 update로 변경해야 한다.

spring:
  jpa:
    hibernate:
      ddl-auto: update  # create → update
옵션동작
create매번 테이블 드롭 후 재생성 (데이터 삭제)
update변경사항만 반영, 데이터 유지
validate테이블 구조 검증만 (변경 없음)
none아무것도 안 함

오늘 배운 것

  • JWT 구조와 Stateless 인증 방식
  • 서명(Signature)의 역할 — 변조 방지
  • JwtProvider 구현 (생성/검증/추출)
  • passwordEncoder.matches()로 암호화 비밀번호 비교
  • Optional.orElseThrow() 패턴
  • ddl-auto: create vs update 차이

다음 포스팅: 상품 API + 주문 API 구현

profile
우당탕탕

0개의 댓글