김코딩

트러블슈팅 - 모든 에러가 500에러? 원인은 DTO 였다.. 본문

TIL

트러블슈팅 - 모든 에러가 500에러? 원인은 DTO 였다..

김코딩딩 2025. 5. 23. 14:16

문제 상황

이번 일정관리 API 과제를 수행하면서 사용자에게 조금 더 친화적인 예외를 보여주기 위해 커스텀 예외를 만들었습니다.

CustomException을 추상 클래스로 만들고, 각 예외마다 getCode(), getStatus()를 구현하게 했으며,
@RestControllerAdvice를 활용해 전역 예외 핸들러도 함께 구성했습니다.

public abstract class CustomException extends RuntimeException {

    public CustomException(String message) {
        super(message);
    }

    public abstract String getCode(); // 에러 코드

    public abstract HttpStatus getStatus(); // 상태 코드
}
public class MemberNotFoundException extends CustomException {

    public MemberNotFoundException(String message) {
        super(message);
    }

    @Override
    public String getCode() {
        return "MEMBER_NOT_FOUND";
    }

    @Override
    public HttpStatus getStatus() {
        return HttpStatus.NOT_FOUND;
    }
}
@AllArgsConstructor
public class CustomErrorResponse {

    private String code; // 에러 코드 : EMAIL_NOT_FOUND
    private String message; // 에러 메시지 : "유효하지 않은 이메일입니다."
    private int status; // HTTP 상태 코드 : 404
    private String path; // 요청 URI : /api/v2/members
    private LocalDateTime errorTime; // 에러 발생 시각
}
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(CustomException.class)
    public ResponseEntity<CustomErrorResponse> handleCustomException(CustomException e,
                                                                     HttpServletRequest request) {

        CustomErrorResponse errorResponse =
                new CustomErrorResponse(
                        e.getCode(),
                        e.getMessage(),
                        e.getStatus().value(),
                        request.getRequestURI(),
                        LocalDateTime.now()
                );

        return new ResponseEntity<>(errorResponse, e.getStatus());
    }

}

 

이렇게 예외를 잘 설계했음에도 불구하고, 실제 실행 결과는 전혀 기대와 달랐습니다.

예외를 분명히 HttpStatus.NOT_FOUND나 HttpStatus.UNAUTHORIZED로 지정했음에도 불구하고,  
클라이언트로 응답되는 HTTP 상태 코드는 항상 500 Internal Server Error였습니다.


GPT에게 물어봤습니다

문제가 너무 이상해서 GPT에게 이 현상에 대해 물어봤습니다. GPT는 아래와 같은 가능성을 제시해줬습니다.

  1. Filter에서 발생한 예외는 스프링의 전역 예외 처리기가 처리하지 못하기 때문에,
    필터 내부에서 직접 처리해줘야 한다. <- 이거는 필터에서 발생한 예외이기 때문에 제외하였습니다.
  2. Repository에서 예외를 던지면 JPA 프록시 내부에서 예외가 발생할 수 있고,
    이 경우 Spring AOP나 트랜잭션 경계에 의해 예외가 제대로 처리되지 않을 수 있다.

 

처음에는 지피티가 말하는대로 코드를 수정해보았습니다. Filter에서 발생한 예외는 아니었기 때문에 1번 답변에 대한 수정은 진행하지 않았고, 2번 답변대로 Repository에서 예외를 발생시키던 것을 Service에서 예외가 발생할 수 있도록 코드를 수정해주었습니다.

 

수정 전 코드

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {

    default Member findByIdOrElseThrow(Long id) {
        return findById(id)
                .orElseThrow(() ->
                        new MemberNotFoundException("존재하지 않는 회원입니다.")
                );
    }

    boolean existsByEmail(String email);

    Member findByEmail(String email);

    default Member findByEmailOrElseThrow(String email) {
        Member member = findByEmail(email);

        if (member == null) {
            throw new EmailNotFoundException("해당 이메일의 회원이 존재하지 않습니다.");
        }
        return member;
    }

}

 

수정 후 코드

@Service
@RequiredArgsConstructor
public class MemberService {

    ...

    public MemberSignupResponseDto findById(Long id) {
        Member findMember = memberRepository.findById(id)
                .orElseThrow(() -> new MemberNotFoundException("존재하지 않는 회원입니다."));

        return new MemberSignupResponseDto(findMember);
    }

