728x90

저번시간에 mock객체를 이용한 테스트코드에 대해 블로그를 적었었다. 

 

테스트 코드를 반영하면서 올린 기존 코드가 수정된 부분이 많아서

참을 수 없어 호다닥 글을 적어보려고 한다.

리팩토링할 수 있어서 행복한 코드의 세계~

 

목표 : http form 데이터 전송으로 처리하던 로직 ->  rest api로 변경하기 위해 json형식으로 데이터를 주고 받기.

목표대로 수정하다보니 하나 둘 씩 기존의 코드의 다른 문제점들도 보이기 시작했는데

 

1. 의미없는 optional 반환 

처음에는 mapper에서만 optiona로 반환으로 수정을 하였다가

굳이 optional로 반환하지 않고 User를 조회하는 mapper에서 있을 경우 User반환, 없을 경우 예외를 반환하기로 하였다.

2. 컨트롤러에서 도메인 객체 등장?

optional<user> 컨트롤러까지 넘어오고 있었다. 그래서 다시 User값을 꺼내는 처리를 컨트롤러에서 하고 있고 도메인인 User객체가 그대로 드러났다. 서비스단에서 처리하여 데이터 전송객체로 반환하도록 수정했다. 

3. 구조 수정

restcontroller와 view리턴하는 컨트롤러를 쪼갰다. api 구조상으로는 좋지 않은 것 같기도 한데 이전보다는 직관적으로 분류가 되었다.

 

 

(하나의 pr에 많은 내용이 담기면 좋지 않은데 바로 수정하게 된다..

그래서 커밋이라도 잘게 쪼개고 메시지를 자세히 담아서 반영하고 있다. )

 

수정한 코드는 이렇다. 

 

로그인 컨트롤러 (확실히 코드가 깔끔해졌다. 짱이다!)

@Slf4j
@RestController
@RequestMapping("/login")
@RequiredArgsConstructor
public class LoginController {

    private final LoginService loginService;

    @PostMapping
    public ResponseEntity<LoginResponseDTO> login(@RequestBody @Valid LoginRequestDTO loginRequestDTO) {

        LoginResponseDTO loginResponseDTO = loginService.checkLoginForm(loginRequestDTO);
        return ResponseEntity.ok(loginResponseDTO);


    }
}

 

로그인 서비스

@Slf4j
@Service
@RequiredArgsConstructor
public class LoginService {

    private final LoginUserService loginUserService;

    public LoginResponseDTO checkLoginForm(LoginRequestDTO loginRequestDTO) {
        String email = loginRequestDTO.getEmail();
        User userEmail = User.builder().email(email).build();
        String password = loginRequestDTO.getPassword();
        
        User userByEmail = loginUserService.getUserByEmail(userEmail);
        boolean equals = userByEmail.getPassword().equals(password);
        if (equals) {
            return LoginResponseDTO.builder().id(userByEmail.getUserId()).name(userByEmail.getName()).email(userByEmail.getEmail()).build();

        } else {
            throw new NoSuchElementException("비밀번호가 일치하지 않습니다.");
        }


    }


}

이메일 확인하는 로그인유저서비스 (볼수록 이름 마음에 안드는데..흠..)

@Service
@RequiredArgsConstructor
public class LoginUserService {
    private final UserMapper userMapper;

    public User getUserByEmail(User user) {

        return Optional.ofNullable(userMapper.getByEmail(user.getEmail())).orElseThrow(() -> new NoSuchElementException("해당하는 이메일이 없습니다."));
    }
}

 

 

다음 목표: @ExceptionHandler를 이용한 전역 API 예외처리 

사실은 스프링 강의에서 들었던 내용인데 이론만 공부하고 프로젝트에 적용을 안 했었다!

생각보다 ExceptionHandler 사용 방법은 간단했다. 

 

@Controller(뷰 반환)나 @RestController(json또는 xml 데이터 반환)를 사용해  특정 클래스 구조 안에서만

@ExceptionHandler로 예외를 처리할 수도 있지만

 

나는 전역에서 관리하고 싶었고 바디값에 예외를 담아서 json형식으로 응답을 반환할 것이기 때문에 

@RestcontrollerAdvice를 사용했다.

 

@Valid 자바 어노테이션을 이용해 바인딩 오류도 전역에서 처리하고자 하였는데

이상하게 전역에서 예외가 안 잡혀서 낑낑댔다. 그 이유는 곧 나온다. 호이~

 

 

@Valid 자바 어노테이션을 사용할 경우,  발성하는 예외와 

@Validated 스프링 어노테이션을 사용할 경우 발성하는 예외가 다르다는 점을 유의해야하고 

 

찾아보니 

@Valid 자바 어노테이션을 사용할 경우에도 상황에 따라 예외타입이 다르다고 한다.

 

1. @RequestBody에 매핑되는 DTO 클래스 검증:

DTO 에 대한 유효성 검사에서 실패하면 MethodArgumentNotValidException 예외가 발생하고,

이 예외는 스프링의 DefaultHandlerExceptionResolver에 의해 처리되며

기본적으로 HTTP 상태 코드 400 (Bad Request)를 반환한다. 

 

2. @PathVariable 또는 @RequestParam 검증:

경로 파라미터(@PathVariable)나 쿼리 파라미터(@RequestParam) 등에 대한 유효성 검사에서 실패하면 ConstraintViolationException 예외가 발생한다. 

 

이 예외는 스프링의 DefaultHandlerExceptionResolver에 기본 핸들러가 선언되어 있지 않아

기본적으로 HTTP 상태 코드 500 (Internal Server Error)를 반환하는데 이 상황에서는

커스텀 예외 핸들러를 추가하여 HTTP 상태 코드 400으로 처리할 수 있다고 한다. 

 

(@Validated 스프링 어노테이션은 BindException이 유효형 검사에 실패한 경우에 발생)

 

나는 @Valid를 사용하고 있었고 전역에서 예외를 처리해 json으로 반환할 것이기 때문에 

