안드로이드 앱 개발이 점점 성숙해지면서, 우리가 과거에 웹시스템을 개발하면서 했던 것처럼, 앱의 여러 위치에서 발생하는 에러를 한 곳에서 일관되게 처리할 수 있는 중앙집중식 에러처리 기능에 대한 인식과 요구가 늘어나고 있습니다.
지난 글에서는 안드로이드 앱에서 중앙집중화된 방식으로 에러를 처리하기 위해 Thread.UncaughtExceptionHandler 를 활용하는 방법을 살펴보았습니다. 유명한 오픈소스 에러리포트 라이브러리인 ACRA (https://github.com/ACRA/acra)도 내부적으로는 UncaughtExceptionHandler 를 이용하고 있는 것으로 알고있습니다.
그러나!!! UncaughtExceptionHandler를 이용하는 방법만 있는 것은 아닙니다.
AOP(Aspect Oriented Programming) 기법을 통해서도 글로벌한 에러처리가 가능합니다.
AOP를 통해서 다양한 곳에서 발생하는(throw) 에러를 자동으로 잡아내고(catch), 잡아낸 에러를 한 곳에서 처리(handle)할 수 있습니다. 실제로 저는 현재 서비스중인 앱에 이 방법을 적용하였습니다.(앱 개발 당시에 ACRA를 몰랐습니다... ㅠㅠ)
1. 던져진 에러를 잡아내는 애스펙트
저의 경우 아래의 위치에서 던져진 에러를 자동으로 잡아내는 애스펙트 구현체를 만들었습니다.
① Activity의 주요 라이프사이클 메소드
② 각종 이벤트 리스너 메소드
③ java.lang.Runnable 구현체의 run() 메소드
④ @ApplyExceptionHandler 어노테이션이 선언된 메소드
다음은 ExceptionCatcherAspect 구현 예제 코드입니다.(AspectJ 라이브러리 이용)
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
... ...
/**
* 다양한 곳에서 발생하는 에러를 잡아내기 위한 애스펙트.
*/
@Aspect
public class ExceptionCatcherAspect {
@Inject
protected ExceptionHandler exceptionHandler;
/** @IgnoreExceptionHandler 어노테이션이 붙은 메소드 및 ExceptionHandler 클래스에는 적용하지 않는다. */
@Pointcut(" @annotation(my.IgnoreExceptionHandler) || within(my.ExceptionHandler+) ")
protected void ignore() {}
/**
* Activity 하위 클래스의 주요 라이프사이클 메소드들.
* 정확히 일치하지는 않을 수 있으며, onCreate(..), onResume(..) 등 주요 메소드만 포함됨
*/
@Pointcut("within(android.app.Activity+) && (execution(void onCreate(..)) || execution(void onStart())
|| execution(void onResume()) || execution(void onPause()) || execution(void onStop())
|| execution(void onRestart()) || execution(void onDestroy()) ")
protected void activityLifecycleMethods() {}
//TODO protected void fragmentLifecycleMethods() {}
/**
* 이벤트 리스너 메소드들.
* On*Listener 형태의 리스너를 구현한 클래스의 on* 형태의 메소드.
* 정확히 일치하지는 않을 수 있음.
*/
@Pointcut(" within(android..On*Listener+) && execution(!private !static * on*(..) throws !Exception)
&& !activityLifecycleMethods() ")
protected void eventListenerMethods() {}
/**
* Runnable 또는 Thread 및 하위 클래스의 run 메소드.
*/
@Pointcut(" execution(public void java.lang.Runnable.run()) ")
protected void runnableMethod() {}
/**
* 액티비티 생명주기 메소드에서 발생한 에러처리.
*/
@Around(" this(activity) && !ignore() && activityLifecycleMethods() ")
public Object catchActivityException(final Activity activity, final ProceedingJoinPoint pjp) {
try {
return pjp.proceed();
} catch (final Throwable e) {
exceptionHandler.handleActivityException(activity, e);
// 정책에 따라 에러처리 후 액티비티를 종료하거나 말거나.
// activity.finish();
return null;
}
}
/**
* 이벤트 리스너 메소드에서 발생한 에러처리.
*/
@Around(" !ignore() && eventListenerMethods() ")
public Object catchListenerException(final ProceedingJoinPoint pjp) {
try {
return pjp.proceed();
} catch (final Throwable e) {
exceptionHandler.handleListenerException(e);
return null;
}
}
/**
* Runnable에서 발생한 에러처리.
*/
@Around(" !ignore() && runnableMethod() ")
public void catchRunnableException(final ProceedingJoinPoint pjp) {
try {
pjp.proceed();
} catch (final Throwable e) {
exceptionHandler.handleBackgroundException(e);
}
}
/**
* @ApplyExceptionHandler 어노테이션이 붙은 메소드에서 발생한 에러처리.
*/
@Around(" !ignore() && @annotation(my.ApplyExceptionHandler) ")
public void catchApplyExceptionHandler(final ProceedingJoinPoint pjp) {
try {
pjp.proceed();
} catch (final Throwable e) {
exceptionHandler.handle(e);
}
}
}
에러가 발생한 위치(포인트컷)에 따라 다른 작업을 수행하는 것을 볼 수 있습니다. 액티비티 생명주기 메소드에서 던져진 에러인 경우에는 ExceptionHandler의 handleActivityException()를 호출하고, 이벤트리스너에서 던져진 에러의 경우에는 handleListenerException() 메소드를 호출하는 식입니다. 물론 에러처리 정책에 따라서 동일하게 작업을 수행하는 것도 가능하겠죠.
참고로, 위 예제에서는 모두 Around 어드바이스를 사용하고 있는데요, AfterThrowing 어드바이스를 이용할 수도 있습니다. AfterThrowing 어드바이스를 이용한다면 에러를 처리하고나서도, caller에 exception이 그대로 다시 전달됩니다. 캐치되지 않은 exception이라면 결국 Thread.UncaughtExceptionHandler에 의해 처리 되겠죠.
이러한 에러처리 애스펙트를 적용하면, 앱 개발시 메소드마다 일일이 try~catch 문으로 에러를 잡아낼 필요가 없습니다. 컴파일시에 ExceptionCatcherAspect가 자동으로 try~catch를 삽입해주기 때문이죠.
상황에 따라서 예외케이스가 있기 때문에 애스펙트가 적용되지 않기를 원하는 메소드에는, 예를들어 @IgnoreExceptionHandler 어노테이션을 붙일 수 있다는 것도 참고하시기 바랍니다.(IgnoreExceptionHandler와 ApplyExceptionHandler는 단순 어노테이션이므로 소스코드는 생략합니다)
2. 잡아낸 에러를 처리하는 ExceptionHandler
에러를 잡아내는 것은 AOP를 이용하고 있지만, 실제로 무슨 작업을 할 것인지의 내용은 별도의 ExceptionHandler 클래스 등에서 구현하는 것이 좋습니다. ExceptionHandler에서는 아래와 같은 작업을 수행할 수 있을 것입니다.
- 에러 상세정보 로깅(로그캣 또는 파일)
- 에러 상세정보를 서버로 전송
- 적절한 메시지를 사용자에게 보여줌
- 특정 액티비티로 이동
- 액티비티 또는 앱 종료
import android.app.Activity;
import android.content.Context;
import android.util.Log;
import android.widget.Toast;
import com.google.inject.Inject;
import com.google.inject.Singleton;
/**
* 에러처리 핸들러.(인터페이스와 구현체로 분리해도 됩니다)
*/
@Singleton
public class ExceptionHandler {
@Inject
protected Context applicationContext;
protected void handle(Context context, Throwable t) {
// 에러타입 t 에 해당하는 사용자 메시지 선택(ErrorMessageFinder 같은 별도 클래스 만드는 것 권장)
String message = "...";
// exception 타입에 따라 아래 작업도 선택적으로 수행 가능.
doLogging(context, t, message);
doMessage(context, t, message);
doReport(context, t, message);
// 그외 에러처리 작업 수행.
// 예를들어 exception 타입이 AuthenticationException 같은 것이라면,
// 여기서 자동으로 로그인 액티비티로 이동하게 할 수 있음.
}
public void handle(Throwable t) {
handle(applicationContext, t);
}
/ ** 액티비티 생명주기 메소드에서 던져진 에러 처리. */
public void handleActivityException(Activity activity, Throwable t) {
handle(activity, t);
// 여기서 액티비티를 종료하는 것도 가능.
}
/ ** 이벤트 리스너 메소드에서 던져진 에러 처리. */
public void handleListenerException(Throwable t) {
handle(t);
// 기타 작업
}
/ ** 백그라운드 작업 메소드에서 던져진 에러 처리. */
public void handleBackgroundException(Throwable t) {
handle(t);
// 기타 작업
}
/**
* 에러정보를 로그에 기록한다.
*/
protected void doLogging(Context context, Throwable t, String message) {
// 에러정보를 로깅
// exception 타입에 따라 에러정보를 간략히 또는 상세히 로깅하거나 다른 레벨에 로깅
}
/**
* 사용자에게 적절한 에러메시지를 보여준다.
* exception.getMessage() 같은 테크니컬한 내용을 보여주지 말 것.
*/
@UiThread
protected void doMessage(Context context, Throwable t, String message) {
// UI쓰레드에서 Toast 또는 Dialog 형태로 메시지를 출력.
// context가 액티비티 컨텍스트인 경우 Dialog를 띄울 수 있음
}
/**
* 에러정보를 서버로 전송한다.
*/
protected void doReport(Context context, Throwable t, String message) {
// 에러 정보 및 발생 환경(디바이스 정보 등)을 서버로 전송
}
}
위 ExceptionHandler 예제코드는 특별히 어려운 내용이 없으므로 상세한 설명은 하지 않겠습니다.
제 생각에 AOP를 이용한 에러처리 방법이 Thread.UncaughtExceptionHandler를 이용한 방법보다 나은 점은 다음과 같습니다.
- 에러가 던져지는 위치(포인트컷)에 따라 다른 방식의 에러처리를 유연하게 적용할 수 있다.
- 액티비티(UI쓰레드)에서 던져진 에러인 경우에도 굳이 해당 액티비티를 종료하지 않아도 된다.
- 잡아낸 에러를 부모(caller)에 다시 던질 수도 있고, 던지지 않을 수도 있다.
- 심지어 에러를 변환해서 다시 던지는 것도 가능하다.
- Runtime Exception뿐 아니라 checked exception도 잡아서 처리할 수 있다.
반면, 단점도 있겠죠.
- AOP(AspectJ)에 대한 학습부담이 있고, 추가적인 개발환경 세팅이 필요하다.
- 포인트컷을 꼼꼼하게 작성하지 않은 경우 캐치하지 못한 에러가 있을 수 있다.
- 이미 컴파일된 클래스/라이브러리에는 적용할 수 없다.
- 적용되는 메소드에 대해서 컴파일시 try/catch 코드가 추가되므로 클래스 용량이 살짝 증가한다.