기존 프로젝트에서 겪은 문제점
기존 프로젝트에서 개발을 함에 있어서 굉장히 많은 Paging 처리를 할 필요가 있었다. 실제로 아래와 같은 코드들이 페이징 처리를 할 때마다 계속 늘어났었다.

모바일 환경에 맞게 인덱스를 활용해서 무한 스크롤을 하다보면 다음과 같은 코드를 계속 해서 작성하게 된다. 어쩔 수 없다. 일반적으로 JPA에서 제공해주는 페이징 처리 API는 Limit-offset 방식이기 때문이다. 물론 해당 방식도 인덱스를 사용할 수는 있지만 해당 방식의 근본적인 뒤쪽으로 갈수록 더 많은 데이터 블록을 읽어야 하기 때문에 늦어진다는 문제점을 해결할 수 없기 때문이다.
-> 그나마 세컨더리 인덱스에서 모든 데이터를 읽어오는 커버링 인덱스를 활용한 방식을 쓰면 어느정도 응답 지연을 막을 수는 있다.
그런데 위와 같은 코드의 문제점은 계속 똑같은 코드가 계속해서 복제하듯이 늘어난다는 점이다. 지금은 SimpleTeamDto라는 것이 List로 들어가 있지만, 만약에 ScheduleDto 같은 게 들어가고, BoardDto가 들어가면 해당 코드는 계속 해서 늘어나게 된다.
그러면 어떻게 문제를 해결할 수 있을까? 정답은 제네릭과 함수형 인터페이스를 통한 방법이다.
페이징 처리에서의 공통점
위의 코드 형태로 된 페이징 처리를 보면 아래와 같은 과정으로 처리가 된다.
그러면, 공통된 건 아래와 같이 정리가 될 것이다.
각각의 조건에 대해서 대응되는 해결방안은 다음과 같다.
그러면 각각의 이론에 대해서 알아보자. 아래 부분은 실제 변경 부분과는 관련이 없고 공부한 내용을 정리해놓은 것에 가깝다.
Enum
Enum 클래스에 대해서는 다양한 블로그나 게시글들을 볼 수 있다. 오래된 글이지만 이전에 향로님이 작성했던 Java Enum 활용기가 정말 좋은 글이라고 생각을 한다.
확실히 Enum을 통해서 개발을 하면 타입적인 안정성을 챙길 수 있다. 그래서 나는 여기서 PAGE 부분을 Enum으로 바꾸었다.
@Getter
public enum PageSize {
SMALL_PAGE(10), PAGE_SIZE(20), LARGE_PAGE_SIZE(100);
private int size;
PageSize(int size) {
this.size = size;
}
}
이렇게만 두어서 굉장히 별 거 없어 보인다. 사실 저렇게 쓸거면 static으로 사용하는 것과 다르지 않다. 하지만, Enum은 static보다 더 많은 기능을 제공해준다.
첫 번째로는 문자열로 된 것으로 값에 대한 안정성을 컴파일러에서 체크를 해주기 때문에 안정성을 얻을 수 있다는 것이다. 그냥 static으로 넣어버리면 문제점은 타입 체크를 해주지 못한다. static으로 설정된 값과 해당 값을 받아서 쓰는 메서드가 있다고 해보자.
public static Integer SMALL_PAGE = 10;
public static PageResponse getPageResponse(Integer pageSize) {
....
}
사실 static으로 설정된 변수는 아무런 죄가 없다. 하지만, getPageResponse 메서드가 Integer pageSize를 받아버리면 SMALL_PAGE와 같은 값으로 강제를 할 수 없다. 결국엔 개발자가 원치 않는 이상한 값이 들어와도 어쩔 수 없이 허용하고 넘기게 되는 것이다.
따라서, 함수를 리팩토링하는 과정에서 타입 상의 안정성을 위해서 해당 Enum 방식으로 값을 정의했다.
추가적으로 위의 것을 활용해서 아래의 findByPayCode처럼 내가 받은 PayType에 관해서 찾아볼 수 있다. 여기 코드에서는 PayType이라는 것에 대해서 했지만, 내부에 있는 필드의 매칭 여부를 PayGroupV2.values()로 모든 값을 가져와서 체크해서 찾아낼 수도 있다.
@Getter
public enum PayGroupV2 {
CASH("현금", Arrays.asList(ACCOUNT_TRANSFER, REMITTANCE, ON_SITE_PAYMENT, TOSS)),
CARD("카드", Arrays.asList(PAYCO, PayType.CARD, KAKAO_PAY, BAEMIN_PAY)),
ETC("기타", Arrays.asList(POINT, COUPON)),
EMPTY("없음", Collections.EMPTY_LIST);
private String title;
private List<PayType> payList;
PayGroupV2(String title, List<PayType> payList) {
this.title = title;
this.payList = payList;
}
public static PayGroupV2 findByPayCode(PayType payType) {
return Arrays.stream(PayGroupV2.values())
.filter(payGroup -> payGroup.hasPayCode(payType))
.findAny()
.orElse(EMPTY);
}
public boolean hasPayCode(PayType payType) {
return payList.stream()
.anyMatch(pay -> pay == payType);
}
}
없을 때 기본 값을 내보내도 되고 예외를 내보내도 된다. 있다면 해당 값을 찾아서 내보내 주기만 하면 된다.
이것 외에도 Enum을 사용하면 static과 다르게 함수형 인터페이스를 활용해서 조금 더 유연하게 외부로부터 받은 값을 처리해주는 로직을 작성한다 던지 등의 작업도 진행할 수 있다. Function의 시그니처는 T -> R로 특정한 T 타입의 값을 R로 바꿔준다.
굉장히 뭔가 거창해보이는 것이지만 우리는 항상 쓰고 있다. 특정 객체에 대해서 객체를 가져오는 getter도 어떻게 보면 Function과 동일하다. 그래서 comparing() 메서드를 정렬에서 사용하면 해당 comparing 내부에 Function이 있어서 Class::getValue 형태로 작성해넣으면 정렬이 가능한 것이다.
import java.util.function.Function;
public enum CalculatorType {
CALC_A(value -> value),
CALC_B(value -> value * 10),
CALC_C(value -> value * 3),
CALC_ETC(value -> 0L);
private Function<Long, Long> expression;
// 함수의 파라미터화를 통해서 이렇게도 리팩토링할 수 있다는 게 좋네.
CalculatorType(Function<Long, Long> expression) {
this.expression = expression;
}
public long calculate(long value) {
return expression.apply(value);
}
}
아무튼 리팩토링 과정에서 앞의 타입적인 안정성만을 활용했지만 뒷 부분도 조금 활용을 하면 더 좋은 코드를 작성할 수 있지 않을까?? 특히 매직 넘버를 사용하는 건 나중에 계속 쓰였을 때 실수를 하거나 변경을 할 때 처리가 굉장히 곤란한데 이를 방지할 수 있다.
제네릭
제네릭은 해당 클래스가 아니라 외부에서 사용하는 클래스에서 타입을 정해서 사용하는 방식이다. 꺽쇠 괄호 형태의 <> 장소 안에 타입의 이름을 기재해서 사용한다.
대부분의 라이브러리들은 제네릭으로 만들어진다. 대표적으로 Collections 클래스가 그렇다. 아래는 Collections 클래스에 정의된 일부 메서드들에 대해서 가져온 것이다.
public static <T extends Comparable<? super T>> void sort(List<T> list) {
list.sort(null);
}
private static <T> T get(ListIterator<? extends T> i, int index) {
T obj = null;
int pos = i.nextIndex();
if (pos <= index) {
do {
obj = i.next();
} while (pos++ < index);
} else {
do {
obj = i.previous();
} while (--pos > index);
}
return obj;
}
그러면 아까 페이징 코드에서 제네릭으로 쓸 수 있는 부분은 어디일까? 내가 보았을 때에는 SimpleTeamDto 부분이 제네릭으로 바뀌면 될 것 같다.

