Post

[Spring] 일정관리 프로젝트 트러블슈팅 기록

일정관리 프로젝트를 진행하면서 겪은 트러블슈팅의 과정들에 대한 기록입니다.
해당 프로젝트의 전체소스는 여기 에서 확인하실 수 있습니다.

트러블슈팅


⭐️ 주제

JPA를 사용해 일정 전체 목록 LIKE 조회하기

🔥 발생

Query Parameter 로 name 값을 받아 작성자명이 해당 문자열을 포함하는 일정 목록을 조회하는 기능을 구현했으나 문제가 발생하였다. 아래는 그 당시 작성했던 코드이다.

ScheduleController

1
2
3
4
5
6
7
@GetMapping
public ResponseEntity<List<ScheduleResponseDto>> findAllSchedules(
        @RequestParam(required = false) String name
) {

    return new ResponseEntity<>(scheduleService.findAllSchedules(name), HttpStatus.OK);
}

ScheduleServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Transactional(readOnly = true)
@Override
public List<ScheduleResponseDto> findAllSchedules(
        String name
) {

    // 💥 문제의 로직
    return scheduleRepository.findAll().stream()
            .filter(schedule -> {
                if (schedule.getName() != null && !schedule.getName().isEmpty()) {
                    return schedule.getName().contains(name);
                }
                return false;
            })
            .map(ScheduleResponseDto::new)
            .toList();
}

이후, Postman으로 테스트를 해보니 몇몇 케이스들은 정상적으로 조회되지 않았다.

Case1

  • 요청: GET /schedules 또는 GET /schedules?test=123
  • 결과: 500 Internal Server Error
1
2
3
4
5
6
{
    "timestamp": "2025-07-30T08:45:04.702+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "path": "/schedules"
}

Case2

  • 요청: GET /schedules?name=abc
  • 결과: 정상적으로 빈 배열 반환
1
[]

Case3

  • 요청: GET /schedules?name=홍길동
  • 결과: 모든 데이터가 필터링 없이 반환됨
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[
    {
        "id": 1,
        "title": "임꺽정의 일정 제목",
        "contents": "임꺽정의 일정 내용",
        "name": "임꺽정",
        "createdAt": "2025-07-30T12:38:28.981426",
        "modifiedAt": "2025-07-30T12:38:28.981426"
    },
    {
        "id": 2,
        "title": "홍길동의 일정 제목",
        "contents": "홍길동의 일정 내용",
        "name": "홍길동",
        "createdAt": "2025-07-30T12:33:37.520361",
        "modifiedAt": "2025-07-30T12:33:37.520361"
    }
]

🔍 원인

Query Parameter의 name 값의 null 체크 누락

1
return schedule.getName().contains(name);

해당 부분에서 name 값이 null 이면 NPE 발생하여 에러 발생 (Case1)

하지만 제일 중요한 문제점은 현재 DB에서 findAll()
즉, 전체 조회를 먼저한 후에 비즈니스 로직에서 필터링한다는 것이다.

✅ 해결

JPA는 메서드 이름을 해석하여 자동으로 JPQL 쿼리를 생성하여 처리한다.
findByNameContaining() 이라는 메서드를 사용하여 LIKE 검색을 할 수 있다.

ScheduleRepository

JpaRepository 기본 제공 메서드가 아니기 때문에 선언이 필요하다.
기본 제공 메서드에는 findById(), findAll(), save(), deleteById() 등이 있다.
이 메서드들은 SimpleJpaRepository 라는 클래스에 미리 구현되어 있다.

1
2
3
public interface ScheduleRepository extends JpaRepository<Schedule, Long> {
    List<Schedule> findByNameContaining(String name); // JPQL: WHERE name LIKE %?%
}

ScheduleServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Transactional(readOnly = true)
@Override
public List<ScheduleResponseDto> findAllSchedules(
        String name
) {

    List<Schedule> foundSchedules;
    if (name != null && !name.isEmpty()) {
        foundSchedules = scheduleRepository.findByNameContaining(name);
    } else {
        foundSchedules = scheduleRepository.findAll();
    }

    return foundSchedules.stream()
            .map(ScheduleResponseDto::new)
            .toList();
}

✨ 추가

만약 수정일 기준으로 내림차순 정렬하여 조회하고 싶다면?

ScheduleRepository

1
2
3
4
5
6
public interface ScheduleRepository extends JpaRepository<Schedule, Long> {

    List<Schedule> findByNameContainingOrderByModifiedAtDesc(String name); // JPQL: WHERE name LIKE %?% ORDER BY modified_at DESC

    List<Schedule> findAllByOrderByModifiedAtDesc(); // JPQL: ORDER BY modified_at DESC
}

💡 결론

  • 조건부 검색은 반드시 JPA 레벨에서 처리해야 한다.
    • 비즈니스 로직 조건 필터링을 하면 전체 데이터를 불필요하게 메모리에 적재하게 되어 성능이 떨어진다.
  • Query Parameter 값은 null 여부를 반드시 체크해야 한다.
    • null 체크 누락 시 NullPointerException 발생
  • JPA 메서드 명명 규칙을 잘 활용하면 간결하고 안전한 코드 작성이 가능하다.

유효성 검사 및 데이터 가공 레이어별 기준


  • DTO or Repository
    간단한 규칙(무조건 영어, 무조건 숫자 등)
  • Controller
    사용하는 DTO는 같은데 규칙이 다를 때
  • Service
    복잡한 비즈니스 로직

Dirty Checking


ScheduleServiceImpl

메서드에 @Transactional 어노테이션이 있다면 Setter만 호출해도 DB 업데이트가 반영된다.

1
2
if (StringUtils.hasText(requestDto.getTitle())) schedule.updateTitle(requestDto.getTitle());
if (StringUtils.hasText(requestDto.getName())) schedule.updateName(requestDto.getName());