이번 프로젝트를 진행하면서 사용해보고 싶었던 백엔드 기술 중 하나는 JWT이다.
이전 관통 프로젝트에서 프론트를 담당하며 백엔드 친구가 준 토큰을 전달받아서 사용했었는데,
그 방식이 구현이 좀 힘들지만 대중적이고 인증과 인가에 용이하게 쓰인다고 해서 구현해보고 싶었다.
나는 이번에 Spring Security와 Bearer토큰 방식을 이용하여 로그인을 구현하였다.
JWT 란?
JSON Web Token의 약자로 주로 사용자 인증과 정보 전달을 위해 사용되는 토큰 기반의 인증 방식.
Access Token과 Refresh Token을 이용한 방식이 사용되는데,
Access Token
- 사용자가 인증된 후, 서버에 요청을 보낼 때 사용하는 토큰
- 특징
- 짧을 유효기간 : 15분 - 1시간 정도 짧게 설정해서 보안 위험을 줄임
- 클라이언트가 이 토큰을 Authorization 헤더에 담아 서버에 요청
- 유효기간이 지나면 만료되고, 다시 로그인하거나 리프레시 토큰으로 갱신해야함
Refresh Token
- 엑세스 토큰이 만료되었을 때, 새로운 엑세스 토큰을 발급받기 위해 사용
- 특징
- 긴 유효기간 : 일주일 - 몇 달까지 설정 가능
- 주로 서버 또는 DB에 안전하게 저장하거나 클라이언트의 Htttp Only 쿠키에 저장
- 재로그인 없이 엑세스 토큰을 갱신할 때 사용
인증 과정 흐름
- 로그인 요청 : 사용자가 로그인하면 서버가 JWT 발급한다.
- 토큰 사용 : 클라이언트는 엑세스 토큰을 헤더에 포함하여 응답한다.
- 필터 적용 : JWT Autorization Filter를 적용하여 보호된 API 요청 시 JWT 검증을 한다.
- 로그 아웃 : 클라이언트가 로그아웃하면 서버는 리프레시 토큰을 폐기해서 더 이상 재발급 안되게 한다.
나는 토큰 내에 사용자 id인 therapistId를 담아서 토큰으로 생성하여 발급할 예정이다.
먼저 Spring boot 프로젝트를 생성해준다.
프로젝트 환경
1. intellij IDEA
2. Spring boot 버전 : 3.4.2
3. JDK 버전 : Java 17
4. Spring Web, Spring Security, MyBatise Framwork, MySQL Driver, Spring Boot DevTools, Lombok
build.gradle 의존성 추가
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}
applicaiton.yml : MySQL 연결 설정
server:
port: 7001
spring:
datasource:
url: jdbc:mysql:// 내 mysql url
username: // 사용자 이름
password: // 사용자 비밀번호
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: none # MyBatis를 사용하므로 JPA 자동 테이블 생성 비활성화
show-sql: true
mybatis:
mapper-locations: classpath*:mappers/*.xml
type-aliases-package: com.ssafy.aitalk.user.dto
configuration:
map-underscore-to-camel-case: true
jwt:
secret: // 시크릿 키
expiration: 86400000 # 1일 (밀리초 단위)
MySQL에서 테이블을 생성해준다.
테이블 명은 user로 지정했다.
📂프로젝트 구조
src
├─main
│ ├─java
│ │ └─com
│ │ └─ssafy
│ │ └─aitalk
│ │ │ AiTalkApplication.java
│ │ │
│ │ ├─test
│ │ │ ApiController.java
│ │ │
│ │ └─user
│ │ ├─config // Spring Security 설정
│ │ │ MyBatisConfig.java
│ │ │ SecurityConfig.java
│ │ │
│ │ ├─controller // API
│ │ │ UserController.java
│ │ │
│ │ ├─dto
│ │ │ LoginRequest.java
│ │ │ LoginResponse.java
│ │ │ UserResponse.java
│ │ │
│ │ ├─entity
│ │ │ User.java
│ │ │
│ │ ├─mapper // MyBatis 매퍼
│ │ │ UserMapper.java
│ │ │
│ │ ├─security // JWT 필터
│ │ │ JwtAuthorizationFilter.java
│ │ │
│ │ ├─service
│ │ │ UserService.java
│ │ │ UserServiceImpl.java
│ │ │
│ │ └─util
│ │ JwtUtil.java
│ │
│ └─resources
│ │ application.yml
│ │ data.sql
│ │ schema.sql
│ │
│ └─mappers
│ UserMapper.xml
🗄DTO 및 Entity
📌 LoginRequest: 로그인 요청을 위한 DTO
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequest {
private String id;
private String password;
}
클라이언트에서 ID와 비밀번호를 JSON으로 보내면, 이 DTO에 매핑된다.
📌 LoginResponse: 로그인 성공 시 응답 DTO
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginResponse {
private Integer therapistId;
private String token;
}
로그인 성공 시 therapistId와 JWT 토큰을 반환한다.
🔐로그인 API 및 로직 구현
📌 UserController.java (로그인 엔드포인트)
@PostMapping("/login")
public ResponseEntity<Integer> loginUser(@RequestBody LoginRequest request) {
LoginResponse response = userService.login(request);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + response.getToken());
return ResponseEntity.ok()
.headers(headers)
.body(response.getTherapistId()); // therapist_id 반환
}
1. @PostMapping("/login") : 사용자가 로그인 요청을 보낸다.
2. userService.login(request) : 로그인 로직을 실행한다.
3. JWT를 생성 후, 응답 헤더에 Authorization: Bearer <토큰>을 포함해서 보낸다.
4. therapistId를 응답 본문으로 반환한다.
📌 UserServiceImpl.java (로그인 로직)
public LoginResponse login(LoginRequest request) {
User user = userMapper.findById(request.getId());
if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new RuntimeException("Invalid credentials");
}
// JWT 토큰 생성
String token = jwtUtil.generateToken(user.getTherapistId());
return new LoginResponse(user.getTherapistId(), token);
}
- 사용자의 ID로 DB에서 조회한다. (userMapper.findById(request.getId()))
- 비밀번호 비교 → passwordEncoder.matches(request.getPassword(), user.getPassword())
- 인증 성공 시 JWT 토큰 생성 후 반환
- 토큰이 유효하면 SecurityContextHolder에 저장 → 인증된 사용자로 처리
JWT Util 로직
📌 1. generateToken(int therapistId) – JWT 토큰 생성
public String generateToken(int therapistId) {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
Date expiryDate = new Date(nowMillis + expirationTime);
System.out.println("발급 시간 (issuedAt): " + now);
System.out.println("만료 시간 (expiration): " + expiryDate);
return Jwts.builder()
.setSubject(String.valueOf(therapistId)) // therapistId를 subject로 설정
.setId(UUID.randomUUID().toString()) // JWT 고유 ID (jti) 추가
.setIssuedAt(new Date()) // 발급 시간
.setExpiration(new Date(System.currentTimeMillis() + expirationTime)) // 만료 시간 설정
.signWith(SignatureAlgorithm.HS256, getSigningKey()) // HS256 서명 적용
.compact();
}
- therapistId를 토큰의 subject (사용자 식별 값)으로 설정
- UUID.randomUUID()를 사용해 고유한 토큰 ID(jti)를 추가
- issuedAt, expiration을 포함해 토큰의 만료 시간 관리
- HS256 알고리즘을 사용하여 서명 (signWith)을 진행
결과적으로, 이 메서드는 therapistID를 포함하는 JWT를 생성하고 반환한다.
📌 2. validateToken(String token) – JWT 검증
public boolean validateToken(String token) {
try {
Jwts.parser()
.setSigningKey(getSigningKey()) // 서명 키 설정
.parseClaimsJws(token); // 서명 검증 및 파싱
return true;
} catch (Exception e) {
return false;
}
}
- Jwts.parser().setSigningKey(getSigningKey()).parseClaimsJws(token)
- 토큰을 파싱&서명 검증
- 유효하면 true, 아니면 false 반환
- 예외처리 : 변조 혹은 만료 상황에 false 반환
📌 3. extractAllClaims(String token) – JWT에서 Claims(정보) 추출
public Claims extractAllClaims(String token) {
return Jwts.parser()
.setSigningKey(getSigningKey()) // 서명 키 설정
.parseClaimsJws(token) // 토큰 파싱
.getBody();
}
- 토큰을 파싱하여 Claims(페이로드 데이터)를 반환
- 토큰 내의 subject, issuedAt, expiration 등의 정보를 가져올 수 있음
📌 4. extractId(String token) – JWT에서 사용자 ID(therapistId) 추출
public String extractId(String token) {
return extractAllClaims(token).getSubject(); // Subject(ID) 추출
}
- extractAllClaims(token)을 이용해 Claims 정보를 가져온 후
- subject(therapistId)를 반환
👨✈️JWT 인증 필터 (JwtAuthorizationFilter)
@Component
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtAuthorizationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authorizationHeader = request.getHeader("Authorization");
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
String token = authorizationHeader.substring(7);
if (jwtUtil.validateToken(token)) {
String userId = jwtUtil.extractId(token);
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userId, null, null);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
filterChain.doFilter(request, response);
}
}
- HTTP 요청에서 Authorization 헤더를 가져온다.
- "Bearer " 접두사를 제거한 후 JWT 토큰만 추출한다.
- jwtUtil.validateToken(token)으로 유효성 검사를 진행한다.
- 토큰이 유효하면 SecuritiyContextHolder에 저장하고 인증된 사용자로 처리하여 api를 사용할 수 있는 권한을 부여한다.
현재 코드에서 Refresh Token이 사용되고 있지 않다.
처음 사용해보는 JWT 로그인이라 어떤 방식을 이용해야할지 고민했는데, 여러가지 사항을 고려하여
Bearer Token방식을 선택하게 되었다.
프로젝트의 특징
현재 웹페이지는 다음과 같은 특징을 가진다.
- 언어치료사만 로그인 가능 (일반 사용자가 접근할 필요 없음)
- 로그인한 치료사만 본인의 일정 및 아동 정보를 관리 (다른 사용자와의 상호작용 없음)
- 서버에서 인증된 사용자만 데이터를 조회 & 수정 가능 (자신의 정보만 관리)
💡 즉, "내 계정"에 대한 데이터만 접근 가능하므로, 인증 방식이 단순할수록 유리함.
Bearer Token 방식을 선택한 이유
- 다른 사용자가 없는 개인 서비스이므로, Refresh Token이 필요 없음
- Refresh Token은 다중 사용자 시스템에서 Access Token을 갱신할 때 주로 사용
- 하지만 현재 시스템은 "하나의 사용자가 로그인 후 본인 데이터만 관리"하므로 별도의 갱신 로직 없이, Access Token만으로 충분
- 서버의 상태를 유지할 필요가 없음 (Stateless)
- Bearer Token 방식은 서버가 세션을 관리하지 않아도 됨
- 매 요청마다 클라이언트가 JWT를 포함하여 인증 → 서버는 단순히 JWT만 검증하면 됨
- 세션 기반 인증 방식보다 메모리 사용이 적고, 확장성이 뛰어남
- 보안 위험이 상대적으로 적음
- 다중 사용자 간 데이터 공유 및 권한 관리가 필요하지 않음
- 본인의 JWT가 유출되지 않는 한, 타인이 내 데이터에 접근할 일이 없음
- 로그인 지속 시간이 길어도 문제 없음
- 만약 다중 사용자 시스템이라면, Access Token이 짧게 유지되고, Refresh Token으로 자동 연장하는 게 일반적
- 하지만 치료사만 로그인해서 본인 정보만 관리하는 시스템에서는 적절한 만료 시간을 설정하여 Bearer Token을 유지하는 것이 더 효율적
- 구현이 단순하고, 유지보수 비용이 적음
- Refresh Token을 사용하면, Redis 같은 저장소를 운영해야 하고, 만료/갱신 로직을 추가해야 함
- 하지만 현재 서비스는 로그인이 필요한 사용자가 한정적이며, 추가적인 인증 관리가 필요하지 않음
서비스 특성을 고려하여 Refresh Token이 없는 Bearer Token방식을 선택하였지만 단점도 있다.
- 토큰이 만료되면 재로그인이 필요하다. -> 만료시간을 적절히 조정하기
- 탈취된 토큰을 강제로 무효화할 수 없다. -> 탈취 방지를 위해 HTTPS 적용하기
이후에 다양한 사용자를 고려한 로그인 방식을 구현해보고 싶다.
'공통 프로젝트' 카테고리의 다른 글
[공통 프로젝트] Nfc 정복하기 (1) | 2025.01.30 |
---|---|
[공통 프로젝트] DB 설계, 필수 체크 리스트! (0) | 2025.01.23 |
[공통 프로젝트] Jira 사용하기 (0) | 2025.01.13 |
[공통 프로젝트]도커/쿠버네티스 실전 활용하기 (0) | 2025.01.09 |
[공통 프로젝트] 아이디어 해커톤 (1) | 2025.01.09 |