그러면, 이제 SimpleTeamDto건 ScheduleDto건 BoardDto건 상관없이 모두를 다 받을 수 있다.
함수형 인터페이스
마지막으로는 함수형 인터페이스이다. 함수형 인터페이스와 그 활용은 모던 자바 인 액션이라는 책을 보면 앞쪽에 람다와 스트림 부분을 설명하면서 자세하게 나온다.
해당 책에서는 다음과 같은 내용이 정리가 되어 있는데, 처음 이걸 보는 사람은 여기가 잘 이해가 되지 않을 것이다. 하지만, 계속 이 부분을 반복해서 보면서 느낀 건 인자와 시그니처만 기억을 해두면 어떤 게 어떤 곳에 쓰이는 지를 대략적으로 예측할 수 있다는 것이다.

그리고 생각보다 이름도 직관적인 편이다. Predicate는 Boolean으로 판별용으로 사용된다. Stream의 filter나 그런 곳에 사용이 될 수 있는 것, Comparator도 Comparable<> 형태(velog에 꺽쇠 사이에 값이 안 보인다. Class가 있다고 생각을 부탁한다.)로 작성할 수 있다. 어차피 두 개의 값을 비교해서 값 기준으로 compareTo 값을 내보내는 것처럼 이해를 하면 언젠가 이해가 된다.
아무튼 함수형 인터페이스의 정말 큰 장점은 내가 앞으로 받아야 할 동작을 미래의 작성자로부터 받을 수 있다. 단지, 내가 앞으로 어떤 타입에 대해서만 처리를 해줄 건지에 대해서 명시만 해주면 된다.

