프로젝트/taka: 대여사업 관리 서비스

[taka] 예외처리란 어떻게 해야할까?

k9yuw 2024. 7. 16. 23:53

 

원래는 그냥 자체 회원가입을 진행하려고 했는데, 학교 학생들을 대상으로 한 서비스이다보니 학교 이메일 인증이 필수적이라고 생각해서 아예 아이디 대신 학교 이메일을 대신 사용하는게 낫겠다는 생각이 들었다. 그래서 userId 필드 자체를 없애버리고 학교 이메일로 대체했다. 그렇다면 이메일로 인증 코드를 발송하고 그것이 프론트단에서 사용자가 확인차 입력한 코드와 일치하는지 확인하는 로직이 추가적으로 필요했다. 그렇게 어려운 과정이 아닌 것 같았는데 원래 회원가입 하나로 구성된 api 엔드포인트를 인증번호 전송 / 인증번호 확인 / 회원가입 세 개의 엔드포인트로 나눠서 구성해야 했고, 그 과정에서 군데군데 에러가 터졌는데 예외처리를 세분화하지 않으면 어디서 에러가 터졌는지 찾기가 힘들어서 좀 까다로웠다. (계속 인증번호가 틀렸다고 하며 가입이 안됐는데 어디서 문제가 발생했는지 찾는데 애를 먹었다. ) 그 동안 예외처리 없이 코드를 짜는 경우가 많았는데 이 과정을 통해서 조금이나마 예외처리를 어떻게 해야할지에 대해서 생각해보게 되었다. 

기존 코드 흐름

1. SMTP 설정

먼저 인증메일을 보내기 위해서는 application.properties에 smtp 설정을 간단하게 해주어야 한다. 

spring.mail.host=smtp.gmail.com
spring.mail.port=587
spring.mail.username={아이디. 이 때 메일 통째로 넣을 필요 없이 아이디만 넣으면 된다.}
spring.mail.password={2차 앱 비밀번호}
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.timeout=5000
spring.mail.properties.mail.smtp.starttls.enable=true

 

이 때 유의해야 할 점은 gmail에서 2차 보안을 설정해서 앱 비밀번호를 받아줘야 한다. 그냥 비밀번호를 넣으면 로그인을 못해서 jakarta.mail.AuthenticationFailedException: 534-5.7.9 Application-specific password required.  이 에러가 계속 뜨면서 메일이 안보내지니 조심

 

그 후에는 EmailService를 간단하게 만들어줘서 이메일을 보낼 수 있게 한다. 

@Service
public class EmailService {

    ....

    public void sendSimpleMessage(String to, String subject, String text) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(to);
        message.setSubject(subject);
        message.setText(text);
        emailSender.send(message);
    }

}

 

2. 인증번호 전송

// AuthController

	@PostMapping("/send-verification-code")
    public ResponseEntity<Api<String>> sendVerificationCode(@RequestParam String email) {
        authService.sendVerificationCode(email);
        return ResponseEntity.ok(Api.<String>builder()
                .status(Api.SUCCESS_STATUS)
                .message("회원가입 인증 코드가 이메일로 전송되었습니다.")
                .data("Email: " + email)
                .build());
    }

 

이렇게 /send-verification-code로 post 요청이 들어오면

// AuthService

    public void sendVerificationCode(String email) {
        try {
            // 6자리 인증번호 생성
            String verificationCode = UUID.randomUUID().toString().substring(0, 6);
            verifyMap.put(email, verificationCode);

            String subject = "[taka] 회원가입 인증번호 발송";
            String message = "taka 회원가입 인증번호입니다." + System.lineSeparator() + "인증번호: "+ verificationCode;

//            logger.info("회원가입 인증번호 전송 완료 {}: {}", email, verificationCode);
            emailService.sendSimpleMessage(email, subject, message);
        } catch (Exception e) {
            System.err.println("이메일 전송에 실패했습니다: " + e.getMessage());
            e.printStackTrace();
        }
    }

 

AuthService에서 다음과 같이 메일을 보내준다. 

메일이 안올 경우 서버에서는 보내지고 있는지 / 아닌지를 확인하기 위해 로그를 찍어보았다. 

다행히 메일 발송까지는 잘 이루어졌다. 

 

3. 인증번호 확인

// AuthService 

   // 프론트단에서 사용자가 확인차 입력한 verification code와 sendVerificationCode에서 생성한 verificationCode가 일치하는지 확인하는 로직
    public boolean verifyCode(String email, String code) {
        String storedCode = verifyMap.get(email);
//        logger.info("Stored code for {}: {}", email, storedCode);

        if (storedCode != null && storedCode.equals(code)) {
//            verifyMap.remove(email); // 인증이 완료되면 인증번호를 제거
            return true;
        }
        return false;
    }

 