다음과 같이 적용하였다. 

 

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(value = HttpStatus.BAD_REQUEST)
    public ResponseEntity<ExceptionResponse> processValidationError(MethodArgumentNotValidException exception) {
        List<String> Messages = new ArrayList<>();
        exception.getBindingResult().getFieldErrors().forEach((e) -> {
            String errorMessage = e.getField() + ": " + e.getDefaultMessage();
            Messages.add(errorMessage);
        });
        ExceptionResponse elementNotFound = ExceptionResponse.builder().message("유효한 값이 아닙니다").exceptions(Messages).status(HttpStatus.BAD_REQUEST).build();
        return new ResponseEntity<>(elementNotFound, elementNotFound.getStatus());

    }


    @ExceptionHandler(NoSuchElementException.class)
    @ResponseStatus(value = HttpStatus.NOT_FOUND) //명시적 선언, 하지만 ErrorResponse의 status세부 설정이 우선권 가진다.
    public ResponseEntity<ExceptionResponse> handleNoSuchElementException(NoSuchElementException exception) {
        ExceptionResponse elementNotFound = ExceptionResponse.builder().message(exception.getMessage()).exceptions(Collections.singletonList("Element not found")).status(HttpStatus.NOT_FOUND).build();
        return new ResponseEntity<>(elementNotFound, elementNotFound.getStatus());

    }


}

 

허무하게도 내가 적용이 안된 이유는 

첫번째 - 기존 컨트롤러 메서드에서 try catch를 제거하지 않아서 예외가 catch문으로 넘어가서 전역에서 잡을 수가 없었다!

두번째 - 그리고 컨트롤러 메서드에  bindingresult 파라미터가 사용하지 않더라도 추가되어 있어서 

수동으로 예외를 잡고 있어서 전역에서 호출되지 않았었다. 

 

코드 수정할 때 새로운 코드도 중요하지만 기존 코드의 설정을 잘 제거해놔야겠다.

 

 

이메일 조회 실패시 예외, 해당 메일의 비번 일치여부 실패시 예외를 던져서 실패요인도 담아냈다. 

 

 

 

 

 

 

 

 

728x90
728x90

테스트를 해보자~!

 

 

처음에는 @SpringBootTest를 이용해 실제 데이터베이스를 조회해서 값을 가져오며 테스트를 해봤다.

그러다 사용하고 있는 Mapper가 잘 동작되는지를 시작으로 Mock을 이용해 테스트를 하게 되었다.

이후 Mock 테스트를 하며 알게 된 것인데,

내가 Mock 객체의 특징을 잘모르고 쓰고 있었구나를 깨달았다. 가짜 객체..그려그려.. 딱 요렇게만 생각했다.

그래서 나름의 깨달음을 토대로, 테스트 하기 전 생각해야할 점들을 적어보았다.

 

먼저 공부했던 개념들을 짧게 정리해보겠다.

테스트하면 단위테스트, 통합테스트를 주로 말하는데 둘의 차이가 뭘까?

 

단위 테스트와 통합테스트의 구분을 나는

 

메소드 단위를 테스트 : 단위테스트

기능 단위를 테스트 : 통합테스트

 

로 얼추 분류하고 있었다. 좀 더 큰 범위가 통합테스트가 아닐까? 하는 막연한 생각을 갖고 있었다.

 

스스로도 분류기준이 애매하다는 찜찜함이 있었다. 

 

찾아본 개념부터 정리하면

- 단위 테스트 Unit Test

: 프로그램의 각 부분을 테스트로 언제든지 수행이 가능해야 한다.

: 외부 종속성과 관련없이 테스트를 진행한다.

- 통합 테스트 Integration Test

: 소프트웨어 모듈간의 통합을 테스트하는 것으로 

: 코드가 외부종속성과 올바르게 작동하는지 확인한다.

 

즉, 분류기준은 외부 종속성과 관련이 있냐, 없냐에 있었다.

 

값이 일치한지 여부를 따지는 메서드를 테스트하고자 할때

@SpringBootTest 를 통한 테스트에서는 실제 데이터베이스의 값을 조회해 비교했다면 

Mock 테스트는 값이 일치한다는 상황을 가정하고 테스트를 한다. 실제 데이터베이스의 값이 필요없는 것이다. (외부 종속성과 관련없다.)

 

결론적으로 SpringBootTest는 단위테스트에 적합하지 않고,통합테스트에 적합하다.

 

개발자는 단위테스트를 통해 자신이 짠 코드를 검증한 후에 통합테스트를 진행한다. 

 

그렇다면 단위테스트에 대해 알아보고 사용해보자

이처럼 종속성없이 테스트를 하기 위해 실제 객체를 사용하지 않고 가짜 객체를 사용하며 테스트하는 것을 

Test Double이라고 한다. Test Double 은 여러 종류가 있다. 이중 많이 사용 되는 것이 Mock객체이다. 

 

(testdouble은 주로 단위 테스트에서 사용되지만, 목적에 따라 다양한 테스트 수준에서 활용될 수 있습니다. 예를 들어, 단위 테스트 외에도 통합 테스트나 시스템 테스트에서도 사용될 수 있습니다. 라고 =chatpgpt가 알려줬다. )

 

