티스토리 뷰

Java

Utils 클래스 리팩토링

LichKing 2017. 8. 27. 15:25

얼마전 사내에서 작업했던 내용이다. 간단한 코드긴하지만... 코드는 각색해서 해당 내용을 포스팅하고자 한다.


1. Utils 클래스

1-1. Utils 클래스 생성

요구사항

* 현재 전화번호, 주민번호, 카드번호 데이터는 - 없이 순수 숫자로만 저장되어있다.

* 해당 데이터를 노출할때 -를 추가하고싶다. 


음...뭐 딱히 어렵지 않은 내용이다. 어떤 내용으로 작업할지 고민을 했고, 특정 상태에 얽매이는게 아니라 인자로 넘어오는 값의 양식만 변경하면 되니 정적 메서드로 만들어도 충분하다고 판단했다. 그렇게 FormatUtils 클래스가 추가됐다.


class FormatUtils {
private static final String TEL_NO_PATTERN = "(02|0[\\d]{2})([\\d]{3,4})([\\d]{4})";
private static final String REGIDENT_REGISTRATION_NO_PATTERN = "([\\d]{6})([\\d]{7})";
private static final String CARD_NO_PATTERN = "([\\d]{4})([\\d]{4})([\\d]{4})([\\d]{4})";

public static String telNoFormatting(String target) {
if (Objects.isNull(target)) {
return "";
}

if (target.matches(TEL_NO_PATTERN)) {
return target.replaceAll(TEL_NO_PATTERN, "$1-$2-$3");
}

return target;
}

public static String regidentRegistrationNoFormatting(String target) {
if (Objects.isNull(target)) {
return "";
}

if (target.matches(REGIDENT_REGISTRATION_NO_PATTERN)) {
return target.replaceAll(REGIDENT_REGISTRATION_NO_PATTERN, "$1-$2");
}

return "";
}

public static String cardNoFormatting(String target) {
if (Objects.isNull(target)) {
return "";
}

if (target.matches(CARD_NO_PATTERN)) {
return target.replaceAll(CARD_NO_PATTERN, "$1-$2-$3-$4");
}

return target.substring(0, 3) + "***";

}
}


정규 표현식을 이용해 데이터를 검증하고, -를 붙여주는 방식으로 구현했다. 의도한대로 작동은 잘 한다. 하지만 각 정적 메서드들의 패턴이 거의 동일한걸 볼 수 있다.


1-2. Utils 클래스 리팩토링

각 메서드들의 패턴을 파악해보자.


* null check 후 null 이면 빈 문자열 반환

* 정규식 패턴이 맞으면 치환

* 정규식 패턴에 맞지 않으면 각 데이터별 기본 양식으로 출력


이런식으로 패턴이 동일하다. 아마도 추후에 추가될 데이터들도 이 패턴을 크게 벗어나지 않을것이다. 그때마다 메서드를 복붙한다음 명칭과 정규식 데이터만 바꿀게 뻔하다. 이런 동일 패턴을 공통 코드로 추출했다.


class FormatUtils {
private static final String TEL_NO_PATTERN = "(02|0[\\d]{2})([\\d]{3,4})([\\d]{4})";
private static final String REGIDENT_REGISTRATION_NO_PATTERN = "([\\d]{6})([\\d]{7})";
private static final String CARD_NO_PATTERN = "([\\d]{4})([\\d]{4})([\\d]{4})([\\d]{4})";

private static String commonFormatting(String target, String regexp, String replacement, UnaryOperator<String> unaryOperator) {
if (Objects.isNull(target)) {
return "";
}

if (target.matches(regexp)) {
return target.replaceAll(regexp, replacement);
}

return unaryOperator.apply(target);
}

public static String telNoFormatting(String target) {
return commonFormatting(target, TEL_NO_PATTERN, "$1-$2-$3", UnaryOperator.identity());
}

public static String regidentRegistrationNoFormatting(String target) {
return commonFormatting(target, REGIDENT_REGISTRATION_NO_PATTERN, "$1-$2", str -> "");
}

public static String cardNoFormatting(String target) {
return commonFormatting(target, CARD_NO_PATTERN, "$1-$2-$3-$4", str -> str.substring(0, 3) + "***");
}
}


다른부분은 크게 어려운게 없었는데 마지막 인자는 UnaryOperator를 작성할때는 꽤나 고민을 했었다. 그냥 디폴트 값을 전달할수도있었고, Supplier 인터페이스를 이용할수도있었으나 이번 나의 선택은 UnaryOperator 였다. UnaryOperator 에 대해선 검색해보거나 이전 포스팅을 확인해보자.


개인적으로 꽤나 만족스러울만큼 중복코드가 제거됐다. 하지만 이렇게 만들고보니 몇가지 의문이 들었다.


* 이렇게 정적 메서드들로 처리하는게 과연 올바를까?

* 앞으로 필드가 추가될때마다 Utils 클래스가 변경될텐데 그래도 괜찮을까?


