[SpringBoot(4)] 스프링 부트 예외처리, TDD

배지원·2022년 12월 4일
0

실습

목록 보기
21/24

이번부터는 로그인기능을 구현하기 위해 중복검사 및 예외처리를 진행하도록 하겠다.

파일 구조

로그인 아이디 중복 검사


1. 유저에게 RequestDto안에 데이터를 입력받는다.
2. Service에서 Repository거쳐 DB에서 데이터를 가져와 중복검사를 한다.
3. 데이터가 중복되지 않다면 ResponseDto에 담고 Response의 메서드중 성공메서드로 감싸 성공했다는 메시지와 함께 유저에게 반환한다.
3-1. 데이터가 중복이 되었다면 예외처리를 진행한다. Service안에 중복되었을때 Exception Manager를 통해 exception을 발생시킨 후 Response의 메서드 중 error 메서드로 감싸 에러 코드를 담는아 유저에게 반환한다.

1. Domain

(1) Entity

User

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

    @Column(unique = true)      // userName은 중복값이 있으면 안됨. (회원가입할때 아이디중복이 안되는것)
    private String userName;
    private String password;
    private String emailAddress;
}
  • 사용자 로그인 정보를 저장하는 DB와 1대1 구조의 Entity
  • 로그인을 할때는 아이디는 중복이 되면 안되기 때문에 @Column(unique = true)를 통해 유니크 값을 만들어 준다

(2) DTO

Response

@AllArgsConstructor
@Getter
// 에러코드를 포함시켜 코드의 상태확인(정상인지, 오류인지)
// 모든 Response는 현재 Response객체로 감싸서 Return 한다.
public class Response<T> {
    private String resultCode;
    private T result;


    //  Service에서 데이터가 중복이 되었다면 오류 처리를 하는데
    public static Response<Void> error(String resultCode){
        return new Response(resultCode,null);
    }

    // Controller에서 반환할때 한번 감싸서 반환해줌(성공했다는 메시지와 result 결과 데이터와 같이)
    public static <T> Response<T> success(T result){
        return new Response("Success",result);      // resultCode에는 성공이라는 메시지와 result라는 결과 데이터를 반환함
    }
}
  • Service의 결과값을 Response으로 한번 감싸서 결과 상태를 알려준다.
  • Service에서 오류가 발생하면 resultCode에 현재 오류메시지와 결과값을 null로 반환해준다.
  • Service에서 정상동작하면 Controller에서 결과값을 반환할때 Response로 한번 감싸서 현재 상태에 "Success"라는 메시지와 결과값을 반환해준다.
    여기서만 보면 이해가 잘가지 않을 수도 있으니 아래에서 다시 설명하겠다.

UserDto

@AllArgsConstructor
@Getter
@Builder

// 코드상에서 메서드별 데이터를 주고 받을때 사용하기 위한 DTO
public class UserDto {
    private Long id;
    private String userName;
    private String password;
    private String email;

    // Entity의 값을 UserDto에 저장함
    public static UserDto fromEntity(User user){
        UserDto userDto = UserDto.builder()
                .id(user.getId())
                .userName(user.getUserName())
                .email(user.getEmailAddress())
                .build();

        return userDto;
    }
}
  • Controller <-> Service <-> Repository 간에 데이터를 주고 받기 위한 용도로만 사용하기 위하 DTO이다.

UserJoinRequest

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder    
// 사용자에게 데이터를 입력받기 위한 DTO
public class UserJoinRequest {
    private String userName;
    private String password;
    private String email;

    // 사용자에게 입력받은 데이터를 Entity로 보내줌
    public User toEntity(){
        return User.builder()
                .userName(this.userName)
                .password(this.password)
                .emailAddress(this.email)
                .build();
    }
}
  • 사용자에게 Json 형태로 데이터를 입력받을때 사용하는 DTO이다.

UserJoinResponse

@AllArgsConstructor
@NoArgsConstructor
@Getter

// 사용자에게 데이터를 반환하기 위한 DTO
public class UserJoinResponse {
    private String userName;
    private String email;
}
  • 사용자에게 Json형태로 반환해주기 위한 DTO이다.


2. Repository

UserRepository

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUserName(String userName);
}
  • 현재 기능으로는 아이디를 중복검사하는 기능만 있으므로 사용자에게 입력받은 데이터중 username만 가지고 DB에서 데이터를 찾아 올 수 있는 쿼리가 필요하다.
    따라서 JPQL을 통해 username을 통한 데이터찾기 기능을 사용했다.


