Spring Boot

spring 필터링

해달's code 2025. 4. 7. 22:15

필터링 기본 흐름

[컨트롤러]
   ↓ @RequestParam or DTO
[서비스]
   ↓
[레포지토리 or Mapper]

→ 단순 조건이면 JPA 쿼리
→ 동적 필터 많으면 QueryDSL / Criteria
→ SQL이 중요하면 MyBatis

필터링의 여러방법

1. 쿼리 파라미터 (Query Parameter)

 

  • 프론트에서 /flights?departure=ICN&arrival=NRT&direct=true 처럼 쿼리 파라미터로 요청 → 백엔드에서 조건을 파악해 DB 쿼리 생성.
  • Spring에서는 @RequestParam, @RequestBody, 또는 DTO로 받아서 처리.

작동 예시

controller

@GetMapping("/flights")
public List<FlightDto> getFlights(
    @RequestParam String departure,
    @RequestParam String arrival,
    @RequestParam(required = false) Boolean direct
) {
    return flightService.getFilteredFlights(departure, arrival, direct);
}

 

service

public List<FlightDto> getFilteredFlights(String departure, String arrival, Boolean direct) {
    List<Flight> flights = flightRepository.findByFilter(departure, arrival, direct);
    return flights.stream().map(FlightDto::from).collect(Collectors.toList());
}

repository

@Query("SELECT f FROM Flight f WHERE f.departure = :departure AND f.arrival = :arrival"
     + " AND (:direct IS NULL OR f.direct = :direct)")
List<Flight> findByFilter(
    @Param("departure") String departure,
    @Param("arrival") String arrival,
    @Param("direct") Boolean direct
);

2. 동적 쿼리 (Dynamic Query)

 

  • 사용자가 입력한 필터 조건만 조합해서 SQL 쿼리를 동적으로 생성.
  • Spring에서는 QueryDSL, JPA Criteria API, 또는 MyBatis에서 if 태그로 구현.

커스텀 인터페이스 구조

FlightController
   ↓
FlightService
   ↓
FlightRepository (Spring Data JPA)
   └─ FlightRepositoryCustom (interface)
       └─ FlightRepositoryImpl (API 구현)
public interface FlightRepositoryCustom {
    List<Flight> searchFlights(String departure, String arrival, Boolean direct);
}
@RequiredArgsConstructor
public class FlightRepositoryImpl implements FlightRepositoryCustom {

    각 방식 코드 구현
}
public interface FlightRepository extends JpaRepository<Flight, Long>, FlightRepositoryCustom {
}

구현한 인터페이스 사용 예

@RequiredArgsConstructor
@Service
public class FlightService {

    private final FlightRepository flightRepository;

    public List<FlightDto> getFlights(String dep, String arr, Boolean direct) {
        List<Flight> flights = flightRepository.searchFlights(dep, arr, direct);
        return flights.stream()
                      .map(FlightDto::from)
                      .collect(Collectors.toList());
    }
}

queryDsl 방식(qclass오류주의)

  • 타입 안정성(IDE에서 필드 자동완성)
  • 조건이 많아져도 깔끔하게 분기 가능
  • 실무에서 자주 사용

사용흐름

1. Gradle 설정 → Q클래스 생성

2. JPAQueryFactory 사용해서 동적 조건 조립

3. selectFrom(Q타입).where(조건).fetch()로 데이터 조회

 

 

 

service및 custom repositry

private final JPAQueryFactory queryFactory;

public List<Flight> findFlights(String departure, String arrival, Boolean direct) {
    QFlight flight = QFlight.flight;
    BooleanBuilder builder = new BooleanBuilder();

    if (departure != null) {
        builder.and(flight.departure.eq(departure));
    }
    if (arrival != null) {
        builder.and(flight.arrival.eq(arrival));
    }
    if (direct != null) {
        builder.and(flight.direct.eq(direct));
    }

    return queryFactory.selectFrom(flight)
                       .where(builder)
                       .fetch();
}

querydsl설정

@Configuration
public class QuerydslConfig {

    @PersistenceContext
    private EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(em);
    }
}

JPA Criteria API 방식

  • 순수 JPA 방식 (Spring Boot에서 추가 라이브러리 필요 없음)
  • 타입 안정성은 있지만 복잡하고 비직관적
  • 실무에서는 보통 QueryDSL로 대체

service 및 customrepository

@RequiredArgsConstructor
public class FlightRepositoryImpl implements FlightRepositoryCustom {

    private final EntityManager em;