문제는 여기서 생기는 것이었다. verifyCode 함수는 /verify-code 와 /signup 이 호출 될 때 각각 한 번씩 총 2번이 실행되었는데 첫번째 실행이 되면서 verifyMap.remove(email)코드가 실행되며 인증번호를 제거하니까 /signup 호출 시 이미 해당 이메일에 대한 인증번호가 제거된 상태여서 검증을 할 수 없었던 것이었다. 그래서 일단 주석처리를 해줬더니 실행은 됨. 

 

4. 회원가입

AuthService에서 1. 이메일이 이미 Repository에 존재하거나 2. verifyCode 로직 실행을 통해 인증 번호가 틀렸다는 것이 확인될 경우 IllegalArgumentException을 발동하고 발동 시 다음과 같이 보여준다. 그리고 나머지 오류들은 그냥 서버 오류로 보여준다.

logger.warn("회원가입 실패 - 이유: {}", e.getMessage());
// AuthService

	@Transactional
    public UserEntity signUp(SignupDto.SignupRequest request) {
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new IllegalArgumentException("이미 존재하는 이메일입니다.");
        }

        if (!verifyCode(request.getEmail(), request.getVerificationCode())) {
            throw new IllegalArgumentException("회원가입 인증코드가 틀렸습니다.");
        }

        String rawPassword = request.getPassword();
        String encPassword = bCryptPasswordEncoder.encode(rawPassword);

        UserEntity joinUser = UserEntity.builder()
                .email(request.getEmail())
                .password(encPassword)
                .name(request.getName())
                .phoneNumber(request.getPhoneNumber())
                .role(USER)
                .build();

        return userRepository.save(joinUser);
    }

 

문제 자체는 그렇게 심각한 문제는 아니고(해결하는데 몇시간 걸렸다) 엄청나게 완벽하게 해결한 것도 아닌 것 같아서(왜냐하면 1. 예외 처리의 기준이 불명확하고, 2. 인증번호가 삭제되는 과정 자체를 삭제했는데 이렇게 하면 계속해서 인증번호를 보관해야 하기 때문에 효율이 좋지 않을 것이라고 생각했다. 유효기간 등을 두어야 하지 않을까?  3. 그냥 임시로 땜빵해둔 느낌....) 예외처리에 대한 공부가 더 필요하다고 느꼈다. 과연 어느정도로 세세하게 분기 하는 것이 좋은 설계인지? 예외는 하나하나 다 커스텀해서 만들어야 하는지? 어떻게 해야 적절하게 예외처리를 할 수 있을지 고민하며 리팩토링에 들어갔다. 

 


예외처리란 어떻게 해야 할까?

모든 시스템에는 예측하지 못하는 상황이 있고, 따라서 이를 제대로 처리하기 위해 예외를 잘 잡고 로그를 꼭 남겨야 한다.

 

복구 가능한 오류와 복구 불가능한 오류 구분

복구 가능한 오류 ( ex 사용자의 오입력, 네트워크 오류, 파일을 못찾는 오류 등 )는 상시로 일어날 수 있는 오류이고, 사용자에게 원인을 빠르게 알려줘야 한다. 이러한 오류들은 어느 정도 예측이 가능하며 이것을 처리하는 과정 자체가 개발의 일부이기 때문에 문제를 해결할 수 있도록 처음에 코드를 짜야 하고, 로그레벨을 warn으로 두어 이러한 오류 발생 시 모니터링 알림을 보내도록 해야한다.

 

반면 복구 불가능한 오류( ex 메모리 부족, stack overflow, 시스템(하드웨어, os) 레벨의 오류, 서버 코드 오류 )는 개발자에게 빨리 원인을 알려야 하므로 로그 레벨을 error로 두고 개발자에게 알람을 보내야 한다. 이는 실시간 처리는 불가능하지만 개선의 여지가 있는 오류들이다.

 

잡았으면 무엇이든 해야하고, 그 상황에 대한 로그를 남겨야 한다.

다음 코드는 정말 쓸모없는 코드이다.  

try {
    ...
} catch (SomeException e) {
	e.printStackTrace();
}

