Kotlin + Spring Boot 입문(6/6)
Kotlin/Spring

JPA와 데이터베이스 연동 - 데이터를 영구 저장하기

메모리 대신 실제 데이터베이스에 저장하는 방법. JPA, Entity, Repository 개념을 처음부터 설명한다.

2026-03-18
10 min read
#Spring Boot#Kotlin#JPA#데이터베이스#입문

왜 데이터베이스가 필요한가?

이전 글에서 만든 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)
@GeneratedValueid를 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 위쪽 코드는 그대로

시리즈: Kotlin + Spring Boot 입문

  1. 개발 환경 설치 - IntelliJ, JDK, 첫 Spring Boot 실행
  2. Kotlin 기초 - 변수, 함수, 클래스, 인터페이스
  3. Spring Boot 구조 - MVC, 3계층 아키텍처
  4. IoC, DI, 인터페이스 추상화
  5. REST API 만들기 - Controller, Service, Repository
  6. JPA와 데이터베이스 연동 ← 현재 글