Hello

JPA 동적쿼리 사용해보기

by 볼빵빵오춘기

토이 프로젝트를 하면서 JPA를 이용하였었다. 

처음에 JPA문법을 통해 제일 길게 만든 메소드 이름은 지도좌표를 가져와 해당 좌표안에 보호소 리스트를 가져오는 메소드였다. 

 

보여지는 지도에 오른쪽 상단 파란색 동그라미 친 부분에 lat, lon 좌표와 아래쪽 하단에 파란색 동그라미 친 부분에 lat, lon 안에 포함되는것이 포인트이고 관리자가 승인했는지아닌지에 따라 row를 가져오는 것이었다. (승인여부는 DB에서 approval 값이 승인 전 = 0, 승인 후 = 1 값을 갖는다.)

 

또한 값이 무조건 들어가는 상황이라 이 정도는 JPA문법으로 만들어도 불편함 없을것이라 생각했다.

⇒ findByLonGreaterThanAndLonLessThanAndLatGreaterThanAndLatLessThanAndApproval(파라미터 생략) 메서드를 만들었다. 

@Repository
public interface ShelterRepository extends JpaRepository<ShelterEntity, Integer> {
	//.. 생략
    public List<ShelterEntity> findByLonGreaterThanAndLonLessThanAndLatGreaterThanAndLatLessThanAndApproval(double lon1, double lon2, double lat1, double lat2, int isApproval);
	//.. 생략
}

 

동적쿼리를 사용해야겠다라고 생각이 든 부분은 보호동물리스트에서 검색을 해서 리스트를 볼 경우였다. 

 

검색 조건에 공고번호(시도-지역-년도-숫자), 접수일시, 품종, 보호상황, 성별 등 컬럼에 맞는 조건 찾을려면 JPA문법으로는 만들었을 경우 너무 길것이라 예상되며, 해당 값 null일 때, 값이 있을 때 상황에 맞게 메소드를 만들어 주기도 해야했다. 

결국 메서드를 만든다하면 메서드이름이 엄청길기도 한데 값이 있냐 없냐에 따라서 조건에 맞게 여러 메소드를 만들어야 했기 때문이다. 

 

Specifications 사용 시도1

ProtectEntity 클래스

@Entity
@Data
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "DA_ProtectBbs")
public class ProtectEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id; // pk(id)

    private String sido; // 공고번호 시도
    private String sigungu; // 공고번호 시군구
    private String byYear; // 공고번호 년도
    private String num; // 공고번호 숫자
    private String kind; // 품종
    private String gender; // 성별
    private String color; // 색상
    private String age; // 나이
    private String weight; // 체중
    private String character; // 구조시 특징
    private String place; // 발견장소
    private String date; // 접수 일시
    private String username; // 작성자

    @Column(columnDefinition = "integer default 0", nullable = false)
    private int isProtect; // 보호 여부

    private String endReason; // 보호 종료 사유
    private int fileAttached; // 파일 첨부 여부

    @CreationTimestamp
    @Column(updatable = false)
    private LocalDateTime createDate; // 작성시간

    @UpdateTimestamp
    private LocalDateTime updatedDate;

	// .. 생략

}

 

 

ProtectSpecifications 클래스

🧐 ProtectSpecifications 클래스는 어디다 만들어야 할까? (더보기 참고)

더보기

ProtectSpecifications 클래스는 일반적으로 특정 엔티티에 대한 검색 조건을 정의하기 위한 클래스이다.

따라서 이 클래스는 repository 패키지나 specification 패키지에 위치하는 것이 좋다.(아래 이미지 참고)

 

 여기서 repository패키지에 밑에 클래스는 정적쿼리를 이용하여 하였을 경우에 쓰고,

specification 패키지 밑에 클래스는 다른 스펙 클래스를 관리할 때 쓰자 라고 결정하여 

specification 패키지 밑에 ProtectSpecifications 클래스를 만들었다.

지금은 specification 패키지 하위 클래스로는 하나밖에 없지만 많아진다고 하면 이 부분이 관련된 기능을 한 곳에 모았기 때문에 관리하기 쉽고, 코드의 유지보수성을 높일 수 있을 것이라 생각했기 때문이다.

public class ProtectSpecifications {

    public static Specification<ProtectEntity> hasSido(String sido) {
        return (root, query, builder) -> {
            if (sido == null || sido.isEmpty()) {
                return builder.conjunction();
            }
            return builder.equal(root.get("sido"), sido);
        };
    }