Test Double 의 종류를 간단히 살펴보자. (참고)

  • Dummy (사전적 정의: 쓰레기)
    • 사용하지 않는 객체
    • 인스턴스화된 객체가 필요하지만 기능은 필요하지 않은 경우 사용
    • 동작하지 않아도 영향 끼치지 않음
  • Fake (사전적 정의: 가짜)
    • 복잡한 로직이 있는 외부객체동작을 단순화해서 구현한 객체
    • 예로 실제로는 DB를 연결해 테스트해야하지만 이를 단순히 구현한 FakeUserRepository를 만들어 테스트할 수 있다.
  • Stub (사전적 정의: 쓰다 남은 토막)
    • 인터페이스 또는 기본 클래스가 최소한으로 구현된 상태를 제공하는 객체
    • Dummy객체가 실제로 동작하는 것처럼보이게 만들어놓은 객체
    • Mockito프레임워크도 Stub와 같은 역할을 함.
  • Spy (사전적 정의: 염탐하다)
    • Stub객체용으로도 쓰고 호출된 내용을 확인하고자 기록하는 용으로도 쓴다.
    • 실제 객체로도 동작할 수 있다.
    • Mockito 프레임워크의 verify() 메서드가 같은 역할을 한다. 확인용
  • Mock (사전적 정의: 모조품)
    • 호출에 대한 결과값을 미리 적어두고 그 내용에 따라 동작하도록 프로그래밍하는 객체이다.

어떤 상황에서 어떤 것으로 할지 테스트의 목적에 따라 선택해야한다. (코드 참고 )

 

 

<Mock 테스트 하기 전 생각해야할 점들>

1. 무엇을 테스트하고 싶은가?

Mock 객체를 사용한 테스트 뿐 아니라 다른 테스트를 행할 때도 마찬가지로 파악하고 넘어가야하는 지점이다.

어떤 것을 테스트하고 싶은지 정해야한다. 단순히 메서드를 테스트한다고 생각하지 않고

"어떤 것을 실행해서 어떤 결과값을 얻고자 하는지"를 정해야한다. 

2. 어떤 결과값을 얻고 싶은가?

위에도 나와 있듯이 Mock 객체는 " 호출에 대한 결과값을 미리 적어두고 그 내용에 따라 동작하도록 프로그래밍하는 객체"

이기 때문에,

 내 코드가 어떤 결과값을 도출할지를 알고 있어야한다.

3. 결과값이 나오기 위해 필요한 것은?

내가 얻고자 하는 바, 즉 기대하는 결과를 안다면 그 결과가 나오기 위한 객체를 알고 원하는 상황에 맞게 설정해주어야한다.

 

 

이제 세 가지를 생각하고 테스트 코드를 짜보자. 테스트 코드를 짜기 위해 나의 코드를 먼저 살펴보겠다.

 

( 코드 수정하였음! )

 

<로그인 컨트롤러이다.> loginForm 객체로 값을 받으면 유저가 맞는지 확인하기 위해

loginService의 checkLoginForm메서드로 넘긴다.

   @PostMapping("/login")
    public String login(@Valid @ModelAttribute("loginForm") LoginForm loginForm, BindingResult bindingResult, HttpServletResponse response, RedirectAttributes redirectAttributes) {
        if (bindingResult.hasErrors()) {
            log.error("bindingResult.hasErrors-[GetMapping/login]");
            return "/loginForm";

        }

        Optional<User> user = loginService.checkLoginForm(loginForm);

        if (user.isEmpty()) {

            bindingResult.reject("loginFail", "일치하는 회원정보가 없습니다. 다시 시도해주세요");
            return "/loginForm";

        }

        //Cookie
        Cookie idCookie = new Cookie("user_id", String.valueOf(user.get().getUserId()));
        response.addCookie(idCookie);


        redirectAttributes.addAttribute("user_id", user.get().getUserId());

        return "redirect:/closet/{user_id}";


    }

 

<loginService 는 컨트롤러에서 loginForm 값을 넘겨받은 서비스이다.>

checkLoginForm 메서드 안에서 

loginUserService객체의 getUserByEmail메서드로 넘겨받은 loginForm의 이메일 주소를 넘겨

이메일 주소와 같은 유저가 있는지 찾는다. 

그 반환값에 따라 User가 있는 경우, 그 유저의 비밀번호와 loginForm의 비밀번호가 일치한지 확인한다.

일치하면 User를 넘기고 없다면 null을 반환한다.

@Slf4j
@Service
@RequiredArgsConstructor
public class LoginService {

    
    private final LoginUserService loginUserService;

    public Optional<User> checkLoginForm(LoginForm loginForm) {
        String email = loginForm.getEmail();
        User userEmail = User.builder().email(email).build();

        String password = loginForm.getPassword();

        Optional<User> userByEmail = loginUserService.getUserByEmail(userEmail);

        if (userByEmail.isPresent()) {
            return Optional.ofNullable(userByEmail.filter(m -> m.getPassword().equals(password)).orElse(null));

        } else {
            throw new NoSuchElementException("User not found");


        }


    }


}

 

<LoginUserService 는 userMapper를 통해 조회 후 결과값을 반환한다.>

@Service
@RequiredArgsConstructor
public class LoginUserService {
    private final UserMapper userMapper;

    public Optional<User> getUserByEmail(User user) {

        return Optional.ofNullable(userMapper.getByEmail(user.getEmail()));
    }
}

 

 

로그인 로직은 이렇다. 

 

1. 여기서 내가 테스트하고 싶은 것은 

loginService 객체가 유효한 loginForm을 받았을 경우이다. 그 경우 이메일도 일치하고 비밀번호도 일치해야한다. 

 

2. 기대하는 결과는, loginForm의 이메일을 넘겨받은

loginUserService의 getUserByEmail 메서드가 

해당하는 이메일과 비밀번호를 잘 조회해서 User값을 문제없이 응답하는 것이다.

 

3. 그러면 기대하는 결과가 나오기 위해서는 loginUserService객체가 필요하다.

 

일단 완성된 테스트 코드를 보자.

public class LoginServiceTest {


    @Mock
    private LoginUserService loginUserService;
    @InjectMocks
    private LoginService loginService;

    private LoginForm loginForm;
    private User user;

    @BeforeEach
    public void init() {
        MockitoAnnotations.openMocks(this);
        loginForm = LoginForm.builder().email("so@naver.com").password("1234").build();
        user = User.builder().email(loginForm.getEmail()).password(loginForm.getPassword()).build();
    }