3. Exception

ErrorCode

// enum = 미리 지정 해놓고 그 값 말고 다른 값들을 넣지 못하게 하여 에측한 범위 내에서만 동작하도록 함
// 예)  1. 요일 선택할때 월~일 사이의 값을 제외한 값을 선택하는 경우
//      2. 룰을 만들어 정의할때 (패스워드에는 대문자가 한개가 꼭 들어가야한다 등)

@AllArgsConstructor
@Getter
public enum ErrorCode {
    // enum을 통해 미리 값을 설정한다.
    // DUPLICATED_USER_NAME의 이름을 가진 에러에 (에러상태 Conflict(409) 출력, 에러메세지)의 값을 넣은 구조만 받는다.
    DUPLICATED_USER_NAME(HttpStatus.CONFLICT, "User name is duplicated.");

    private HttpStatus status;
    private String message;
}
  • enum : 미리 지정 해놓고 그 값 말고 다른 값들을 넣지 못하게 하여 예측한 범위 내에서 프로그램이 작동하도록 하기 위한 기능
  • 기본값설정이라고 생각하면 될 것 같다.
  • 현재 코드에서는 DUPLICATED_USER_NAME를 미리 지정하였다.
  • DUPLICATED_USER_NAME에는 현재 오류 상태(Conflict - 409)를 나타내도록 하였다.

HospitalReviewAppException

// 내가 지정한 클래스내에서만 오류처리하기
@Getter
@AllArgsConstructor
public class HospitalReviewAppException extends RuntimeException{
    private ErrorCode errorCode;    //  Service로부터 생성자를 통해 ErrorCode.DUPLICATED_USER_NAME 저장됨
    private String message;         // Service로부터 생성자를 통해 String.format("Username :"+request.getUserName()) 저장됨

    @Override
    public String toString() {
        if(message == null) {
            //  Service로부터 생성자를 통해 상태만 받아오고 message를 안받아오면 ErrorCode에 미리 설정해둔 Message를 출력한다.
            return errorCode.getMessage();
        }

        return String.format("%s. %s", errorCode.getMessage(), message);
    }
}
  • 에러의 종류를 기존에 있는 것들을 사용해도 좋지만 자신이 직접 기능에 맞게 에러 종류를 만들어 낼 수 있다.

  • 자신이 사용하고 싶은 에러의 종류에 해당하는 기본 에러를 상속받아 사용할 수 있다. 현재는 RuntimeException 에러를 사용하고 싶은데 그대로 사용하면 모든 RuntimeException으로 예외처리한 것들이 적용되므로 상속을 받아 새로운 에러종류를 만들어 낸다.

  • HospitalReviewAppException으로 예외처리를 할 경우에는 errorCode(에러정보), message(에러에 대한 간단한 정보)가 포함된 상태로 객체를 생성해줘야 한다.

    이 방식은 Service에서 다루도록 하겠다.

ExceptionManager

// 예외처리하게 되면 해당 예외에 맞는 기능이 동작됨
// 유저는 어떤 에러가 발생한지 모르기 때문에 여기서 예외처리에 맞는 에러 값을 유저에게 알려주는 공간
@RestControllerAdvice
public class ExceptionManager {

    // (1) 모든 RuntimeException 에러가 발생시 동작
    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<?> runtimeExceptionHandler(RuntimeException e){   // ? 는 모든 값이 올 수 있다는 것
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)      // 서버 에러 상태 메시지와 body에 에러상태 메시지(문자열)을 넣어 반환해줌
                .body(Response.error(e.getMessage()));
    }

    // (2) 기존에 만들어둔 에러(HospitalReviewAppException)가 발생시 동작
    @ExceptionHandler(HospitalReviewAppException.class)
    public ResponseEntity<?> hospitalReviewAppExceptionHandler(HospitalReviewAppException e){
        return ResponseEntity.status(e.getErrorCode().getStatus())
                .body(Response.error(e.getErrorCode().name()));
    }
}
  • 현재까지는 에러가 발생해도 콘솔에만 내용이 찍히기 때문에 유저는 무엇이 에러가 발생한지 모른다. 따라서 이를 위해 에러 메세지를 통제하여 유저도 확인할 수 있도록 구성하는 예외처리를 관리하는 클래스를 만들었다.

  • @ExceptionHandler를 통해 선언한 에러가 예외처리가 됬을경우 현재 클래스로 이동하여 해당 메서드가 실행된다.

  • (1)은 기본적으로 사용하는 에러메세지인 RuntimeException에 해당하는 반환데이터로 @ExceptionHandler를 통해 관리하고 싶은 에러의 종류를 선언해주고 RuntimeException로 예외처리가 발생하면 Json을 통해 반환하게 되는데 status(상태)에는 ok(정상)이 아닌 현재 에러 상태인 INTERNAL_SERVER_ERROR를 담고 body에는 이전에 만들었던 Response DTO에 있는 error 메서드를 호출하여 에러메세지와 데이터는 null값을 반환해준다.

  • (2)에는 HospitalReviewAppException에 대한 예외처리가 발생하면 동작한다. 위에서 HospitalReviewAppException 클래스의 tostring( )보다 우선순위가 앞서기 때문에 해당 반환값이 출력된다.
    반환값으로는 status(ErrorCode의 상태값)과 body(ErrorCode의 이름)이 출력이 된다.
    따라서 ErrorCode의 상태값인 HttpStatus.CONFLICT(409)와 ErrorCode의 이름인 DUPLICATED_USER_NAME이 출력이 된다.

