[프로젝트] Spring Security + OAuth + JWT + Redis를 활용한 로그인 및 회원가입 구현 (7) - CRUD API

김찬미·2024년 7월 10일
0
post-thumbnail

프로젝트 전체 코드: https://github.com/kcm02/JWT_OAuth_Login.git

CRUD API란?

CRUD API에서 CRUD는 Create, Read, Update, Delete의 약자로, 데이터베이스에서 데이터를 생성, 읽기, 수정, 삭제하는 기본적인 작업을 의미한다. 즉, CRUD API란 어떠한 정보를 데이터베이스에 삽입, 조회, 수정, 삭제하는 API를 말하는 것이다.

특히 웹 개발에서는 사용자(User)와 관련해 회원가입, 회원 정보 수정, 탈퇴 등의 기능을 필수로 구현해야 하는 만큼 반드시 구현법을 알고 있어야 한다.

회원 CRUD API 구성 요소

회원 정보를 관리하는 API는 보통 다음과 같은 엔드포인트로 구성된다:

  1. Create (회원 가입)

    • HTTP Method: POST
    • URL: /user/signUp
    • Request Body: 회원 정보 (이름, 이메일, 비밀번호 등)
    • Response: 생성된 회원 정보 (ID 등)
  2. Read (회원 정보 조회)

    • HTTP Method: GET
    • URL: /user/{userId}
    • URL Parameter: userId (조회할 회원의 ID)
    • Response: 조회된 회원 정보
  3. Update (회원 정보 수정)

    • HTTP Method: PUT
    • URL: /user/{userId}
    • URL Parameter: userId (수정할 회원의 ID)
    • Request Body: 수정할 회원 정보
    • Response: 수정된 회원 정보
  4. Delete (회원 삭제)

    • HTTP Method: DELETE
    • URL: /user/{userId}
    • URL Parameter: userId (삭제할 회원의 ID)
    • Response: 삭제된 회원 정보 (또는 삭제 성공 메시지)

💡 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 메서드는 UserRequestDTOPasswordEncoder를 파라미터로 받아 User의 정보를 업데이트한다. 특별히 신경쓴 점은 다음과 같다.

  1. 비밀번호 인코딩: 새로운 비밀번호가 null이 아니고 기존 비밀번호와 다를 때만 인코딩하는 조건문을 붙여 불필요한 비밀번호 인코딩을 방지한다.

  2. 보안 유지: 도메인 클래스 내부에서 데이터를 업데이트하는 방식을 사용해 캡슐화와 정보 은닉을 할 수 있도록 로직을 작성했다.

이를 위해 lombok에서 제공하는 @Setter대신 따로 메서드를 만들었는데, 이렇게 구현해야 하는 이유는 바로 밑에서 설명하도록 하겠다.

🤔 현업에선 Setter를 사용하지 않는다?

현업에선 실제로 Setter를 거의 사용하지 않는다는 말이 있다. 사실 스프링 부트를 공부할 때 처음으로 배우는 것이 GetterSetter이기에 별 생각없이 데이터를 업데이트할 때 Setter를 쓰는 사람들이 많다. 나도 그랬다

그러나 본격적으로 프로젝트를 진행하기 위해서는 Setter를 버리고 따로 메서드를 작성해 정보를 업데이트하는 방식을 사용해야 한다. 이유는 다음과 같다.

  1. 캡슐화: 도메인 객체의 내부 상태를 외부에서 직접 변경할 수 없도록 하고, 필요한 로직을 도메인 객체 내에서 처리하는 방식은 객체 지향 프로그래밍의 기본 원칙 중 하나인 캡슐화를 잘 지키는 방식이다.

  2. 유효성 검사 및 로직 적용: 도메인 객체 내부에서 필드를 업데이트할 때 필요한 유효성 검사나 추가 로직을 적용할 수 있다. 예를 들어, 비밀번호를 변경할 때는 반드시 인코딩 과정을 거쳐야 하는데, 메서드로는 더욱 효율적으로 작성할 수 있다.

  3. 일관성 유지: 도메인 객체가 스스로 자신의 상태를 관리하게 함으로써 데이터의 일관성을 유지할 수 있다. 다양한 곳에서 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("사용자를 찾을 수 없습니다.");
        }
    }

✅ 메서드

1) getUserById()

  • 기능: 회원 정보 조회
  • 매개변수: userId
  • 반환값:
    • 성공 시 UserResponseDTO
    • 실패 시 IllegalStateException
  • 동작:
    • userRepositoryfindById를 통해 정보를 조회하고, 해당 정보가 데이터베이스 안에 존재하면 UserResponseDTO를 반환, 만약 데이터베이스 안에 없으면 IllegalStateException를 던진다.

2) updateUser()

  • 기능: 회원 정보 업데이트
  • 매개변수:
    • userId
    • userRequestDTO
  • 반환값:
    • 성공 시 UserResponseDTO
    • 실패 시 IllegalStateException
  • 동작:
    • 만약 변경한 이메일이 다른 유저의 이메일과 겹치거나 회원 정보를 조회하는 데 실패하면 IllegalStateException를 던지고, 회원 정보 수정에 성공한 경우 UserResponseDTO를 반환한다.

