이 글은 GDSC Ewha 서버 파트의 2024 솔루션 챌린지를 위해 진행한 Springboot 미니 프로젝트를 설명하는 글입니다. GDSC Ewha의 활동을 더 보고 싶다면 여기를 참고해주세요.
프로젝트의 깃허브 레포지토리 링크
이번 시간에는 나머지 절반의 API를 만들어보려고 합니다.
[👥유저]
- [POST] 유저 회원가입: email, password, age, name, address 값을 받아 Member 객체 생성
- [GET] 유저 로그인: email, password 일치하는지 확인
[👟서클]
- [POST] 써클 설명 수정
하나씩 만들어보겠습니다.
[POST] 유저 회원가입
스프링에는 Spring Security라는 프레임워크가 있습니다. 이는 애플리케이션의 보안(인증과 권한, 인가 등)를 담당하는 프레임워크입니다. 회원가입, 로그인, SNS 로그인 등은 이 Spring Security를 사용해야 합니다.
Spring Security를 사용해야 특정 유저의 리퀘스트라는 것을 구분할 수 있습니다. 따라서 Spring Security를 통해서 회원가입과 로그인을 구현해보겠습니다.
예시로 Session/JWT 토큰을 통한 사용자 인증 과정 이해하기
여러분이 어느 집에 들어간다고 가정해보겠습니다. 이때 여러분이 집에 들어갈 수 있다는 허락을 받는 방식에는 두 가지가 있습니다.
- 처음에 집에 들어갈 때 비밀번호를 입력합니다. 두 번째부터 들어갈 때는 집 주인이 여러분의 얼굴을 기억하고 있어서 집에 들어갈 수 있습니다.
- 처음에 집에 들어갈 때 비밀번호를 입력하고, 키를 발급받습니다. 두 번째부터 들어갈 때는 해당 키로 문을 열고 들어가면 됩니다. 키를 잃어버리거나 키가 만료되면 다시 발급 받아야 합니다.
첫 번째 방식을 Session, 두 번째 방식을 JWT 토큰 방식이라고 합니다.
세션 방식은 Session을 발급해서 서버에 저장하는 방식입니다. 사용자가 로그인하면 서버에서 새로운 세션 ID를 생성하고, 이를 세션 서버에 저장합니다. 또한 이 세션은 쿠키에 담아 사용자에게 전송됩니다. 이후 사용자의 모든 요청은 세션 ID를 포함하고 있으며, 서버는 이를 사용하여 사용자를 식별하는 방식입니다.
JWT 방식은 JWT 토큰이라는 키를 클라이언트에게 발급해주는 방식입니다. 사용자가 회원가입을 요청하면 서버는 사용자 정보를 포함한 JWT를 생성합니다. 이 JWT는 서버에 저장되지 않고, 클라이언트 측에 저장됩니다. 이후 사용자의 모든 요청은 JWT를 헤더에 담아 서버에 전송하며, 서버는 이를 사용하여 사용자를 식별합니다.
“로그인했다는 상태”, 즉 state를 세션 방식은 서버에 저장하고 유지하기 때문에 statefu하며, 토큰 방식은 서버에 사용자 상태를 저장하지 않고 클라이언트 측에서 상태를 유지하는 방식으로 stateless합니다.
따라서 최근에는 대부분 JWT 토큰을 통한 방식을 사용합니다. 세션을 계속해서 발급하는 방식은 서버의 용량에도 부하가 가며, 세션 서버가 다운되지 않도록 이중화 등을 신경써야 하는 문제가 많기 때문입니다.
Step1: 종속성 추가
이제 실제로 회원가입을 구현해보겠습니다. 먼저 build.gradle에 다음 종속성을 추가합니다. Spring Security 및 JWT 토큰 관련 종속성입니다.
dependencies {
//Security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
//Jwt
implementation group: 'com.auth0', name: 'java-jwt', version: '3.14.0'
}
Step2: JWT Secret 추가
JWT (JSON Web Token)은 클라이언트와 서버 간에 정보를 안전하게 전송하기 위한 토큰입니다. JWT에는 페이로드(payload)라 불리는 클레임(claim)들이 들어 있습니다. 이 페이로드는 Base64로 인코딩되어 있어서 누구나 디코딩할 수 있습니다. 따라서 페이로드의 내용이 누설되어도 안전한 방법으로 정보를 전송하기 위해 시크릿(Secret)이 사용됩니다.
JWT의 시크릿은 토큰을 생성하는 서버와 검증하는 서버 간에 공유되는 비밀키입니다. 이 시크릿 키를 사용하여 서명(signature)이 생성되며, 이 서명은 토큰의 무결성을 보장합니다. 서버가 토큰을 검증할 때, 시크릿 키를 사용하여 서명을 확인하고 페이로드의 무결성을 검증합니다.
시크릿 키를 생성하기 위해서 application.yml에 간 후, 다음과 같이 값을 추가합니다. 값은 적당한 길이의 랜덤하고 복잡한 값이면 됩니다. (소문자, 대문자, 숫자, 특수기호를 조합이 모두 들어가면 좋습니다.)
# JWT
jwt:
secret: # 랜덤하고 복잡한 값으로 설정-예시) AGalrgn@Tglknagf#TnvanglttlnagGlntl
이렇게 설정한 값은 나중에 JwtTokenProvider에서, JWT 토큰을 만들 때 HMAC512 알고리즘으로 해싱하고, 서명을 생성하는 대 사용됩니다. 우선은 위의 값만 설정하고 넘어가겠습니다.
/* 토큰을 서명하고 검증하는 데 사용되는 비밀 키 */
@Value("${jwt.secret}")
private String secretKey;
/* 해싱 알고리즘(HMAC512) 사용해 서명 생성
* 서명을 통해서 데이터의 무결성과, 특정 소유자가 생성한 것임을 확인 가능 */
private Algorithm getSign(){
return Algorithm.HMAC512(secretKey);
}
Step3: JwtToken.java 생성
이제 JWT 토큰 객체를 만들어주겠습니다. JWT 토큰에는 사실 2가지가 있습니다. 하나는 AccessToken으로, 사용자가 자원에 접근할 수 있는 권한을 주는 것이고, 다른 하나는 RefreshToken으로, AccessToken을 계속해서 발급받을 수 있도록 하는 토큰입니다. 여기서는 우선 AccessToken만 구현하겠습니다.
@Getter
public class JwtToken {
private String jwtAccessToken; //사용자가 자원에 접근할 수 있는 권한 부여
@Builder
public JwtToken(String jwtAccessToken) {
this.jwtAccessToken = jwtAccessToken;
}
}
Step4: JwtTokenProvider.java 생성
위에서 만든 JWT 토큰 객체를 사용하기 위해서 JwtTokenProvider를 만들어보겠습니다. JwtToken 클래스는 어떤 로직도 없는 객체일 뿐이므로, 위에서 봤듯이 HMAC512 알고리즘으로 해싱하고, JWT 토큰에 포함될 값들을 설정하고, 유효한 JWT 토큰인지 검증하는 로직 등은 JwtTokenProvider에 있어야 합니다.
JwtTokenProvider 코드가 매우 기므로, 여기서는 몇몇 중요한 로직만 설명하겠습니다. 다른 코드는 주석을 참고하시면 됩니다.
먼저, 토큰의 만료 시간 설정입니다. 한번 발급받은 토큰이 평생 유효하도록 하면 보안에 문제가 생길 수 있습니다. 따라서 적절한 시간이 필요합니다. 1-3시간 정도가 적당하지만, 개발 테스트용으로는 하루로 설정해두겠습니다. 이 JWT_EXPIRATION_TIME이 지나면, 토큰은 더 이상 유효하지 않습니다. 따라서 다시 발급받아야(즉, 다시 로그인해야) 합니다.
/* 토큰의 만료 시간 설정 */
//private static final long JWT_EXPIRATION_TIME = 1000L * 60 * 60; //1시간
private static final long JWT_EXPIRATION_TIME = 1000L * 60 * 60 * 24; //개발 테스트용: 하루
다음으로 중요한 메서드는 JWT 토큰을 생성하는 메서드입니다. JWT 토큰에는 몇 가지 중요한 요소가 있는데, Subject, ExpireDate, Claim, Sign 등이 그것입니다. Subject는 토큰의 사용자를 식별하는 고유 주제를 의미하고, ExpireDate은 토큰의 만료 시간을, Claim들은 토큰에 포함되는 정보들을, Sign은 JWT를 서명하는 Secret 값들을 의미합니다.
특히 여러분들이 만드는 프로젝트의 DB 구조에 따라서 Subject와 Claim을 바꿀 수도 있습니다. 예를 들어서, 여러분들이 만든 DB에서 사용자의 이메일 값이 unique한 값이 아니라면, Subject는 다른 Unique한 값으로 바꾸어 주어야 합니다.
/* Jwt 토큰 생성 메소드 */
public String generateJwtToken(Long id, String email){
Date tokenExpireDate = new Date(System.currentTimeMillis() + (JWT_EXPIRATION_TIME)); //토큰의 만료 날짜 생성
return JWT.create()
.withSubject(email) //토큰의 사용자를 식별하는 고유 주제
.withExpiresAt(tokenExpireDate) //토큰의 만료 시간
.withClaim("id", id) //토큰에 포함되는 정보인 Claim 설정
.withClaim("email", email)
.sign(this.getSign());
}
Step5: JwtAuthorizationFilter.java 및 JwtAuthenticationFilter.java 생성
이 두 개 클래스에 대해서는 간단하게만 설명하겠습니다. 위에서 설명한 예시, “처음에 집에 들어갈 때 비밀번호를 입력하고, 키를 발급받습니다. 두 번째부터 들어갈 때는 해당 키로 문을 열고 들어가면 됩니다. 키를 잃어버리거나 키가 만료되면 다시 발급 받아야 합니다.”가 기억나시나요? 이 예시에서는 도어락에 키를 가져다 대면 키를 인식 가능합니다. 그러나 백엔드 서버는 어떻게 JWT 토큰을 인식하는 걸까요?
백엔드 서버에서는 filter라는 것을 가지고, 들어오는 모든 HTTP 요청을 끊임없이 걸러냅니다. 그게 바로 JwtAuthorizationFilter와 JwtAuthenticationFilter 입니다. 이 두 개의 필터를 통해서 HTTP 요청의 header를 체크하고, 헤더 중 “Authorization”이라는 값에 JWT 토큰값이 포함되어 있는지를 끊임없이 검사할 수 있습니다.
몇 가지 중요한 코드만 보겠습니다. 먼저 JwtAuthenticationFilter에서는, 다음과 같이 정상적으로 로그인이 완료되면, 사용자의 정보를 Authentication 객체로 캡슐화하여 받습니다. Authentication 객체는 스프링 시큐리티의 프레임워크로 사용자 정보를 캡슐화하는 역할을 합니다.
그 후 jwtTokenProvider를 통해 JWT 토큰을 발급받고, Authorization 헤더에 해당 값을 저장해줍니다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
AuthDetails authDetails = (AuthDetails) authResult.getPrincipal();
Long id = authDetails.getMember().getId();
String email = authDetails.getMember().getEmail();
String jwtToken = jwtTokenProvider.generateJwtToken(id, email);
/* 가장 흔한 방식인 Bearer Token을 사용 */
response.addHeader("Authorization", "Bearer " + jwtToken);
}
JwtAuthorizationFilter는 이와는 달리 사용자에게서 인증이 필요한 요청이 오면, Authorization 헤더의 JWT 토큰 값을 추출하고 정상적인지 확인합니다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
/* 헤더 추출 및 정상적인 헤더인지 확인 */
String jwtHeader = request.getHeader("Authorization");
if (jwtHeader == null || !jwtHeader.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
/* 헤더 안의 JWT 토큰을 검증해 정상적인 사용자인지 확인 */
String jwtToken = jwtHeader.substring(7);
Member tokenMember = jwtTokenProvider.validJwtToken(jwtToken);
if(tokenMember != null){ //토큰이 정상일 경우
AuthDetails authDetails = new AuthDetails(tokenMember);
/* JWT 토큰 서명이 정상이면 Authentication 객체 생성 */
Authentication authentication = new UsernamePasswordAuthenticationToken(authDetails, null, authDetails.getAuthorities());
/* 시큐리티 세션에 Authentication 을 저장 */
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
이렇게 필터가 만들어졌으니, 이제 이 필터를 실제로 적용해보겠습니다. 이 두 가지 필터를 조합하기 위해서는 보안 체인이 필요합니다. Spring Security에서 보안 체인(Security Chain)은 보안 관련 작업을 수행하는 여러 개의 필터(클래스)가 연결된 구조입니다.
보안 체인을 구현하기 위해서 SecurityConfig 클래스를 만들어주겠습니다.
Step6: SecurityConfig.java 생성
SecurityConfig에서는 위에서 만든 필터 뿐만 아니라, 다양한 보안 작업을 수행할 수 있습니다.
혹시 지난 시간에 이야기했던 CORS(Cross-origin resource sharing) 에러가 기억나시나요?
Spring boot에서도 똑같이 CORS를 허용해주어야 프론트엔드가 접근할 때 에러가 발생하지 않습니다. 왜냐하면 백엔드는 기본적으로 동일한 출처(동일한 프로토콜, 호스트명, 포트)의 접근만 허용하기 때문입니다. 즉, 이 Spring boot 서버는 본인 서버(localhost:8080)에서만 접근만 허용하고, 프론트엔드 서버(예시로 react 서버의 localhost:3000)대해서는 차단합니다. 이 둘은 서로 다른 오리진이기 때문입니다.
이를 위해서 SecurityConfig.java에서, CorsConfigurationSource라는 객체를 생성해 CORS를 해결하겠습니다.
아래 코드를 대부분 사용하면 되지만, 여기서 중요한 몇 가지만 설명하겠습니다. 우선 addAllowedOrigin을 통해서 허용되는 오리진을 명시합니다. 지금은 백엔드 서버 및 프론트엔드 서버가 다 로컬에서만 실행되고 있다고 가정하겠습니다. 따라서 http://localhost:3000와 http://localhost:8080을 허용합니다. 여기서 정말 중요한 점은, 만약 프론트엔드 서버가 배포되어 EC2 서버의 public IP를 가지거나, 특정 도메인 주소(http://front-server.com)를 가진다면, 꼭 반드시 addAllowedOrigin으로 추가해주어야 합니다.
그렇지 않으면 CORS 에러가 반드시 발생합니다.
두 번째로 중요한 점은 Authorization 헤더에 대해서 반드시 addExposedHeader를 해주어야 한다는 점입니다. 클라이언트가 백엔드 서버가 주는 응답에 있는 헤더에 접근하기 위해서는 반드시 백엔드 단에서 허용해줘야 합니다. Authorization 헤더에 JWT 토큰 값이 있고, 이 토큰 값에 접근해야 클라이언트가 쿠키 등에 저장해 이후 요청에서 포함시킬 수 있으므로, 반드시 addExposedHeader를 해줍니다.
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedOrigin("<http://localhost:3000>");
configuration.addAllowedOrigin("<http://localhost:8080>");
configuration.addAllowedMethod("*"); //모든 Method 허용(POST, GET, ...)
configuration.addAllowedHeader("*"); //모든 Header 허용
configuration.setMaxAge(Duration.ofSeconds(3600)); //브라우저가 응답을 캐싱해도 되는 시간(1시간)
configuration.setAllowCredentials(true); //CORS 요청에서 자격 증명(쿠키, HTTP 헤더) 허용
configuration.addExposedHeader("Authorization"); // 클라이언트가 특정 헤더값에 접근 가능하도록 하기
configuration.addExposedHeader("Authorization-Refresh");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); //위에서 설정한 Configuration 적용
return source;
}
그 후에는 filterChain을 통해서 위에서 만든 JwtAuthorizationFilter, JwtAuthenticationFilter 그리고 CorsConfigurationSource를 모두 적용해주면 됩니다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.csrf(csrf -> csrf.disable()) //악의적인 공격 방어를 위해서 CSRF 토큰 사용 비활성화
.cors(cors -> cors.configurationSource(corsConfigurationSource())) //CORS 설정
.formLogin(formLogin -> formLogin.disable()) //폼 기반 로그인을 비활성화->토큰 기반 인증 필요
.httpBasic(httpBasic -> httpBasic.disable()) //HTTP 기본 인증을 비활성화->비밀번호를 평문으로 보내지 않음
.sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) //세션을 생성하지 않음->토큰 기반 인증 필요
.addFilter(new JwtAuthenticationFilter(authenticationManager(), jwtTokenProvider())) //사용자 인증
.addFilter(new JwtAuthorizationFilter(authenticationManager(), jwtTokenProvider(), authDetailService)) //사용자 권한 부여
.authorizeHttpRequests(request -> request
.dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll()
.anyRequest().permitAll() //개발 환경: 모든 종류의 요청에 인증 불필요
);
return httpSecurity.build();
}
Step7: AuthDetails.java 및 AuthDetailService.java 구현
AuthDetails는 인증된 사용자에 대한 세부 정보를 다루는 UserDetails의 구현체이고, AuthDetailService는 AuthDetails의 정보를 DB에서 가져오기 위한 서비스입니다. 해당 부분은 코드를 보고 그대로 구현하면 됩니다.
Step8: AuthController.java 및 AuthService.java 구현
드디어 API를 만들어 볼 수 있습니다(오랜 여정이었습니다). 우리는 RegisterRequestDto를 통해서 사용자의 정보들(email, password, age, name, address)를 받아서, 이를 DB에 저장해주겠습니다. 컨트롤러 자체는 간단합니다.
@PostMapping("/signup")
@Operation(summary = "회원가입 API", description = "사용자의 이메일과 비밀번호로 회원가입하는 API 입니다.")
public CommonResponse<?> userRegister(@RequestBody RegisterRequestDto registerRequestDto) {
String registerMessage = authService.registerMember(registerRequestDto);
return CommonResponse.postSuccess(HttpStatus.CREATED.value(), registerMessage);
}
서비스 로직에서만 잠깐 확인해보면 좋은 건, 바로 bCryptPasswordEncoder 입니다. 비밀번호는 절대 DB에 원문 그대로 저장되서는 안 됩니다. 따라서 스프링 시큐리티의 crypto.bcrypt.BCryptPasswordEncoder 종속성을 통해서 이 값을 암호화해줍니다.
@Transactional
public String registerMember(RegisterRequestDto registerRequestDto) {
... (생략) ...
/* 빌더 패턴을 사용해 MemberRole을 넘겨주지 않아도 객체 생성 가능 */
Member member = Member.builder()
.email(registerRequestDto.getEmail())
.password(bCryptPasswordEncoder.encode(registerRequestDto.getPassword())) //비밀번호는 해싱해서 DB에 저장
.age(registerRequestDto.getAge())
.name(registerRequestDto.getName())
.address(address)
.build();
memberRepository.save(member); // DB에 저장하기
return "회원가입이 완료되었습니다.";
}
DB의 값을 확인해보면, 암호화하기 전은 이렇게 원문 그대로가 DB에 저장되고, 암호화하면 해석하기 어려운 값이 저장됩니다. 당연히 사용자는 원문 비밀번호 그대로 로그인할 수 있습니다.
Postman으로 API 테스트
이제 Postman으로 API를 테스트해보겠습니다. 다음 값을 Body의 raw JSON 값으로 넣어주시면 됩니다.
{
"email": "1234@email.com",
"password": "abc123!#",
"age": 20,
"name": "김영희",
"addressDto": {
"country": "한국",
"city": "서울",
"zipcode": "12345"
}
}
다음과 같이 응답이 오는 것을 확인할 수 있습니다.
DB를 확인해봐도 값이 잘 저장되어 있습니다. 비밀번호는 암호화된 것을 확인할 수 있습니다.
[GET] 유저 로그인
위에서 대부분의 JWT와 스프링 시큐리티 관련 로직을 구현해 놓았으니, 로그인 부분은 컨트롤러와 서비스만 확인해보겠습니다. 로그인은 email, password 값을 클라이언트에게서 받아서, 이 값이 DB에 저장되어 있을 경우, JWT 토큰을 발급해주는 프로세스입니다. 그 후 클라이언트는 이렇게 발급받은 JWT 토큰을 Authorization 헤더에 포함시켜서 전송해야 합니다. 그러면 위에서 보았듯이, JwtAuthorizationFilter가 요청을 필터링하면서 확인합니다.
AuthController.java 및 AuthService.java 구현
먼저 컨트롤러를 보겠습니다. LoginRequestDto를 통해서 사용자의 이메일과 비밀번호를 전달 받습니다.
@PostMapping("/login")
@Operation(summary = "로그인 API", description = "사용자의 이메일과 비밀번호로 로그인하는 API 입니다.")
public CommonResponse<?> userLogin(@RequestBody LoginRequestDto loginRequestDto) {
String loginMessage = authService.loginMember(loginRequestDto);
return CommonResponse.postSuccess(HttpStatus.CREATED.value(), loginMessage);
}
이제 서비스 로직에서 인증을 진행합니다. Authentication 객체는 인증 정보를 나타내는 범용적인 인터페이스입니다. 이 Authentication 객체 안에 포함되는 AuthDetails 객체를 통해서 사용자의 정보를 확인할 수 있습니다. 이를 통해서 DB에서 일치하는 맴버가 있는지 찾고, 있을 경우 JWT 토큰을 생성해서 헤더에 넣어 반환합니다.
@Transactional
public String loginMember(LoginRequestDto loginRequestDto) {
String email = loginRequestDto.getEmail();
String password = loginRequestDto.getPassword();
/* 사용자가 제출한 이메일과 비밀번호 확인하기 */
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(email, password);
/* 사용자 인증 완료 */
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
/* 인증이 되었을 경우 */
if(authentication.isAuthenticated()) {
/* 사용자가 인증되면 AuthDetails 객체가 생성되어 Authentication 객체에 포함되고,
* 이 AuthDetails 객체를 통해서 인증된 사용자의 정보를 확인 가능 */
AuthDetails authDetails = (AuthDetails) authentication.getPrincipal();
Long authenticatedId = authDetails.getMember().getId();
String authenticatedEmail = authDetails.getMember().getEmail();
/* JWT 토큰 반환 */
return jwtTokenProvider.generateJwtToken(authenticatedId, authenticatedEmail);
}
return "로그인에 실패했습니다. 이메일 또는 비밀번호가 일치하는지 확인해주세요.";
}
Postman으로 API 테스트
이제 Postman으로 API를 테스트해보겠습니다. 한 가지 주의할 점은, 반드시 DB에 저장된 이메일과 비밀번호로 로그인해야 합니다. Body의 raw JSON 값을 다음과 같이 넣어줍니다.
{
"email": "1234@email.com",
"password": "abc123!#"
}
그러면 이전과는 다르게 응답의 Header의 Authorization에 JWT 토큰 값이 넣어져서 오는 것을 알 수 있습니다.
//예시 JWT 토큰 값
Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIxMjM0QGVtYWlsLmNvbSIsImlkIjoxNTMsImV4cCI6MTcwNzE1MjU5NiwiZW1haWwiOiIxMjM0QGVtYWlsLmNvbSJ9.7VDMZI-OIU4pF9_-p95x0H8im5fjnf5L8SCSMehnCjTDnvghyM2GIyN4-Gxd4Oi1rCJskuDUPmW55m6P6yYvjQ
이 토큰 값은 클라이언트에서 그대로 쿠키에 저장했다가, 필요 시 꺼내서 Authorization 헤더에 넣어야 합니다. (주의: 맨 앞에 “Bearer “ 접두사를 제외하거나 변경하지 마세요.)
참고로 DB에 해당 사용자가 없거나, 사용자가 중복되어 있으면 JWT 토큰이 반환되지 않습니다. 꼭 단 한 명의 이메일-비밀번호 쌍이 있도록 DB에 저장해주세요.
[GET] 추가 API- JWT 토큰 값에서 데이터 가져오기
이제 Postman으로 API를 테스트해보겠습니다. 프론트엔드에서는 직접 HTTP 요청을 보낼 수 있겠지만, 우리는 Postman 상에서 Header에 수동으로 JWT 토큰 값을 넣어서 테스트합니다.
그런데 이 JWT 토큰 값은 시간이 지나면 변경될 수 있는 값입니다. 따라서 하드코딩하기 보다는 환경 변수로 설정해주고 값이 변경되면 이 변수 값만 바꿔주겠습니다. 왼쪽 탭의 Environments에 가서, jwt_token이라는 변수를 만들어주고, Curent Value에 발급 받은 토큰 값을 넣어줍니다.
이제 토큰 값이 변경될 때마다 이 변수 값만 바꿔주면 됩니다.
그 후, API를 만들기 위해서 MemberController를 만들어 주겠습니다. 이 API는 HTTP 요청에서 Authorization 헤더에 정상적인 JWT 토큰 값이 있는지 검사하고, 있으면 Authentication 객체에 해당 값을 넣어옵니다. 그 후 UserDetails 인터페이스를 통해서 JWT 토큰에서 원하는 값을 찾을 수 있습니다. 여기서는 getUsername() 함수가 email을 리턴하도록 구현되어 있으므로(AuthDetails 클래스에 그렇게 구현되어 있습니다. 다른 값으로 변경해도 됩니다.) JWT 토큰에서 email을 가져오고, 이 이메일 값을 기반으로 DB에서 Member 엔티티를 찾아서 반환해주는 API를 만들어보겠습니다. (원래대로라면 패스워드 등의 민감한 정보가 다 있는 Member 엔티티를 이렇게 바로 반환하지 말고, Dto를 통해서 반환해야 합니다. 지금은 테스트이므로 이렇게 반환하겠습니다.)
@RestController
@RequiredArgsConstructor
@Tag(name = "인증 테스트 API", description = "JWT 토큰 인증 테스트용 API.")
public class MemberController {
private final MemberRepository memberRepository;
@GetMapping("/user/info")
@Operation(summary = "맴버 엔티티 반환 API", description = "JWT 토큰을 바탕으로 맴버 엔티티를 반환하는 테스트용 API 입니다.")
public ResponseEntity<Member> getMemberData(Authentication authentication) {
// 인증된 사용자의 정보를 인증 객체(authentication)를 통해 가져오기
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String email = userDetails.getUsername(); // 여기서는 username에 email을 담았습니다.
// 사용자 ID를 이용하여 데이터베이스에서 사용자 정보 조회
Member member = memberRepository.findMemberByEmail(email);
return new ResponseEntity<>(member, HttpStatusCode.valueOf(200));
}
}
이제 서버를 띄우고, Postman으로 가서 Headers 섹션에서 새롭게 Authorization 헤더를 만들어주고, 값으로는 {{jwt_token}}을 넣습니다. 그 후 body에는 아무것도 넣지 않고 send를 누르면, 적절한 맴버 엔티티가 반환되는 것을 확인할 수 있습니다. JWT 토큰 및 스프링 시큐리티 로직이 정상적으로 잘 작동하는 것을 확인했습니다.
이렇게 로그인과 회원가입 과정을 구현하고, 발급 받은 JWT 토큰에서 값을 추출하는 과정까지 살펴보았습니다.
[POST] 써클 정보 수정
지난 시간에는 객체를 생성하거나 조회하는 API를 만들어보았습니다. 그렇다면, 이미 생성되어 있는 객체의 특정 필드만 변경하는 경우는 어떻게 하는 걸까요? 예를 들어 써클 정보 수정은 이미 있는 써클 객체에서 description 필드만 변경해주어야 합니다.
이때CircleRepository에서 EntityManager의 merge 메서드를 사용하면, 업데이트 비슷하게 만들 수 있습니다. 즉 처음 저장될 때는 persist로 객체 전체를 저장해주고, 두 번째 저장될 때는 merge로 이미 있는 객체에서 업데이트해주면 됩니다.
private final EntityManager em;
public void save(Item item){
if (item.getId() == null){
em.persist(item); // JPA에서 처음 저장하기 전까지는 id 값이 없다.
// 즉, 완전히 새로 생성하는 객체이다.
} else {
em.merge(item); // 두 번째 저장할 때는 다음과 같이 해준다. 업데이트 비슷하다.
}
}
그러나 이 방식은 문제점이 있습니다. merge를 사용한 방식은 DB에 있는 엔티티의 값을 새로운 엔티티의 값으로 모두 교체하는 방식입니다. 즉, 우리가 업데이트하려는 description 뿐만 아니라 업데이트하지 않으려는 name 필드도 모두 업데이트됩니다. 만약 따로 name 필드 값을 명시해주지 않았으면 null 값이 됩니다.
그래서 이 경우에는 JPA의 자동 변경 감지 기능을 사용합니다. 서비스 계층해서 변경하고 싶은 엔티티를 DB에서 꺼내오고(findCircleById) 해당 엔티티에서 원하는 속성만 변경해줍니다. Setter를 통해서 그때 그때 원하는 속성을 변경해주어도 괜찮지만, Setter를 많이 사용할 경우 어떤 상황에서 어떻게 값이 변경되었는지 추척하기 매우 어렵습니다. 따라서 changeDescription이라는 메서드를 명확하게 만들어서, 해당 메서드 이름으로 어떤 상황에서 값이 변경되었는지를 추적 가능하게 합니다(이는 로그 같은 것을 남겨서 추적할 수 있지만, 여기서는 우선 넘어가겠습니다.)
@Service
@RequiredArgsConstructor
@Transactional
public class CircleService {
private final CircleRepository circleRepository;
public void updateCircleDesc(Long id, String description){
/* JPA의 변경 감지를 사용하면, 단순히 DB에서 꺼내온 엔티티의 필드를 변경해주기만 해도
* 자동으로 변경이 감지되고, 변경사항이 트랜잭션에 의해서 커밋된다.
*/
Circle circle = circleRepository.findCircleById(id);
/* Setter를 사용하는 것보다, changeDescription라는 명확한 메서드를 통해
* 어떤 상황에서 값이 변경된 것인지를 tracking 되게 만들어준다.
*/
circle.changeDescription(description);
}
}
참고로 changeDescription는 이렇게 Circle 내부에 구현된 메서드입니다.
@Entity
@Getter
@NoArgsConstructor
public class Circle {
...(생략)...
public void changeDescription(String description){
this.description = description;
}
}
이와 같이, 엔티티를 변경할 때는 항상 변경 감지를 사용합니다. 반드시 트랜잭션이 있는 서비스 계층에 식별자(id)와 변경할 데이터를 명확하게 전달(파라미터 or dto)하고, 서비스 계층에서 영속 상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경하게 만듭니다. 트랜잭션 커밋 시점에 변경 감지된 내용이 실행되어 DB에 저장되게 됩니다.
마무리
여기까지 해서 솔루션 챌린지를 위한 Springboot 미니 프로젝트가 끝났습니다:) 배운 점들을 각자 프로젝트에 맞게 변형 및 적용하고, 좋은 마무리를 짓기를 바랍니다.
'🗄️Backend > [GDSC] SpringBoot 프로젝트' 카테고리의 다른 글
Spring Boot 단기 프로젝트: 5. Make API(Profile, Posts) (0) | 2024.02.08 |
---|---|
Spring Boot 단기 프로젝트: 4. Connect to GCP Cloud Storage & make Dto (0) | 2024.02.08 |
Spring Boot 단기 프로젝트: 3. RestController & Swagger-UI & Postman (0) | 2024.02.08 |
Spring Boot 단기 프로젝트: 2. Build Entity & Connect to GCP Cloud SQL (0) | 2024.02.08 |
Spring Boot 단기 프로젝트: 1. Build RDB & Class Diagram (1) | 2024.02.08 |