Representational State Transfer라는 용어의 약자로서 API 작동 방식에 대한 조건을
부과하는 소프트웨어 아키텍처입니다. 2000년도에 로이 필딩 (Roy Fielding)의 박사학위 논문에서 최초로 소개되었습니다.
한편으로 REST 아키텍처 스타일을 따르는 API를 REST API라고 하며, 이를 구현하는 웹 서비스를 RESTful 웹 서비스라고 합니다. AWS의 설명에 의하면, Restful API와 REST API은 일반적으로 같은 의미로 사용할 수 있다고 하네요.
헤더란 데이터 앞 부분에 파일에 대한 정보를 실어놓은 부분을 말합니다. 웹 개발에서 헤더는 주로 HTTP의 정보를 지니고 있는 HTTP 헤더를 뜻합니다.
REST API에 대해 공부하다 보면 URI와 URL의 차이에 대해 혼동하는 경우가 많습니다. 사실, 두 단어 자체가 끝에 한 글자만 다르다보니 더더욱 그렇습니다. 일단, 두 단어의 'U'와 'R'의 의미부터 설명해볼까요?
U - Uniform, 제복, 통일성(?)
Uniform은 리소스를 식별하는 통일된 방식을 의미합니다.
R - Resource, 자원
인터넷 환경에서의 Resource란, 웹 브라우저 파일(및 그 이외의 리소스 모두를 포함)을 지칭합니다.
그럼 REST API를 구현하기 전에 두 개념의 차이를 간략하게 짚고 넘어가겠습니다.
URI은 ‘통합 자원 식별자’라는 말의 준말로, 인터넷 상에서 리소스를 식별하는 구분자를 말합니다. 다시 말해, URI는 인터넷상의 리소스 “자원 자체”를 식별하는 고유한 문자열 시퀀스입니다.
URL은 Uniform Resource Locator, 네트워크상에서 통합 자원(리소스)의 “위치”를 나타내기 위한 규약입니다. 즉, '자원 식별자'와 '리소스의 위치'를 동시에 보여주는 형식입니다.
예를 들어, 아래와 같은 링크가 있다고 합시다.
http://example.com/board/view?seq=13
여기서 example.com은 리소스의 이름(식별자)만 나타내고 있으므로, URI입니다.
그러나, http://example.com/은, 프로토콜인 'https'를 포함하고 있으므로, URL입니다. 식별자와 리소스의 위치를 보여주는 프로토콜이 함께 표기되어있기 때문입니다.
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationService service;
private final AuthorizationService authorizationService;
@PostMapping("/register") //말 그대로 회원 가입
public ResponseEntity<AuthenticationResponse> register(@RequestBody RegisterRequest request){
return ResponseEntity.ok(service.register(request));
}
@PostMapping("/authenticate") //로그인 시 Token 체크. //로그인 때 마다 accessToken 발급.
public ResponseEntity<AuthenticationResponse> authenticate(@RequestBody AuthenticationRequest request){
return ResponseEntity.ok(service.authenticate(request));
}
@PutMapping("/re-authenticate/{email}") //아이디 혹은 패스워드 바꿀 때.
public ResponseEntity<AuthenticationResponse> reAuthenticate(@PathVariable(name = "email") String email, @RequestBody AuthenticationRequest request){
return ResponseEntity.ok(service.reAuthenticate(email, request));
}
@PutMapping("/withdraw/{email}")
public ResponseEntity<?> withdraw(@PathVariable(name = "email") String email){
authorizationService.withdraw(email);
HttpHeaders headers = new HttpHeaders();
headers.setLocation(URI.create("/api/v1/auth/logout"));
return new ResponseEntity<>(headers, HttpStatus.MOVED_PERMANENTLY);
}
}
@RestController
@RequestMapping("/api/v1/author")
@RequiredArgsConstructor
public class AuthorizationController {
private final AuthorizationService authorizationService;
private final LogoutService logoutService;
@PutMapping("/withdraw/{email}")
public void withdraw(@PathVariable(name = "email") String email){
authorizationService.withdraw(email);
}
}
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthenticationService {
private final UserRepository userRepository;
private final TokenRepository tokenRepository;
private final RefreshTokenRepository refreshRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final UserDetailsService userDetailsService;
private final AuthenticationManager authenticationManager;
//DB에 등록. 토큰 생성
public AuthenticationResponse register(RegisterRequest request) {
var user = Userinfo.builder()
.emailId(request.getEmail_id())
.password(passwordEncoder.encode(request.getPassword()))
.role(Role.USER)
.withdraw(false)
.build();
userRepository.save(user);
var savedUser = userRepository.save(user);
var accessToken = jwtService.generateAccessToken(user);
saveUserAccessToken(savedUser, accessToken);
var refreshToken = jwtService.generateRefreshToken(user);
saveUserRefreshToken(savedUser, refreshToken);
return AuthenticationResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
//새로운 accessToken과 refreshToken 생성
//jwtToken이 expired됐을 시, client에서는 header의 authorization을 null하고 authenticate로 보낸다.
public AuthenticationResponse authenticate(AuthenticationRequest request) {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getEmail_id(), request.getPassword())
);
var user = userRepository.findByEmail(request.getEmail_id())
.orElseThrow(null);
if(user.getWithdraw()) {
user.setWithdraw(false);
user.setWithdrawDate(null);
}
revokeAllUserTokens(user);
var accessToken = jwtService.generateAccessToken(user);
saveUserAccessToken(user, accessToken);
var refreshToken = jwtService.generateRefreshToken(user);
saveUserRefreshToken(user, refreshToken);
return AuthenticationResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
//참조
//https://webcache.googleusercontent.com/search?q=cache:sNMEucCGzjkJ:https://wonit.tistory.com/130&cd=1&hl=ko&ct=clnk&gl=kr
//https://velog.io/@sun1203/Spring-BootPut-Patch
public AuthenticationResponse reAuthenticate(String email, AuthenticationRequest request){
var user = userRepository.findByEmail(email)
.orElseThrow(() -> new NullPointerException("해당 responseBody가 무존재"));
user.setEmailId(request.getEmail_id());
user.setPassword(passwordEncoder.encode(request.getPassword()));
userRepository.save(user);
return authenticate(request);
}
//case1 : access token과 refresh token 모두가 만료된 경우 → 에러 발생 (재 로그인하여 둘다 새로 발급)
//case2 : access token은 만료됐지만, refresh token은 유효한 경우 → refresh token을 검증하여 access token 재발급
//case3 : access token은 유효하지만, refresh token은 만료된 경우 → access token을 검증하여 refresh token 재발급
//case4 : access token과 refresh token 모두가 유효한 경우 → 정상 처리
//https://junhyunny.github.io/spring-boot/spring-security/issue-and-reissue-json-web-token/ <- 참조할것
public AuthenticationResponse reIssuance(RestRequest request, String jwtAccessToken) {
var user = userRepository.findByEmail(request.getEmail_id())
.orElseThrow(null);
UserDetails userDetails = this.userDetailsService.loadUserByUsername(request.getEmail_id());
var jwtRefreshToken = refreshRepository.findRefreshTokenByUsername(request.getEmail_id())
.orElseThrow(null);
String accessToken = jwtAccessToken.substring(7);
String refreshToken = jwtRefreshToken.getToken();
if(!jwtService.isTokenValid(accessToken, userDetails) && !jwtService.isTokenValid(refreshToken, userDetails)){
revokeAllUserTokens(user);
}else{
if(jwtService.isTokenIssuer(accessToken, userDetails)){
var token = tokenRepository.findByToken(accessToken)
.orElseThrow(null);
token.setExpired(true);
token.setRevoked(true);
tokenRepository.save(token);
accessToken = jwtService.generateAccessToken(userDetails);
saveUserAccessToken(user, accessToken);
}
if(jwtService.isTokenIssuer(refreshToken, userDetails)){
var token = refreshRepository.findByToken(refreshToken)
.orElseThrow(null);
token.setExpired(true);
refreshRepository.save(token);
refreshToken = jwtService.generateRefreshToken(userDetails);
saveUserRefreshToken(user, refreshToken);
}
}
return AuthenticationResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
//토큰 저장 메서드 -> accessToken은 localStorage 혹은 redis 저장될 예정. 지금은 sql로.
//아마 OAuth2.0 적용하면서 한번 더 갈아야 할지도.
private void saveUserAccessToken(Userinfo user, String jwtToken) {
var accessToken = AccessToken.builder()
.userinfo(user)
.token(jwtToken)
.tokenType(TokenType.BEARER)
.expired(false)
.revoked(false)
.build();
tokenRepository.save(accessToken);
}
private void saveUserRefreshToken(Userinfo user, String jwtToken) {
var refreshToken = RefreshToken.builder()
.userinfo(user)
.token(jwtToken)
.expired(false)
.build();
refreshRepository.save(refreshToken);
}
private void revokeAllUserTokens(Userinfo user) {
var validUserTokens = tokenRepository.findAllValidTokenByUser(user.getId());
var validRefreshTokens = refreshRepository.findAllValidTokenByUser(user.getId());
if(!validUserTokens.isEmpty()){
validUserTokens.forEach(token -> {
token.setExpired(true);
token.setRevoked(true);
});
tokenRepository.saveAll(validUserTokens);
}
if(!validRefreshTokens.isEmpty()){
validRefreshTokens.forEach(token -> {
token.setExpired(true);
});
refreshRepository.saveAll(validRefreshTokens);
}
}
}
public class AuthenticationController {
...
@PostMapping("/register") //말 그대로 회원 가입
public ResponseEntity<AuthenticationResponse> register(@RequestBody RegisterRequest request){
return ResponseEntity.ok(service.register(request));
}
...
}
public class AuthenticationService{
...
public AuthenticationResponse register(RegisterRequest request) {
var user = Userinfo.builder()
.emailId(request.getEmail_id())
.password(passwordEncoder.encode(request.getPassword()))
.role(Role.USER)
.withdraw(false)
.build();
userRepository.save(user);
var savedUser = userRepository.save(user);
var accessToken = jwtService.generateAccessToken(user);
saveUserAccessToken(savedUser, accessToken);
var refreshToken = jwtService.generateRefreshToken(user);
saveUserRefreshToken(savedUser, refreshToken);
return AuthenticationResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
...
}
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class RegisterRequest {
@Column(name="email_id")
private String email_id;
@Column(name="password")
private String password;
}
AuthenticationService.register(request)는 Controller에서 해당하는 DTO를 건너 받습니다.
해당 메서드에서는 빌더 패턴을 통해 각 속성 값이 등록되고, 이를 통해 UserRepository를 거쳐 DB에 저장 됩니다. 이후 accessToken와 refreshToken을 생성하여, 역시 DB에 저장합니다.
모든 프로세스가 끝나면, 해당 메서드는 acceeToken과 refreshToken을 빌더패턴으로 등록해 responseEntity로 아래와 같이 클라이언트에게 응답합니다.
public class AuthenticationController {
...
@PutMapping("/re-authenticate/{email}") //아이디 혹은 패스워드 바꿀 때.
public ResponseEntity<AuthenticationResponse> reAuthenticate(@PathVariable(name = "email") String email, @RequestBody AuthenticationRequest request){
return ResponseEntity.ok(service.reAuthenticate(email, request));
...
}
public class AuthenticationService {
...
public AuthenticationResponse reAuthenticate(String email, AuthenticationRequest request){
var user = userRepository.findByEmail(email)
.orElseThrow(() -> new NullPointerException("해당 responseBody가 무존재"));
user.setEmailId(request.getEmail_id());
user.setPassword(passwordEncoder.encode(request.getPassword()));
userRepository.save(user);
return authenticate(request);
}
...
}
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AuthenticationRequest {
@Column(name="email_id")
private String email_id;
@Column(name="password")
private String password;
}
회원 정보 수정은 웹 브라우저 환경에서 email를 변수로 받습니다. AuthenticationRequest가 분리된 이유는, URI를 호출 할 때 마다 해당 dto의 멤버 변수를 주고 받기 때문입니다.
AuthenticationService.reAuthenticate(email, request)에서는 헤더에서 액세스 토큰의 유효성을 체크한 뒤, 해당 이메일 정보가 DB에 등록 되어 있는지 확인 과정을 거친 뒤에 절차를 진행합니다.
public class AuthenticationController {
...
@PutMapping("/withdraw/{email}")
public void withdraw(@PathVariable(name = "email") String email){
authorizationService.withdraw(email);
}
...
}
public class AuthorizationService {
private final UserRepository userRepository;
private final TokenRepository tokenRepository;
private final RefreshTokenRepository refreshRepository;
public void withdraw(String email){
var user = userRepository.findByEmail(email)
.orElseThrow(null);
user.setWithdraw(true);
user.setWithdrawDate(new Date(System.currentTimeMillis() + 100 * 60 * 24L));
userRepository.save(user);
}
@Scheduled(fixedRate = 24 * 60 * 60 * 10) //시간 예약 수정 필요
@Transactional
public void withdrawMembers(){
Date date = new Date(System.currentTimeMillis());
List<Userinfo> memberList = userRepository.isWithdraws(date);
for(var user : memberList){
userRepository.delete(user);
}
}
}
withdraw에서는 회원 탈퇴 절차를 진행합니다. 곧바로 탈퇴하는 것이 아닌, 시간을 두고 유예 기간을 주는 식으로 염두했기에 DB에 저장된 Userinfo 객체에서 withdraw값을 true로 설정하고, 탈퇴 시간 기준을 등록합니다.
'withdrawMembers'는 @Scheduled 어노테이션을 통해 일정 기간을 지정합니다. 'userRepository'에서 withdraw가 true인 회원 정보를 조회하여, 이를 기반으로 DB 시간에 맞춰 삭제합니다.
비문, 오탈자와 코드 오류 및 잘못된 지식에 대한 지적 및 질문은 언제나 환영합니다.