본문 바로가기

개발

[스프링부트] JPA Specification 이용한 멤버 조회 구현

※ 어드민 기능: Member 조회
1. 기간 설정 (생일, 가입일)
2. 필터링 조건 설정 (MemberId, 이메일, 닉네임, 생년월일, 성별, 권한, 임신 유무, 흡연 유무, 고혈압 유무, 당뇨병 유무)
→ Member 객체 (MemberId, 이메일, 닉네임, 생년월일, 성별, 권한), Profile 객체 (임신 유무, 흡연 유무, 고혈압 유무, 당뇨병 유무)
→ Profile은 메인 프로필만 사용한다. (추가 프로필은 사용 X)
→ 이메일, 닉네임은 대소문자를 구분하지 않는다.
조건이 두가지만 들어가서 필터링 자체는 간단하다. QueryDsl보다는 JPA Specification이 더 적합하다. 

 

JPA Specification

JPA Specification은 JPA의 Criteria API를 기반으로 하며, 동적 쿼리 생성을 위한 표준 JPA API입니다.

주요 특징:

  • 표준화: JPA Specification은 JPA의 표준 API이므로, 모든 JPA 구현체에서 호환됩니다.
  • 동적 쿼리: Specification 객체를 사용하여 동적으로 쿼리를 조합할 수 있습니다.
  • 유연성: 쿼리 조건을 메서드 체이닝으로 조합할 수 있으며, 복잡한 쿼리 조건을 표현할 수 있습니다.
  • 설정 간편성: JPA Repository와 통합되어 있어, JpaSpecificationExecutor를 통해 쉽게 사용할 수 있습니다.

적합한 상황:

  • 표준 JPA 환경: JPA를 사용하는 프로젝트에서 JPA의 표준 API를 사용하여 동적 쿼리를 생성하고 싶을 때.
  • 간단한 쿼리: 복잡하지 않은 동적 쿼리를 작성할 때.
  • JPA에 익숙한 개발자: JPA의 기존 메커니즘에 익숙하고 추가적인 의존성을 피하고 싶을 때.

 

[module-common] → Member default-profile 컬럼 추가

: 필터링시 메인(default) 프로필만 사용하는데, 프로필 DB의 owner값이 true를 찾아 처리하니 복잡해져서 default-profile에 대한 컬럼을 추가했음.

@NoArgsConstructor
@Getter
@Setter
@Entity
public class Member {

    @Id
    @Column(name = "member_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true)
    private String email;

    @Column(unique = true)
    private String nickname;

    @Column(unique = true)
    private String phone;

    @Enumerated(EnumType.STRING)
    private Gender gender;

    @Column
    private String password;

    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate birthday;

    @DateTimeFormat(pattern = "yyyy-MM-dd")
    @Column(name = "created_at")
    private LocalDate createdAt;

    @Enumerated(EnumType.STRING)
    private Role role;

    @Enumerated(EnumType.STRING)
    private Provider provider;

    @OneToOne(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
    @JoinColumn(name = "default_profile_id")
    private Profile defaultProfile;

    @OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
    private List<Profile> profiles = new ArrayList<Profile>();
    
    .
    .
    
}

 

 

[module-admin] → AdminController

: 필터링 조건

   - filed: id, email, nickname, gender, role

  - value: filed 값

 

  - profileFiled: pregnancy, smoking, hypertension, diabetes

  - profileValue: YES, NO, UNKNOWN

  

  - dateFiled: birthday, createdAt

  - startDate, endDate

@GetMapping("/search")
public ResponseEntity<List<FilterResponse>> searchMembers(
        @RequestParam(required = false) String field,
        @RequestParam(required = false) Object value,
        @RequestParam(required = false) String profileField,
        @RequestParam(required = false) Object profileValue,
        @RequestParam(required = false) LocalDate startDate,
        @RequestParam(required = false) LocalDate endDate,
        @RequestParam(required = false) String dateField
) {
    List<FilterResponse> responses = adminService.searchMembers(field, value, profileField, profileValue, startDate, endDate, dateField);
    return ResponseEntity.ok(responses);
}

 

 

[module-admin] → AdminServiceImpl

spec.and를 사용하여 여러 조건 결합

public List<FilterResponse> searchMembers(String field, Object value, String profileField, Object profileValue, LocalDate startDate, LocalDate endDate, String dateField) {
        Specification<Member> spec = Specification.where(null);

        if (value != null) {
            spec = spec.and(MemberSpecification.filterByField(field, value));
        }

        if (profileValue != null) {
            spec = spec.and(MemberSpecification.filterByProfileField(profileField, profileValue));
        }

        if (startDate != null && endDate != null && dateField != null) {
            spec = spec.and(MemberSpecification.filterByDateRange(dateField, startDate, endDate));
        }
        List<Member> members = memberRepository.findAll(spec);
        return members.stream()
                .map(MemberMapper::toFilterResponse)
                .collect(Collectors.toList());
    }

 

[module-admin] → MemberSpecification

<filterByField>

: field에 해당하는 value를 가지고 일치하는 데이터 반환. 이메일과 닉네임은 해당 value가 포함될 경우 반환

Like VS Contains

1) 간단한 패턴 매칭: LIKE는 간단한 문자열 패턴 매칭에는 적합하지만, 인덱스의 활용 여부에 따라 성능이 저하될 수 있습니다.
2) 전문 검색: CONTAINS는 대량의 텍스트 데이터를 처리하는 데 최적화되어 있으며, Full-Text Search 인덱스를 활용하여 검색 성능을 높입니다. 대량의 데이터를 처리할 때 CONTAINS가 더 나은 성능을 보일 수 있습니다.

