프로젝트 전체 코드: https://github.com/kcm02/JWT_OAuth_Login.git
CRUD API에서 CRUD는 Create, Read, Update, Delete의 약자로, 데이터베이스에서 데이터를 생성, 읽기, 수정, 삭제하는 기본적인 작업을 의미한다. 즉, CRUD API란 어떠한 정보를 데이터베이스에 삽입, 조회, 수정, 삭제하는 API를 말하는 것이다.
특히 웹 개발에서는 사용자(User
)와 관련해 회원가입, 회원 정보 수정, 탈퇴 등의 기능을 필수로 구현해야 하는 만큼 반드시 구현법을 알고 있어야 한다.
회원 정보를 관리하는 API는 보통 다음과 같은 엔드포인트로 구성된다:
Create (회원 가입)
POST
/user/signUp
Read (회원 정보 조회)
GET
/user/{userId}
userId
(조회할 회원의 ID)Update (회원 정보 수정)
PUT
/user/{userId}
userId
(수정할 회원의 ID)Delete (회원 삭제)
DELETE
/user/{userId}
userId
(삭제할 회원의 ID)💡 CRUD와 HTTP Method에 대한 더 자세한 내용은 HTTP Method 구현하기를 참고
com.project.securelogin
├── config
│ └── RedisConfig.java
│ └── SecurityConfig.java
├── controller
│ └── AuthController.java ✔️
│ └── UserController.java ✔️
├── domain
│ └── CustomUserDetails.java
│ └── User.java ✔️
├── dto
│ └── JsonResponse.java ✔️
│ └── UserRequestDTO.java
│ └── UserResponseDTO.java
├── jwt
│ └── JwtAuthenticationFilter.java
│ └── JwtTokenProvider.java
├── repository
│ └── JwtTokenRedisRepository.java
│ └── UserRepository.java
└── service
└── AuthService.java
└── CustomUserDetailsService.java
└── UserService.java ✔️
User
: 추후 사용될 회원 정보 업데이트를 위해 setter
대신 updateUser
를 만들었다.
JsonResponse
: 기존에 AuthController
안에 있던 Response
클래스를 따로 분리시켜 UserController
에서도 사용할 수 있게 해주었다.
AuthController
: 기존 응답 객체를 모두 JsonResponse
로 변환했다.
UserController
: 회원 조회, 정보 수정, 삭제 API를 개발하고, 결과를 JsonResponse
로 반환한다.
UserService
: 사용자(User
) CRUD에 대한 로직을 작성했다.
DTO
JsonResponse
@Getter
@AllArgsConstructor
public class JsonResponse {
private final int statusCode;
private final String message;
private UserResponseDTO data;
}
기존에 AuthController
에서 존재하던 Response
클래스를 따로 분리시켜 활용성을 높여주었다.
또한 필드에 UserResponseDTO
를 추가해 반환할 데이터가 없을 때는 null을, 회원 정보를 반환해야 할 때는 data
안에 넣어줌으로 JSON 형태의 응답을 완성시켰다.
Domain
User
public void updateUser(UserRequestDTO requestDTO, PasswordEncoder passwordEncoder) {
this.username = requestDTO.getUsername();
this.email = requestDTO.getEmail();
// 새로운 비밀번호가 null이 아니고, 기존 비밀번호와 다를 때만 인코딩하여 업데이트
if (requestDTO.getPassword() != null && !requestDTO.getPassword().equals(this.password)) {
this.password = passwordEncoder.encode(requestDTO.getPassword());
}
}
updateUser
updateUser
메서드는 UserRequestDTO
와 PasswordEncoder
를 파라미터로 받아 User
의 정보를 업데이트한다. 특별히 신경쓴 점은 다음과 같다.
비밀번호 인코딩: 새로운 비밀번호가 null이 아니고 기존 비밀번호와 다를 때만 인코딩하는 조건문을 붙여 불필요한 비밀번호 인코딩을 방지한다.
보안 유지: 도메인 클래스 내부에서 데이터를 업데이트하는 방식을 사용해 캡슐화와 정보 은닉을 할 수 있도록 로직을 작성했다.
이를 위해 lombok
에서 제공하는 @Setter
대신 따로 메서드를 만들었는데, 이렇게 구현해야 하는 이유는 바로 밑에서 설명하도록 하겠다.
Setter
를 사용하지 않는다?현업에선 실제로 Setter
를 거의 사용하지 않는다는 말이 있다. 사실 스프링 부트를 공부할 때 처음으로 배우는 것이 Getter
와 Setter
이기에 별 생각없이 데이터를 업데이트할 때 Setter
를 쓰는 사람들이 많다. 나도 그랬다
그러나 본격적으로 프로젝트를 진행하기 위해서는 Setter
를 버리고 따로 메서드를 작성해 정보를 업데이트하는 방식을 사용해야 한다. 이유는 다음과 같다.
캡슐화: 도메인 객체의 내부 상태를 외부에서 직접 변경할 수 없도록 하고, 필요한 로직을 도메인 객체 내에서 처리하는 방식은 객체 지향 프로그래밍의 기본 원칙 중 하나인 캡슐화를 잘 지키는 방식이다.
유효성 검사 및 로직 적용: 도메인 객체 내부에서 필드를 업데이트할 때 필요한 유효성 검사나 추가 로직을 적용할 수 있다. 예를 들어, 비밀번호를 변경할 때는 반드시 인코딩 과정을 거쳐야 하는데, 메서드로는 더욱 효율적으로 작성할 수 있다.
일관성 유지: 도메인 객체가 스스로 자신의 상태를 관리하게 함으로써 데이터의 일관성을 유지할 수 있다. 다양한 곳에서 setter를 사용하여 필드를 변경하면 예상치 못한 사이드 이펙트가 발생할 수 있기 때문이다.
💡 그래서 캡슐화가 뭔데?
캡슐화 (Encapsulation)는 객체 지향 프로그래밍에서 객체의 내부 상태를 외부에서 직접 접근할 수 없도록 보호하는 개념이다. 이를 통해 정보 은닉을 실현하며, 객체의 내부 구현을 숨기고 외부 코드는 객체의 상태에 간접적으로 접근할 수 있도록 한다.
이렇듯 Setter
대신 도메인 객체 내부에서 상태를 업데이트하는 메서드를 사용하는 것은 객체 지향 설계 원칙을 준수하면서 더 안전하고 일관성 있는 코드를 작성하는 방법이다. 더 좋은 개발자가 되기 위해서는 이 방식에 익숙해지도록 많이 만들어 보자!
Service
UserService
// 회원 정보 조회
public UserResponseDTO getUserById(Long userId) {
Optional<User> user = userRepository.findById(userId);
if (user.isPresent()) {
return new UserResponseDTO(user.get().getUsername(), user.get().getEmail());
} else {
throw new IllegalStateException("사용자를 찾을 수 없습니다.");
}
}
// 회원 정보 수정
public UserResponseDTO updateUser(Long userId, UserRequestDTO userRequestDTO) {
return userRepository.findById(userId).map(user -> {
if (!user.getEmail().equals(userRequestDTO.getEmail()) && isEmailAlreadyExists(userRequestDTO.getEmail())) {
throw new IllegalStateException("이미 등록된 이메일입니다.");
}
user.updateUser(userRequestDTO, passwordEncoder);
userRepository.save(user);
return new UserResponseDTO(user.getUsername(), user.getEmail());
}).orElseThrow(() -> new IllegalStateException("사용자를 찾을 수 없습니다."));
}
// 회원 삭제
public void deleteUser(Long userId) {
if (userRepository.existsById(userId)) {
userRepository.deleteById(userId);
} else {
throw new IllegalStateException("사용자를 찾을 수 없습니다.");
}
}
getUserById()
userId
UserResponseDTO
IllegalStateException
userRepository
의 findById
를 통해 정보를 조회하고, 해당 정보가 데이터베이스 안에 존재하면 UserResponseDTO
를 반환, 만약 데이터베이스 안에 없으면 IllegalStateException
를 던진다.updateUser()
userId
userRequestDTO
UserResponseDTO
IllegalStateException
IllegalStateException
를 던지고, 회원 정보 수정에 성공한 경우 UserResponseDTO
를 반환한다.deleteUser()
userId
void
IllegalStateException
userId
가 데이터베이스 안에 존재하지 않으면 IllegalStateException
를 던지고, 그렇지 않은 경우 회원 정보를 데이터베이스에서 삭제한다.Controller
UserController
@PostMapping("/signup")
// @Valid 어노테이션을 사용해 SignUpRequest의 유효성 검사를 활성화, 통과한 경우 서비스 코드 호출
public ResponseEntity<JsonResponse> signUp(@Valid @RequestBody UserRequestDTO userRequestDTO) {
UserResponseDTO userResponseDTO = userService.signUp(userRequestDTO);
JsonResponse response = new JsonResponse(HttpStatus.CREATED.value(), "회원 가입이 성공적으로 실행되었습니다.", userResponseDTO);
return ResponseEntity.ok().body(response);
}
// 회원 정보 조회
@GetMapping("/{userId}")
public ResponseEntity<JsonResponse> getUser(@PathVariable Long userId) {
try {
UserResponseDTO userResponseDTO = userService.getUserById(userId);
JsonResponse response = new JsonResponse(HttpStatus.OK.value(), "회원 정보를 성공적으로 조회했습니다.", userResponseDTO);
return ResponseEntity.ok().body(response);
} catch (IllegalStateException e) {
JsonResponse errorResponse = new JsonResponse(HttpStatus.NOT_FOUND.value(), "사용자를 찾을 수 없습니다.", null);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
}
// 회원 정보 수정
@PutMapping("/{userId}")
public ResponseEntity<JsonResponse> updateUser(@PathVariable Long userId, @Valid @RequestBody UserRequestDTO userRequestDTO) {
try {
UserResponseDTO userResponseDTO = userService.updateUser(userId, userRequestDTO);
JsonResponse response = new JsonResponse(HttpStatus.OK.value(), "회원 정보를 성공적으로 수정했습니다.", userResponseDTO);
return ResponseEntity.ok().body(response);
} catch (IllegalStateException e) {
JsonResponse errorResponse = new JsonResponse(HttpStatus.BAD_REQUEST.value(), e.getMessage(), null);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
}
}
// 회원 삭제
@DeleteMapping("/{userId}")
public ResponseEntity<JsonResponse> deleteUser(@PathVariable Long userId) {
try {
userService.deleteUser(userId);
JsonResponse response = new JsonResponse(HttpStatus.NO_CONTENT.value(), "회원 정보를 성공적으로 삭제했습니다.", null);
return ResponseEntity.ok().body(response);
} catch (IllegalStateException e) {
JsonResponse errorResponse = new JsonResponse(HttpStatus.NOT_FOUND.value(), "사용자를 찾을 수 없습니다.", null);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse);
}
}
@PostMapping("/signup")
@RequestBody
: UserRequestDTO
ResponseEntity<JsonResponse>
UserRequestDTO
를 받아서 유효성 검사를 수행한다.userService
를 호출하여 회원 가입을 처리한다.HttpStatus.CREATED
와 함께 성공 메시지와 함께 회원 정보를 담은 UserResponseDTO
를 반환한다.@GetMapping("/{userId}")
@PathVariable
: Long userId
ResponseEntity<JsonResponse>
userId
를 경로 변수로 받아 해당 회원의 정보를 조회한다.HttpStatus.OK
와 함께 성공 메시지와 조회된 회원 정보를 담은 UserResponseDTO
를 반환한다.IllegalStateException
이 발생하며, HttpStatus.NOT_FOUND
와 함께 에러 메시지를 반환한다.@PutMapping("/{userId}")
@PathVariable
: Long userId
@RequestBody
: UserRequestDTO
ResponseEntity<JsonResponse>
userId
와 수정할 회원 정보가 담긴 UserRequestDTO
를 받아서 회원 정보를 수정한다.HttpStatus.OK
와 함께 성공 메시지와 수정된 회원 정보를 담은 UserResponseDTO
를 반환한다.IllegalStateException
이 발생하며, HttpStatus.BAD_REQUEST
와 함께 에러 메시지를 반환한다.@DeleteMapping("/{userId}")
@PathVariable
: Long userId
ResponseEntity<JsonResponse>
userId
를 경로 변수로 받아 해당 회원 정보를 삭제한다.HttpStatus.NO_CONTENT
를 반환한다.IllegalStateException
이 발생하며, HttpStatus.NOT_FOUND
와 함께 에러 메시지를 반환한다.AuthController
@PostMapping("/login")
public ResponseEntity<JsonResponse> login(@RequestBody AuthRequest authRequest) {
try {
HttpHeaders headers = authService.login(authRequest.getEmail(), authRequest.getPassword());
JsonResponse response = new JsonResponse(HttpStatus.OK.value(), "로그인에 성공했습니다.", null);
return ResponseEntity.ok()
.headers(headers)
.body(response);
} catch (AuthenticationException e) {
JsonResponse errorResponse = new JsonResponse(HttpStatus.UNAUTHORIZED.value(), "이메일 주소나 비밀번호가 올바르지 않습니다.", null);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(errorResponse);
}
}
@DeleteMapping("/logout")
public ResponseEntity<JsonResponse> logout(@RequestHeader(name = "Refresh-Token") String refreshToken) {
boolean logoutSuccess = authService.logout(refreshToken);
if (logoutSuccess) {
JsonResponse response = new JsonResponse(HttpStatus.NO_CONTENT.value(), "성공적으로 로그아웃되었습니다.", null);
return ResponseEntity.ok().body(response); // 204 No Content
} else {
JsonResponse errorResponse = new JsonResponse(HttpStatus.INTERNAL_SERVER_ERROR.value(), "잘못된 접근입니다.", null);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); // or INTERNAL_SERVER_ERROR
}
}
logout()
메서드ResponseEntity<JsonResponse>
을 반환하도록 변경HttpStatus.NO_CONTENT
와 성공 메시지를 포함한 JsonResponse
객체를 반환HttpStatus.INTERNAL_SERVER_ERROR
와 에러 메시지를 포함한 JsonResponse
객체를 반환Response
객체 JsonResponse
로 분리AuthController
안에 존재하던 Response
클래스를 UserController
에서도 사용할 수 있도록 dto
패키지 안으로 위치를 수정했다.회원가입과 로그인을 제외한 API들은 permitAll()
처리가 되지 않았다. 즉, Authorization
을 헤더에 제대로 입력해야 정상적으로 처리된다. 만약 액세스 토큰이 잘못된 경우 Spring Security
에 의해 가로막힌다.
전과 달리 data
항목에 회원 정보가 들어갔으며 statusCode
와 message
를 담아 더 가독성 좋게 응답을 확인할 수 있다. 또한 데이터베이스에도 정상적으로 회원 정보가 삽입되었다.
경로 뒤에 userId
를 삽입하면 회원 정보를 확인할 수 있다. 현재는 username
과 email
만 확인 가능하다.
헤더에 Authorization
을 삽입하고 바디에 최종적으로 변경할 회원 정보를 담는다. 정상적으로 처리될 경우 수정된 회원 정보를 data
에 담아 반환하고, 데이터베이스에서도 수정된 모습을 확인할 수 있다.
마지막으로 회원 탈퇴 API가 정상적으로 처리되면 데이터베이스에서 해당 회원이 삭제된다. 이 때에도 Authrozation
을 헤더에 추가해야 한다.
만약 조회, 수정, 삭제에 실패하면 화면과 같이 statusCode 400
과 함께 "사용자를 찾을 수 없습니다."라는 message
가 반환된다.
회원 CRUD API 작업은 비교적 쉬운 편이지만, 백엔드의 초석이 되는 기본적인 API인 만큼 반드시 제대로 만들 줄 알아야 한다. 또한 프론트와의 협업을 위해 가독성 좋게 JSON
형태로 데이터를 보내야 한다.
다음으로는 이메일 인증과 OAuth를 다룰 예정이다. 초반엔 비교적 낯설 수 있는 내용인 만큼 제대로, 자세히 다루도록 하겠다.