예외를 잡아서 출력만 하면 되는 경우는 없다. 잡았으면 무엇이라도 해야하고, 어떻게 처리할지 모른다면 잡지 않는 것과도 다름 없다. 컴파일 된다고 코딩이 끝난 것이 아니며, 예외를 잡은 이후에는 1. 해결을 하고 2. 구체적인 로그를 남겨야 한다. 

 

처리를 했다고 하더라도, 로그를 남기지 않으면 시스템을 개선할 여지가 없어진다. println(), break point에 의지하여 디버깅을 해야하는데 이는 굉장히 비효율적이며 애초에 로그가 예외 발생에 대한 정보를 자세히 담고 있다면 이러한 일을 할 필요가 없다. 

로그도 대충 남겨서는 안된다. "exception occured" "IOException occured"와 같은 경우는 있으나마나 하다. 좋은 로그는, 상황을 알려주고, 메시지를 남겨야 하며, 문제를 해결할 수 있는 구체적인 정보까지 포함을 해야한다. 예외에 잘못된 입력값을 포함하면 디버깅이 용이해지고, 문제의 원인을 빠르게 파악할 수 있다. 예외에 어떤 입력값을 잘못넣어서 예외가 발생했는지, 추적가능하도록 만들어야 하고 예외의 이름 또한 마찬가지로 의미를 담고있게 만들어야 한다. 

 

출처 : https://www.slideshare.net/slideshow/ss-2804901/2804901

 

 

예외값으로 null, -1 등을 사용하지 않고, 추상적인 예외를 던지지 말자. 

null을 반환하거나 사용하면 코드의 복잡성이 증가하고, 누락된 null 체크로 인해 NullPointerException과 같은 예기치 않은 오류가 발생할 수 있다. 이러한 상황이 발생하면 정확히 어떤 문제인지, 어디서 터진 문제인지 알기가 어렵다. 반면 exception을 던지거나 크기가 0인 컬렉션을 반환하면 이러한 문제를 방지할 수 있으며, 클라이언트 코드에서 추가적인 null 체크를 하지 않아도 되므로 코드가 단순하고 직관적이게 된다.

// Bad
List<String> list = getItems();
if (list == null) {
    // handle null case
}

// Good
List<String> list = getItems();
if (list.isEmpty()) {
    // handle empty case
}

 

예외값이 아닌 예외를 던진다고 해도, Error, Exception, Throwable, RuntimeException 과 같이 추상적인 클래스로 예외를 던지면 안된다. 예외 클래스는 그 이름만으로도 정보를 제공할 수 있어야 한다. 따라서 RuntimeException을 상속하는 클래스를 만들어 외부 인터페이스로부터 발생하는 예외를 잡아 로그에 남겨야 한다. 

 

 

각 레이어에 맞는 에러 발생시키기

각 레이어에 맞는 에러를 발생시키면 코드의 책임 분리가 명확해지고, 에러 발생 지점과 원인을 쉽게 파악할 수 있다. 하지만 너무 많은 예외 클래스를 만들면 코드가 복잡해지고 관리하기 어려워지므로, 예외 계층 구조를 설계할 때는 각 레이어의 역할과 책임에 맞는 적절한 수준의 예외 클래스를 정의해야 한다. 이를 통해 코드의 가독성과 유지보수성을 높일 수 있다.

 

 


Custom Exception 만들고 적용해보기

Exception 간 계층 관계 형성

 

먼저 Exception에서 쓰일 에러 코드를 enum으로 생성하였다. enum으로 구성한 이유는 메시지가 반복될 경우 실수로 잘못된 값을 사용하는 것을 방지하고, 코드의 가독성과 유지보수성을 높이기 위해서이다. 

또한 각 에러 코드에 대응하는 HTTP 상태 코드를 일관되게 관리하기 위해서 해당 클래스 내부에서 httpStatus 까지 관리하도록 하였다. 이렇게 하면 예외 처리 로직에서 별도로 HTTP 상태 코드를 지정할 필요 없이 에러 코드만으로도 적절한 응답을 생성할 수 있다.

 

 

그 후 CustomException 클래스를 생성하고, 그를 상속하는 예외들 중 자주 발생하는 것(예: DuplicateException, NotFoundException) 등을 만들었다. RuntimeException을 바로 상속하지 않고 CustomException을 한 번 거치면 공통적인 예외 처리 로직을 CustomException에서 한 번에 처리하고, 중복 코드를 줄일 수 있다. 

 