    public static Specification<ProtectEntity> hasSigungu(String sigungu) {
        return (root, query, builder) -> {
            if (sigungu == null || sigungu.isEmpty()) {
                return builder.conjunction();
            }
            return builder.equal(root.get("sigungu"), sigungu);
        };
    }

    public static Specification<ProtectEntity> hasByYear(String byYear) {
        return (root, query, builder) -> {
            if (byYear == null || byYear.isEmpty()) {
                return builder.conjunction();
            }
            return builder.equal(root.get("byYear"), byYear);
        };
    }

    public static Specification<ProtectEntity> hasNum(String num) {
        return (root, query, builder) -> {
            if (num == null || num.isEmpty()) {
                return builder.conjunction();
            }
            return builder.equal(root.get("num"), num);
        };
    }

    public static Specification<ProtectEntity> hasKind(String kind) {
        return (root, query, builder) -> {
            if (kind == null || kind.isEmpty()) {
                return builder.conjunction();
            }
            return builder.equal(root.get("kind"), kind);
        };
    }

    public static Specification<ProtectEntity> hasGender(String gender) {
        return (root, query, builder) -> {
            if (gender == null || gender.isEmpty()) {
                return builder.conjunction();
            }
            return builder.equal(root.get("gender"), gender);
        };
    }

    public static Specification<ProtectEntity> hasDate(String date) {
        return (root, query, builder) -> {
            if (date == null || date.isEmpty()) {
                return builder.conjunction();
            }
            return builder.equal(root.get("date"), date);
        };
    }
}

 

ProtectRepository 인터페이스

JpaRepository<ProtectEntity, Long> extends 되있는 상태 였는데 JpaSpecificationExecutor<ProtectEntity>도 extends 해준다.

public interface ProtectRepository extends JpaRepository<ProtectEntity, Long>, JpaSpecificationExecutor<ProtectEntity> {
}

 

ProtectService 클래스

  • listWithImagesAndSearch 메서드에서 Specification.where을 사용하여 여러 조건을 결합하고 있다.
  • ProtectSpecifications 클래스에서 각 조건을 Specification으로 정의한 후, Specification.where을 사용하여 각 조건을 and 연산자로 결합한다.

※ Spring Data JPA에서 여러 Specification을 결합할 때 사용하는 메서드이다.
이 메서드를 사용하면 여러 개의 Specification을 and 조건으로 결합할 수 있다. 

 

✅ 한 번의 조회로 모든 조건을 만족하는 데이터를 가져올 수 있다.
✅ 여러 조건을 하나의 쿼리로 결합하여 데이터베이스에 효율적으로 조회할 수 있게 한다.
✅ 조건이 추가되거나 변경되더라도 쉽게 수정할 수 있어 유지보수에 유리하다.

@Service
public class ProtectService {

    @Autowired
    private ProtectRepository protectRepository;

    @Transactional(readOnly = true)
    public Page<ProtectDto> listWithImagesAndSearch(Pageable pageable, String sido, String sigungu, String byYear, String num, String kind, String gender, String date) {
        Specification<ProtectEntity> spec = Specification.where(ProtectSpecifications.hasSido(sido))
                                                         .and(ProtectSpecifications.hasSigungu(sigungu))
                                                         .and(ProtectSpecifications.hasByYear(byYear))
                                                         .and(ProtectSpecifications.hasNum(num))
                                                         .and(ProtectSpecifications.hasKind(kind))
                                                         .and(ProtectSpecifications.hasGender(gender))
                                                         .and(ProtectSpecifications.hasDate(date));

        Page<ProtectEntity> protectEntities = protectRepository.findAll(spec, pageable);

        // Mapping ProtectEntity to ProtectDto
        Page<ProtectDto> protectDtos = protectEntities.map(entity -> {
            // mapping logic
            return new ProtectDto(entity);
        });

        return protectDtos;
    }
}

 

Specifications 사용 시도1 - 결과

검색 시 공고번호(protectNum)에 값이 없고 접수일시(date), 품종(kind), 보호상황(isProtect), 성별(gender)에 값이 없을 때는 에러가 나지않는데, 공고번호(protectNum)에 값이 있고 나머지(date, kind, isProtect, gender) 값이 없을 때는 에러가 난다.