2) 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);
        }
    }

✅ 엔드포인트

1) 회원 가입 API

  • 주소: @PostMapping("/signup")
  • 매개변수:
    • @RequestBody: UserRequestDTO
  • 반환값: ResponseEntity<JsonResponse>
  • 동작:
    • 클라이언트가 전송한 UserRequestDTO를 받아서 유효성 검사를 수행한다.
    • 유효성 검사를 통과하면 userService를 호출하여 회원 가입을 처리한다.
    • 회원 가입이 성공적으로 이루어지면 HttpStatus.CREATED와 함께 성공 메시지와 함께 회원 정보를 담은 UserResponseDTO를 반환한다.

2) 회원 정보 조회 API

  • 주소: @GetMapping("/{userId}")
  • 매개변수:
    • @PathVariable: Long userId
  • 반환값: ResponseEntity<JsonResponse>
  • 동작:
    • userId를 경로 변수로 받아 해당 회원의 정보를 조회한다.
    • 회원 정보가 데이터베이스에 존재하면 HttpStatus.OK와 함께 성공 메시지와 조회된 회원 정보를 담은 UserResponseDTO를 반환한다.
    • 회원 정보가 존재하지 않는 경우 IllegalStateException이 발생하며, HttpStatus.NOT_FOUND와 함께 에러 메시지를 반환한다.

3) 회원 정보 수정 API

  • 주소: @PutMapping("/{userId}")
  • 매개변수:
    • @PathVariable: Long userId
    • @RequestBody: UserRequestDTO
  • 반환값: ResponseEntity<JsonResponse>
  • 동작:
    • userId와 수정할 회원 정보가 담긴 UserRequestDTO를 받아서 회원 정보를 수정한다.
    • 회원 정보가 성공적으로 수정되면 HttpStatus.OK와 함께 성공 메시지와 수정된 회원 정보를 담은 UserResponseDTO를 반환한다.
    • 수정할 회원 정보가 존재하지 않거나 유효성 검사를 통과하지 못하는 경우 IllegalStateException이 발생하며, HttpStatus.BAD_REQUEST와 함께 에러 메시지를 반환한다.

4) 회원 삭제 API

  • 주소: @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
        }
    }

✅ 변경된 점

1) logout() 메서드

  • 변경 전:
    • 반환값 X
  • 변경 후:
    • ResponseEntity<JsonResponse>을 반환하도록 변경
    • 로그아웃 성공 여부에 따라 다른 HTTP 상태 코드와 함께 응답한다.
      • 로그아웃 성공 시 HttpStatus.NO_CONTENT와 성공 메시지를 포함한 JsonResponse 객체를 반환
      • 로그아웃 실패 시 HttpStatus.INTERNAL_SERVER_ERROR와 에러 메시지를 포함한 JsonResponse 객체를 반환

2) Response 객체 JsonResponse로 분리

  • 원래 AuthController 안에 존재하던 Response 클래스를 UserController에서도 사용할 수 있도록 dto 패키지 안으로 위치를 수정했다.

Test

🚨 주의점

회원가입과 로그인을 제외한 API들은 permitAll() 처리가 되지 않았다. 즉, Authorization을 헤더에 제대로 입력해야 정상적으로 처리된다. 만약 액세스 토큰이 잘못된 경우 Spring Security에 의해 가로막힌다.

➕ 회원가입 성공

전과 달리 data 항목에 회원 정보가 들어갔으며 statusCodemessage를 담아 더 가독성 좋게 응답을 확인할 수 있다. 또한 데이터베이스에도 정상적으로 회원 정보가 삽입되었다.

🔍 회원 정보 조회

경로 뒤에 userId를 삽입하면 회원 정보를 확인할 수 있다. 현재는 usernameemail만 확인 가능하다.

📝 회원 정보 수정

헤더에 Authorization을 삽입하고 바디에 최종적으로 변경할 회원 정보를 담는다. 정상적으로 처리될 경우 수정된 회원 정보를 data에 담아 반환하고, 데이터베이스에서도 수정된 모습을 확인할 수 있다.

➖ 회원 정보 삭제

마지막으로 회원 탈퇴 API가 정상적으로 처리되면 데이터베이스에서 해당 회원이 삭제된다. 이 때에도 Authrozation을 헤더에 추가해야 한다.

만약 실패하면?

만약 조회, 수정, 삭제에 실패하면 화면과 같이 statusCode 400과 함께 "사용자를 찾을 수 없습니다."라는 message가 반환된다.


마치며

회원 CRUD API 작업은 비교적 쉬운 편이지만, 백엔드의 초석이 되는 기본적인 API인 만큼 반드시 제대로 만들 줄 알아야 한다. 또한 프론트와의 협업을 위해 가독성 좋게 JSON 형태로 데이터를 보내야 한다.

다음으로는 이메일 인증과 OAuth를 다룰 예정이다. 초반엔 비교적 낯설 수 있는 내용인 만큼 제대로, 자세히 다루도록 하겠다.

profile
백엔드 개발자

0개의 댓글

관련 채용 정보