    @Test
    @DisplayName("checkLoginForm메서드 : 유저의 이메일이 맞고 이메일의 비밀번호도 맞다")
    void checkLoginFormTest() {
        //given
        //loginUserService가 email에 맞는 user값을 반환했다고 가정하면
        when(loginUserService.getUserByEmail(any())).thenReturn(Optional.of(user));

        //when
        //checkLoginForm 를 호출했을 때
        Optional<User> result = loginService.checkLoginForm(loginForm);

        //then
        //값이 있고 이메일과 비밀번호도 같아야한다.
        assertThat(result).isNotNull();
        assertThat(result.get().getEmail().equals("so@naver.com"));
        assertThat(result.get().getPassword().equals("1234"));


    }




}

 

 

나는 명시적으로 메서드 호출 전에 호출하기 위해  BeforeEach를 사용해 

 MockitoAnnotations.openMocks(this); 로 Mock객체를 만들어주었다. 

 

이거 대신 이 어노테이션을 사용해도 똑같이 필요한 mock객체들을 초기화해서 가져오는 역할을 한다.

(@ExtendWith(MockitoExtension.class)를 사용하는 것이 JUnit 5에 더 적합한 방법이라고 함. )

@ExtendWith(MockitoExtension.class)
class LoginServiceTest {
}

 

기본적으로 Mockito를 사용할 때는 목 객체를 생성하고 초기화 하는 과정이 필요한데

 

@Mock으로 선언할 때 사용한다.  MockitoExtension이 이를 감지해 테스트 메서드 실행 전에 초기화를 수행한다. 

@InjectMocks 을 사용해 필요한 목 객체를 주입받는 객체도 초기화 한다. 

 

내 코드로 보자면, @Mock으로 선언한 LoginUserService 목 객체를 @InjectMocks로 선언한 LoginService에 주입합니다.

그러면 필요한 객체를 가져왔으니 테스트 메서드를 보면, (LoginService가 받는 loginForm을 만들고 타입에 맞게 User로도 변환해주었다. )

 

//given

내가 원하는 결과를 얻기 위해서는 LoginUserService에서 문제없이 User값을 반환해야한다. 그렇게 했다고 가정하고

 

//when

loginService의 checkLoginForm를 실행하면 응답값User가 나온다.

 

//then

그러면 그 User는 입력한 User의 이메일과 비밀번호 값이 동일할 것이다. 

 

 

여기서 내가 많이 헤맨 부분은 given 이었다. 원하는 결과값을 얻기 위해 필요한 상황을 제시해주어야한다.

 

given이 when으로 되어있는 기존 코드에서 통일된 given을 사용하는 BDD(행동 주도 개발, Behavior-Driven Development) 로도 작성해보았다.

       //given
        //loginUserService가 email에 맞는 user값을 반환했다고 가정하면
        given(loginUserService.getUserByEmail(any(User.class))).willReturn(Optional.of(user));

        //when
        //checkLoginForm 를 호출했을 때
        Optional<User> result = loginService.checkLoginForm(loginForm);

        //then
        //값이 있고 이메일과 비밀번호도 같아야한다.
        assertThat(result).isNotNull();
        assertThat(result.get().getEmail().equals("so@naver.com"));
        assertThat(result.get().getPassword().equals("1234"));

        then(loginUserService).should().getUserByEmail(any(User.class));

 

 

그렇다면 지금 checkLoginForm가 잘 동작하는지 확인하기 위해서 

LoginUserService 객체가 올바르게 반환한다! 전제조건을 깔았는데,

 

LoginUserService 객체의 메소드를 테스트하는 코드도 짜봤다. 

1. 테스트하고자 하는것 LoginUserService이 유효한 user값을 받았을 경우이다. 

2. 기대하는 결과는 mapper를 통해 유효하는 user가 검증되었다면 user값을 반환해야한다.

3. 필요한 객체는 userMapper이다.

 

@Slf4j
@ExtendWith(MockitoExtension.class) //초기화로 mock 객체 가져옴
public class LoginUserServiceTest {

    @Mock
    private UserMapper userMapper;

    @InjectMocks
    private LoginUserService loginUserService;
    

    @Test
    void testGetUserByEmail() {
        //Given
        String userEmail = "test@example.com";
        String password = "123";
        User user = User.builder().email(userEmail).password(password).build();
        when(userMapper.getByEmail(userEmail)).thenReturn(user); //조회해서 값이 있다고 가정했을 때

        //when
        Optional<User> result = loginUserService.getUserByEmail(user);

        //Then
        assertThat(result.isPresent()); //값이 있다고 나온다.
        assertEquals(userEmail, result.get().getEmail());
        assertEquals(password, result.get().getPassword());

        //userMapper 목 객체에 대해 getByEmail메서드가 정확히 1번 호출되었는지 검증
        verify(userMapper, times(1)).getByEmail(userEmail);


    }
}

 

 

사실 이렇게만 보면 Mock테스트는

메서드 내에서 필요한 객체들이 문제없이 반환값을 잘 가져왔을 때 

테스트하고자하는 메서드가 잘 동작하는지 확인하는 용도일뿐이다.

 

그래도 테스트 코드를 짜다보면 코드의 문제점을 발견하고 고치게 된다. 

특히 예외테스트가 중요하다고 한다!

 

나도 테스트를 하면서 많은 리팩토링을 거쳤다. 

1. 단일책임 원칙으로 필요한 메서드만 담긴 서비스 객체 만듦.

기존 코드에서는

UserService에서 User값을 조회하고 검증하는 여러개의 메소드를 가진 UserSerivce를 주입했었다.

하지만 이렇게 될 경우 사용하지 않는 메소드들도 모두 주입된다고 하여 

UserLoginService를 따로 만들어서 처리하였다. 

2. 예외테스트를 하면서 예외의 경우 로직 추가 및 optional추가

 

기존 코드에서는

loginUserService.getUserByEmail(userEmail); 는  Optional로 처리하였지만

였고 그 결과값이 존재할때 null이 아닌경우, 해당하는 비밀번호가 있을 경우 return User타입으로 반환을 하였었는데

 