@RestControllerAdvice

  • @ControllerAdvice + @ResponseBody = @RestControllerAdvice
  • 전역적으로 예외를 처리할 수 있는 어노테이션 (응답을 JSON으로 해줌)
  • 단순히 예외만 처리하고 싶다! → @ControllerAdvice
  • 응답으로 객체도 리턴하고 싶다! → @RestControllerAdvice

@ExceptionHandler

  • 이 어노테이션을 메서드에 선언하고 특정 예외 클래스를 지정해주면 해당 예외가 발생했을 때 메서드에 정의한 로직으로 처리

ResponseEntity<?>

  • 제네릭에는 ? = 와일드 카드가 있다.
  • 제네릭 타입으로 데이터 타입을 명시 하지 않고. 런타임까지 유연하게 가져가겠다는 것.
  • 타입을 따로 선언하지 않고 모든 타입을 허용한다는 의미이다.


4. Service

UserService

@Service
@RequiredArgsConstructor	// DI를 할때 생성자를 자동으로 만들어줌
public class UserService {

    private final UserRepository userRepository;

    // 데이터가 없을경우 정상동작, 데이터가 이미 있을겨우 오류 발생(회원가입 불가)
    // 유저에게 입력받은 데이터 중복 검사 및 DB 저장
    public UserDto join(UserJoinRequest request){
        userRepository.findByUserName(request.getUserName())
    // 1. RuntimeException 에러타임 보내기(에러 설정클래스에서 RuntimeException에 해당하는 메서드 실행됨
    //            .ifPresent(user -> new RuntimeException("해당 UserName이 중복 됩니다"));   // 데이터가 있을경우 예외처리(콘솔에만 출력됨)
    //            .orElseThrow(() -> new RuntimeException("해당 UserName이 중복 됩니다"));  // 데이터가 없을경우 예외처리

    // 2. 내가 원하는 에러코드를 만들어서 설정하기
    // enum클래스를 통해 미리 설정해둔 에러구조를 통해 에러를 넘겨준다.
                .ifPresent(user -> {
                    throw new HospitalReviewAppException(ErrorCode.DUPLICATED_USER_NAME,String.format("Username :",request.getUserName()));
                });

        User saveUser = userRepository.save(request.toEntity());    // UserJoinRequest -> User Entity변환후 데이터 DB 저장


        return UserDto.fromEntity(saveUser);    // User에게 입력받아 회원가입한 데이터를 UserDto에 저장함
    }
}
  • Service에서는 아이디의 중복검사를 하도록 구성했다.
  • Repository에서 username을 통해 해당 데이터를 찾아오고 해당 데이터가 존재하면 Optional의 예외처리를 하도록 구성했다.
    이때 데이터가 존재할때 예외처리이므로 ifPresent를 사용하였다.
  • 예외는 기존에 만들었던 HospitalReviewAppException을 통해 예외를 해주었다.
  • 유저이름이 중복이 되지 않아 예외처리가 되지 않는다면 해당 정보를 DB에 저장하고 UserDto로 변환하여 반환해준다.

에러처리 구조

