메서드 이름을 findByNameContaining처럼 정해진 규칙대로 지으면 Spring Data JPA가 SQL을 직접 만들어준다. 정확 일치·부분 일치·범위·정렬·조합·개수·존재 확인까지 6명의 실제 데이터로 검증했다. MariaDB의 LIKE는 기본적으로 대소문자를 구분하지 않는다는 것도 직접 확인했다.
들어가며
지난 글에서 JpaRepository를 상속하는 것만으로 저장·조회·수정·삭제가 전부 됐다. 그런데 "이름에 '길'이 들어간 회원만 찾고 싶다"거나 "나이가 25살 이상인 회원만 뽑고 싶다" 같은 조건은 JpaRepository가 기본으로 주는 메서드에 없다. findAll()로 전부 가져와서 자바 코드로 걸러내는 방법도 있지만 그러면 DB가 할 수 있는 일을 애플리케이션 메모리에서 다시 하는 셈이다.
결론만 이야기하면 이렇다. MemberRepository 인터페이스에 findByNameContaining(String keyword)처럼 정해진 규칙대로 메서드 이름만 지어 놓으면, 구현 코드를 한 줄도 안 써도 Spring Data JPA가 이름을 분석해서 SQL을 직접 만들어준다.
List<Member> findByNameContaining(String keyword);
이 메서드 하나만 인터페이스에 선언하면, 실제로는 이런 SQL이 나가는 것을 확인할 수 있다.
select m1_0.id, m1_0.age, m1_0.email, m1_0.name
from member m1_0
where m1_0.name like ? escape '\\'
본 글에서는 정확 일치·부분 일치·범위 비교·정렬·조건 조합·개수 세기·존재 확인까지 자주 쓰는 쿼리 메서드 패턴을 하나씩 실제로 만들어보고 show-sql로 켜놓은 콘솔에서 진짜 SQL이 어떻게 나가는지 직접 확인해보도록 하겠다.
준비물
| 항목 | 버전 | 비고 |
|---|---|---|
| JDK | 21 LTS | 지난 글과 동일 |
| Spring Boot | 3.3.4 | Spring Web + Validation + Data JPA |
| MariaDB | 10.11 | Ubuntu 24.04 apt 기본 패키지 버전 |
| MariaDB Connector/J | 3.4.1 | JDBC 드라이버 |
| 이전 프로젝트 | 이전 프로젝트 | Member, MemberRepository, MemberController가 있는 상태 |
demo 프로젝트를 그대로 이어서 쓴다. Member 엔티티에 age(나이) 필드 하나만 추가하고, MemberRepository에 쿼리 메서드를 늘려가는 방식으로 진행한다.
따라 하기
Step 1. Member에 age 필드 추가하기
범위 비교(GreaterThan 등)를 보여주려면 숫자 필드가 하나 필요하다. Member.java에 age를 추가한다.
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private Integer age;
// ... 생성자·getter·setter는 아래 "전체 코드" 참고
}
애플리케이션을 재시작하면 ddl-auto: update가 컬럼을 추가하는 것을 로그에서 확인할 수 있다.
Hibernate:
alter table if exists member
add column age integer not null
여기서 미리 확인해볼 게 하나 있다. 이미 데이터가 들어있는 테이블에 NOT NULL 컬럼을 추가했는데, 기존 행은 어떻게 됐을까 싶어서 직접 조회해봤다.
$ mysql -u demo -pdemo1234 demo -e "SELECT * FROM member;"
id email name age
1 hong@example.com 홍길동 0
2 kim@example.com 김철수 0
age는 자동으로 0이 채워진 것을 확인할 수 있다. MariaDB가 NOT NULL인데 DEFAULT를 안 준 컬럼을 추가할 때, 정수 타입이면 묵시적으로 0을 채워 넣기 때문이다. INSERT 문에서 값을 빼먹으면 에러가 나는 것과는 다르게 동작한다는 점이 눈에 띄었다. 실제 서비스라면 이렇게 조용히 채워진 0살짜리 회원을 그대로 두면 안 되고 마이그레이션 스크립트에서 실제 나이로 갱신하는 절차가 필요하다.
ALTER TABLE ... ADD COLUMN age 로그가 찍히면 이 단계는 끝난 것이다.
Step 2. 정확히 일치하는 값 찾기 — findByName
MemberRepository에 메서드 선언 한 줄만 추가한다.
List<Member> findByName(String name);
컨트롤러에 엔드포인트를 붙인다.
@GetMapping("/members/search/by-name")
public List<MemberResponse> searchByName(@RequestParam String name) {
return memberRepository.findByName(name).stream()
.map(MemberResponse::from)
.toList();
}
findBy 뒤에 오는 Name은 Member 엔티티의 name 필드를 그대로 가리킨다. 대소문자만 다를 뿐 필드 이름과 정확히 일치해야 한다. 실행하면 이런 SQL이 나가는 것을 확인할 수 있다.
select m1_0.id, m1_0.age, m1_0.email, m1_0.name
from member m1_0
where m1_0.name=?
Step 3. 부분 일치 검색 — findByNameContaining
이름에 특정 글자가 들어가는 회원을 전부 찾고 싶으면 Containing을 붙인다.
List<Member> findByNameContaining(String keyword);
Containing은 SQL의 LIKE '%keyword%'로 변환된다. 실행해보면 이렇게 나간다.
select m1_0.id, m1_0.age, m1_0.email, m1_0.name
from member m1_0
where m1_0.name like ? escape '\\'
escape '\\'가 붙는 이유는, 검색어 안에 우연히 %나 _ 같은 LIKE의 특수문자가 들어있어도 리터럴 문자로 취급하기 위해서다. 직접 신경 쓸 필요 없이 Hibernate가 알아서 처리해준다.
Step 4. 범위 비교와 정렬 — findByAgeGreaterThanEqualOrderByAgeDesc
나이가 특정 값 이상인 회원을, 나이가 많은 순서로 뽑고 싶으면 메서드 이름에 조건과 정렬을 이어 붙이면 된다.
List<Member> findByAgeGreaterThanEqualOrderByAgeDesc(int minAge);
GreaterThanEqual은 >=로, OrderByAgeDesc는 ORDER BY age DESC로 바뀐다.
select m1_0.id, m1_0.age, m1_0.email, m1_0.name
from member m1_0
where m1_0.age>=?
order by m1_0.age desc
Step 5. 조건 두 개 조합하기 — And
이름 조건과 나이 조건을 동시에 걸고 싶으면 두 조건 사이에 And를 넣는다.
List<Member> findByNameContainingAndAgeGreaterThanEqual(String keyword, int minAge);
메서드 이름이 길어 보이지만 규칙은 단순하다. NameContaining(이름 부분 일치) + And + AgeGreaterThanEqual(나이 이상), 순서 그대로 파라미터도 (keyword, minAge) 순서로 받는다.
select m1_0.id, m1_0.age, m1_0.email, m1_0.name
from member m1_0
where m1_0.name like ? escape '\\'
and m1_0.age>=?
Step 6. 개수만 세기 / 존재 여부만 확인하기
전체 데이터를 가져올 필요 없이 개수나 존재 여부만 필요할 때가 있다. 이럴 땐 find 대신 count, exists를 접두어로 쓴다.
long countByAgeGreaterThanEqual(int minAge);
boolean existsByEmail(String email);
countByAgeGreaterThanEqual은 이렇게 나간다.
select count(m1_0.id)
from member m1_0
where m1_0.age>=?
existsByEmail을 실행해보고 눈에 띈 부분이 있다. 컬럼을 전부 가져오는 게 아니라 id만, 그것도 limit을 걸어서 조회한다는 점이다.
select m1_0.id
from member m1_0
where m1_0.email=?
limit ?
id + limit 1로 끝낸다. 회원가입 전에 이메일 중복을 미리 확인하고 싶을 때, findByEmail(...).isPresent()보다 existsByEmail(...) 쪽이 더 가볍다.
Step 7. 컨트롤러에 검색 엔드포인트 모아 붙이기
지금까지 만든 쿼리 메서드를 전부 엔드포인트로 노출한다. 전체 코드는 아래 "전체 코드" 절에 있다.
/members/search/by-name, /members/search/by-keyword, /members/search/by-age, /members/search, /members/count, /members/exists 6개 엔드포인트가 전부 응답하면 이 단계는 끝난 것이다.
전체 코드 (복붙용)
package com.example.demo;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private Integer age;
protected Member() {
}
public Member(String name, String email, Integer age) {
this.name = name;
this.email = email;
this.age = age;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public Integer getAge() {
return age;
}
public void setAge(Integer age) {
this.age = age;
}
}
package com.example.demo;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface MemberRepository extends JpaRepository<Member, Long> {
// 정확히 일치 - WHERE name = ?
List<Member> findByName(String name);
// 단건 조회. email에 unique 제약이 있으니 결과는 0개 아니면 1개
Optional<Member> findByEmail(String email);
// 부분 일치 - WHERE name LIKE %keyword%
List<Member> findByNameContaining(String keyword);
// 이상 비교 + 내림차순 정렬 - WHERE age >= ? ORDER BY age DESC
List<Member> findByAgeGreaterThanEqualOrderByAgeDesc(int minAge);
// 조건 조합(AND) - WHERE name LIKE %keyword% AND age >= ?
List<Member> findByNameContainingAndAgeGreaterThanEqual(String keyword, int minAge);
// 개수만 세기 - SELECT count(*) ... WHERE age >= ?
long countByAgeGreaterThanEqual(int minAge);
// 존재 여부만 확인 - SELECT id ... WHERE email = ? LIMIT ? (엔티티를 통째로 안 가져옴)
boolean existsByEmail(String email);
}
package com.example.demo;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public record MemberRequest(
@NotBlank(message = "name은 비어 있을 수 없습니다") String name,
@NotBlank(message = "email은 비어 있을 수 없습니다")
@Email(message = "email 형식이 올바르지 않습니다") String email,
@NotNull(message = "age는 비어 있을 수 없습니다")
@Min(value = 0, message = "age는 0 이상이어야 합니다") Integer age
) {
}
package com.example.demo;
public record MemberResponse(Long id, String name, String email, Integer age) {
public static MemberResponse from(Member member) {
return new MemberResponse(member.getId(), member.getName(), member.getEmail(), member.getAge());
}
}
package com.example.demo;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
@RestController
public class MemberController {
private final MemberRepository memberRepository;
public MemberController(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@PostMapping("/members")
public MemberResponse create(@Valid @RequestBody MemberRequest request) {
Member saved = memberRepository.save(new Member(request.name(), request.email(), request.age()));
return MemberResponse.from(saved);
}
// 이름 정확히 일치 - GET /members/search/by-name?name=홍길동
@GetMapping("/members/search/by-name")
public List<MemberResponse> searchByName(@RequestParam String name) {
return memberRepository.findByName(name).stream()
.map(MemberResponse::from)
.toList();
}
// 이름 부분 일치 - GET /members/search/by-keyword?keyword=길동
@GetMapping("/members/search/by-keyword")
public List<MemberResponse> searchByKeyword(@RequestParam String keyword) {
return memberRepository.findByNameContaining(keyword).stream()
.map(MemberResponse::from)
.toList();
}
// 나이 조건 + 정렬 - GET /members/search/by-age?minAge=20
@GetMapping("/members/search/by-age")
public List<MemberResponse> searchByAge(@RequestParam int minAge) {
return memberRepository.findByAgeGreaterThanEqualOrderByAgeDesc(minAge).stream()
.map(MemberResponse::from)
.toList();
}
// 조건 조합(AND) - GET /members/search?keyword=길동&minAge=20
@GetMapping("/members/search")
public List<MemberResponse> search(@RequestParam String keyword, @RequestParam int minAge) {
return memberRepository.findByNameContainingAndAgeGreaterThanEqual(keyword, minAge).stream()
.map(MemberResponse::from)
.toList();
}
// 개수만 세기 - GET /members/count?minAge=20
@GetMapping("/members/count")
public Map<String, Long> count(@RequestParam int minAge) {
return Map.of("count", memberRepository.countByAgeGreaterThanEqual(minAge));
}
// 존재 여부만 확인 - GET /members/exists?email=hong@example.com
@GetMapping("/members/exists")
public Map<String, Boolean> exists(@RequestParam String email) {
return Map.of("exists", memberRepository.existsByEmail(email));
}
}
build.gradle, application.yml, GlobalExceptionHandler.java는 지난 글과 동일하다.
쿼리 메서드 이름이 SQL로 바뀌는 흐름
graph LR
A["findByNameContainingAndAgeGreaterThanEqual"] --> B["Spring Data가 메서드 이름 파싱"]
B --> C["Name Containing → LIKE %?%<br/>Age GreaterThanEqual → >= ?"]
C --> D["And → WHERE 절 AND 결합"]
D --> E["PartTree → JPQL/SQL 생성"]
E --> F[("MariaDB 실행")]
실행 결과 확인
실제로 회원 6명(홍길동 25세, 홍길순 31세, 김철수 19세, 이영희 42세, 박민수 28세, David Kim 30세)을 저장한 뒤 각 검색 엔드포인트를 순서대로 호출해서 나온 결과를 그대로 옮겨왔다.
| 순서 | 요청 | 결과 |
|---|---|---|
| 1 | GET /members/search/by-name?name=홍길동 | [{"id":1,"name":"홍길동","email":"hong@example.com","age":25}] |
| 2 | GET /members/search/by-keyword?keyword=길 | 홍길동, 홍길순 2명 |
| 3 | GET /members/search/by-age?minAge=25 | 이영희(42), 홍길순(31), David Kim(30), 박민수(28), 홍길동(25) — 나이 내림차순 |
| 4 | GET /members/search?keyword=길&minAge=30 | [{"id":2,"name":"홍길순",...,"age":31}] 1명만 |
| 5 | GET /members/count?minAge=25 | {"count":5} |
| 6 | GET /members/exists?email=hong@example.com | {"exists":true} |
| 7 | GET /members/exists?email=nobody@example.com | {"exists":false} |
3번 결과를 보면 OrderByAgeDesc가 실제로 나이 순서를 뒤집어서 반환하는 것을 확인할 수 있다. 4번은 "이름에 '길'이 들어가면서 30살 이상"인 조건이라 홍길동(25살, 조건 미달)은 빠지고 홍길순(31살)만 남는다.
한 가지 더 확인한 것 — Containing의 대소문자 처리
David Kim이라는 영문 이름을 하나 추가하고, 소문자 david와 대문자 DAVID로 각각 검색해봤다.
$ curl "http://localhost:8080/members/search/by-keyword?keyword=david"
$ curl "http://localhost:8080/members/search/by-keyword?keyword=DAVID"
[{"id":6,"name":"David Kim","email":"david@example.com","age":30}]
[{"id":6,"name":"David Kim","email":"david@example.com","age":30}]
둘 다 똑같이 찾아지는 것을 확인할 수 있다. Java 코드나 JPQL 어디에도 대소문자를 무시하라는 설정을 안 넣었는데 왜 이럴까 싶어서 컬럼 정보를 확인해봤다.
$ mysql -u demo -pdemo1234 demo -e "SHOW FULL COLUMNS FROM member WHERE Field='name';"
Field Type Collation Null Key Default Extra
name varchar(255) utf8mb4_general_ci NO
utf8mb4_general_ci의 ci가 case-insensitive(대소문자 구분 안 함)라는 뜻이다. MariaDB는 기본 콜레이션이 대소문자를 구분하지 않기 때문에, LIKE 검색도 자동으로 대소문자 무관하게 동작한다. PostgreSQL이었다면 LIKE는 기본적으로 대소문자를 구분해서 ILIKE를 따로 써야 하는데, MariaDB에서는 신경 쓸 필요가 없었다. 반대로 말하면, 일부러 대소문자를 구분하고 싶다면 컬럼에 utf8mb4_bin 같은 구분 콜레이션을 지정해야 한다는 뜻이기도 하다.
[TODO: 본인 환경에서 실행한 터미널 화면을 여기에 캡처해서 추가하세요]
자주 만나는 에러와 해결

에러 1: 메서드 이름에 필드명을 잘못 써서 애플리케이션이 아예 안 뜸
findByName을 findByNaem처럼 오타를 내고 실행해보면 이렇게 뜬다.
org.springframework.data.mapping.PropertyReferenceException: No property 'naem' found for type 'Member'; Did you mean 'name'
원인: Spring Data JPA는 메서드 이름을 애플리케이션이 뜨는 시점에 미리 파싱해서 쿼리를 만들어둔다. Member 엔티티에 naem이라는 필드가 없으니 쿼리를 만들다가 그대로 실패한다.
특징: 이 에러는 /members/search/...를 호출했을 때가 아니라, 애플리케이션을 시작하는 순간 바로 터진다. 잘못된 쿼리 메서드가 있으면 서버 자체가 안 뜬다. 처음엔 당황스러웠는데, 오히려 이게 나은 방식이라는 걸 깨달았다. 요청이 들어왔을 때 터지는 게 아니라 배포 직후 바로 알 수 있으니, 오타 있는 코드가 운영 서버에 올라가는 걸 막아준다.
해결: 에러 메시지의 "Did you mean 'name'"을 그대로 참고해서 메서드 이름을 고치면 된다. Spring이 친절하게 후보를 제시해주는 편이라 오타 자체는 찾기 어렵지 않다.
에러 2: NOT NULL 컬럼을 추가했는데 기존 행이 이상한 기본값으로 채워짐
Step 1에서 확인한 내용이다. age 컬럼을 NOT NULL로 추가했는데도 에러 없이 통과됐고, 기존 두 행의 age는 0으로 채워졌다.
원인: MariaDB가 ALTER TABLE ... ADD COLUMN에서 DEFAULT를 명시하지 않은 NOT NULL 정수 컬럼에 묵시적 기본값(0)을 채워 넣기 때문이다. INSERT할 때 값을 빼먹으면 막히는 것과는 다른 동작이라 헷갈리기 쉽다.
해결: 컬럼을 새로 추가한 뒤에는 SELECT * FROM member WHERE age = 0처럼 의심 가는 조건으로 기존 행을 직접 조회해보는 습관이 필요하다. 실제 값으로 채워야 한다면 UPDATE 문으로 마이그레이션을 한 번 더 돌려야 한다. ddl-auto: update는 스키마만 맞춰줄 뿐 데이터의 의미까지 챙겨주지는 않는다.
에러 3: 조건을 다 채웠는데 결과가 하나도 안 나옴
findByNameContainingAndAgeGreaterThanEqual처럼 조건이 여러 개인 메서드를 쓰다 보면, 파라미터 순서를 메서드 이름 순서와 다르게 넘겨서 원하는 결과가 안 나오는 경우가 있다.
// 메서드 이름 순서: NameContaining, AgeGreaterThanEqual
List<Member> findByNameContainingAndAgeGreaterThanEqual(String keyword, int minAge);
다행히 타입이 다르면(String과 int) 컴파일 단계에서 바로 걸린다. 문제는 조건이 전부 같은 타입(예: String 두 개)일 때다. 그럴 땐 컴파일러가 못 잡아주므로, 메서드 이름에 적힌 조건 순서와 파라미터 순서가 항상 같은지 눈으로 한 번 더 확인하는 수밖에 없다.
findBy+ 필드명 + 키워드(Containing/GreaterThanEqual/OrderBy/And)만 규칙대로 조합하면 SQL이 자동으로 만들어진다- 쿼리 메서드는 애플리케이션 시작 시점에 미리 검증된다 — 오타는 요청 전에 바로 걸린다(fail-fast)
existsBy...는id+limit만 조회해서findBy...().isPresent()보다 가볍다- MariaDB 기본 콜레이션(
utf8mb4_general_ci)은 대소문자를 구분하지 않아Containing검색도 대소문자 무관하게 동작한다 - 조건이 많아 메서드 이름이 너무 길어지면
@Query나 QueryDSL로 넘어가는 게 낫다
마무리
findBy 접두어에 필드 이름과 Containing, GreaterThanEqual, And, OrderBy 같은 키워드를 정해진 규칙대로 조합하는 것만으로 SQL을 직접 안 짜고도 조건 검색이 되는 걸 확인했다. show-sql로 콘솔에 찍히는 진짜 SQL을 하나씩 대조해보니, 메서드 이름이 곧 SQL의 설계도라는 게 감이 잡혔다.
동시에 한계도 보였다. 조건이 두세 개 정도는 메서드 이름이 읽을 만한데, 조건이 대여섯 개로 늘어나면 이름이 한 줄을 넘어가서 오히려 가독성이 떨어질 것 같다. 그런 경우엔 @Query로 JPQL을 직접 쓰거나 QueryDSL 같은 도구를 쓰는 게 낫다는 이야기를 들었는데, 이 부분은 다음에 직접 확인해볼 예정이다.
다음 단계로 해볼 것들.
- 서비스 계층 분리와
@Transactional경계: 지금은 컨트롤러가 리포지토리를 직접 부르고 있는데, 서비스 계층을 분리하고 트랜잭션 경계를 어디에 둬야 하는지 @OneToMany연관관계: 회원 하나가 여러 개의 주문을 갖는 구조 만들어보기- 조건이 많아질 때: 메서드 이름이 너무 길어지는 시점에
@Query나 QueryDSL로 넘어가는 기준
existsByEmail이 id만 limit 1로 가볍게 확인한다는 걸 실제 SQL로 눈으로 보고 나니, 앞으로 단순 존재 확인엔 findBy...().isPresent() 대신 existsBy...를 먼저 떠올리게 될 것 같다.
이전 글: Spring Boot에 MariaDB 연결하고 JPA로 첫 CRUD 만들기
다음 글: 서비스 계층 분리와 @Transactional 경계 (예정)
'개발 이야기 > Spring Boot' 카테고리의 다른 글
| 포인트 이력에서 회원 이름을 보려면 매번 따로 조회해야 했다 - @OneToMany 연관관계 매핑과 Jackson 무한 재귀 (0) | 2026.07.08 |
|---|---|
| 회원가입은 됐는데 포인트가 안 쌓였다 - 서비스 계층 분리와 @Transactional 트랜잭션 경계 (0) | 2026.07.07 |
| Spring Boot에 MariaDB 연결하고 JPA로 첫 CRUD 만들기 (0) | 2026.07.03 |
| Spring Boot POST 요청 받기 — @RequestBody, 레코드, @Valid로 회원가입 API 완성하기 (0) | 2026.07.02 |
| Spring Boot 첫 프로젝트 만들기 — start.spring.io 세팅부터 첫 GET API 실행까지 (0) | 2026.06.30 |