※ protectNum은 엔티티에서 sido, sigungu, byYear, num이 - 로 이어져서 합쳐진 글자가 공고번호가 된다.

"org.springframework.data.domain.Page.map(java.util.function.Function)" because "protectEntities" is null
java.lang.NullPointerException: Cannot invoke "org.springframework.data.domain.Page.map(java.util.function.Function)" because "protectEntities" is null



원인 분석

  • protectEntities가 null인상태에서 map을 호출한다.
  • protectNum(공고번호)이 null이거나 빈 문자열이 아닌 경우에만 protectEntities가 초기화되기 때문이다.
  • protectNum이 null이거나 빈 문자열인 경우에는 protectEntities가 초기화되지 않아서 발생하는 문제이다.

 

해결 방안

  • protectNum이 제공되지 않은 경우에도 Specification을 구성하고 protectEntities를 초기화해야 한다.
  • kind, gender, date 초기화하는 코드는 매개변수가 null일 경우를 처리하는 로직을 추가하여 검색 조건을 추가하는지 여부를 결정해야 한다.

 

Specifications 사용 시도2

ProtectService 클래스 수정

  • Specification은 초기 빈 Specification으로 시작하며, 각 검색 조건이 제공되는 경우 and로 추가된다.
    ⇒ 모든 경우에 대해 protectEntities가 초기화되어 null이 되지 않도록 한다.
  • protectNum, kind, gender, date 중 하나라도 입력되지 않더라도 나머지 조건으로 필터링을 수행할 수 있도록 설정한다.
@Service
public class ProtectService {

    @Transactional(readOnly = true)
    public Page<ProtectDto> listWithImagesAndSerach(Pageable pageable, String protectNum, String kind, String gender, String date) {
        Specification<ProtectEntity> spec = Specification.where(null); // 초기 빈 Specification

        if (protectNum != null && !protectNum.isEmpty()) {
            String[] protectStr = protectNum.split("-");
            if (protectStr.length == 4) {
                String sido = protectStr[0];
                String sigungu = protectStr[1];
                String byYear = protectStr[2];
                String num = protectStr[3];

                spec = spec.and(ProtectSpecifications.hasSido(sido))
                           .and(ProtectSpecifications.hasSigungu(sigungu))
                           .and(ProtectSpecifications.hasByYear(byYear))
                           .and(ProtectSpecifications.hasNum(num));
            }
        }

        if (kind != null && !kind.isEmpty()) {
            spec = spec.and(ProtectSpecifications.hasKind(kind));
        }

        if (gender != null && !gender.isEmpty()) {
            spec = spec.and(ProtectSpecifications.hasGender(gender));
        }

        if (date != null && !date.isEmpty()) {
            spec = spec.and(ProtectSpecifications.hasDate(date));
        }

        Page<ProtectEntity> protectEntities = protectRepository.findAll(spec, pageable);

        return protectEntities.map(this::convertToDto);
    }

    private ProtectDto convertToDto(ProtectEntity protectEntity) {
        ProtectDto protectDto = new ProtectDto();

        protectDto.setId(protectEntity.getId());
        protectDto.setSido(protectEntity.getSido());
        protectDto.setSigungu(protectEntity.getSigungu());
        protectDto.setByYear(protectEntity.getByYear());
        protectDto.setNum(protectEntity.getNum());
        protectDto.setKind(protectEntity.getKind());
        protectDto.setColor(protectEntity.getColor());
        protectDto.setGender(protectEntity.getGender());
        protectDto.setAge(protectEntity.getAge());
        protectDto.setWeight(protectEntity.getWeight());
        protectDto.setCharacter(protectEntity.getCharacter());
        protectDto.setPlace(protectEntity.getPlace());
        protectDto.setDate(protectEntity.getDate());
        protectDto.setUsername(protectEntity.getUsername());
        protectDto.setIsProtect(protectEntity.getIsProtect());
        protectDto.setEndReason(protectEntity.getEndReason());
        protectDto.setFileAttached(protectEntity.getFileAttached());

        // 파일 경로 설정
        List<BoardFileEntity> files = boardFileRepository.findByBoardIdAndBoard(protectEntity.getId(), "protect");
        if (files != null && !files.isEmpty()) {
            protectDto.setImagePath(files.get(0).getStoredFileName());
        }

        return protectDto;
    }
}

 

블로그의 정보

Hello 춘기's world

볼빵빵오춘기

활동하기