Java

[Java] 적절한 예외 처리(Exception Handling) 방안

nooblette 2023. 12. 8. 18:43

목차

    배경

    프로그램을 개발하면서 예외가 발생한다면 어떻게 처리할지, 적절한 예외 처리 방안을 무엇일지 고민하는 것은 개발자의 필수 덕목이라고 생각한다. 하지만 나조차도 이러한 고민을 놓쳤던 경험이 종종 있어, 이번 글로 에외 처리시 고려해야할 부분과 적절한 예외 처리 방안에 대해 정리해보고자 한다.

    (적절한 예외 처리 방안을 도출하기 위해서 선행지식으로 (java에서) 예외에 대한 개념과 어떤 예외 클래스가 있는지를 알아두어야한다고 생각한다. 이를 위해 이 글을 작성하기에 앞서 [Java] 예외와 예외 처리에서 java의 예외에 대한 개념, 예외 클래스와 사용하는 방법 등을 정리해두었다.)

    내용

    실제로 추가 기능 개발 업무를 하고나서 코드리뷰를 받던 중에 "여기서 만약 예외가 발생하면 어떻게 돼요?" 라는 질문을 받은 적이 있다. 해당 기능을 개발하던 당시에는 (부끄럽게도) 고려하지 못했던 부분인데, 이 질문을 받고 다시 코드를 살펴보니 내가 새로 개발한 지점에서 예외가 발생하면 신규 코드가 아니라 그 코드를 호출하는 기존 로직이 진행을 멈추고 그 예외를 처리하도록 구현되어 있었다.

     

    내가 개발한 기능은 편의상 개발한 부가 기능이였는데, 만일 이 부가기능에서 예외가 발생한다면 그로 인해 주요 기능이 동작하지 못하는 상황이 발생할 것이였고, 결국 이는 근본적으로 적절하지 못한 예외 처리에서 비롯된 것이였다.

     

    예를 들어 다음과 같이 결제를 하면 그 금액만큼 돈을 차감하는 코드가 있다.

    public void pay(PayInfo payInfo){
    	try {
        	// 결제를 하면 계좌에서 돈을 차감
        	account.withdraw(payInfo);
        } catch(Exception e){
        	log.info("결제 실패 : ", e.getMessage());
        }
    }

     

    만일 결제 방법으로 계좌 이체 뿐만 아니라 카드 결제도 제공한다면 그 코드는 다음과 같을 것이다.

    public void pay(PayInfo payInfo){
    	try {
        	// 결제를 하면 계좌에서 돈을 차감
        	account.withdraw(payInfo);
        } catch (Exception e){
        	log.info("결제 실패 : ", e.getMessage());
        }
    }
    public void pay(PayInfo payInfo){
    	try {
        	// 결제를 하면 신용카드 비용 청구
        	creditcard.bill(payInfo);
        } catch (Exception e){
        	log.info("결제 실패 : ", e.getMessage());
        }
    }

     

    위 코드에서 결제를 진행할때마다 그 결제 예정 정보를 메일로 전송 해달라는 요구사항이 생긴다고 가정해본다면, 그리고 이러한 메일 전송은 어디까지나 부가 기능이며 이 기능이 실패하더라도 결제는 기존대로 진행되어야 한다는 요구사항이 발생한다고 가정해본다면,

    이 상황에서 모든 pay 메서드마다 결제 예정 메일 전송 로직을 추가하는게 아니라 메일 전송용 객체를 생성하고 두 pay 메서드에서는 그 객체에 의존하여 메일 전송 메서드를 호출하는 방법이 적절한 것이다.

     

    다음과 같이 결제 예정 정보를 메일로 전송하는 PayExpectedMailSendService 클래스 생성한다.

    public class PayExpectedMailSendService {
    
    	public void sendMail(PayInfo payInfo){
        	// 결제 정보를 매개변수로 전달 받아 메일 전송 로직 호출
            ...
        }
    }

     

     

    결제를 진행하는 각 메서드에서는 위 PayExpectedMailSendService 클래스의 sendMail() 메서드를 다음과 같이 호출하여 결제 직전에 그 예정 정보를 메일로 전송할 것이다.

    public void pay(int price){
    	try {
        	// 메일 전송
            payExpectedMailSendService.sendMail(payInfo);
            
        	// 메일 전송 후 계좌에서 돈을 차감
        	account.withdraw(payInfo);
        } catch(Exception e){
        	log.info("결제 실패 : ", e.getMessage());
        }
    }
    
    public void pay(PayInfo payInfo){
    	try {
        	// 메일 전송
            payExpectedMailSendService.sendMail(payInfo);
            
        	// 메일 전송 후 신용카드 비용 청구
        	creditcard.bill(payInfo);
        } catch(Exception e){
        	log.info("결제 실패 : ", e.getMessage());
        }
    }

     

    이때 만약 sendMail() 메서드 내부에서 메일 전송 중에 어떠한 원인으로 예외가 발생한다면 PayExpectedMailSendService 클래스의 mailSend() 메서드에는 적절한 예외 처리 로직이 없으므로 이 예외는 sendMail() 메서드를 호출한 pay() 메서드로 전파될 것이다. 하지만 결제 예정 정보 메일 전송은 어디까지나 부가 기능이고, 이 기능이 실패하더라도 결제는 기존대로 진행되어야 한다는 요구사항이 있었기에 위 코드는 그 요구사항을 만족하지 못하는 코드가 될 것이다.

    결국, 부가 기능인 메일 전송으로 인해 결제라는 주요 기능이 진행될 수 없고 이는 주요 서비스인 결제 서비스가 정지되는 상황으로 이어질 것이다.

     

    따라서, 위와 같이 PayExpectedMailSendService 클래스를 생성하여 메일 전송이라는 부가 기능을 작성하는 경우, sendMail() 메서드에 적절한 예외 처리 코드를 작성하여 만일 부가 기능인 메일 전송 중 예외가 발생하더라도 결제라는 주요 기능이 진행될 수 있도록 적절히 코드를 작성하는 것이 필요하다.

    public class PayExpectedMailSendService {
    
    	public void mailSend(PayInfo payInfo){
        	try{
            	// 결제 정보를 매개변수로 전달 받아 메일 전송 로직 호출
            	...
            } catch(Exception e){
            	// 로깅 등 적절한 예외 처리 코드 작성
            }	
        }
    }

     

     

    또한 sendMail() 메서드에서 발생한 예외를 pay() 메서드에서 처리하는 것은 계층별 혹은 기능별로 적절하지 못한 예외 처리를 유발할 수 있다. 만일 아래 코드와 같이 sendMail()에서 발생한 예외를 pay() 메서드에서 처리한다면, 앞서 설명했던 메일 전송으로 인해 결제 서비스 진행이 안되는 상황뿐만 아니라 메일 전송 예외와 결제 서비스 예외가 동일한 방식으로 처리되는 상황도 발생한다.

    public void pay(int price){
    	try {
        	// 메일 전송
            payExpectedMailSendService.sendMail(payInfo);
            
        	// 메일 전송 후 계좌에서 돈을 차감
        	account.withdraw(payInfo);
        } catch(Exception e){
        	log.info("결제 실패 : ", e.getMessage());
        }
    }
    
    public void pay(PayInfo payInfo){
    	try {
        	// 메일 전송
            payExpectedMailSendService.sendMail(payInfo);
            
        	// 메일 전송 후 신용카드 비용 청구
        	creditcard.bill(payInfo);
        } catch(Exception e){
        	log.info("결제 실패 : ", e.getMessage());
        }
    }

     

    예를 들어, 결제 서비스는 실행 중 예외가 발생하는 경우 그 기능은 바로 중지하는 반면 메일 전송 중 실패했을 경우에는 최대 3번 retry 후 실패했을시 예외로 처리하는게 적절한 예외 처리 방안일 수 있을 것이다.

    하지만 위 코드에서는 결제 중 발생한 예외와 메일 전송 중 발생한 예외 모두 동일한 catch 블록에서 처리되고 있어 각 서비스별로 적절하게 예외를 처리할 수 없다.

     

    이러한 상황에서도 PayExpectedMailSendService 클래스의 sendMail() 메서드에 예외 처리 로직을 심어둠으로써, 메일 전송 중 실패했을 경우 그 예외가 결제 서비스까지 전파되어 결제가 진행되지 못하는 상황을 방지할 뿐만 아니라 메일 전송 예외와 결제 실패 예외를 각각 적절한 방안으로 별도로 처리할 수 있을 것이다.

     

    그 코드는 다음과 같다. (메일 실패시 최대 3번까지 재시도한다고 가정하였다.)

    public class PayExpectedMailSendService {
    	private static final int MAX_RETRIES = 3;
    
        public void mailSendWithRetry(PayInfo payInfo) {
            int retries = 0;
    
            while (retries < MAX_RETRIES) {
            	try {
                	mailSend(payInfo);
                	return;
                } catch(Exception e) {
                	retries++;
                    if(retries == MAX_RETRIES){
                    	// 적절한 예외처리 코드 작성
                    }
                }
            }
        }
        
        private void mailSend(PayInfo payInfo){
        	// 결제 정보를 매개변수로 전달 받아 메일 전송 로직 호출
            ...
        }
    }
    
    public void pay(int price){
    	try {
        	// 메일 전송
            payExpectedMailSendService.sendMail(payInfo);
            
        	// 메일 전송 후 계좌에서 돈을 차감
        	account.withdraw(payInfo);
        } catch(Exception e){
        	log.info("결제 실패 : ", e.getMessage());
        }
    }
    
    public void pay(PayInfo payInfo){
    	try {
        	// 메일 전송
            payExpectedMailSendService.sendMail(payInfo);
            
        	// 메일 전송 후 신용카드 비용 청구
        	creditcard.bill(payInfo);
        } catch(Exception e){
        	log.info("결제 실패 : ", e.getMessage());
        }
    }

     

    결론

    글 최상단에 작성했듯이 프로그램을 개발하면서 예외가 발생한다면 어떤 처리 방안이 적절할지, 어디서 누가 처리하는게 처리할지 적절할지 고민하는 과정은 꼭 필요하다고 생각한다. 하지만 나 조차도 이러한 부분을 놓쳐서 코드를 작성한적이 있었고, 그 상황을 기회삼아 예외 처리와 관련된 내 생각을 글로 작성해보았다. 개발자로서 항상 프로그램이 내 의도대로 잘 동작할지, 예상과 다르게 동작할 포인트는 없을지, 만일 예외 상황이 발생한다면 어떻게 동작하는게 적절할지 고민해보며 개발하는 자세가 필요하다고 생각한다.

    추가적으로 예외 처리와 관련된 내용을 찾다가 Silencing Error/Exceptions 이라는 관련된 영상이 있어 함께 공유하면서 마무리하고자 한다.

    Silencing Error/Exceptions : https://www.youtube.com/watch?v=ixOk13jC50w)

     

    참고