(1) 아이디가 중복이 되면 .ifPresent를 통해 해당 데이터를 예외처리를 한다.
이때 예외를 HospitalReviewAppException를 통해 처리를 하는데 유저가 무엇이 에러인지를 알아야 하기 때문에 enum을 통해 기본설정한 ErrorCode의 값(DUPLICATED_USER_NAME)과 message(에러에 대한 간단한 정보)를 보내 HospitalReviewAppException에 저장한다.

(2) 이때, ExceptionManager에서 @ExceptionHandler를 통해 해당 에러를 선언해줬기 때문에 해당 에러를 통해 예외처리가 진행되면 바로 ExceptionManager에 해당 메서드로 이동하여 기능을 수행하여 그 값을 반환해준다.

값 : Json형식으로 반환하는데 status (HttpStatus.CONFLICT(409)) 와 ErrorCode의 이름인 DUPLICATED_USER_NAME이 출력이 된다.


그리고 최종적으로 그 값을 Response 클래스의 error안에 넣어 유저에게 출력시켜준다.




정리
Service(에러 발생) -> HospitalReviewAppException(예외처리/에러값(ErrorCode),간단한 에러내용 포함) -> ExceptionManager(미리 선언한 에러 동작시 설정한 값 출력) + Response DTO를 통해 구조를 다듬어 유저에게 출력함



5. Controller

UserController

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

    private final UserService userService;


    // 회원가입 기능
    @PostMapping("/join")
    public ResponseEntity<Response<UserJoinResponse>> join(@RequestBody UserJoinRequest userJoinRequest){
        UserDto userDto = userService.join(userJoinRequest);        // 유저가 입력한 데이터 중복검사 및 DB에 저장
        return ResponseEntity.ok().body(Response.success(new UserJoinResponse(userDto.getUserName(),userDto.getEmail())));
    }
}
  • 유저에게 정보(유저이름-아이디,비밀번호,이메일)를 입력받아 중복검사를 하고 DB에 저장하기 위해 Service로 데이터를 보내주고 그 결과를 Dto형식으로 반환받음
  • Dto형식으로 받은 값 중 비밀번호는 유저에게 보안상 보여줄 필요가 없으니 UserJoinResponse DTO를 통해 이름(아이디)와 이메일 정보만 반환시켜줌
    이때, 정상적으로 반환되었다는 것을 알려주기 위해 Response의 success메서드로 한번 감싸서 결과를 반환시켜준다.


6. 결과

  • 중복없이 정상동작

  • 중복으로 인한 에러발생

    이미 test2에 대한 유저아이디가 있으므로 중복 발생하여 에러 발생



7. TDD

@WebMvcTest
class UserControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    UserService userService;

    @Autowired
    ObjectMapper objectMapper;

    @Test
    @DisplayName("회원가입 성공")
    void join_success() throws Exception {
        // given
        UserJoinRequest userJoinRequest = UserJoinRequest.builder()
                .userName("han")
                .password("1q2w3e4r")
                .email("oceanfog1@gmail.com")
                .build();

        User user = userJoinRequest.toEntity();
        UserDto userDto = UserDto.fromEntity(user);

        when(userService.join(any())).thenReturn(userDto);

        // when, then
        mockMvc.perform(post("/api/v1/users/join")
                        .contentType(MediaType.APPLICATION_JSON)        // Json 타입으로 사용
                        .content(objectMapper.writeValueAsBytes(userJoinRequest)))      // 삽입한 데이터 dto를 json 형식으로 변환
                .andDo(print())
                // userName 존재 여부 확인
                .andExpect(jsonPath("$..userName").exists())
                // userName의 값 비교
                .andExpect(jsonPath("$..userName").value("han"))
                .andExpect(status().isOk());

    }

    @Test
    @DisplayName("회원가입 실패")
    void join_fail() throws Exception {
        UserJoinRequest userJoinRequest = UserJoinRequest.builder()
                .userName("han")
                .password("1q2w3e4r")
                .email("oceanfog1@gmail.com")
                .build();

        // 이전에는 when/thenReturn을 통해 구현했는데 그렇게 하면 given 구역에서 when을 사용하면 헷갈릴 수 있으므로 given으로 구역을 표시하며 정확히 한다.
        given(userService.join(any()))
                .willThrow(new HospitalReviewAppException(ErrorCode.DUPLICATED_USER_NAME, ""));

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

        verify(userService).join(any());
    }
}

TDD에 관한 내용은 TDD/BDD 차이점 블로그를 통해 정리해 두었다.

profile
Web Developer

1개의 댓글

comment-user-thumbnail
2023년 6월 27일

좋은 글 감사합니다.

답글 달기