오늘 정리해 보고자 하는 건 누구나 다 아는 패턴 중 하나일 Null Object Pattern 에 대해서 한번 생각해 보려고 한다.
일단은 글작성 동기는 어떻게?
퇴사를 얼마 안남기고 막내 프로그래머(경력상 막내지만 착실한 친구)에게 Optional 을 설명해 주고 있었는데, 뭔가 부족한 설명을 한 것 같은 느낌이라 좀 더 어딘가에 정리를 해 놓고 싶었습니다.
Optional 을 설명하다 보면 참 많이 돌아가게 되더구요. 전혀 생각도 못했습니다.
- 시스템 내에서 NullPointerException 이 어떤 의미로 다가왔었는가
- Null Check 로직이 많아지면 얼마나 코드를 읽기 어렵게 만들어 주는가
- 이런 체크 로직이 많은 상황에서 새로운 다향성 객체가 만들어질 때 얼마나 많은 수정이 일어난는가
위키피디아에도 설명이 있어서 링크를 걸어봅니다. 링크는 여기를.
설명은 급조된 느낌의좀 이상한 코드긴 한데 간단히 예제로 설명해 볼까 합니다.
Null Object Pattern은 보통 Strategy Pattern이나 Command Pattern 등을 사용할 때 조연으로 많이 보이는 패턴이기도 한데, 일단 가장 쉽고 편하게 만들어봤습니다.
public class PlainNullPattern { public static void main(String[] args) { ActionManager am = new ActionManager(); am.performAction("P"); am.performAction("T"); am.performAction(""); am.performAction("C"); } static class ActionResult { public ActionResult(String actionName) { this.actionName = actionName; this.actionList = new ArrayList<>(); } private String actionName; private ListactionList; public String getActionName() { return actionName; } public void setActionName(String actionName) { this.actionName = actionName; } public List getActionList() { return actionList; } @Override public String toString() { return "Performed action " + this.actionName + ", Actions " + this.actionList.toString(); } } static class ActionManager { private final Action defaultAction = new NoAction(); private final Action physicalAction = new PhysicalAction(); public void performAction(String type) { Action action = getAction(type); ActionResult result = action.normalAction(); System.out.println(result.toString()); } private Action getAction(String type) { switch(type) { case "P": return physicalAction; default: return defaultAction; } } } interface Action { ActionResult normalAction(); } static class PhysicalAction implements Action { @Override public ActionResult normalAction() { ActionResult result = new ActionResult("PhysicalAction"); result.getActionList().add("FirstAction"); result.getActionList().add("SecondAction"); return result; } } static class NoAction implements Action { @Override public ActionResult normalAction() { return new ActionResult("No Action"); } } }
뭐 대충 위와 같은 스타일이긴 합니다.
시나리오를 설명하면
- 몸과 마음이 너무 엉망이 되서 다양한 활동을 하고 활동 결과를 출력하려고 합니다.
- 다양한 활동 중에서 몸의 건강을 되찾기 위해서 신체활동을 먼저 하려고 합니다. 나중엔 명상 활동도 추가되겠지만 지금은 일단 신체 활동만 하려고 해요.
- 시작하면 다른것은 없고 해당 신체활동에 정의된 내용으로 몸을 움직이고 나중에 결과를 출력하면 됩니다.
- 여러 활동을 해보려고 해서 행동마다 특정 코드를 줬습니다. 일반 신체활동은 P, 훈련은 T, 커뮤니티 활동은 C 그리고 아무것도 안하는 것도 일단 활동으로 하려고 해요. 다만 이 경우는 특정 로그만 남길겁니다.
- 일단 Action 의 인터페이스를 정의해요. 이 클래스에는 normalAction 이라는 메서드 밖에 없습니다. 나중에 specialAction 이 있을지도 모른다는 생각에 명명을 저렇게 했습니다.
- Action 인터페이스를 확장해서 구현한 PhysicalAction 이라는 클래스를 만듭니다. 신체활동은 두개의 Action 이 존재해서 결과의 actionList 에 두개의 활동 내용을 넣었어요. 물론 Action Result 이름엔 PhysicalAction 이라는 이름을 기록해서 이게 신체활동의 결과라는 것을 알려줬죠.
- 그리고 아무것도 안하는 Action 구현 클래스를 하나 만듭니다. 하는 것이라고는 결과에 아무 활동도 안함 이라는 것을 넣었어요.
- Action Manager 의 getAction 을 보면 "P" 파라미터 값을 제외하고 나머지는 모두 활동 안하는 것으로취급합니다.
- 그리고 performAction 에서는 가져온 Action 객체의 normalAction 을 호출하기만 하면 됩니다. 어떤 활동인지는 관심을 가지지 않는거죠. 일단 활동을 한다고 알려왔으니 해당 활동 클래스한테 활동을 시키고 결과를 받습니다.
public class UseOptional { public static void main(String[] args) { ActionManager am = new ActionManager(); am.performAction("P"); am.performAction("T"); am.performAction(""); am.performAction("C"); } static class ActionResult { public ActionResult(String actionName) { this.actionName = actionName; this.actionList = new ArrayList<>(); } private String actionName; private ListactionList; public String getActionName() { return actionName; } public void setActionName(String actionName) { this.actionName = actionName; } public List getActionList() { return actionList; } @Override public String toString() { return "Performed action " + this.actionName + ", Actions " + this.actionList.toString(); } } static class ActionManager { private final Action physicalAction = new PhysicalAction(); public void performAction(String type) { Optional actionOptional = getAction(type); // ActionResult result = action.normalAction(); actionOptional.ifPresentOrElse(action -> System.out.println(action.normalAction().toString()), () -> { System.out.println((new ActionResult("No Action")).toString()); }); } private Optional getAction(String type) { switch(type) { case "P": return Optional.of(physicalAction); default: return Optional.empty(); } } } interface Action { ActionResult normalAction(); } static class PhysicalAction implements Action { @Override public ActionResult normalAction() { ActionResult result = new ActionResult("PhysicalAction"); result.getActionList().add("FirstAction"); result.getActionList().add("SecondAction"); return result; } } }
거의 비슷합니다. 다만 NoAction 클래스가 사라졌어요. 그리고 ActionManager.getAction 이 Optional 을 리턴합니다. 그리고 NoAction 대신 Optional.empty() 를 리턴합니다.
그리고 performAction 부분을 보시면 actionOptional.ifPresentOrElse 라는 것을 쓰고 있어요. 뭐 이 이외에도 Optional 이 제공해주는 다른 메소드를 써서도 가능합니다. 예제 특성상 저 정도가 가장 짧은 내용이더군요.
구현 클래스가 하나 줄어든 정도로 보이지만 실제로 개발자들이 만든는 프로젝트에서는 꽤 많은 클래스를 만들지 않아도 되는 장점이 있습니다.
거의 모든 패턴이 그렇지만, 저의 생각이지만 이 패턴을 꼭 써야 한다같은 생각은 해 본적이 없습니다. 보통 패턴을 쓸때는 처음부터 이 패턴을 써야지 하고 쓰는 경우는 많지 않았어요. 그 보다는 단순하게 개발해놨다가 필요한 시점에서 이런 패턴이 들어가면 코드도 간결해지고 가독성이 생긴다는 판단이 서면 리팩터링을 하는 것이 더 좋더군요.
실제 개발 과정에서 우리가 가장 많이 사용하는 Null Object Pattern 이 있는데, Collections.emptyList(). 라는 거 많이 쓰지 않으시나요? 결과가 Optional 일 때 해당 Optional 을 이용해서 다양한 메소드를 호출하곤 하죠.
Optional.emptyList 를 기본으로 하고 이후 적절한 결과를 다시 할당하면, 이후에 해당 Collection 을 처리하는 부분은 별다른 체크 없이 그대로 처리할 수 있습니다. 매번 이 Collection이 null 인지 체크가 없으니 코드는 단순해지고 가독성은 올라가게 됩니다.
참고로 일반적인 장/단점을 잠시 정리해보죠
장점
- Null을 체크하던 보일러플레이트 코드들의 양이 확 줄어듭니다. 따라서, 가독성이 향상될 수 있습니다.
- 적당한 Null 대용 코드들을 만들어야 하기 때문에 개발시간이 오래 걸리지만, 작동 방식을 이해하고 유지보수하는 노력 자체는 줄어들 수 있습니다.
- Null Check 누락으로 인해 발생하는 오류를 줄일 수 있습니다. 그래서 자바 개발자들이 다 싫어하는 NullPointerException 에서 벗어날 수 있다는 것은 큰 장점일 수 있습니다. 하지만 예를 들어 Optional 은 NullPointerException 대신 다른 Exception 을 발생시킵니다.
- 클래스가 추가로 늘어나니 메모리 사용양은 증가합니다. 기본 행동에 따라서 다른 리소스들도 추가될 수 있어서 상대적으로 메모리 사용량이 많이 늘어날 수 있습니다.
- 해당 처리에 대한 소스코드의 가독성은 향상되지만, 클래스 계층구조가 복잡해지는 단점이 있습니다.
- Null 이 빈번하게 발생하는 시스템에서는 이 처리로 인해서 잠재적인 성능 하락을 경험할 수 있습니다.
댓글
댓글 쓰기