SpringBoot Security - 회원가입 기능

고관운·2022년 11월 28일
0

SpringBoot Security - 회원가입 기능

목적

회원가입 기능
userName, password, emailAddress를 입력받고 userName이 DB에 존재하는지 중복 Check한 후, 이상이 없다면 저장

Talend 예상결과

  • 성공했을 경우 : userName 중복 없음, result에는 보안으로 인해 password 제외하고 출력
  • 실패했을 경우
    🔹 아래 그림처럼 출력됐을 경우, 어떤 에러인지 알 수 없음

👉 resultCode에 어떤 이유인지 출력하고, result는 추후 기능 추가 예정

구현

UserController
🔴 @RequiredArgsConstructor : 생성자가 필요한 부분을 자동으로 생성
(ex. UserService의 생성자를 자동으로 생성해줌)
🔸 join

  1. @RequestBody로 UserJoinRequest의 형식으로 입력받음)
    (UserJoinRequest : userName, password, emailAddress)
  2. UserJoinRequest을 userService의 join 메소드에 넣기
    (UserJoinRequest의 userName의 중복 체크하고, 기존에 있다면 예외처리, 정상이라면 저장한 후 UserDto형식으로 리턴)
    UserDto : 변수는 User와 동일하나 User를 직접적으로 사용하지 않기 위해 사용
  3. Response.success 메소드를 사용하여 리턴
    (Response.success : 제네릭이 사용됐기 때문에 입력받은 매개변수 타입에 맞춰 리턴 ➡ UserJoinResponse를 입력받아 UserJoinResponse 리턴)
    (UserJoinResponse : userName, emailAddress)
import com.hospitalreview.service.UserService;
import com.hospitalreview.domain.Response;
import com.hospitalreview.domain.dto.UserDto;
import com.hospitalreview.domain.dto.UserJoinRequest;
import com.hospitalreview.domain.dto.UserJoinResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;

    @PostMapping("/join")
    public Response<UserJoinResponse> join(@RequestBody UserJoinRequest userJoinRequest) {
        UserDto userDto = userService.join(userJoinRequest);
        return Response.success(new UserJoinResponse(userDto.getUserName(), userDto.getEmailAddress()));
    }
}

Response
🔹 error, success : 모두 static으로 처리됐기 때문에 어디서든 사용 가능

  • error : 어떤 에러인지 알 수 있도록 만든 메소드
    (resultCode : 어떤 에러인지 표시, null : 추후 기능 추가 예정)
  • success : 저장에 성공하면 성공 메세지와 함께 어떤 데이터가 저장됐는지 알 수 있도록 만든 메소드
    (SUCCESS : 성공 메세지, result : 제네릭을 사용했지만 주로 UserJoinResponse(userName, emailAddress)형식으로 리턴되며 어떤 데이터가 저장되는지 알 수 있음)
import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class Response<T> {
    private String resultCode;
    private T result;

    public static Response<Void> error(String resultCode) {
        return new Response<>(resultCode, null);
    }

    public static <T> Response<T> success(T result) {
        return new Response<>("SUCCESS", result);
    }
}

UserDto
🔹 User을 직접적으로 다루지 않기 위해 사용

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class UserDto {
    private Long id;
    private String userName;
    private String password;
    private String emailAddress;
}

UserJoinRequest
🔹 회원가입할 때 입력받을 정보(userName, password, emailAddress)

import com.hospitalreview.domain.User;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class UserJoinRequest {
    private String userName;
    private String password;
    private String emailAddress;

    public User toEntity(){
        return User.builder()
                .userName(this.userName)
                .password(this.password)
                .emailAddress(this.emailAddress)
                .build();
    }
}

UserJoinResponse
🔹 회원가입이 성공했을 때 저장된 정보를 띄우기 위한 클래스 (단, 보안으로 인해 password는 생략)

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@Getter
public class UserJoinResponse {
    private String userName;
    private String emailAddress;
}

User
🔹 user 테이블과 연결할 클래스
(@Column(unique = true) : 해당 열의 unique 옵션 적용)

import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor
@Entity
@Getter
@Builder
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String userName;
    private String password;
    private String emailAddress;
}

ErrorCode
🔹 발생하는 에러코드를 미리 담아놓는 클래스
🔴 enum : 미리 지정 해놓고 그 값 말고 다른 값들을 넣지 못하게 하여 예측한 범위 내에서 프로그램이 작동하도록 하기 위한 기능
(ex. 아래의 경우. DUPLICATED_USER_NAME를 미리 지정)

import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

@AllArgsConstructor
@Getter
public enum ErrorCode {
    DUPLICATED_USER_NAME(HttpStatus.CONFLICT, "User name is duplicated.");

    private HttpStatus status;
    private String message;
}

ExceptionManager
🔹 예외처리를 관리하는 클래스