return userByEmail.filter(m -> m.getPassword().equals(password)).orElse(null);

그대로 optional로 반환하였다.

Optional<User> userByEmail = loginUserService.getUserByEmail(userEmail);

if (userByEmail.isPresent()) {
   // 같은 코드임 return Optional.ofNullable(userByEmail.filter(m -> m.getPassword().equals(password)).orElse(null));
    return userByEmail.filter(m -> m.getPassword().equals(password));

} else {
    throw new NoSuchElementException("User not found");


}

 

 

테스트코드는 다음과 같다. 

    @Test
    @DisplayName("조회한 이메일이 없을 때 checkLoginForm 실패")
    void uncheckLoginFormTest() {
    
     	// Given: Mock 객체에 예외를 발생시키도록 설정
        when(loginUserService.getUserByEmail(any())).thenReturn(Optional.empty());
        
        // When: 특정 메서드를 호출 () -> 메서드 호출하면
        // Then: 예외가 발생하는지 확인 NoSuchElementException이 나온다.

        assertThrows(NoSuchElementException.class, () ->
                loginService.checkLoginForm(loginForm)

        );


    }

 

예외테스트가 훨씬 어려운 느낌이다.. 공부 많이 해야지..

 

그리고 처음에 테스트하면서 테스트 코드에 given에

thenReturn에 null값을 넣어놓고

왜 값이 없을 때 else 로 빠져서 NosuchElementException이 나오는게 아니라

userByEmail.isPresent()에 멈춰서 nullpointException이 나지? 라며 계속 헤맸다. 

 

Optional을 검증하는 메서드ispresent를 할 건데 그 값에 null인 값이 설정돼서 그랬다.

 

단위테스트 재밌기도 한데 방향을 놓치면 엄청 헤매거나 의미없는 테스트코드만 만들 수가 있다.

공부 더 해보자 이얏호~!

728x90
728x90

 

스프링 프로젝트를 시작하면서 공부할게 정말 많다! 

그래도 좋은 점은 어떻게 구현해야하는지 어떤 것이 좋은지 알아가는 시간이 즐겁다

(그만큼 진도가 조금 느리기도 함..)

 

요청이 실패하다가 성공했을 경우 pk인 id값이 autoincrement로 요청이 실패한 횟수만큼 증가해있었다.

이 점을 막을 수 있으려나 고민하다가

(결론적으로 말하면 auto_increment 트랜잭션을 롤백할 수 없기 때문에 값 사이의 갭이 생기는 건 어쩔 수 없다고.. sql 설정을 바꿔주는 방법이 있긴한데 로직으로 처리하고 싶었어서 그것은 스킵! 이것도 할말이 많음)

 

예외처리를 먼저 하게 되었다

 

java.sql.SQLException: Cannot add or update a child row: 
a foreign key constraint fails 
(messycloset.usercloset, CONSTRAINT usercloset_ibfk_1 FOREIGN KEY (clothes_id) 
REFERENCES Clothes (clothes_id))

 

발생한 예외는

유효하지 않은 외래 키값을 가져와 insert하려고 해서 문제가 생긴 것이었다.

요청을 잘못해서 DAO에서 insert를 못했는데

컨트롤러에서는 문제가 없다고 api 응답이 성공적으로 postman에 찍혀서 프론트에서는 감감무소식..안돼~

개인 프로젝트가 그렇듯 하나씩 살을 붙여나갔다.

 

내가 구축한 계층(백엔드만 보자면)은

DAO - Service - Controller 

이렇게 되는데 예외를 어떻게 넘겨주고 잡아서 처리할지 고민이 되었다

 

고민 끝에 

DAO에서 exception을 service layer에 던지고 

service layer에서도 controller 계층으로 던져서 

controller에서 에러코드에 따라 다른 응답이 되도록 처리했다.

 

구현하기 앞서서 예외 Exception에 대해 공부를 하는 

시간을 가졌는데 이 내용을 정리해보고자 한다!!

 

출처 : https://www.javamadesoeasy.com/2015/05/exception-handling-exception-hierarchy.html

 

" Throwable 클래스는 Java 언어의 모든 오류 및 예외의 슈퍼클래스입니다. 이 클래스(또는 해당 하위 클래스 중 하나)의 인스턴스인 객체만 Java Virtual Machine에 의해 던져지거나 Java throw 문에 의해 던져질 수 있습니다. 마찬가지로 이 클래스 또는 해당 하위 클래스 중 하나만 catch 절의 인수 유형이 될 수 있습니다. " 출처: https://docs.oracle.com/javase/8/docs/api/java/lang/Throwable.html

 

예외의 종류

Throwable 클래스 아래에 

Error 클래스와 Exception 클래스가 나뉘어져 있다

 

 

이렇듯 Error 에러는 

즉시 체크가 되지 않고 실행중에  unchecked / runtime exception

문제가 발생한다. 그래서 언제 발생할지 알 수 없으니 예외처리와 같이 처리를 할 수도 없다

에러가 나지 않도록 유념해야한다

 

이처럼 시스템 상의 문제가 발생하는 것으로 보통 메모리 부족, 스택오버플로우와 같은 복구할 수 없는 것들을 말한다.

 

Exception 예외는

컴파일로 즉시 체크가 되어서 checked / compile exception

바로 알려준다. 

그래서 throw를 하든 try catch를 하든

예외처리를 해야한다. 

 

예외를 다루는 방법

1. 예외를 회피한다. throw 쓸거니까, 예외던질거니까 나 호출하는 니가 대신 해결해~~

 

내가 선택한 방법이다. 기존에 있던 try catch를 지우고 

dao에서 예외를 처리하지 않고  throw로 던졌다.

 

그래서 dao를 사용하는 service계층에서 예외를 받았고 이걸 다시 컨트롤러로 throws 했다.

 

참고로 SQLException이

RuntimeException가 아니라 checked exception로 분류되는 이유는