또한 common 폴더 내에 exception이 너무 많아지는 것을 방지하기 위해 이를 각각 상속한 EmailDuplicateException, UserNotFoundException 등을 각 사용되는 도메인 폴더 내에 위치시켰다. 이렇게 하면 각 도메인과 관련된 예외를 해당 도메인 폴더에서 관리할 수 있어서 유지보수가 더욱 쉬울 것이라고 판단했다. 이 때 CustomException을 바로 상속받아 EmailDuplicateException을 만들지 않고, CustomException -> DuplicateException -> EmailDuplicateException 계층 구조로 나눈 이유는 예외 계층을 체계적으로 관리하여 계층별로 역할을 분명하게 나누고, 코드의 가독성을 높이기 위해서이다. 

 

GlobalExceptionHandler의 사용

GlobalExceptionHandler 클래스는 @RestControllerAdvice와 @ExceptionHandler를 사용하여 애플리케이션 전역에서 발생하는 예외를 처리한다. 이를 통해 각 컨트롤러에서 일일이 예외 처리를 할 필요 없이 한 위치에서 예외 처리가 가능하다.

@RestControllerAdvice의 작동 방식

@RestControllerAdvice는 @RestController에서 발생하는 예외를 가로채어 전역적으로 예외를 처리하고, 적절한 응답을 생성하여 클라이언트에게 반환하는 역할을 한다.  

 

애플리케이션이 시작될 때, Spring은 @RestControllerAdvice 어노테이션이 붙은 클래스를 스캔하고, 이를 전역 예외 처리기로 등록한다. 애플리케이션의 어느 컨트롤러에서든 예외가 발생하면, @RestControllerAdvice가 해당 예외를 가로채고, 예외 객체 e에서 ErrorCode를 추출하고, 이를 사용하여 ErrorResponse 객체를 생성한다. 생성자에서는 ErrorCode에서 HTTP 상태 코드와 에러 코드, 예외 메시지를 받아와서 설정한다. 이후 예외 처리 메서드는 ErrorResponse와 같은 표준화된 응답 객체를 생성하고, ResponseEntity를 사용하여 적절한 HTTP 상태 코드와 함께 클라이언트에게 반환한다. 이를 통해 일관된 에러 응답을 제공할 수 있다. 

 


리팩토링 이후 코드

 

member 도메인 내에 회원가입을 하며 생길 수 있는 커스텀 예외들을 정의하였다.

AuthService

 

AuthController

컨트롤러로 들어오는 에러들은 다 RestControllerAdvice 어노테이션에서 처리해주기 때문에, try - catch문을 삭제하고 더 간결하게 코드를 바꾸었다.

 


참고 자료

https://jojoldu.tistory.com/734

 

좋은 예외(Exception) 처리

좋은 예외 처리는 견고한 프로그램을 만들고, 좋은 사용자 경험을 줄 수 있다. 예외 처리를 통해 애플리케이션이 예기치 않게 종료되는 것을 방지하고, 갑작스런 종료 대신 사용자는 무엇이 잘

jojoldu.tistory.com

 

https://velog.io/@doforme/%ED%81%B4%EB%A6%B0%EC%BD%94%EB%93%9C-chap-7.-%EC%9A%B0%EC%95%84%ED%95%98%EA%B2%8C-%EC%98%88%EC%99%B8-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0

 

클린코드 chap 7. 우아하게 예외 처리하기

오류 코드를 리턴하지 말고 예외를 던져라현재는 예외를 던지는 것이 일반화 되었다. 처리 흐름이 깔끔하다.Exception을 상속하면 checked Exception. 명시적인 예외 처리가 필요하다.예 : IOException, SQLEx

velog.io

https://www.slideshare.net/slideshow/ss-2804901/2804901

 

예외처리가이드

예외처리가이드 - Download as a PDF or view online for free

www.slideshare.net

https://dukcode.github.io/spring/spring-custom-exception-and-exception-strategy/

 

[개발고민] Spring Custom Exception과 예외 처리 전략에 관한 고민

💡 개발고민은 개발을 공부하며 했던 저의 생각들 입니다. 정답이 아니며 정답을 찾아가는 과정이라고 봐주시면 감사하겠습니다. Github Repository Spring Custom Exception과 예외 처리 전략

dukcode.github.io

https://devpoong.tistory.com/90

 

Custom Exception, ExceptionHandler 설계에서 나쁜 코드에 대한 고민과 리팩토링 과정

1. 문제 상황 기존 코드 흐름 @Getter public enum ErrorCode { DUPLICATED_LONGIND("0101", 이미 사용중인 아이디(학번) 입니다.)"), ... public DuplicatedLoginIdException extends RuntimeException { public DuplicatedLoginIdException() { } }

devpoong.tistory.com