JPA Specification에서는 contains를 사용할 수 없고 like만 사용가능하다. 만약 더 좋은 성능이 필요하다면 이후에 QueryDsl로 변경하도록 하자
 public static Specification<Member> filterByField(String field, Object value) {
        return (root, query, criteriaBuilder) -> {
            if (field != null && value != null) {
                switch (field) {
                    case "id":
                        return criteriaBuilder.equal(root.get("id"), value);
                    case "email":
                        // 대소문자를 무시하고 포함 여부 검사
                        String lowerEmail = ((String) value).toLowerCase();
                        return criteriaBuilder.like(
                                criteriaBuilder.lower(root.get("email")),
                                "%" + lowerEmail + "%"
                        );
                    case "nickname":
                        // 대소문자를 무시하고 포함 여부 검사
                        String lowerNickname = ((String) value).toLowerCase();
                        return criteriaBuilder.like(
                                criteriaBuilder.lower(root.get("nickname")),
                                "%" + lowerNickname + "%"
                        );
                    case "gender":
                        return criteriaBuilder.equal(root.get("gender"), value);
                    case "role":
                        return criteriaBuilder.equal(root.get("role"), value);
                }
            }
            return null; // 아무 필터도 없을 경우 null 반환
        };
    }

 

 

<filterByProfileField>

: root.join 기능을 사용하여 Member와 Profile을 조인하여 필터링을 수행

public static Specification<Member> filterByProfileField(String profileField, Object profileValue) {
        return (root, query, criteriaBuilder) -> {
            if (profileField != null && profileValue != null) {
                Join<Member, Profile> profile = root.join("defaultProfile");
                switch (profileField) {
                    case "pregnancy":
                        return criteriaBuilder.equal(profile.get("pregnancy"), profileValue);
                    case "smoking":
                        return criteriaBuilder.equal(profile.get("smoking"), profileValue);
                    case "hypertension":
                        return criteriaBuilder.equal(profile.get("hypertension"), profileValue);
                    case "diabetes":
                        return criteriaBuilder.equal(profile.get("diabetes"), profileValue);
                }
            }
            return null;
        };
    }

 

 

<filterByDateRange>

public static Specification<Member> filterByDateRange(String dateField, LocalDate startDate, LocalDate endDate) {
    return (root, query, criteriaBuilder) -> {
        if (startDate != null && endDate != null && dateField != null) {
            switch (dateField) {
                case "birthday":
                    return criteriaBuilder.between(root.get("birthday"), startDate, endDate);
                case "createdAt":
                    return criteriaBuilder.between(root.get("createdAt"), startDate, endDate);
            }
        }
        return null;
    };
}

 

 


Postman 테스트 성공