[백엔드 입문 #4] JPA Entity 매핑과 Repository 메서드 이름 규칙
지난 글에서 Spring이 객체를 대신 만들어 연결해 주는 의존성 주입(DI)을 정리했다. 이번에는 그동안 그냥 넘어갔던 부분을 정리한다. existsByDetailInformationCode처럼 메서드 이름만 썼는데 SQL이 실행되는 이유, 그리고 Java 객체가 DB 테이블과 어떻게 연결되는지다.
글에 나오는 코드는 이해를 돕기 위해 일부 단순화했다.
JPA가 하는 일
DB는 표(테이블)로 데이터를 저장하고, Java는 객체로 다룬다. 둘을 잇는 방법은 두 가지다.
// JPA 없이: SQL을 직접 쓰고, 결과를 손으로 객체에 옮김
ResultSet rs = db.query("SELECT * FROM feature_flag WHERE feature_flag_id = ?", 12);
FeatureFlag flag = new FeatureFlag();
flag.setCode(rs.getString("feature_flag_identifier_code"));
...
// JPA 사용: 객체 단위로 요청하면 SQL 생성과 변환을 대신 해 줌
FeatureFlag flag = featureFlagJpaRepository.findById(12L);
fetch 응답을 손으로 파싱하는 대신 Prisma 같은 ORM을 쓰는 것과 같다. JPA도 ORM(Object-Relational Mapping)이다.
① 테이블과 Entity를 나란히 놓고 보기
실제 테이블 정의:
CREATE TABLE `feature_flag` (
`feature_flag_id` bigint NOT NULL AUTO_INCREMENT,
`feature_flag_identifier_code` varchar(300) NOT NULL,
`feature_flag_status` varchar(50) NOT NULL,
`feature_flag_name` varchar(50) NOT NULL,
`feature_flag_description` varchar(500) DEFAULT NULL,
`registered_at` datetime NOT NULL,
`created_by` bigint NOT NULL,
`updated_by` bigint NOT NULL,
PRIMARY KEY (`feature_flag_id`)
);
이 테이블과 연결된 Entity:
@Entity // "이 클래스는 테이블과 연결된다"
@Table(name = "feature_flag") // 어떤 테이블과?
public class FeatureFlag {
@Id // PRIMARY KEY
@GeneratedValue(strategy = GenerationType.IDENTITY) // AUTO_INCREMENT
@Column(name = "feature_flag_id")
private Long id;
@Column(name = "created_by", nullable = false) // created_by ↔ createdBy
private Long createdBy;
@Column(name = "registered_at", nullable = false)
private LocalDateTime registeredAt; // datetime ↔ LocalDateTime
}
@Column(name = "...")은 컬럼 이름과 필드 이름을 짝지어 준다. DB는 snake_case, Java는 camelCase를 쓰니까 연결이 필요하다. API 응답의 created_by를 프론트에서 createdBy로 바꾸는 것과 같은 일이다.
| MySQL | Java |
|---|---|
bigint |
Long |
varchar |
String |
datetime |
LocalDateTime |
NOT NULL |
nullable = false |
AUTO_INCREMENT |
@GeneratedValue(IDENTITY) |
id가 long이 아니라 Long(대문자)인 이유도 여기서 이해됐다. new FeatureFlag(...)로 만든 직후에는 아직 DB에 저장되지 않아서 번호가 없다(null). 저장하는 순간 DB가 AUTO_INCREMENT로 번호를 매기고, JPA가 그 값을 id에 채운다.
② @Embedded: 테이블은 하나, 객체는 여러 개
Entity를 보면 code, status, name 컬럼이 바로 보이지 않는다. 묶여 있다.
public class FeatureFlag {
@Embedded private FeatureFlagBasicInformation basicInformation; // name, description
@Embedded private FeatureFlagDetailInformation detailInformation; // code, status
}
@Embeddable // "나는 독립된 테이블이 아니라, 다른 Entity에 끼워 들어간다"
public class FeatureFlagDetailInformation {
@Column(name = "feature_flag_identifier_code", length = 300, nullable = false)
private String code;
@Enumerated(value = EnumType.STRING) // enum을 "ON"/"OFF" 문자열로 저장
@Column(name = "feature_flag_status", length = 50, nullable = false)
private FeatureFlagStatus status;
public FeatureFlagDetailInformation(String code) {
validateCode(code); // 2~100자 검증
this.code = code;
this.status = FeatureFlagStatus.OFF; // 처음엔 항상 OFF
}
}
DB에서는 컬럼이 한 줄에 평평하게 나열돼 있지만, Java에서는 관련 있는 것끼리 묶었다.
DB (평평함) Java (묶음)
feature_flag_identifier_code ──┐
feature_flag_status ──┴→ detailInformation { code, status }
feature_flag_name ──┐
feature_flag_description ──┴→ basicInformation { name, description }
평평한 API 응답을 { detail: {...}, basic: {...} }로 재구성하는 것과 비슷하다. 이렇게 묶으면 “code는 2~100자” 같은 규칙을 code를 가진 객체 안에 둘 수 있다. 생성자에서 검증하니까 잘못된 code를 가진 객체는 만들어질 수조차 없다.
③ @Enumerated(STRING)을 빼먹으면?
@Enumerated를 생략하면 JPA는 기본값인 EnumType.ORDINAL을 쓴다. enum을 이름이 아니라 순서 번호로 저장하는 방식이다.
public enum FeatureFlagStatus {
ON, // 0번
OFF; // 1번
}
| 방식 | DB에 저장되는 값 |
|---|---|
STRING |
"ON" / "OFF" |
ORDINAL (기본값) |
0 / 1 |
이미 "ON"으로 저장된 데이터가 있는 상태에서 이 줄을 지우면:
- 조회할 때 에러가 난다. JPA가
"ON"을 숫자로 바꾸려다 실패한다. - 더 무서운 건 저장이다. 새 플래그를 저장하면 에러 없이
"1"이 들어간다. 테이블에"ON","OFF","0","1"이 섞인다.
나중에 누군가 알아채고 @Enumerated(STRING)을 다시 붙여도, 이번에는 "1"이라는 이름의 enum이 없어서 또 에러가 난다. 그때는 코드만 고쳐서는 안 되고 DB 데이터까지 고쳐야 한다.
에러가 나는 버그보다 에러 없이 데이터가 이상해지는 버그가 더 위험하다.
프론트는 새로고침하면 상태가 초기화되지만, DB에 잘못 저장된 데이터는 누군가 직접 고치기 전까지 계속 남는다. 백엔드에서 가장 크게 다르게 느낀 감각이다. 그래서 실무에서는 enum에 거의 항상 STRING을 쓴다고 한다. ORDINAL을 쓰면 누가 enum 순서만 바꿔도 DB 데이터의 의미가 통째로 뒤바뀐다.
④ JpaRepository: 인터페이스만 쓰면 구현이 생긴다
public interface FeatureFlagJpaRepository extends JpaRepository<FeatureFlag, Long> {
// ↑ Entity ↑ id 타입
List<FeatureFlag> findAllByOrderByIdDesc();
boolean existsByDetailInformationCode(String code);
Optional<FeatureFlag> findByDetailInformationCode(String code);
}
interface라서 구현이 없다. 그런데도 동작한다. 서버가 켜질 때 Spring Data JPA가 구현체를 자동으로 만들어서 Bean으로 등록하기 때문이다(지난 글의 DI 덕분에 다른 곳에서 주입받아 쓸 수 있다).
JpaRepository를 상속하는 것만으로 기본 메서드가 생긴다.
| 메서드 | SQL |
|---|---|
save(flag) |
INSERT 또는 UPDATE |
findById(12L) |
SELECT ... WHERE feature_flag_id = 12 |
findAll() |
SELECT * FROM feature_flag |
delete(flag) |
DELETE ... |
메서드 이름이 곧 쿼리
직접 선언한 메서드는 이름을 문법대로 해석해서 SQL을 만든다.
existsByDetailInformationCode(String code)
└─┬──┘└┬┘└───────┬───────┘└┬─┘
exists By detailInformation .code
존재여부 조건 필드 경로 (Embedded 안으로 들어감)
SELECT COUNT(*) > 0 FROM feature_flag
WHERE feature_flag_identifier_code = ?
주의할 점은 이름이 DB 컬럼명이 아니라 Java 필드 경로를 따른다는 것이다. findByFeatureFlagIdentifierCode라고 쓰면 그런 필드가 없어서 서버 시작 시 에러가 난다.
직접 만들어 보기
문제 1: ON인 플래그를 전부 조회하기
처음에는 어떻게 써야 할지 감이 오지 않았다. 이미 있는 메서드를 조금씩 고쳐 가는 방식으로 접근하니 풀렸다.
출발점:
Optional<FeatureFlag> findByDetailInformationCode(String code);
1단계: 조건 필드를 code → status로 바꾼다. status도 detailInformation 안에 있으니 이름 끝만 바꾸고, 파라미터 타입도 status 필드 타입에 맞춘다.
Optional<FeatureFlag> findByDetailInformationStatus(FeatureFlagStatus status);
2단계: 여러 개를 받는다. ON인 플래그는 여러 개일 수 있다. Optional은 “0개 또는 1개”라서 결과가 2개 이상이면 에러가 난다.
List<FeatureFlag> findAllByDetailInformationStatus(FeatureFlagStatus status);
All은 읽기 쉽게 붙인 것이고, JPA는 이름보다 반환 타입을 보고 하나를 줄지 여러 개를 줄지 정한다.
SELECT * FROM feature_flag WHERE feature_flag_status = 'ON'
문제 2: OFF인 플래그를 최신순으로 조회하기
기존 메서드 두 개를 합치면 된다.
List<FeatureFlag> findAllByOrderByIdDesc();
List<FeatureFlag> findAllByDetailInformationStatus(FeatureFlagStatus status);
내가 처음 쓴 답:
findAllByOrderByDetailInformationStatusIdDesc(FeatureFlagStatus status);
재료는 맞았는데 순서가 틀렸다.
내 답: findAll By OrderBy DetailInformationStatus IdDesc
정답: findAll By DetailInformationStatus OrderBy IdDesc
└ 조건(WHERE) ┘ └ 정렬(ORDER BY) ┘
List<FeatureFlag> findAllByDetailInformationStatusOrderByIdDesc(FeatureFlagStatus status);
규칙은 SQL 순서와 같다. 조건(WHERE)이 먼저, 정렬(ORDER BY)이 나중.
SELECT * FROM feature_flag
WHERE feature_flag_status = 'OFF' -- By DetailInformationStatus
ORDER BY feature_flag_id DESC -- OrderBy IdDesc
내 답을 JPA는 “조건 없이, detailInformation.status.id로 정렬”로 읽는다. status에는 id가 없으니 서버 시작 시 에러가 난다. 반환 타입(List<FeatureFlag>)도 빠뜨렸다. Java 메서드 선언은 항상 반환타입 이름(파라미터) 형태다.
이름 짓는 공식
반환타입 find(All) By [조건 필드들] OrderBy [정렬 필드] Asc|Desc (파라미터)
| 결과 | 반환 타입 |
|---|---|
| 하나 (없을 수도 있음) | Optional<X> |
| 여러 개 | List<X> |
| 있는지만 확인 | boolean (existsBy) |
조건이 여러 개면 And로 잇는다. 다만 이름이 너무 길어지면 읽기 어려워서, 조건이 복잡하면 @Query나 QueryDSL로 쿼리를 직접 쓴다고 한다. 메서드 이름 방식은 조건 1~2개짜리 간단한 조회용이다.
⑤ 테이블은 누가 만들까? Flyway 마이그레이션
JPA가 테이블을 자동으로 만들 수도 있지만, 실무 프로젝트에서는 SQL 파일로 직접 관리하고 있었다.
src/main/resources/db/migration/
V202605200001__initial_schema.sql ← 초기 테이블
V202607020001__add_featureflag_deleted_at.sql ← 나중에 컬럼 추가
파일 이름 앞의 V날짜번호가 버전이고, Flyway라는 도구가 아직 실행 안 된 파일을 순서대로 DB에 적용한다. DB용 git 커밋 기록이라고 생각하면 된다.
재미있던 점: 초기 스키마의 feature_flag 테이블에는 deleted_at 컬럼이 없었다. soft delete는 나중에 추가됐고, 그때 이 파일이 생겼다.
ADD COLUMN deleted_at datetime DEFAULT NULL COMMENT '삭제일시';
...
ADD UNIQUE KEY uk_feature_flag_01 (feature_flag_identifier_code, (if(deleted_at is null, true, null)));
마지막 줄은 “code는 중복되면 안 되는데, 삭제된 것끼리는 중복돼도 된다“는 제약이다. soft delete는 지운 행이 남아 있어서, 같은 code로 새 플래그를 만들면 DB가 중복이라고 막는다. 그 문제를 해결한 것이다. 1편에서 본 soft delete가 DB 설계에도 영향을 준 사례다.
그리고 Entity에 필드를 추가하는 것만으로는 DB가 바뀌지 않는다. 컬럼을 추가하려면 마이그레이션 SQL과 Entity 필드를 둘 다 바꿔야 한다. 프론트에서 API 응답 타입만 바꿔도 서버 응답은 안 바뀌는 것과 같다.
정리
| 개념 | 하는 일 | 프론트 비유 |
|---|---|---|
@Entity + @Table |
클래스 ↔ 테이블 연결 | Prisma model |
@Column(name=...) |
필드 ↔ 컬럼 연결 | snake → camel 변환 |
@Embedded / @Embeddable |
컬럼 여러 개를 객체 하나로 묶음 | 평평한 응답을 중첩 객체로 재구성 |
@Enumerated(STRING) |
enum을 이름으로 저장 | — |
JpaRepository |
기본 CRUD 자동 제공 | 자동 생성된 API 클라이언트 |
findBy... 이름 |
이름으로 쿼리 생성 | — |
| Flyway 마이그레이션 | 테이블 구조 변경 이력 | DB용 git 커밋 |
다음 글에서는 트랜잭션(@Transactional)을 정리한다. save를 안 불러도 저장되는 변경 감지가 정확히 언제 일어나는지, 중간에 에러가 나면 왜 앞에서 저장한 것까지 취소되는지 알아볼 예정이다.