Spring Security에서 원래 구현하려 했던 것은 Thymeleaf
방식이었으나, 이것은 현실적으로 서비스를 구현할 때 재사용성도 크게 없고, 실무에 쓰일 가능성도 현저히 적다고 생각하여, React로 간단한 프론트엔드를 생성한 후 통신을 주고받는 방식으로 설정하였다.
DB와 그것을 연결하는 Entity는 회원을 나타내는 Member
하나로 구현하기로 한다.
Member
의 구성요소는 id
, email
, password
, nickname
으로 구성되어 있다.
이 과정에서 서버는 토큰이 유효한지 검증하는 과정을 거친다.
백엔드 부분에서는 해당 블로그에서 많은 걸 참고했다.
build.gradle
plugins {
id 'org.springframework.boot' version '2.7.0'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
runtimeOnly 'mysql:mysql-connector-java'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
}
tasks.named('test') {
useJUnitPlatform()
}
Spring 2.7.0을 사용했으며, DB는 MYSQL을 사용했다.
주요 의존성에는 JPA
, Spring Security
, jjwt-api
가 있다.
aplication.yml
spring:
datasource:
url: url
username:
password:
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: create-drop
properties:
hibernate:
format_sql: true
show_sql: true
logging:
level:
com.tutorial: debug
jwt:
secret:
mysql의 주소와 username, password를 입력했고,
jpa설정에서는 가독성을 위해 format_sql과 show_sql를 등록했으며
ddl-auto를 통해 db를 자동으로 생성했다.
이후 jwt에 필요한 secret key는 HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
따라서 특정 문자열을 Base64 로 인코딩한 값을 사용했다.
/entity/Member.java
@Entity
@Getter
@Builder
@NoArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String nickname;
@Enumerated(EnumType.STRING)
private Authority authority;
public void setNickname(String nickname) {
this.nickname = nickname;
}
public void setPassword(String password) { this.password = password; }
@Builder
public Member(Long id, String email, String password, String nickname, Authority authority) {
this.id = id;
this.email = email;
this.password = password;
this.nickname = nickname;
this.authority = authority;
}
}
/entity/Authority.java
public enum Authority {
ROLE_USER, ROLE_ADMIN
}
앞서 말했듯이 id
, email
, password
, nickname
로 구성되어 있으며, Spring Security에는 user의 role이 필요하므로 enum타입의 role을 추가하였다.
이후 닉네임과 비밀번호 변경에 필요한 함수인 setNickname
과 setPassword
를 추가했다.
/repository/MemberRepository.java
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmail(String email);
boolean existsByEmail(String email);
}
간단하게 JPARepository
를 사용했다.
email로 Member
를 찾는 로직과, email이 존재하는가 판별하는 로직을 추가한다.
/jwt/TokenProvider.java
@Component
public class TokenProvider {
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_TYPE = "bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;
private final Key key;
// 주의점: 여기서 @Value는 `springframework.beans.factory.annotation.Value`소속이다! lombok의 @Value와 착각하지 말것!
// * @param secretKey
public TokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// 토큰 생성
public TokenDto generateTokenDto(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date tokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
System.out.println(tokenExpiresIn);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.setExpiration(tokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.tokenExpiresIn(tokenExpiresIn.getTime())
.build();
}
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if (claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
하나 하나 살펴보자.
일단 제일 윗단의 AUTHORITIES_KEY
와 BEARER_TYPE
은 토큰을 생성하고 검증할 때 쓰이는 string값이다.
ACCESS_TOKEN_EXPIRE_TIME
는 토큰의 만료 시간이다.
key
는 JWT 를 만들 때 사용하는 암호화 키값을 사용하기 위해 security에서 불러왔다.
@Value
어노테이션으로 yml에 있는 secret key를 가져온 다음 이것을 decode한다
이후 의존성이 주입된 key의 값으로 정한다.
토큰을 만드는 메소드다.
Authentication
인터페이스를 확장한 매개변수를 받아서 그 값을 string으로 변환한다.
이후 현재시각과 만료시각을 만든 후 Jwts
의 builder를 이용하여 Token을 생성한 다음
TokenDto
에 생성한 token의 정보를 넣는다.
토큰을 받았을 때 토큰의 인증을 꺼내는 메소드다.
아래 서술할 parseClaims
메소드로 string 형태의 토큰을 claims형태로 생성한다.
다음 auth가 없으면 exception을 반환한다.
GrantedAuthority
을 상속받은 타입만이 사용 가능한 Collection을 반환한다.
그리고 stream을 통한 함수형 프로그래밍으로 claims형태의 토큰을 알맞게 정렬한 이후 SimpleGrantedAuthority
형태의 새 List를 생성한다. 여기에는 인가가 들어있다.
SimpleGrantedAuthority
은 GrantedAuthority
을 상속받았기 때문에 이 지점이 가능하다.
SimpleGrantedAuthority.java
public final class SimpleGrantedAuthority implements GrantedAuthority {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
...
}
이후 Spring Security에서 유저의 정보를 담는 인터페이스인 UserDetails
에 token에서 발췌한 정보와, 아까 생성한 인가를 넣고,
이를 다시 UsernamePasswordAuthenticationToken
안에 인가와 같이 넣고 반환한다.
여기서 UsernamePasswordAuthenticationToken
인스턴스는 UserDetails
를 생성해서 후에 SecurityContext
에 사용하기 위해 만든 절차라고 이해하면 된다.
왜냐하면 SecurityContext
는 Authentication
객체를 저장하기 때문이다.
토큰을 검증하기 위한 메소드다.
토큰을 claims형태로 만드는 메소드다.
이를 통해 위에서 권한 정보가 있는지 없는지 체크가 가능하다.
/jwt/JwtFilter.java
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final TokenProvider tokenProvider;
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
return null;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String jwt = resolveToken(request);
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
Request Header에서 토큰 정보를 꺼내오는 메소드다.
필터링을 실행하는 메소드다.
resolveToken
을 통해 토큰 정보를 꺼내온 다음, validateToken
으로 토큰이 유효한지 검사를 해서,
만약 유효하다면 Authentication
을 가져와서 SecurityContext
에 저장한다.
SecurityContext
에서 허가된 uri 이외의 모든 Request 요청은 전부 이 필터를 거치게 되며, 토큰 정보가 없거나 유효치않으면 정상적으로 수행되지 않는다.
반대로 Request가 정상적으로 Controller까지 도착했으면 SecurityContext
에 Member ID가 존재한다는 것이 보장이 된다.
/jwt/JwtAuthenticationEntryPoint.java
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
/jwt/JwtAccessDeniedHandler.java
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
두가지 모두 유효치 않은 접근을 할때 response에 error를 만들어주는 컴포넌트다.
/config/JwtSecurityConfig.java
@RequiredArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final TokenProvider tokenProvider;
@Override
public void configure(HttpSecurity http) {
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>
인터페이스를 구현하는 구현체다.
직접 만든 TokenProvider
와 JwtFilter
를 SecurityConfig
에 적용할 때 사용한다.
메인 메소드인 configure
은TokenProvider
를 주입받아서 JwtFilter
를 통해 SecurityConfig
안에 필터를 등록하게 되고, 스프링 시큐리티 전반적인 필터에 적용된다.
/config/WebSecurityConfig.java
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
@Component
public class WebSecurityConfig {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.authorizeRequests()
.antMatchers("/auth/**").permitAll()
.anyRequest().authenticated()
.and()
.apply(new JwtSecurityConfig(tokenProvider));
return http.build();
}
}
하나하나 파고들면, 먼저 request로부터 받은 비밀번호를 암호화하기 위해 PasswordEncoder
빈을 생성했다.
이후 실질적인 로직인 filterChain
에 있어서 참조한 블로그 에서는 WebSecurityConfigurerAdapter
을 상속받아 사용했다.
그러나 Spring Security는 2022년, 해당 기능을 deprecated 했다.
대신 HttpSecurity
를 Configuring해서 사용하라는 대안방식을 제시했으며, 본인 또한 그 방법을 사용했다.
https만을 사용하기위해 httpBasic
을 disable했으며,
우리는 리액트에서 token을 localstorage에 저장할 것이기 때문에 csrf
방지또한 disable했다.
또한 우리는 REST API를 통해 세션 없이 토큰을 주고받으며 데이터를 주고받기 때문에 세션설정또한 STATELESS
로 설정했다.
이후 예외를 핸들링하는 것에서는 이전에 작성했던 JwtAuthenticationEntryPoint
와 JwtAccessDeniedHandler
를 넣었다.
모든 Requests에 있어서 /auth/**
를 제외한 모든 uri의 request는 토큰이 필요하다. /auth/**
는 로그인 페이지를 뜻한다.
마지막으로 전에 설정한 JwtSecurityConfig
클래스를 통해 tokenProvider
를 적용시킨다.
/config/SecurityUtil.java
public class SecurityUtil {
private SecurityUtil() { }
public static Long getCurrentMemberId() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || authentication.getName() == null) {
throw new RuntimeException("Security Context에 인증 정보가 없습니다.");
}
return Long.parseLong(authentication.getName());
}
}
SecurityContext
에 유저 정보가 저장되는 시점을 다루는 클래스다.
Request가 들어오면 JwtFilter
의 doFilter에서 저장되는데 거기에 있는 인증정보를 꺼내서, 그 안의 id를 반환한다.
우리는 Entity를 정할때 id의 타입을 Long으로 했기 때문에 Long을 반환한다.
/dto/TokenDto.java
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TokenDto {
private String grantType;
private String accessToken;
private Long tokenExpiresIn;
}
토큰의 값을 헤더에서 뽑거나 헤더에서 삽입할때 쓰는 dto다.
/dto/MemberResponseDto.java
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class MemberResponseDto {
private String email;
private String nickname;
public static MemberResponseDto of(Member member) {
return MemberResponseDto.builder()
.email(member.getEmail())
.nickname(member.getNickname())
.build();
}
}
Response를 보낼때 쓰이는 dto다.
/dto/MemberRequestDto.java
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class MemberRequestDto {
private String email;
private String password;
private String nickname;
public Member toMember(PasswordEncoder passwordEncoder) {
return Member.builder()
.email(email)
.password(passwordEncoder.encode(password))
.nickname(nickname)
.authority(Authority.ROLE_USER)
.build();
}
public UsernamePasswordAuthenticationToken toAuthentication() {
return new UsernamePasswordAuthenticationToken(email, password);
}
}
Request를 받을 때 쓰이는 dto다. UsernamePasswordAuthenticationToken
를 반환하여 아이디와 비밀번호가 일치하는지 검증하는 로직을 넣을 수 있게 된다.
/dto/ChangePasswordRequestDto.java
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class ChangePasswordRequestDto {
private String email;
private String exPassword;
private String newPassword;
}
비밀번호를 수정할 때 쓰이는 dto다. 이전의 비밀번호가 제대로 입력하지 않는다면 실행되지 않는다.
이제 실제 로직들이 수행되는 장소에 대해서 서술해보자.
/service/CustomUserDetailsService.java
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByEmail(username)
.map(this::createUserDetails)
.orElseThrow(() -> new UsernameNotFoundException(username + " 을 DB에서 찾을 수 없습니다"));
}
private UserDetails createUserDetails(Member member) {
GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getAuthority().toString());
return new User(
String.valueOf(member.getId()),
member.getPassword(),
Collections.singleton(grantedAuthority)
);
}
}
실행한 loadUserByUsername
은 받은 email을 통해 user가 실제로 존재하는지 알아보는 메소드다. 존재하지 않으면 예외를 날린다.
그렇다면 이건 어디에서 쓰일까? 나중에 서술하도록 한다.
/service/AuthService.java
@Service
@RequiredArgsConstructor
@Transactional
public class AuthService {
private final AuthenticationManagerBuilder managerBuilder;
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final TokenProvider tokenProvider;
public MemberResponseDto signup(MemberRequestDto requestDto) {
if (memberRepository.existsByEmail(requestDto.getEmail())) {
throw new RuntimeException("이미 가입되어 있는 유저입니다");
}
Member member = requestDto.toMember(passwordEncoder);
return MemberResponseDto.of(memberRepository.save(member));
}
public TokenDto login(MemberRequestDto requestDto) {
UsernamePasswordAuthenticationToken authenticationToken = requestDto.toAuthentication();
Authentication authentication = managerBuilder.getObject().authenticate(authenticationToken);
return tokenProvider.generateTokenDto(authentication);
}
}
signup 메소드는 평범하게 회원가입을 하는 메소드로, Spring Data JPA의 주요 로직으로 구성된다.
login 메소드의 상세한 구현과정은 약간 복잡하다.
MemberRequestDto
에 있는 메소드 toAuthentication
를 통해 생긴 UsernamePasswordAuthenticationToken
타입의 데이터를 가지게된다.AuthenticationManager
를 구현한 ProviderManager
를 생성한다.ProviderManager
는 데이터를 AbstractUserDetailsAuthenticationProvider
의 자식 클래스인 DaoAuthenticationProvider
를 주입받아서 호출한다.DaoAuthenticationProvider
내부에 있는 authenticate
에서 retrieveUser
을 통해 DB에서의 User의 비밀번호가 실제 비밀번호가 맞는지 비교한다.retrieveUser
에서는 DB에서의 User를 꺼내기 위해, CustomUserDetailService
에 있는 loadUserByUsername
을 가져와 사용한다.여기서 위에서 만든 loadUserByUsername
이 사용됨을 알 수 있다.
/service/MemberService.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
public MemberResponseDto getMyInfoBySecurity() {
return memberRepository.findById(SecurityUtil.getCurrentMemberId())
.map(MemberResponseDto::of)
.orElseThrow(() -> new RuntimeException("로그인 유저 정보가 없습니다"));
}
@Transactional
public MemberResponseDto changeMemberNickname(String email, String nickname) {
Member member = memberRepository.findByEmail(email).orElseThrow(() -> new RuntimeException("로그인 유저 정보가 없습니다"));
member.setNickname(nickname);
return MemberResponseDto.of(memberRepository.save(member));
}
@Transactional
public MemberResponseDto changeMemberPassword(String email, String exPassword, String newPassword) {
Member member = memberRepository.findById(SecurityUtil.getCurrentMemberId()).orElseThrow(() -> new RuntimeException("로그인 유저 정보가 없습니다"));
if (!passwordEncoder.matches(exPassword, member.getPassword())) {
throw new RuntimeException("비밀번호가 맞지 않습니다");
}
member.setPassword(passwordEncoder.encode((newPassword)));
return MemberResponseDto.of(memberRepository.save(member));
}
getMyInfoBySecurity
는 헤더에 있는 token값을 토대로 Member의 data를 건내주는 메소드다
changeMemberNickname
는 닉네임 변경이다.
changeMemberPassword
는 패스워드 변경이다. 패스워드 변경 또한 token값을 토대로 찾아낸 member를 찾아낸 다음 제시된 예전 패스워드와 DB를 비교한다.
이후 맞으면 새 패스워드로 교체한다.
서비스에서 구현한 로직들을 그대로 적용한다.
/controller/AuthController.java
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/signup")
public ResponseEntity<MemberResponseDto> signup(@RequestBody MemberRequestDto requestDto) {
return ResponseEntity.ok(authService.signup(requestDto));
}
@PostMapping("/login")
public ResponseEntity<TokenDto> login(@RequestBody MemberRequestDto requestDto) {
return ResponseEntity.ok(authService.login(requestDto));
}
}
/controller/MemberController.java
@RestController
@RequiredArgsConstructor
@RequestMapping("/member")
public class MemberController {
private final MemberService memberService;
@GetMapping("/me")
public ResponseEntity<MemberResponseDto> getMyMemberInfo() {
MemberResponseDto myInfoBySecurity = memberService.getMyInfoBySecurity();
System.out.println(myInfoBySecurity.getNickname());
return ResponseEntity.ok((myInfoBySecurity));
// return ResponseEntity.ok(memberService.getMyInfoBySecurity());
}
@PostMapping("/nickname")
public ResponseEntity<MemberResponseDto> setMemberNickname(@RequestBody MemberRequestDto request) {
return ResponseEntity.ok(memberService.changeMemberNickname(request.getEmail(), request.getNickname()));
}
@PostMapping("/password")
public ResponseEntity<MemberResponseDto> setMemberPassword(@RequestBody ChangePasswordRequestDto request) {
return ResponseEntity.ok(memberService.changeMemberPassword(request.getExPassword(), request.getNewPassword()));
}
}
RestController
와 ResponseEntity
를 통해 RestFul하게 통신하는 컨트롤러를 구현했다.
// Request
POST "http://localhost:8080/auth/login"
{
"email": "token-test@test.com",
"password": "test1234"
}
// Response
{
"grantType": "bearer",
"accessToken": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1IiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTY1NDI0NjMyOX0.ODqkBbI-x9RyU1i7iPG9NEKwe3zigTrLdzn49SBqlKNATrhlJLyPaR-LfdFf67JH-NprgWWNtTYesJ9eFkj0Lg",
"tokenExpiresIn": 1654246329912
}
// Request
GET "http://localhost:8080/member/me"
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI1IiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTY1NDI0NjMyOX0.ODqkBbI-x9RyU1i7iPG9NEKwe3zigTrLdzn49SBqlKNATrhlJLyPaR-LfdFf67JH-NprgWWNtTYesJ9eFkj0Lg
// Response
{
"email": "token-test@test.com",
"nickname": "token-tester"
}
정상적으로 실행이 된다.
👍👍👍👍