이런 고민들을 하기 시작했고 그래서 내린 나의 결론은 이렇다.


* 결국 해야하는건 포맷팅이다. 역할의 분담을 메서드로 나누는건 객체지향스럽지 못하다.


2. 좀 더 객체지향적으로

2-1. 첫번째 리팩토링

결국 행위를 포맷팅이고 그 대상이 추가되는것인데 그럴때마다 메서드를 늘리는건 결코 올바르지못한 코드라고 결론을 내렸다(잘 내린 결정인지는 모르겠지만 여튼).


그래서 좀 더 객체지향적인 코드로 바꾸고자 고민했고 인터페이스를 생성했다.


interface FieldFormatter {
String formatting(String target, String regexp, String replacement, UnaryOperator<String> unaryOperator);
}


이제 전화번호, 주민번호, 카드번호에 알맞게 구현체들을 추가해주면 되는데 위 Utils 클래스에서 commonFormatting() 으로 추출한 코드는 어떻게 해야할까? 각 구현체마다 저 코드를 가져가게된다면 결국 다시 코드중복이 발생하게 되는건 아닐까? 자바에서 중복코드를 제거하는데 유용한 기법은 상속과 위임이 있다. 개인적으로 상속은 어지간하면 사용하지 않는다.


* 이펙티브 자바를 보다보면 상속에 대해 여러가지 규칙들이 등장한다. 그거 보면서 느낀건 "아몰랑 그냥 상속안쓸게" 가 되어버렸다;;;


하지만 이 경우엔 공통코드가 결국 핵심 로직이 되는건데 그걸 헬퍼 클래스로 추출해서 위임하는건 좀 아니지않나 라고 생각했다. 그래서 이번만큼은 상속을 이용하기로 했다.


abstract class AbstractFieldFormatter implements FieldFormatter {
@Override
public String formatting(String target, String regexp, String replacement, UnaryOperator<String> unaryOperator) {
if (Objects.isNull(target)) {
return "";
}

if (target.matches(regexp)) {
return target.replaceAll(regexp, replacement);
}

return unaryOperator.apply(target);
}

public abstract String execute(String target);
}


공통 로직을 추상 클래스에 구현하고 추상클래스에선 execute() 메서드를 추상메서드로 제공하게했다. 이걸 기반으로 전화번호 클래스를 먼저 구현해보자.


class TelNoFormatter extends AbstractFieldFormatter {
@Override
public String execute(String target) {
return super.formatting(target, "(02|0[\\d]{2})([\\d]{3,4})([\\d]{4})", "$1-$2-$3", UnaryOperator.identity());
}
}


다형성을 기반으로 작성하여 필드가 추가될때마다 기존 클래스가 수정되는게 아니라 타입이 추가되게끔 작성했다. 공통코드도 몰아넣어 중복코드가 사라졌다. 매우 만족스러운 코드의 탄생이다.


2-2. 두번째 리팩토링

하지만 마음 속에 찜찜한 부분이 하나 있었다. 그건 execute() 메서드가 formatting() 메서드를 호출하는 부분이었다. execute() 메서드가 formatting() 메서드를 호출하기위해선 결국 하위 클래스가 상위 클래스의 구현을 알아야된다는 의미가 된다. 이게 찜찜했던 이유는 이전에 HeadFirst Design Pattern 책에서 봤던 헐리우드 원칙(Hollywood Principle) 때문이다.


헐리우드 원칙

* 먼저 연락하지마세요. 저희가 연락 드릴게요.

* 고수준에서 저수준을 호출한다. 저수준에서 고수준을 호출하지 않는다. 


현재의 호출 구조를 완전히 거꾸로 바꿔야하는 일이다. 개인적으로는 이작업을 할때 가장 고민의 시간이 길었었다. 어떻게하면 바꿀 수 있을까? 이 구조를 바꾸려면 FieldFormater 인터페이스부터 바뀌어야 한다.


interface FieldFormatter {
String formatting(String target);
}
abstract class AbstractFieldFormatter implements FieldFormatter {
@Override
public String formatting(String target) {
if (Objects.isNull(target)) {
return "";
}

Meta meta = getMeta();

if (target.matches(meta.pattern)) {
return target.replaceAll(meta.pattern, meta.replacement);
}

return meta.unaryOperator.apply(target);
}

protected abstract Meta getMeta();
}
class Meta {
final String pattern;
final String replacement;
final UnaryOperator<String> unaryOperator;

Meta(String pattern, String replacement, UnaryOperator<String> unaryOperator) {
this.pattern = pattern;
this.replacement = replacement;
this.unaryOperator = unaryOperator;
}
}


의존 순서를 바꾸기위해선 기존 formatting() 메서드에 필요한 인자들을 하위 클래스에서 제공해주는 형식으로 바꿔야한다고 생각했고, 그렇게 작업했다. 그리고 그 과정에서 인자들을 전달해주는 Meta 클래스가 추가됐고, 하위 클래스는 Meta 클래스를 생성해주면 된다.


