문제 상황
회사 업무를 수행하다 한 가지 불편한 점이 생겼다. 사실 오래 전부터 불편하다 느꼈던 부분이기는 했었는데 이번에 이 부분과 관련하여 제대로 이슈가 터졌기 때문에 그 불편함이 인식의 수준으로 올라온 것 같다.
이슈가 발생한 위치는 바로 검색 기능과 페이지 변경 부분이었다. 보통 특정 리스트에서 검색 기능을 수행하면 우리는 다음 페이지로 가더라도 그 검색 기능이 유지되기를 기대한다. 예를 들어 현재 내가 보고 있는 회원 리스트의 1페이지가 '김'씨로 시작하는 회원의 리스트라면 우리는 2번째 페이지를 가더라도 '김'씨로 시작하는 회원의 리스트가 유지되기를 기대하는 것이다. 그런데 내가 맡은 프로젝트에서는 그러지 않았다. 1페이지에서 '김'씨로 시작하는 회원의 리스트를 보다 2페이지로 넘어가게 되면 검색 기능이 풀리고 전체 회원의 두번째 페이지가 노출되는 이슈가 있었던 것이었다.
사실 해결방안은 너무 명확했다. 같은 이슈를 회사를 다니며 여러번 보았었기 때문이었다. 그건 바로 두번째 페이지로 이동하는 버튼에 검색 기능이 유지된 쿼리 파라미터가 실려 있지 않았기 때문이었다. http는 stateless 프로토콜이기 때문에 새로운 요청 시에 과거 요청이 유지되지 않는다. 따라서 두번째 페이지로 이동하는 버튼에 "회원 이름이 '김'씨로 시작하는 사람을 출력해주세요"라는 정보를 기입해줬어야 했던 것이었다.
우리 회사 프로젝트는 검색 기능을 수행할 때 검색 기능을 탑재한 검색용 클래스를 만들어 사용한다. 그리고 상황에 맞게 그 클래스를 상속받아 그 때마다 필요한 검색값을 추가해 사용한다. 예를 들어 검색을 위한 클래스의 이름이 Search라고 한다면, 회원의 리스트 중 특정 이름의 회원을 찾는 검색 기능을 수행할 때는 Search 클래스를 상속 받은 UserSearch를 만들어 사용하는 것이다.
문제는 위에 발생하는 이슈를 피하기 위해 Search 클래스를 상속하는 모든 클래스마다 검색 기능을 유지해주는데 사용하는 메소드를 오버라이딩해 재정의해 주어야 한다는 것. 예를 들어 아래와 같이 정의된 Search 클래스와 UserSearch 클래스가 있다고 할 때 UserSearch에서 Search의 메소드를 오버라이딩 해 주지 않으면 UserSearch의 필드는 검색 기능의 지속성을 보장받을 수 없다는 것이었다.
class Search {
private Integer pageNumber;
private Integer size;
private String sortProperty;
private SortDirection sortDirection;
private Boolean isPaged;
public String queryString() {
StringBuilder sb = new StringBuilder();
if (StringUtils.hasText(Integer.toString(getSize()))) {
sb.append("&size=").append(getSize());
}
if (StringUtils.hasText(getSortProperty())) {
sb.append("&sortProperty=").append(getSortProperty());
}
if (getSortDirection() != null) {
sb.append("&sortDirection=").append(getSortDirection());
}
if (getIsPaged() != null) {
sb.append("&isPaged=").append(getIsPaged());
}
return sb.toString();
}
}
public class UserSearch extends Search {
private String username; // 아이디
private String name; // 회원 이름
private String email; // 이메일
// 이 경우 username, name, email은 getQueryString()에 의해 관리되지 않으므로
// 검색조건에서 아이디, 회원이름, 이메일을 이용해 검색할 경우
// 두번째 페이지로 이동하게 되면 검색 조건이 풀려서 리스트가 조회됨.
}
따라서 UserSearch의 필드들까지 검색 조건의 유지를 보장받기 위해서는 아래의 메소드를 UserSearch 내부에 추가해주어야 한다.
@Override
public String queryString() {
StringBuilder sb = new StringBuilder();
sb.append(super.queryString());
if (StringUtils.hasText(getUsername())) {
sb.append("&username=").append(getUsername());
}
if (StringUtils.hasText(getName())) {
sb.append("&name=").append(getName());
}
if (StringUtils.hasText(getEmail())) {
sb.append("&email=").append(getEmail());
}
return sb.toString();
}
Search 클래스를 상속받는 클래스마다 매번 메소드를 오버라이딩을 해주어야 한다는 부분도 그렇고, 내부에 있는 단순 반복적인 부분도 그렇고 참 불편하고 번거로운 작업이다, 하는 생각이 들었다.
개선 이유
코드를 개선해야겠다는 생각이 들었던 건 두 가지 이유에서였다.
첫 번째, 구현하지 않고 넘어갈 여지가 많다. 반드시 오버라이딩 해야 제대로 동작하는 기능인데, 주의 깊게 생각하지 않으면 구현하지 않고 넘어갈 일이 많았다.
두 번째, 구현하기 귀찮다. 구현을 하더라도 참 귀찮은 작업인 게, 위의 코드를 보면 알 수 있듯 반복되는 코드가 너무 많았다. 구현의 난이도가 높은 것도 아닌데, 손이 많이 가고 또 무엇보다 필드의 값만 바꿔주는 단순 작업이기 때문에 코드를 고치는 것이 여러모도 낫겠다 하는 판단이 들었다.
그래서 여러가지 고민을 해본 결과, 런타임 시점에 동적으로 필드의 정보와 필드의 값을 추출해 메소드에 넣어줄 수 있는 방법이 있지 않을까 하는 생각이 들었고 구글에 검색을 해 본 결과 java의 리플렉션이라는 기능을 사용할 수 있다는 것을 알게 되었다.
리플렉션이란
리플렉션은 런타임 시점에 클래스의 정보를 추출하거나 변경하는 것이 가능한 자바의 기본 API이다. java 코드가 컴파일이된 이후 jvm 내부의 메모리 영역에서 동적으로 클래스의 정보를 꺼내 필요한 데이터를 사용하는 기술을 일컫는다.
흔히 자바라고 하면 정적인 언어라고 이야기를 많이 한다. 정적인 언어는 타입 체크, 컴파일의 도움 등 많은 부분에서 개발에 필요한 도움을 받을 수 있지만 객체의 정보가 컴파일이 완료된 시점 이후에는 변경될 수 없기 때문에 javascript와 같은 언어에 비해서는 유연성이 떨어진다. 리플렉션은 자바 코드를 컴파일러 너머에서 조작할 수 있도록 도와주는 기술이라고 할 수 있을 것 같다. 스프링 프레임워크의 DI(Dependency Injection), JPA의 데이터 주입 등이 바로 리플렉션을 이용해 이루어지는 기능이라고 한다.
사실 리플렉션에 대해서는 초보적인 수준에서만 이해를 하고 있으므로, 아래의 영상을 통해 학습을 하면 조금 더 많은 이해를 하는데 도움이 되지 않을까 싶다.
https://www.youtube.com/watch?v=67YdHbPZJn4
코드에 적용
나는 위의 이슈 상황을 해결하기 위해 다음과 같이 코드를 작성해 이슈를 해결하고 해당 기능을 자동화할 수 있었다. 새로운 코드 작성의 결과로 앞서 언급한 두 가지 개선사항을 모두 개선할 수 있었다. 새로운 코드의 개선점은 다음과 같았다.
- 더 이상 Search 클래스를 상속한 자식 클래스에서 쿼리 스트링을 추출하는 메소드를 오버라이드 하지 않아도 될 수 있게 되었다.
- 필드의 값과 이름이 자동으로 시스템에 입력되기 때문에 더 이상 불필요한 단순반복 작업을 수행하지 않아도 될 수 있게 되었다.
아래는 개선된 Search 클래스와 UserSearch 클래스, 그리고 새로운 클래스인 PagingUtils 클래스이다. PagingUtils 클래스는 리플렉션을 사용해 자동으로 필드의 정보를 입력하는 클래스이다.
class Search {
private Integer pageNumber;
private Integer size;
private String sortProperty;
private SortDirection sortDirection;
private Boolean isPaged;
public String getQueryString() throws IllegalAccessException {
return PagingUtils.getQueryString(new StringBuilder(), this); // 현재 클래스가 가진 필드 정보를 이용해 쿼리스트링 추출
}
}
public class UserSearch extends Search {
private String username; // 아이디
private String name; // 회원 이름
private String email; // 이메일
// UserSearch 클래스는 코드가 동일하다.
}
public class PagingUtils {
public static <T> String getQueryString(StringBuilder sb, T t) throws IllegalAccessException {
List<Field> fields = getAllFields(t);
// 쿼리 스트링용 StringBuilder에 값이 비지 않은 필드 값 주입
for (Field field : fields) {
field.setAccessible(true); // 외부에서 private 필드에 접근할 수 있도록 해주는 설정. oop의 추상화를 깨는 것이기 때문에 필요한 곳에서만 사용이 필요하다.
if (field.get(t) != null) {
if (StringUtils.hasText(String.valueOf(field.get(t)))) {
sb.append("&").append(field.getName()).append("=")
.append(field.get(t));
}
}
}
return sb.toString();
}
public static <T> List<Field> getAllFields(T t) {
Class<?> clazz = t.getClass(); // 리플렉션으로 클래스 정보 추출
List<Field> fields = new ArrayList<>();
while (clazz != null) { // 상위 클래스가 null 이 아닐때까지 모든 필드를 list 에 담는다.
fields.addAll(Arrays.asList(clazz.getDeclaredFields())); // 클래스의 필드 정보 추출
clazz = clazz.getSuperclass();
}
return fields;
}
}
PagingUtils의 getAllFields(T t) 메소드 내부에서 클래스의 부모 클래스를 계속 호출하며 부모 클래스의 필드를 넣고 있기 때문에 UserSearch 같은 자식 클래스의 경우 부모 클래스의 getQueryString() 메소드를 호출할 경우 자신과 부모인 Search 객체의 필드 모두를 순회하며 필드의 값을 쿼리스트링으로 바인딩할 수 있다. 따라서 자식 클래스에서 부모의 메소드를 오버라이드 해 재정의할 필요 없이 부모의 메소드를 호출하는 것 만으로도 정상적인 동작을 기대할 수 있다.
(헷갈렸던 부분 중 하나인데, 디버깅 툴을 통해 알게 된 부분은 자식 클래스로 부모 클래스의 메소드를 호출할 경우, 데이터 타입이 자식 클래스로 들어가는 것을 확인할 수 있었다. 따라서 UserSearch에서 오버라이딩 해 메소드를 재구현하지 않아도 자식 클래스인 UserSearch와 부모 클래스인 Search 객체 모두 활용이 가능했다!)
'JAVA' 카테고리의 다른 글
[자바 웹 프로그래밍 Next Step 실습] HTTP 웹 서버 구현하며 적용 사항 기록하기 (0) | 2023.03.13 |
---|---|
왜 JUnit을 써야 하는가? (feat. main 메소드로 테스트를 하는 부분의 단점) (0) | 2023.01.20 |
[디자인 패턴 - 생성 패턴] 팩토리 메소드 패턴 (0) | 2022.11.03 |
[JPA] 연관관계 매핑 기초 (0) | 2022.05.21 |
[번역] cron4j quickstart (0) | 2022.04.01 |