    @Transactional
    public void updateMember(Long id, MemberUpdateRequestDto updateRequestDto) {
        Member findMember = memberRepository.findById(id)
                .orElseThrow(() -> new MemberNotFoundException("존재하지 않는 회원입니다."));

        if (!passwordEncoder.matches(updateRequestDto.getPassword(), findMember.getPassword())) {
            throw new InvalidPasswordException("비밀번호가 일치하지 않습니다.");
        }

        findMember.update(updateRequestDto);
    }

    public void deleteMember(Long id, String password) {
        Member findMember = memberRepository.findById(id)
                .orElseThrow(() -> new MemberNotFoundException("존재하지 않는 회원입니다."));

        if (!passwordEncoder.matches(password, findMember.getPassword())) {
            throw new InvalidPasswordException("비밀번호가 일치하지 않습니다.");
        }

        memberRepository.delete(findMember);
    }


    public Member login(LoginRequestDto requestDto) {

        Member findMember = memberRepository.findByEmail(requestDto.getEmail());
        if (findMember == null) {
            throw new EmailNotFoundException("해당 이메일의 회원이 존재하지 않습니다.");
        }

        if (!passwordEncoder.matches(requestDto.getPassword(), findMember.getPassword())) {
            throw new InvalidPasswordException("비밀번호가 일치하지 않습니다.");
        }

        return findMember;
    }
}

 

하지만 여전히 500 에러는 사라지지 않았습니다..

 

결국, 문제는 전혀 다른 곳에 있었습니다.


문제의 진짜 원인

예외 클래스도, 핸들러도, HTTP 상태도 전부 정상인데도 계속 500이 발생했던 진짜 이유는...

바로 예외 응답을 담고 있는 CustomErrorResponse 클래스에 @Getter가 빠져 있었기 때문이었습니다.

 

Spring은 @ResponseBody 혹은 ResponseEntity를 통해 객체를 반환할 때,
내부적으로 Jackson(ObjectMapper)을 사용해 객체를 JSON으로 직렬화합니다.

그런데 CustomErrorResponse에는 @Getter가 없었고,
그로 인해 Jackson이 직렬화에 실패하면서 내부 예외가 발생했고,
결국 클라이언트는 500 Internal Server Error를 받게 된 것입니다.

 

직렬화란?

Spring에서 @ResponseBody나 ResponseEntity를 사용해 객체를 반환할 때,
Spring은 이 객체를 JSON 문자열로 변환해서 클라이언트에게 전달합니다.

이때 사용되는 것이 바로 Jackson이라는 직렬화 도구입니다.
그리고 이 Jackson은 객체의 필드 값을 읽기 위해 반드시 getter 메서드가 필요합니다.

즉,

private String code; // 에러 코드 : EMAIL_NOT_FOUND

 

이 필드를 JSON으로 출력하려면 내부적으로 getCode()라는 메서드를 호출해야 하죠.
그런데 이 메서드가 존재하지 않으면 → Jackson은 값을 읽을 수 없음 → 직렬화 실패 → 내부 예외 발생 → 500 에러 발생

 

해결 방법

아주 단순했습니다.

@Getter <- 문제의 원인입니다.
@AllArgsConstructor
public class CustomErrorResponse {

    private String code; // 에러 코드 : EMAIL_NOT_FOUND
    private String message; // 에러 메시지 : "유효하지 않은 이메일입니다."
    private int status; // HTTP 상태 코드 : 404
    private String path; // 요청 URI : /api/v2/members
    private LocalDateTime errorTime; // 에러 발생 시각
}

 

이제 500 에러가 아닌 커스텀 에러가 잘 작동하는 것을 확인할 수 있었습니다.


회고

이번 문제를 통해 다음과 같은 점을 다시 한 번 느꼈습니다.

  • 예외를 잘 설계했다고 해도, 응답 객체가 직렬화되지 않으면 클라이언트는 아무것도 받지 못한다.
  • 500 Internal Server Error가 발생할 때는 예외 클래스나 핸들러뿐 아니라, 응답 객체 자체도 반드시 확인해야 한다.
  • 원인을 찾기 어려울 때는 작은 실수 하나가 시스템 전체 흐름에 큰 영향을 줄 수 있다는 걸 항상 염두에 둬야 한다.
  • 그리고… 결국 @Getter 하나였다.