데이터베이스 연산 중 발생할 수 있는 예외를 개발자가 바로 명시적으로 처리하도록 하기 위해서이다. 

checked exception 은 컴파일러가 예외를 감지해서 개발자에게 예외처리를 강제하도록 한다. 

그래서 try catch나 throws로 명시적 예외처리하지 않으면 컴파일 오류난다.

 

2. 예외를 처리한다.

 

controller 계층에서 service내 함수를 호출하는데 이때

그쪽에서 던진 예외를 받게된다. catch로 잡았다.

 

 

SQLExceptionHandler라는 클래스를 만들어서 

받은 예외코드에 따라 응답메시지로 보낼 문구를 리턴되도록 했다.

 

 

 

 

3. 예외를 바꿔준다.(예외전환)

 

SQLException 중 어떤 에러인지 다른 에러로 바꿔서 파악할 수 있게 해주는 방법

public void add(User user) throws DuplicateUserIdException{
	try{
	    // add 작업
	} catch(SQLException e){
	    if(e.getErrorCode()==중복코드){
	        throw new DuplicateUserIdException(e);
	    } else{
	        throw e;
	    }
	}
}

 

getErrorCode 메서드 같은 경우, DB 제조사별로 제각각의 에러코드를 반환한다는 점 때문에 DB에 독립적인 프로그래밍이 거의 불가능하다.  출처: https://joont.tistory.com/157 [Toward the Developer:티스토리]

 

내가 짠 코드도 같은 문제가 있다. 에러코드가 문자 자체가 같아야 그 경우에 맞게 예외를 다루고 있어서 

orm 라이브러리를 변경해 다른 에러코드가 나오면 해결이 힘들어진다.

추후에 어노테이션을 사용해 이 부분을 변경해봐야겠다!

 

스프링에서는 이런 걱정을 위해 인터페이스를 통한 독립적인 예외처리가 가능하도록 추상화를 해 두었다고 한다.

JDBC , jpa 등 DAO에 쓰이는 라이브러리가 달라서 예외 클래스도 다른데

다른 예외를 발생시키지만 공통된 상위 예외 DataIntegrityViolationException클래스를 가지도록 해두었다.

(어떻게 사용하고 처리할지는 아직 더 공부해봐될 부분이다!)

 

예외를 다루는 좋지 않은 방법

 

1. 무작정 try catch

 

예외를 잡고 적절하게 처리를 해야하는데

아무 처리 없이 catch로 잡는다면

의미없는 코드가 되고 예외 발생의 원인을 찾기도 어렵다. 차라리 throws하는게 낫다고 한다.

 

2. 무작정 throws

 

마찬가지로 계속해서 예외를 처리하지 않고 넘기기만 한다면

예외가 어디서 발생했는지 파악하기 힘들다. 

throws Exception이 발생했구나.. 그래서..? 예외..? 그래서?

이렇게 된다.

 

 

예외를 다룰 때 주의사항

1. 예외타입!

 

예외를 catch로 잡아서 처리하려는데

잡으려는 예외가 아닌 다른 예외 타입으로 지정해주면 

예외는 잡히지 않는다..

 

+

코드를 수정하다가 분명 내가 컨트롤러에서 요청이 잘못되었다는 bad request로 상태를 담아 

응답으로 보냈었는데

막상 프론트에서 열어본 응답값의 상태는 500 internal server에러였다

아까까지만 해도 400 bad request 으로 잘찍혔는데 무슨일이야? 뭐를 수정했지?

 

알고보니~~

 

 

서비스

catch (SQLException e) {
           System.out.println("e.getSQLState() 여긴service = " + e.getSQLState());
           throw new RuntimeException(e.getCause());
       }

 

컨트롤러

@PostMapping
public ResponseEntity<String> addCloset(@RequestBody UserCloset userCloset) {
    try {
        closetService.addCloset(userCloset);
        return ResponseEntity.ok("Item added successfully");
    } catch (SQLException e) {
        System.out.println("컨트롤러 catch= " + e.getMessage());
        String errorMessage = SQLExceptionHandler.handleSQLException(e);
	return new ResponseEntity<>(errorMessage, HttpStatus.BAD_REQUEST);
    }
}

 

 

DAO에서 SQLException으로 넘겨준 에러를

service 계층에서 받아서 runtime exception을 발생시켜 보내고

컨트롤러에서 SQLException으로 받으려고 하는 이상한 짓을 하고 있었다..

 

500 서버 오류는 주로 서버에서 요청을 처리하는 동안 예상치 못한 상황이 발생했을 때 일어난다.

런타임 오류를 발생시켰는데 SQLException으로는 해결이 안되어 500 에러가 떴던 것이다.

 

 

RuntimeException은 언체크 예외(unchecked exception)이기 때문에, 컴파일러가 강제로 예외 처리를 요구하지 않는다.

따라서 예외를 던질 때 try catch 블록으로 묶지 않아도 되고, throws 선언도 필요하지 않는다.

 

하지만 아래코드의 경우에서

SQLExceptionRuntimeException의 하위 클래스가 아니기 때문에

이 코드는 컴파일 오류를 발생시키는 것을 볼 수 있다.

 

 Exception 예외타입은 상위 클래스이기 때문에 처리할 수 있다.

 

 

이처럼 예외를 잡아서 처리하려고 할 때

적절한 예외 클래스를 사용해야한다. 

 

 

 

 

그렇다면 RuntimeException은  언제 쓰일까?

 

 

아래 출처 chatgpt: 

RuntimeException 및 그 하위 클래스들은 주로 프로그래머의 실수에 기인하는 예외 상황이나 논리 오류를 나타내는 데 사용됩니다. 이러한 예외는 주로 프로그램의 로직이나 흐름에 오류가 있는 경우 발생하며, 컴파일러가 강제적인 예외 처리를 요구하지 않기 때문에 비교적 자유롭게 사용됩니다.