    @Override
    public List<Flight> searchFlights(String departure, String arrival, Boolean direct) {
        CriteriaBuilder cb = em.getCriteriaBuilder();
        CriteriaQuery<Flight> cq = cb.createQuery(Flight.class);
        Root<Flight> flight = cq.from(Flight.class);

        List<Predicate> predicates = new ArrayList<>();

        if (departure != null) {
            predicates.add(cb.equal(flight.get("departure"), departure));
        }
        if (arrival != null) {
            predicates.add(cb.equal(flight.get("arrival"), arrival));
        }
        if (direct != null) {
            predicates.add(cb.equal(flight.get("direct"), direct));
        }

        cq.where(predicates.toArray(new Predicate[0]));
        return em.createQuery(cq).getResultList();
    }
}

 

 

MyBatis (동적 SQL with XML)

  • SQL을 직접 제어 가능 (쿼리 성능 튜닝에 유리)
  • if, choose, where 태그로 유연한 쿼리 작성 가능
  • JPA보다 복잡한 SQL에서 유리하지만, XML 양이 많아짐

xml파일

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.example.mapper.FlightMapper">

    <select id="searchFlights" resultType="com.example.domain.Flight">
        SELECT * FROM flight
        <where>
            <if test="departure != null">
                AND departure = #{departure}
            </if>
            <if test="arrival != null">
                AND arrival = #{arrival}
            </if>
            <if test="direct != null">
                AND direct = #{direct}
            </if>
        </where>
    </select>

</mapper>

 

mapper interface

@Mapper
public interface FlightMapper {

    List<Flight> searchFlights(@Param("departure") String departure,
                               @Param("arrival") String arrival,
                               @Param("direct") Boolean direct);
}

service단에서 실사용

@Service
@RequiredArgsConstructor
public class FlightService {

    private final FlightMapper flightMapper;

    public List<Flight> getFilteredFlights(String dep, String arr, Boolean direct) {
        return flightMapper.searchFlights(dep, arr, direct);
    }
}

 

3. Specification (Spring JPA)

 

  • JpaSpecificationExecutor를 이용해 조건을 조립식으로 작성 가능.
  • 필터 조건이 많거나 복잡할 때 유용.

기본설정

public interface FlightRepository extends JpaRepository<Flight, Long>,
                                          JpaSpecificationExecutor<Flight> {
}

필터 조건 정의 파일

public class FlightSpecifications {

    public static Specification<Flight> hasDeparture(String departure) {
        return (root, query, cb) -> departure == null
                ? null
                : cb.equal(root.get("departure"), departure);
    }

    public static Specification<Flight> hasArrival(String arrival) {
        return (root, query, cb) -> arrival == null
                ? null
                : cb.equal(root.get("arrival"), arrival);
    }

    public static Specification<Flight> isDirect(Boolean direct) {
        return (root, query, cb) -> direct == null
                ? null
                : cb.equal(root.get("direct"), direct);
    }
}

service단에서 사용

@RequiredArgsConstructor
@Service
public class FlightService {

    private final FlightRepository flightRepository;

    public List<Flight> searchFlights(String departure, String arrival, Boolean direct) {

        Specification<Flight> spec = Specification
                .where(FlightSpecifications.hasDeparture(departure))
                .and(FlightSpecifications.hasArrival(arrival))
                .and(FlightSpecifications.isDirect(direct));

        return flightRepository.findAll(spec);
    }
}

4. ElasticSearch / NoSQL 필터링

 

  • 데이터가 많고 복잡한 검색 조건 (ex. 상품 검색, 항공편 다중 조건 등)이 있다면 RDB가 아닌 검색 엔진을 도입.
  • 필터링 조건을 JSON 형태로 보내고, ElasticSearch에서 처리.

기본설정(의존성추가)

implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'

 

도메인 매핑(문서)

ElasticSearch에서 문서는 하나의 json객체 형식의 데이터 단위

"문서" = "검색/필터링/정렬 대상 데이터"

Spring에서 ElasticSearch를 쓸 때, 우리가 JSON 대신 사용하기 쉽게
Java 클래스 하나에 매핑해서 사용하는데, 이걸 흔히 Document 클래스, ES 엔티티라고 부름

@Document(indexName = "flights") // ES의 flights 인덱스에 저장됨
public class FlightDocument {
    @Id
    private String id;
    private String departure;
    private String arrival;
    private String departureDate;
    private String airline;
    private Integer price;
    private Boolean direct;
}

문서 예시

{
  "id": "F1234",
  "departure": "ICN",
  "arrival": "NRT",
  "departureDate": "2025-04-10",
  "airline": "Asiana",
  "price": 320000,
  "direct": true
}

repository정의

public interface FlightSearchRepository extends ElasticsearchRepository<FlightDocument, String> {
    List<FlightDocument> findByDepartureAndArrivalAndDirect(String departure, String arrival, Boolean direct);
}