그러면 이제 이렇게 한번 정의를 해보자.
그렇기 때문에 함수형 인터페이스 내부에서도 제네릭을 사용해서 정의를 해주어야 한다. 미래에 어떤 종류의 식별자가 들어올 지 모르기 때문이다. DB에 들어갈 식별자가 varchar인지 bigint인지 현재는 결정된 게 없고, 해당 식별자를 소유할 클래스도 정의되지 않았다. 그러니 제네릭은 이제 T과 R로 두 개가 필요한 것이다.
또한, T타입에서 R타입으로 받아오는 함수형 인터페이스는 Function<T, R>이므로 다음과 같이 코드를 작성할 수 있을 것 같다. 개인적으로는 일반 class로 정의를 해봤었는데, 줄여보려고 record 클래스로 정리를 GPT한테 부탁해봤다.
그런데, 짧은 건 좋은데 보는 사람의 입장에선 안 좋은 것 같다. 코드를 바꾸기보단 차라리 주석을 조금 위에 적어서 어떤 건지 설명을 해주는 것도 좋은 방법일 것 같다.
public record PageResponse<T, R>(
Boolean hasNext,
Integer size,
List<T> data,
R lastId
) {
public PageResponse(List<T> data, Function<T, R> extractIdFunction, PageSize pageSize) {
this(
data.size() > pageSize.getSize(),
Math.min(data.size(), pageSize.getSize()),
List.copyOf(data.subList(0, Math.min(data.size(), pageSize.getSize()))),
data.isEmpty() ? null : extractIdFunction.apply(data.get(Math.min(data.size(), pageSize.getSize()) - 1))
);
}
}
그렇게 해서 Controller의 코드는 다음과 같이 바뀌었다.
// 변경 전
@GetMapping
public ResponseEntity<BoardResponse> getBoardsByIdDesc(
@RequestParam(value = "boardId", required = false) Long boardId
) {
List<Board> boards = boardService.getBoardByIdDesc(boardId, SMALL_PAGE.getSize() + 1);
Boolean hasNext = boards.size() > SMALL_PAGE.getSize();
int size = hasNext ? SMALL_PAGE.getSize() : boards.size();
if (hasNext) {
boards = boards.subList(0, size - 1);
}
Long lastBoardId = !boards.isEmpty() ? boards.get(boards.size() - 1).getId() : null;
BoardResponse boardResponse = new BoardResponse(size, hasNext, lastBoardId, boards);
return ResponseEntity.ok().body(boardResponse);
}
// 변경 후
@GetMapping("/v2")
public ResponseEntity<PageResponse<Board, Long>> getBoardsByIdDesc2(
@RequestParam(value = "boardId", required = false) Long boardId
) {
List<Board> boards = boardService.getBoardByIdDesc(boardId, SMALL_PAGE.getSize() + 1);
PageResponse<Board, Long> pageResponse = new PageResponse<>(boards, Board::getId, SMALL_PAGE);
return ResponseEntity.ok().body(pageResponse);
}
이런 식으로 반복되는 부분에 대해서 객체에게 전달해서 생성하는 방법들에 대해서도 계속 고민을 해보고 리팩토링해서 작성해봐야겠다.
참고자료
모던 자바 인 액션 - 함수형 인터페이스 부분
https://techblog.woowahan.com/2527/ (PayGroupV2, CalculatorType)