Meta는 사실상 데이터만 들고있는 객체라 필드만 final로 선언하고 getter는 만들지않았다(만들어도 무방하다.). 이를 기반으로 구현한 하위 클래스를 살펴보자.


class TelNoFormatter extends AbstractFieldFormatter {
@Override
protected Meta getMeta() {
return new Meta("(02|0[\\d]{2})([\\d]{3,4})([\\d]{4})", "$1-$2-$3", UnaryOperator.identity());
}
}


getMeta() 메서드를 구현해주면 실질적으로 클라이언트에서 호출하는건 getMeta()가 아니라 formatting()이 된다. 그렇기때문에 getMeta() 의 접근제어자는 기존 public에서 protected로 변경되었고, 해당 클래스에 public 메서드는 1개만 존재하게됐다. 개인적인 생각에는 protected에서 default(package-private)로 한단계 더 보수적으로 접근제어자를 지정해도 좋을거라고 생각하고있다. 


2-3. 코드 정리

개인적으로 매우 만족스러운 코드가 탄생한 기분이었고, 이제 코드들을 정리해보려한다.


abstract class AbstractFieldFormatter implements FieldFormatter {
@Override
public final String formatting(String target) {
if (Objects.isNull(target)) {
return "";
}

Meta meta = getMeta();

if (target.matches(meta.pattern)) {
return target.replaceAll(meta.pattern, meta.replacement);
}

return meta.unaryOperator.apply(target);
}

protected abstract Meta getMeta();
}


공통코드들을 추출한 AbstractFieldFormater 클래스에 변경점이 생겼다. 뭐가 변했는지 보이는가? formatting() 메서드에 final이 추가됐다. 이 클래스를 제공하는 시점에서 하위 클래스에 요구하는건 getMeta() 를 오버라이딩하는것이지 formatting() 메서드를 오버라이딩하는 것이 아니다. formatting()에 대한 오버라이딩을 방지하고자 final을 붙였다.


class TelNoFormatter extends AbstractFieldFormatter {
private static final String PATTERN = "(02|0[\\d]{2})([\\d]{3,4})([\\d]{4})";
private static final String REPLACEMENT = "$1-$2-$3";
private static final UnaryOperator<String> OPERATOR = UnaryOperator.identity();

@Override
protected Meta getMeta() {
return new Meta(PATTERN, REPLACEMENT, OPERATOR);
}
}


전화번호 포맷터에 대한 생성자 인자들을 전부 상수로 분리했다. 그런데 코드를 보면 알겠지만 3개의 인자가 모두 상수화된다. 즉 변경되는게 없다는 것이다. 그렇다면...?


3. 마무리

수많은 고민을 해가며 위 구조로 리팩토링하게되었다. 현재의 구조는 이렇다.



각 필드는 타입으로 구분되며 전화번호, 주민번호, 카드번호 외의 필드가 추가되게되면 구현클래스만 추가해주면 된다. OCP를 지키게된것이다. 하지만 위 코드에서 봤듯 Meta 객체의 생성자가 모두 상수화가 가능하다면 이런식으로 아주 간단하게 문제 해결이 되는거 아닐까?

 

class SimpleFieldFormatter implements FieldFormatter {
private final String pattern;
private final String replacement;
private final UnaryOperator<String> unaryOperator;

public SimpleFieldFormatter(String pattern, String replacement, UnaryOperator<String> unaryOperator) {
this.pattern = pattern;
this.replacement = replacement;
this.unaryOperator = unaryOperator;
}

@Override
public final String formatting(String target) {
if (Objects.isNull(target)) {
return "";
}

if (target.matches(pattern)) {
return target.replaceAll(pattern, replacement);
}

return unaryOperator.apply(target);
}
}

 

기존 AbstractFieldFormatter 클래스를 추상 클래스가 아닌 구현 클래스로 변경하고 필요하던 Meta를 스스로 직접 필드로 들고있게 변경했다. 각 필드는 생성자를 통해 주입받는다. 어차피 실제 로직은 formatting()에 다 구현되어있으므로 이렇게해도 사용하는데는 크게 지장이 없다. 다이어그램은 훨씬 깔끔하다.

 

물론 사용하는곳마다 Formatter 객체를 생성해서 사용한다면 타입별로 구현체가 나뉘어있는 쪽이 훨씬 나을것이라고 본다. 하지만 스프링 빈으로 등록하고, 사용하는 곳에서 빈을 주입받아 사용한다면 이 방식도 좋을것이라고 본다.

 

무엇이 답인지는 모르겠다. 애초에 답이 없는 것이긴 하겠지만... 이 작업을 하면서 개인적으로 매우 즐거웠다. 각 역할에 대한 분리, 좀 더 나은 코드에 대한 고민, 좀 더 객체지향적인 사고를 할 수 있었던 시간같아 이렇게 포스팅으로 그 흔적을 남긴다.

댓글
댓글쓰기 폼