🔴 @RestControllerAdvice: 전역적으로 예외를 처리할 수 있는 어노테이션 (응답을 JSON으로 해줌)

  • RuntimeException의 경우, status를 INTERNAL_SERVER_ERROR, Response의 error 메소드에 메세지를 담아 호출
  • HospitalReviewException의 경우, status를 해당 에러의 Status를 가져오고, Response의 error 메소드에는 에러의 name을 담아 호출
import com.hospitalreview.domain.Response;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ExceptionManager {
    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<?> runtimeExceptionHandler(RuntimeException e){
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(Response.error(e.getMessage()));
    }

    @ExceptionHandler(HospitalReviewException.class)
    public ResponseEntity<?> hospitalReviewExceptionHandler(HospitalReviewException e){
        return ResponseEntity.status(e.getErrorCode().getStatus())
                .body(Response.error(e.getErrorCode().name()));
    }
}

HospitalReviewException
🔹 RuntimeException을 상속받은 HospitalReviewException 클래스

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class HospitalReviewException extends RuntimeException{
    private ErrorCode errorCode;
    private String message;

    @Override
    public String toString() {
        if(message == null) return errorCode.getMessage();
        return String.format("%s. %s", errorCode.getMessage(), message);
    }
}

UserRepository
🔹 DB의 user 테이블과 연결
(findByUserName : userName의 중복체크를 위한 메소드 생성)

import com.hospitalreview.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUserName(String userName);
}

UserService

  1. userRepository의 findByUserName 메소드로 입력받은 request의 userName과 동일한 데이터가 있는지 찾기
  2. 이미 존재한다면(ifPresent), HospitalReviewException로 ErrorCode.DUPLICATED_USER_NAME를 넣어 예외처리
    (🔴 throw가 꼭 있어야함!)
  3. 이상 없다면, userRepository의 save 메소드로 저장하고 UserDto 형식으로 바꾸어 리턴
import com.hospitalreview.domain.User;
import com.hospitalreview.domain.dto.UserDto;
import com.hospitalreview.domain.dto.UserJoinRequest;
import com.hospitalreview.exception.ErrorCode;
import com.hospitalreview.exception.HospitalReviewException;
import com.hospitalreview.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    public UserDto join(UserJoinRequest request){
        // 비즈니스 로직 - 회원 가입

        // 회원 userName(id) 중복 Check
        // 중복이면 회원가입 X -> Exception(예외) 발생
        userRepository.findByUserName(request.getUserName())
                .ifPresent(user -> {
                    throw new HospitalReviewException(ErrorCode.DUPLICATED_USER_NAME, String.format("Username:%s", request.getUserName()));
                });

        // 중복 Check 통화하면 회원가입 -> .save()
        User savedUser = userRepository.save(request.toEntity());

        return UserDto.builder()
                .id(savedUser.getId())
                .userName(savedUser.getUserName())
                .emailAddress(savedUser.getEmailAddress())
                .build();
    }
}

UserController Test 구현

import com.fasterxml.jackson.databind.ObjectMapper;
import com.hospitalreview.domain.dto.UserDto;
import com.hospitalreview.domain.dto.UserJoinRequest;
import com.hospitalreview.exception.ErrorCode;
import com.hospitalreview.exception.HospitalReviewException;
import com.hospitalreview.service.UserService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest
class UserControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Autowired
    ObjectMapper objectMapper;

    @MockBean
    UserService userService;

    @Test
    @DisplayName("회원가입 성공")
    void join_success() throws Exception {
        UserJoinRequest userJoinRequest = UserJoinRequest.builder()
                .userName("naver")
                .password("naver123")
                .emailAddress("email.@naver.com")
                .build();

        when(userService.join(any())).thenReturn(mock(UserDto.class));

        mockMvc.perform(post("/api/v1/users/join")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(userJoinRequest)))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("회원가입 실패")
    void join_fail() throws Exception {
        UserJoinRequest userJoinRequest = UserJoinRequest.builder()
                .userName("naver")
                .password("naver123")
                .emailAddress("email.@naver.com")
                .build();

        when(userService.join(any())).thenThrow(new HospitalReviewException(ErrorCode.DUPLICATED_USER_NAME, ""));

        mockMvc.perform(post("/api/v1/users/join")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(userJoinRequest)))
                .andDo(print())
                .andExpect(status().isConflict());
    }
}

🔸 join_success() : userJoinRequest로 요청이 왔을 때, mockMvc를 활용하여 일치하는지 테스트
🔸 join_fail() : 실패했을 때 예외처리가 잘 되는지 확인해야함 ➡ thenThrow로 처리
➡ 🔴 마지막 줄에 isConflict로 한 이유 : ErrorCode의 DUPLICATED_USER_NAME에 conflict로 설정했기 때문

0개의 댓글