
이 내용은 Spring Boot 백엔드에서 Persistence Layer(DB 접근 계층)에 해당한다. 프론트(HTML/JS/React)가 API로 요청하면, 백엔드(Spring Boot)가 Service/Repository를 통해 DB에 접근하고, JPA가 SQL을 직접 작성하지 않아도 Entity 기반으로 테이블/쿼리 생성과 매핑을 수행한다.
JPA(Jakarta Persistence API)는 Java에서 데이터 영속성(Persistence)을 표준 방식으로 다루기 위한 API다. 핵심은 “엔티티(Entity) 클래스”를 기준으로 테이블 구조를 정의하고, 객체를 저장/조회하는 방식으로 DB를 다룬다는 점이다.
즉, HeidiSQL에서 직접 DB/TABLE/COLUMN 만들고 SQL로 CRUD하던 방식과 달리, Spring Boot 프로젝트 내부 코드(Entity/Repository)로 DB 구조와 접근을 처리한다.
ORM(Object Relational Mapping)은 객체(Object)와 관계형 DB(Relational)를 매핑(Mapping)해서 객체 조작이 SQL 실행으로 이어지도록 연결하는 기술이다.
JPA는 “표준”이고, 실제 동작은 구현체가 수행한다. Spring Boot에서 가장 널리 쓰이는 구현체가 Hibernate이며, 기본 구현체로 많이 사용된다.
@Entity를 붙인 클래스가 “테이블”에 대응된다. 클래스 이름이 테이블 이름이 되고, field 이름이 column 이름으로 매핑된다.
애플리케이션 초기화 시점에 JPA 설정에 따라 Entity 기반으로 테이블 생성이 가능하다. 테이블 이름을 바꾸고 싶으면 @Table(name="...")를 사용한다. 특히 User 같은 예약어 충돌 가능성이 있어 @Table(name="users")처럼 명시하는 패턴이 자주 쓰인다.
package com.korit12.cardatabase.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Car {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String brand, model, color, registrationNumber;
private int modelYear, price;
}
엔티티에는 기본 키(PK)가 필요하다. DB는 어떤 field가 PK인지 자동으로 알 수 없으므로 @Id로 명시한다.
field명 그대로 column이 만들어지는 것이 기본이지만, 컬럼명/길이/nullable 같은 제약을 명시하고 싶으면 @Column을 쓴다.
import jakarta.persistence.Column;
@Column(name = "explanation", nullable = false, length = 512)
private String description;
id는 자동 생성이므로 생성자에 id까지 포함되는 AllArgsConstructor를 무조건 쓰면 설계가 꼬일 수 있다. 보통은 기본 생성자 + 필요한 필드 생성자 형태를 가져가거나, Lombok의 @NoArgsConstructor와 @RequiredArgsConstructor를 조합한다. JPA는 기본 생성자(no-args constructor)를 요구한다.
package com.korit12.cardatabase.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Car {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String brand, model, color, registrationNumber;
private int modelYear, price;
public Car() {}
public Car(String brand, String model, String color, String registrationNumber, int modelYear, int price) {
this.brand = brand;
this.model = model;
this.color = color;
this.registrationNumber = registrationNumber;
this.modelYear = modelYear;
this.price = price;
}
public Long getId() { return id; }
public String getBrand() { return brand; }
public void setBrand(String brand) { this.brand = brand; }
public String getModel() { return model; }
public void setModel(String model) { this.model = model; }
public String getColor() { return color; }
public void setColor(String color) { this.color = color; }
public String getRegistrationNumber() { return registrationNumber; }
public void setRegistrationNumber(String registrationNumber) { this.registrationNumber = registrationNumber; }
public int getModelYear() { return modelYear; }
public void setModelYear(int modelYear) { this.modelYear = modelYear; }
public int getPrice() { return price; }
public void setPrice(int price) { this.price = price; }
}
package com.korit12.cardatabase.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
@Entity
@Data
@NoArgsConstructor
@RequiredArgsConstructor
public class Car {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@NonNull
private String brand, model, color, registrationNumber;
@NonNull
private int modelYear, price;
}
Spring Data JPA는 Repository 인터페이스만 정의하면 기본 CRUD 구현을 자동으로 제공한다. 이 부분이 Persistence Layer에서 “SQL 대신 메서드 호출”로 DB를 다룰 수 있게 해주는 핵심이다.
CrudRepository는 기본 CRUD 메서드를 제공한다. JpaRepository는 CrudRepository + Paging/Sorting 등을 포함하는 상위 종합 패키지로 실무에서 많이 선택한다.
package com.korit12.cardatabase.domain;
import org.springframework.data.repository.CrudRepository;
public interface CarRepository extends CrudRepository<Car, Long> {
}
여기서 <Car, Long>은 Generic으로, Repository가 다룰 Entity 타입과 PK 타입을 명확히 해준다. 그래서 save()에 Car를 넣고, findById()에 Long을 넣는 흐름이 타입 안정적으로 굳어진다.
Optional은 “없을 수도 있음”을 타입으로 강제해서 NullPointerException을 줄이려는 목적이다.
Optional<Car> opt = repository.findById(1L);
if (opt.isPresent()) {
Car car = opt.get();
}
H2 in-memory DB를 쓰면 실행할 때마다 초기화된다. 매번 수동 INSERT가 귀찮으니, 앱 시작 시점에 더미 데이터를 저장하도록 CommandLineRunner를 활용할 수 있다.
이 코드는 Spring Boot 백엔드의 “애플리케이션 부팅 단계”에서 Repository를 통해 데이터를 주입하는 구조다. DI(Dependency Injection)로 CarRepository를 주입받아 run()에서 save()를 호출한다.
package com.korit12.cardatabase;
import com.korit12.cardatabase.domain.Car;
import com.korit12.cardatabase.domain.CarRepository;
import lombok.RequiredArgsConstructor;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.slf4j.Logger;
@SpringBootApplication
@RequiredArgsConstructor
public class CardatabaseApplication implements CommandLineRunner {
private static final Logger logger = LoggerFactory.getLogger(CardatabaseApplication.class);
private final CarRepository repository;
public static void main(String[] args) {
SpringApplication.run(CardatabaseApplication.class, args);
logger.info("Application started.");
}
@Override
public void run(String... args) {
repository.save(new Car("현대", "소나타", "검정", "123가4567", 2026, 30000000));
repository.save(new Car("기아", "K9", "흰색", "987나5432", 2025, 20000000));
repository.save(new Car("람보르기니", "쿤타치", "빨강", "159다7532", 2010, 130000000));
}
}
Spring Data JPA는 메서드 이름 규칙(findBy...)만으로도 WHERE 조건 쿼리를 자동 생성한다. 복잡한 경우에는 @Query로 JPQL을 직접 작성할 수도 있다.
package com.korit12.cardatabase.domain;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface CarRepository extends JpaRepository<Car, Long> {
List<Car> findByBrand(String brand);
List<Car> findByColor(String color);
List<Car> findByModelYear(int modelYear);
List<Car> findByBrandAndModel(String brand, String model);
List<Car> findByBrandOrColor(String brand, String color);
List<Car> findByBrandOrderByModelYearAsc(String brand);
@Query("select c from Car c where c.model = ?1")
List<Car> findByModel(String model);
@Query("select c from Car c where c.brand like %?1")
List<Car> findByBrandEndWith(String brand);
}
@Query는 강력하지만, 특정 DB에 종속적인 SQL(native query)까지 쓰기 시작하면 이식성이 떨어질 수 있다. 가능하면 Query Method로 해결하고, 정말 필요한 경우에만 @Query를 쓰는 게 안전하다.
요구사항: Owner 1명이 Car 여러 대를 가질 수 있고, Car는 Owner 1명만 가진다. 이게 1:N(One-to-Many) 관계다.
중요 포인트는 “ownerId 같은 FK 필드를 직접 추가”하는 방식이 아니라, Car 엔티티 안에 Owner 타입을 field로 둬서 객체로 연결한다는 점이다.
package com.korit12.cardatabase.domain;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
@Entity
@Data
@NoArgsConstructor
@RequiredArgsConstructor
public class Car {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@NonNull
private String brand;
@NonNull
private String model;
@NonNull
private String color;
@NonNull
private String registrationNumber;
@NonNull
private int modelYear;
@NonNull
private int price;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner")
private Owner owner;
}
package com.korit12.cardatabase.domain;
import jakarta.persistence.*;
import lombok.*;
import java.util.List;
@Entity
@NoArgsConstructor
@RequiredArgsConstructor
public class Owner {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Getter
private Long ownerId;
@Getter @Setter @NonNull
private String firstName;
@Getter @Setter @NonNull
private String lastName;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "owner")
@Getter @Setter
private List<Car> cars;
}
FetchType은 연관 데이터 로딩 전략이다.
toMany는 기본이 LAZY인 경우가 많고, toOne은 명시하지 않으면 EAGER로 동작하는 케이스가 있어서 예제처럼 toOne 쪽에 fetch=FetchType.LAZY를 습관처럼 명시하는 편이 안전하다.
cascade는 “부모 작업이 자식에게 전파되는 범위”다. CascadeType.ALL이면 Owner를 삭제할 때 Owner가 가진 Car들도 함께 삭제될 수 있다. 실무에서는 요구사항에 따라 ALL을 쓰기도 하지만, 삭제 전파는 특히 신중하게 선택한다.
관계 매핑 결과로 Car 테이블에 owner라는 컬럼(FK)이 생기고, 그 값으로 Owner의 PK(owner_id)가 들어간다. 즉, 물리적으로는 PK-FK 관계가 만들어지고, 필요하면 SQL JOIN으로 확인할 수 있다.
// example SQL
select *
from CAR c
inner join OWNER o
on c.OWNER = o.OWNER_ID;
최종적으로 웹 앱에서 데이터가 오가는 흐름은 보통 아래처럼 간다.
이번 글은 그 중에서 “Entity/Repository로 DB 구조를 만들고 접근하는 부분”을 정리한 것이다.