여러 상황에서 RuntimeException을 사용할 수 있습니다:

  1. 논리 오류(Legal Argument): 예를 들어, 메소드에 잘못된 인수가 전달된 경우 또는 배열에서 범위를 벗어나는 인덱스로 접근할 때 발생할 수 있습니다.
  2. 널 포인터 예외(NullPointerException): 객체가 null인 상태에서 객체의 메소드나 속성에 접근하려고 할 때 발생합니다.
  3. 캐스팅 오류(ClassCastException): 잘못된 형변환이 일어날 경우 발생합니다.
  4. 인덱스 오류(ArrayIndexOutOfBoundsException): 배열이나 리스트에서 범위를 벗어나는 인덱스로 접근할 때 발생합니다.

다만, 이러한 예외를 남용하지 않도록 주의해야 합니다. 예외는 예상치 못한 상황에 대한 메커니즘이므로, 합리적인 이유 없이 일반적인 흐름 제어를 위해 예외를 사용하는 것은 권장되지 않습니다. 일반적인 코드 로직에서는 명시적인 조건문 등을 활용하여 예외를 최소화하고 프로그램의 안정성을 높이는 것이 좋습니다.

 

그렇구나..

 
SQLException에러를 갑자기 runtime으로 던진 이유는 SQLException을 처리하지 못했을 경우 
컴파일 오류가 생기니 runtimeException을 발생시킨다라는 대목을 읽고 저렇게 짰었는데 역시 모르고 짜니까 문제가 생긴다. 
 
 

 

2. try-catch 그리고 finally

신기한게 return 을 쓰면서 finally를 하면 덮어씌워지기가 된다. 

return 이 없을 때는 try 블록을 실행하고 finally블록 실행이 잘되는데

finally를 쓸 때 주의해야하는 부분이다.

 

 

 

 

도움 많이 받은 자료들:

https://joont.tistory.com/157

 

예외처리, 스프링 예외처리

이번에는, 프로그램을 만들때 중요하지만 대부분의 개발자가 귀찮아 하는 예외처리에 대해 알아보겠습니다.잘못된 예외처리는 찾기 힘든 버그를 낳을 수 있고, 더욱 난처한 상황을 만들 수 있

joont.tistory.com

https://velog.io/@coalery/finally-evaluation-with-spec

 

Finally... 어라?

finally가 일으키는 이런저런 기묘한 동작! ECMAScript 명세와 함께 알아봅니다.

velog.io

https://youtu.be/bCPClyGsVhc?feature=shared

 

728x90
728x90

 

웹서비스를 개발할 때 늘 사용하는 REST API에 대해 알아보자

그리고 REST API를 어떻게 설계해야하는지

REST API의 버전을 어떻게 관리해야하는지 정리해보고자 한다.

 

API란?

Application Programming Interface 애플리케이션 프로그래밍 인터페이스의 약자이다.

여기서 인터페이스는 장치라는 뜻으로 애플리케이션끼리의 통신을 도와주는 중개자 역할을 한다.

 

즉, 많이들 말하는 API 문서란

어떻게 요청을 하고 어떤 형식으로 응답을 할지, 통신을 어떻게 주고 받을지

약속이 적힌 문서와도 같다.

 

이러한 통신규약 역할을 하는 API는 REST API뿐아니라 GraphQL, gRPC 등 다양한 종류를 가지고 있다.  

 

그렇다면 REST API의 특징은 무엇일까?

REST API는  Representational State Transfer의 약자로 restful한 API라고도 한다.

 

"restful하다는 것은 restful 원칙에 부합하다는 것"을 뜻한다.

restful 원칙에 부합한 인터페이스가 REST API인 것이다.

 

restful원칙이란?

출처:&nbsp;https://restfulapi.net/

 

1. Uniform Interface 균일한 인터페이스

아래와 같은 특징을 가진 인터페이스여야 한다. 

  • 각 자원은 고유한 식별자를 가져야한다.
    • 인터페이스는 클라이언트와 서버간에 상호작용에 관련된 각 리소스를 고유하게 식별해야한다.
    • 예) http URI --식별가능-->고유한 주소로 보내겠다  , application/json --식별가능--> json으로 보내겠다
  • 표현을 통한 상태 전달
    • 리소스는 서버 응답에 균일한 표현을 가져야한다.
    • 예) 서버 응답 상태코드, 미디어 타입 content-type ..

 

  • 자체 설명 메시지
    • 각 리소스 표현에는 메시지 처리 방법을 설명하는데 충분한 정보가 포함되어야한다. 설명 필요없이 자체만으로도 어떻게 처리해야되는지 이해가 되어야한다.
    • 예) GET, POST,, JSON 어떻게 처리할지 
  • 애플리케이션 상태의 엔진으로서의 하이퍼미디어 
    • 클라이언트는 애플리케이션의 초기 URI만 가지고 있어야한다. 클라이언트 애플리케이션은 해당 url을 사용해 다른 모든 리소스와 상호작용을 한다. 

 

2. 클라이언트 - 서버 통신

클라이언트와 서버 통신 구조를 가진다. 

 

3. 무상태 

http프로토콜을 기반으로 하는 rest api는 http의 특성을 그대로 물려받았다.

그리하여 무상태 특징을 가진다.

무상태란, 서버가 클라이언트의 이전 상태를 저장하지 않고 독립적으로 처리되어야함을 뜻한다.

http 요청은 독립적이어서 이전 요청과 상태정보를 공유하지 않는다. 

 

4. 캐시를 사용할 수 있다. 

http를 사용하기 때문에 http의 캐시 저장 기능을 사용할 수 있다.

무상태 특징으로 이전 요청에 대해 상태를 공유하지 않는다.

그 결과, 동일 요청에 대한 응답이 일관적이다!

 

특정 url을 가지고 이전에 어떤 요청을 했든 상관없이 동일 요청 동일 url에 대한 응답이 같기 때문에

응답데이터를 캐시로 저장하고 사용하기 좋다.

 

5. 계층화 Layered System

