JPA와 데이터베이스 연동 - 데이터를 영구 저장하기
메모리 대신 실제 데이터베이스에 저장하는 방법. JPA, Entity, Repository 개념을 처음부터 설명한다.
왜 데이터베이스가 필요한가?
이전 글에서 만든 Repository는 메모리(리스트)에 저장했다.
private val users = mutableListOf<User>() // 서버 끄면 사라짐
서버를 껐다 켜면 데이터가 사라진다. 실제 서비스라면 데이터베이스(DB)에 저장해야 한다.
메모리 저장: 빠르지만 서버 재시작하면 사라짐
데이터베이스: 서버를 꺼도 데이터가 남아있음
ORM이란?
데이터베이스는 데이터를 테이블(표) 형태로 저장한다.
users 테이블
┌────┬────────┬──────────────────┐
│ id │ name │ email │
├────┼────────┼──────────────────┤
│ 1 │ 김철수 │ chul@test.com │
│ 2 │ 이영희 │ young@test.com │
└────┴────────┴──────────────────┘
그런데 우리가 코드에서 다루는 데이터는 객체 형태다.
data class User(val id: Long, val name: String, val email: String)
테이블과 객체는 구조가 다르기 때문에, 이 둘을 연결해주는 작업이 필요하다.
ORM(Object-Relational Mapping) 은 이 변환을 자동으로 해주는 기술이다.
[Kotlin 객체] ←──── ORM ────→ [DB 테이블]
User(id=1, ↕ id=1, name='김철수',
name="김철수", email='chul@test.com'
email="chul@test.com")
ORM이 없으면 SQL을 직접 작성하고, 결과를 다시 객체로 변환하는 코드를 일일이 짜야 한다.
// ORM 없이 직접 처리하는 경우 (번거롭다)
val rs = statement.executeQuery("SELECT * FROM users WHERE id = $id")
val user = User(
id = rs.getLong("id"),
name = rs.getString("name"),
email = rs.getString("email")
)
JPA란?
JPA(Java Persistence API) 는 자바/코틀린 진영의 ORM 표준 명세다. "ORM을 어떻게 써야 하는지"에 대한 규칙을 정의한 것이고, 실제로 동작하는 구현체는 Hibernate가 담당한다.
개발자 코드
│ JPA 인터페이스 사용
▼
JPA (표준 명세)
│ 내부적으로 위임
▼
Hibernate (실제 구현체)
│ SQL 자동 생성
▼
Database
Spring Boot에서 spring-boot-starter-data-jpa를 추가하면 JPA + Hibernate가 함께 세팅된다.
데이터베이스는 SQL이라는 언어로 다룬다.
SELECT * FROM users WHERE id = 1;
INSERT INTO users (name, email) VALUES ('철수', 'chul@test.com');
JPA를 쓰면 SQL을 직접 쓰지 않아도 Kotlin 코드로 같은 작업을 할 수 있다.
// SQL 없이 Kotlin으로 - JPA가 SQL을 자동 생성해줌
userRepository.findById(1)
userRepository.save(user)
H2 데이터베이스 설정
개발할 때는 설치가 필요 없는 H2 데이터베이스를 쓴다. Spring Boot에서 의존성만 추가하면 자동으로 실행된다.
// build.gradle.kts
dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
runtimeOnly("com.h2database:h2")
}
# application.properties
spring.datasource.url=jdbc:h2:mem:testdb
spring.h2.console.enabled=true # 웹에서 DB 확인 가능
spring.jpa.show-sql=true # 실행되는 SQL 로그 출력
spring.jpa.hibernate.ddl-auto=create # 앱 시작 시 테이블 자동 생성
서버 실행 후 http://localhost:8080/h2-console에 접속하면 DB 내용을 직접 볼 수 있다.
Entity - 테이블과 연결되는 클래스
Entity는 데이터베이스 테이블과 1:1로 대응하는 클래스다.
// User.kt
@Entity
@Table(name = "users")
class User(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(nullable = false)
val name: String,
@Column(nullable = false, unique = true)
val email: String
)
어노테이션 설명:
| 어노테이션 | 역할 |
|---|---|
@Entity | 이 클래스가 DB 테이블과 연결됨 |
@Table(name = "users") | 테이블 이름 지정 |
@Id | 이 필드가 기본키(Primary Key) |
@GeneratedValue | id를 DB가 자동으로 생성 |
@Column(nullable = false) | NULL 불허 컬럼 |
@Column(unique = true) | 중복 불허 컬럼 |
이 클래스 하나로 아래 SQL과 같은 테이블이 자동 생성된다.
CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE
);
JpaRepository - CRUD 기본 제공
JpaRepository를 상속하면 기본적인 저장/조회/삭제 함수를 자동으로 쓸 수 있다.
// UserRepository.kt
@Repository
interface UserRepository : JpaRepository<User, Long> {
// 기본 제공: save(), findById(), findAll(), deleteById() 등
// 이름으로 찾기 - 메서드 이름만 맞게 쓰면 자동으로 동작
fun findByEmail(email: String): User?
fun findByName(name: String): List<User>
}
JpaRepository<User, Long> 에서:
User: 어떤 Entity를 다루는지Long: 기본키(id)의 타입
자동 제공되는 함수들
userRepository.save(user) // 저장 (없으면 INSERT, 있으면 UPDATE)
userRepository.findById(1L) // id로 조회, Optional<User> 반환
userRepository.findAll() // 전체 조회
userRepository.deleteById(1L) // id로 삭제
userRepository.count() // 개수
userRepository.existsById(1L) // 존재 여부
메서드 이름으로 쿼리 자동 생성
메서드 이름 규칙만 지키면 SQL 없이 쿼리가 만들어진다.
fun findByEmail(email: String): User?
// → SELECT * FROM users WHERE email = ?
fun findByNameContaining(keyword: String): List<User>
// → SELECT * FROM users WHERE name LIKE '%keyword%'
fun findByNameAndEmail(name: String, email: String): User?
// → SELECT * FROM users WHERE name = ? AND email = ?
Service 수정
이전 글의 Service를 실제 DB를 쓰도록 바꾼다.
// UserService.kt
@Service
class UserService(
private val userRepository: UserRepository
) {
fun getAllUsers(): List<User> {
return userRepository.findAll()
}
fun getUser(id: Long): User {
return userRepository.findById(id)
.orElseThrow { IllegalArgumentException("유저를 찾을 수 없습니다. id: $id") }
}
fun createUser(request: CreateUserRequest): User {
// 이메일 중복 확인
if (userRepository.findByEmail(request.email) != null) {
throw IllegalArgumentException("이미 사용 중인 이메일입니다.")
}
val user = User(
name = request.name,
email = request.email
)
return userRepository.save(user)
}
fun deleteUser(id: Long) {
if (!userRepository.existsById(id)) {
throw IllegalArgumentException("유저를 찾을 수 없습니다. id: $id")
}
userRepository.deleteById(id)
}
}
findById()는 Optional<User>를 반환한다. .orElseThrow { } 는 값이 없으면 예외를 던진다.
Controller에 삭제 API 추가
// UserController.kt
@RestController
@RequestMapping("/users")
class UserController(
private val userService: UserService
) {
@GetMapping
fun getAllUsers(): List<User> = userService.getAllUsers()
@GetMapping("/{id}")
fun getUser(@PathVariable id: Long): User = userService.getUser(id)
@PostMapping
@ResponseStatus(HttpStatus.CREATED) // 성공 시 201 반환
fun createUser(@RequestBody request: CreateUserRequest): User {
return userService.createUser(request)
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT) // 성공 시 204 반환
fun deleteUser(@PathVariable id: Long) {
userService.deleteUser(id)
}
}
트랜잭션
트랜잭션은 여러 DB 작업을 하나로 묶는 것이다. 중간에 실패하면 전부 취소된다.
돈을 이체하는 경우:
1. A 계좌에서 10만원 차감
2. B 계좌에 10만원 추가
1번 성공, 2번 실패하면? → 돈이 사라짐
→ 트랜잭션: 둘 다 성공해야 반영, 하나라도 실패하면 전부 취소
Spring에서는 @Transactional을 붙이면 된다.
@Service
@Transactional // 클래스 전체에 적용
class UserService(
private val userRepository: UserRepository
) {
@Transactional(readOnly = true) // 읽기 전용 (성능 최적화)
fun getAllUsers(): List<User> {
return userRepository.findAll()
}
fun createUser(request: CreateUserRequest): User {
// 예외 발생 시 자동으로 롤백됨
...
}
}
readOnly = true는 데이터를 변경하지 않는 조회에 붙인다. DB 성능이 약간 좋아진다.
실행 확인
서버를 실행하고 http://localhost:8080/h2-console에 접속하면:
JDBC URL: jdbc:h2:mem:testdb
로 접속할 수 있다. users 테이블이 자동으로 만들어진 것을 확인할 수 있다.
API로 유저를 만들고 h2-console에서 직접 데이터가 들어갔는지 확인해볼 수 있다.
전체 구조 정리
[Controller] ── 요청/응답
│
[Service] ──── 비즈니스 로직, 트랜잭션
│
[Repository] ── DB 접근 (JpaRepository)
│
[Entity] ────── DB 테이블과 1:1 대응
│
[Database] ──── 데이터 영구 저장
각 계층이 명확히 분리되어 있어서:
- Controller 수정이 Service, DB에 영향을 안 줌
- DB를 H2에서 MySQL로 바꿔도 Repository 위쪽 코드는 그대로