복잡한 쿼리 일시 커스텀 repository사용

예시

  • 출발지: ICN
  • 도착지: NRT
  • 직항만
  • 가격은 50만원 이하
  • 가격 오름차순 정렬
  • 1페이지(0번), 10개씩

 

SearchHits<FlightDocument> searchFlights(String departure, String arrival, Boolean direct) {
    BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();

    if (departure != null) {
        boolQuery.must(QueryBuilders.termQuery("departure", departure));
    }

    if (arrival != null) {
        boolQuery.must(QueryBuilders.termQuery("arrival", arrival));
    }

    if (direct != null) {
        boolQuery.must(QueryBuilders.termQuery("direct", direct));
    }

    boolQuery.filter(QueryBuilders.rangeQuery("price").lte(500000));

    NativeSearchQuery query = new NativeSearchQueryBuilder()
            .withQuery(boolQuery)
            .withSort(Sort.by("price").ascending())
            .withPageable(PageRequest.of(0, 10))
            .build();

    return elasticsearchRestTemplate.search(query, FlightDocument.class);
}

추가설명 더 복잡한 쿼리 전체 작동 방식(정렬 및 페이징도 포함해서 처리 할 수 있음)

구조

 

  • FlightSearchRepositoryCustom ← 인터페이스
  • FlightSearchRepositoryImpl ← 구현 (검색 쿼리 조립)
  • FlightSearchRepository ← 통합 리포지토리
  • FlightSearchService ← 비즈니스 호출

 

public interface FlightSearchRepositoryCustom {
    List<FlightDocument> advancedSearch(String departure, String arrival, List<String> airlines,
                                        Integer minPrice, Integer maxPrice, Boolean direct);
}
@RequiredArgsConstructor
public class FlightSearchRepositoryImpl implements FlightSearchRepositoryCustom {

    private final ElasticsearchRestTemplate elasticsearchRestTemplate;

    @Override
    public List<FlightDocument> advancedSearch(String departure, String arrival, List<String> airlines,
                                               Integer minPrice, Integer maxPrice, Boolean direct) {

        BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();

        // ① match 검색 (부분 검색 가능)
        if (departure != null) {
            boolQuery.must(QueryBuilders.matchQuery("departure", departure));
        }

        if (arrival != null) {
            boolQuery.must(QueryBuilders.matchQuery("arrival", arrival));
        }

        // ② in 조건
        if (airlines != null && !airlines.isEmpty()) {
            boolQuery.must(QueryBuilders.termsQuery("airline", airlines));
        }

        // ③ 범위 검색 (가격)
        if (minPrice != null || maxPrice != null) {
            RangeQueryBuilder range = QueryBuilders.rangeQuery("price");
            if (minPrice != null) range.gte(minPrice);
            if (maxPrice != null) range.lte(maxPrice);
            boolQuery.filter(range);
        }

        // ④ 직항 여부
        if (direct != null) {
            boolQuery.must(QueryBuilders.termQuery("direct", direct));
        }

        // 전체 쿼리 조립
        NativeSearchQuery query = new NativeSearchQueryBuilder()
                .withQuery(boolQuery)
                .withSort(Sort.by("price").ascending())
                .withPageable(PageRequest.of(0, 20))
                .build();

        SearchHits<FlightDocument> hits = elasticsearchRestTemplate.search(query, FlightDocument.class);

        return hits.stream()
                .map(SearchHit::getContent)
                .collect(Collectors.toList());
    }
}
public interface FlightSearchRepository extends ElasticsearchRepository<FlightDocument, String>,
                                               FlightSearchRepositoryCustom {
}
@RequiredArgsConstructor
@Service
public class FlightSearchService {

    private final FlightSearchRepository flightSearchRepository;

    public List<FlightDocument> searchFlights(String departure, String arrival,
                                              List<String> airlines, Integer minPrice,
                                              Integer maxPrice, Boolean direct) {
        return flightSearchRepository.advancedSearch(departure, arrival, airlines, minPrice, maxPrice, direct);
    }
}

5. 필터 DTO를 활용한 통합 처리

프론트에서 모든 필터 조건을 하나의 객체로 전달 → 백엔드는 이를 받아 내부에서 분기 처리

public class FlightFilterRequest {
    private String departure;
    private String arrival;
    private LocalDate departureDate;
    private Boolean direct;
    private String seatClass;
    private Integer adultCount;
    // ...
}

 

 

필터링 추가 고려사항

 

  • 정렬 옵션 (price ASC, departureTime DESC)
  • 페이징 (Pageable)
  • 필터 조건 validation
  • 검색 속도 (인덱스 설정)