Troubleshooting: 무엇이 문제였는가?/본캠프 6주 차: 플러스 프로젝트

10단계: Cannot invoke "Object.getClass()" because "constant" is null

writingforever162 2025. 1. 20. 17:27

[문제]

QueryDSL을 사용해서 조회할 때 특정 검색어가 없으면 일정 목록이 전부 조회되어야 하는데 이상하게 '403 Forbidden' 오류 메시지가 떴다. 메시지야 직접 설정하지 않았으니 그렇다 쳐도, 어디선가 문제가 생겼다는 뜻이라 눈을 동그랗게 뜨고 원인을 찾았다.

 

[원인]

private long countByTitle(String search) {
    return Optional.ofNullable(
            jpaQueryFactory.select(Wildcard.count)
                .from(todo)
                .where(todo.title.contains(search))
                .fetchOne()
        )
        .orElse(0L);
}

오류 메시지를 한 줄씩 꼼꼼하게 읽은 결과, 'Cannot invoke "Object.getClass()" because "constant" is null' 메시지가 눈에 띄었다. 즉, 특정 검색어를 입력하지 않으면, null인 'search'가 contains() 메서드(method)에 들어가서 오류가 발생했다. null이 들어가면 안 된다는 점은 Javadoc에서도 확인할 수 있었다.

 

[해결]

/**
 * 검색 조건에 맞는 일정의 총 개수 반환
 * 결과가 없으면 0 반환
 */
private long countTotalElements(TodoSearchRequestDto dto) {
    return Optional.ofNullable(
            jpaQueryFactory.select(todo.count()) // 일정 Entity 개수 조회
                .from(todo) // 일정 테이블에서
                .where( // 검색 조건 추가 
                    containsTitle(dto.getTitle()), // (1) 일정 제목
                    goeStartsAt(dto.getStartsAt()), // (2) 검색 시작일
                    loeEndsAt(dto.getEndsAt()) // (3) 검색 종료일
                )
                .fetchOne() // 총 개수 값 반환 (결과가 없을 수 있음)
        )
        .orElse(0L); 
        // 결과가 없으면 0 반환 
        // Optional을 사용하여 null 안전성 처리
}

원인을 찾은 다음엔 일정 제목뿐만 아니라 특정한 조건으로 조회한 일정의 총 개수 값을 정확하게 반환할 수 있도록 메서드를 수정했다. 특히 조건에 맞는 일정이 없어서 값이 없을 때는 null을 안전하게 처리하고자 Optional.ofNullable을 사용했으며, 'orElse(0L)'이라고 코드를 작성하여 Optional이 비었을 때는 기본값인 0을 반환하도록 했다.

더보기
package org.example.expert.domain.todo.repository;

import static org.example.expert.common.entity.QTodo.todo;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.example.expert.common.entity.Todo;
import org.example.expert.domain.todo.dto.request.TodoSearchRequestDto;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class TodoQueryRepository {

    private final JPAQueryFactory jpaQueryFactory;

    // 주어진 조건에 맞는 일정 리스트를 페이징 처리하여 반환
    public Page<Todo> search(
        TodoSearchRequestDto dto,
        Pageable pageable
    ) {
        List<Todo> todoList = new ArrayList<>();

        todoList = findTodoList(dto, pageable);

        long totalElements = countTotalElements(dto);

        return new PageImpl<>(
            todoList,
            pageable,
            totalElements
        );
    }

    // 검색 조건에 맞는 일정 목록을 조회
    private List<Todo> findTodoList(
        TodoSearchRequestDto dto,
        Pageable pageable
    ) {
        List<Todo> todoList = new ArrayList<>();

        todoList = jpaQueryFactory.selectFrom(todo)
            .leftJoin(todo.user).fetchJoin()
            .where(
                containsTitle(dto.getTitle()),
                goeStartsAt(dto.getStartsAt()),
                loeEndsAt(dto.getEndsAt())
            )
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .orderBy(todo.createdAt.desc())
            .fetch();

        return todoList;
    }

    // 제목이 주어진 값으로 포함되는 일정을 찾는 조건 생성
    private BooleanExpression containsTitle(String title) {
        if (title == null) {
            return null;
        }
        return todo.title.contains(title);
    }

    // 시작 시간이 주어진 값 이후인 일정을 찾는 조건 생성
    private BooleanExpression goeStartsAt(LocalDateTime startsAt) {
        if (startsAt == null) {
            return null;
        }
        return todo.createdAt.goe(startsAt);
    }

    // 종료 시간이 주어진 값 이전인 일정을 찾는 조건 생성
    private BooleanExpression loeEndsAt(LocalDateTime endsAt) {
        if (endsAt == null) {
            return null;
        }
        return todo.createdAt.loe(endsAt);
    }

    // 검색 조건에 맞는 일정의 총 개수 반환
    private long countTotalElements(TodoSearchRequestDto dto) {
        return Optional.ofNullable(
                jpaQueryFactory.select(todo.count())
                    .from(todo)
                    .where(
                        containsTitle(dto.getTitle()),
                        goeStartsAt(dto.getStartsAt()),
                        loeEndsAt(dto.getEndsAt())
                    )
                    .fetchOne()
            )
            .orElse(0L);
    }
}

메서드를 수정한 다음, 가장 먼저 아무런 조건 없이 검색했을 때 데이터베이스(database)에 저장된 모든 일정이 조회되는지, totalElements가 올바르게 출력되는지 점검했다. 다행히 메서드를 고친 뒤에는 문제없이 일정 목록이 조회되었다.

두 번째로는 일정 제목만 입력했을 때와 일정 생성일 범위만 설정했을 때 각 조건에 맞게 일정 목록이 조회되는지 점검했고, 'N+1' 문제나 오류 없이 조회하는 데에 성공했다. 차츰차츰 막막하게만 느껴진 queryDSL에 한걸음 가까워진 기분이 들었다.