요청과 응답이 분리되어 계층화된 레이어로 시스템을 구성할 수 있다. 예로 MVC 패턴이 있다.

 

http프로토클을 기반으로 하는 REST API는

표준 HTTP 메서드(GET, POST, PUT, DELETE..)와 URI(Uniform Resource Identifiers)를 사용하여 리소스를 식별한다.

 

결론적으로 

REST API는

클라이언트와 서버의 통신에 있어

리소스를 알아보기 쉽게 표현한, 통일된 인터페이스를 구성하는 것을 목적으로 한다. 

 

 

REST는 크게 다음과 같이 구성되어있다. 

  • 자원 : HTTP URL
  • 자원에 대한 행위: HTTP Method 
  • 자원에 대한 표현: Representation

 

REST API 설계 규칙을 알아보자

 

1. 엔드포인트에 동사 대신 명사를 사용한다. 

 

HTTP 요청 메서드에 이미 동사가 있기 때문이다. 

만약 개발자가 선택적으로 동사를 넣는다면 불필요하게 길어지기만 할 뿐이다. 

 

작업은 HTTP 요청 메서드로 표시되어야한다. 

  • GET 리소스 검색한다
  • POST 새 데이터를 서버에 제출한다
  • PUT 기존 데이터를 업데이트한다
  • DELETE 데이터를 제거한다

동사는 다음처럼 CRUD 작업에 매핑된다

출처:&nbsp;https://en.wikipedia.org/wiki/Create,_read,_update_and_delete

 

만약 쇼핑몰에서 옷을 가져온다면 

다음과 같은 'HTTP 메서드 작업 + 경로' 로 이루어진다.

GET /articles/

새 기사를 추가한다면

POST /articles/:id

기존 기사를 삭제한다면 

DELETE /articles/:id

 

이처럼 경로 자체에는 동사가 없어야한다. 

 

2. 엔드포인트에 논리적 중첩을 사용한다

 

엔드포인트를 디자인할 때 관련 정보가 포함된 엔드포인트를 그룹화하는 것이 좋다. 

한 개체가 다른 개체를 포함할 수 있는 경우 이를 반영해 디자인할 수 있다.

실제 데이터베이스에 어떻게 구성되어있는지와 상관없이 설정해주면 좋다. 

오히려 엔드포인트에서 데이터베이스 구조를 그대로 가져오는 것이 좋지 않다. 

 

각 기사에 대해 고유한 댓글이 있을 때 이 댓글을 가져올 때 

/articles/:articleId/comments 

경로로 묶어줄 수 있다. 

 

하지만 중첩이 너무 심하지 않게 아예 새로운 경로로 만들어주는 방법도 고려해야한다. 

예로 댓글에 대한 작성자를 

"/users/:userId" 로 만들 수도 있다.

 

3. 표준 오류 코드를 적절히 반환한다.

 

API를 통해 오류가 발생했을 때 이에 적절히 대처할 수 있도록

HTTP 상태코드로 오류 코드를 반환해야한다.

 

4. 데이터 필터링이 필요하다. (필터링, 정렬, 페이지 매김)

 

 코드를 API를 이용해 한번에 너무 많은 데이터를 반환하려고 하면 안된다. 

따라서 항목을 필터링하는 방법이 필요하다.

쿼리 문자열을 이용해 정렬할 필드를 지정할 수도 있고 url을 통해 쿼리 문자열을 추출할 수도 있다. 

 

5. 보안을 유지해야한다.

 

API를 통해 클라이언트와 서버가 통신하는 동안, 중요한 데이터가 유실될 수도 있다.

이를 막기 위해 디지털 인증서 " SSL 보안소켓 계층, TLS 전송계층보안"를 사용한 

HTTPS 프로토콜이 있다.

 

6. REST API Versioning 버전관리 전략을 사용한다.

 

 

운영중인 서비스의 API를 업데이트하는 경우 API versioning 전략이 필수적이다. 

클라이언트는 기존 REST API를 계속 사용하다 준비가 되면 최신 API로 마이그레이션 할 수 있다. 

 

1) 엔드포인트 경로를 통한 버전 관리 

http://www.example.com/v1

 기본적으로 가장 많이 사용한다 API 시작부분에 /v1, /v2 등을 추가한다. 직관적이고 간단하다는 장점이 있다.

 

2) 쿼리 매개변수를 통한 버전관리

http://www.example.com/product?version=1 

 

3) 사용자 정의 헤더를 통한 버전관리

curl -H "Accepts -version:1.0"

맞춤헤더가 필요하다.

하지만 버전관리로 url이 복잡해지지 않는다는 장점이 있다.

 

4) 콘텐츠 협상을 통한 버전관리 

 

 

6. 슬래시 는 계층관계를 나타나는데 사용되며 url마지막에는 포함하지 않는다. 

http://www.example.com/artlists/arts 

 

7. _ 언더바는 사용하지 않고 - 하이픈을 사용한다.  

 

8. 대문자 대신 소문자를 사용한다.

 

9. 파일 확장자는 URI에 포함하지 않는다.

 

 

출처: 

https://restfulapi.net/

https://gmlwjd9405.github.io/2018/09/21/rest-and-restful.html

https://stackoverflow.blog/2020/03/02/best-practices-for-rest-api-design/

 

 

 

 

 

 

 

 

 

 

 

 

 

728x90
728x90

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-1

 

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 - 인프런 | 강의

웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습니다., 원

www.inflearn.com

728x90
728x90

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B8%B0%EB%B3%B8%ED%8E%B8/

 

스프링 핵심 원리 - 기본편 - 인프런 | 강의

스프링 입문자가 예제를 만들어가면서 스프링의 핵심 원리를 이해하고, 스프링 기본기를 확실히 다질 수 있습니다., 스프링 핵심 원리를 이해하고, 성장하는 백엔드 개발자가 되어보세요! 📢

www.inflearn.com

공부 기록

728x90

+ Recent posts