강의/Java Spring Boot

Spring과 Spring Boot로 JPA와 Hibernate 시작하기

studylida 2024. 7. 30. 21:00

이번 시간에는 JPA와 Hibernate, 그리고 JDBC에 대해 배워볼 것이다.

순서대로 JPA가 없었을 때 JDBC와 Spirng JDBC를 다루고, 그 뒤에 JPA를 왜 써야 하는지, 왜 Hibernate인지, 마지막으로 JPA와 Hibernate의 차이는 뭔지 배울 것이다.

그리고 Spring Boot가 있으면 Spring Boot Data JPA로 JPA를 아주 쉽게 다룰 수 있는데 이 과정 또한 다룰 것이다.

이 과정들은 실습을 통해 이루어질텐데 먼저 H2를 인메모리 데이터베이스로 사용해서 Spring Boot 프로젝트를 생성하고, 그 다음 H2 데이터베이스에 Course 테이블을 생성할 것이다. 테이블이 준비되면 JDBC를 사용해서 COURSE 테이블의 데이터를 활용해볼 것이다.

이렇게 Spring JDBC를 이해하고 나면 JPA와 Hibernate를 사용해서 동일한 작업을 하고 JPA와 Hibernate 사이의 차이점을 알아볼 것이다. 끝으로는 Spring Data JPA를 사용할 것이다.

시작해보자.

언제나처럼 start.spring,io를 사용하자 Project 설정은 지금까지 해왔던 것처럼 알아서 하자. 추가 해야 할 Dependencies는 Spring Web, Spring Data JDBC, Spring Data JPA, 그리고 H2 Database를 추가하면 된다.

이제 무엇을 해보느냐 하면, 아까 추가한 종속성을 살펴보면 H2 Database라는 게 있었는데, 이걸 연결해보려고 한다.

일단 어플리케이션을 실행해보면 로그가 뜰텐데, 여기서 출력된 H2 Databasse의 URL을 확인할 수 있다. jdbc:h2:mem으로 시작하고 있는데, 이 URL에서 실행되는 H2 Database로 연결하고 싶을 때, H2 콘솔을 사용 설정하거나, 데이터베이스로 액세스 하기 위해서는 application.properties로 들어가서 설정을 건드리면 된다. Spring.h2.console.enabled=true를 입력하고 저장한다. 그리고 서버를 멈추고 다시 실행하자.

이제 localhost::8080/h2-console을 입력해보자.

이렇게 하면 h2 database가 실행된 것을 확인할 수 있을텐데, 주의해야 할 점은, URL이 항상 달라지기 때문에 매번 확인해보아야 한다는 것이다. 반드시 확인해보자.

지금까지 따라왔다면 아직 URL을 명시적으로 정해놓지 않았기 때문에 URL이 동적으로 생성되는 것을 확인할 수 있다. jdbc:h2:mem 이후로 난수가 이어지는데 이걸 복사하고 JDBC URL에 붙여넣기 하자. 그리고 Connect를 누르면 이제 H2 콘솔로 연결할 수 있다.

이제 번거롭지 않게 URL이 정적으로 구성되도록 해보자. application.properties에 들어가서 spring.datasource.url=jdbc:h2:mem:testdb라는 코드를 입력하자. 그리고 프로그램을 다시 시작한 뒤 아까와 같은 과정을 거쳐서 이번엔 JDBC URL에 방금 application.properties에 입력한 spring.datasource.url=jdbc:h2:mem:testdb를 입력하자.

이제 연결했으니 테이블을 구성할 차례다. 이를 위해 src/main/resource에 schema.sql 파일을 만들어보자.

sql 파일 만들기가 보이지 않는다면 제목 없는 텍스트 파일을 생성한 다음 schema.sql로 이름을 바꾸어도 된다.

이제 여기에 sql 쿼리를 입력할 수 있다. course라는 테이블을 생성해보자.

create table course
(
    id bigint not null,
    name varchar(255) not null,
    author varchar(255) not null,
    primary key (id)
);

이제 서버를 멈추고 다시 시작한 뒤 아까와 같은 과정을 거치면 course 테이블이 생성된 것을 확인할 수 있다.

이는 h2 콘솔에서 SELECT * FROM COURSE 쿼리문을 실행함으로써 확인할 수 있다.

이제 Spring JDBC에 대해 중점적으로 살펴보겠다.

Spring JDBC를 통해서 무엇을 할 수 있느냐 하면, 우리는 Spring JDBC를 사용해서 테이블에 데이터를 삽입하고 삭제할 수 있다.

먼저 JDBC와 Spring JDBC의 개념을 살펴보고 시작하자.

JDBC (Java Database Connectivity)는 Java 애플리케이션이 데이터베이스와 상호작용할 수 있도록 해주는 API로, SQL 쿼리를 직접 작성하고 관리해야 한다. 이를 통해 데이터베이스에 연결하고, 데이터를 조회하거나 수정할 수 있다. 하지만 JDBC를 사용할 때는 반복적인 코드 작성과 예외 처리가 필요해 다소 복잡할 수 있다.

반면 Spring JDBC는 Spring 프레임워크를 기반으로 하여 JDBC의 복잡성을 줄이고, 더 간편하게 데이터베이스 작업을 수행할 수 있도록 돕는 라이브러리다. Spring JDBC를 사용하면 코드의 양이 줄어들고, 예외 처리 및 리소스 관리를 자동으로 처리해 주어 개발 생산성이 향상된다.

일단 우리는 course 테이블을 가지고 있다. 지금까지 배운 지식으로는 우리가 이 테이블에 데이터를 삽입하기 위해서는 쿼리를 날려야 한다.

insert into course(id, name, author)
values(1, 'Learn AWS', 'in28minutes')

select * from course;

삭제하고 싶다면 delete from course where id=1 쿼리를 통해 삭제할 수 있다.

이처럼 JDBC와 Spring JDBC를 사용할 때는 SQL 쿼리를 많이 작성하게 된다.

JDBC와 Spring JDBC의 차이라고 한다면, Spring JDBC를 사용하면 Java 코드를 훨씬 더 적게 쓴다는 것이다.

순서대로 JDBC, Spring JDBC로 작성한 쿼리다.

public void deleteTodo(int id) {
    PrepareStatement st = null;
    try {
        st = db.conn.prepareStatement("delete from todo where id=?);
        st.serInt(1, id);
        st.execute();
    } catch (SQLExecption e) {
        logger.fatal("Query Failed: ", e);
    } finally {
        if (st != null) {
            try {st.close();}
            catch {SQLExecption e) {}
        }
    }
}
public void deleteTodo(int id) {
    jdbcTemplate.update("delete from todo where id=?", id);
}

코드의 양이 줄어드는 걸 확인할 수 있다.

하여, Spring JDBC가 좋다는 건 알았는데, Spring JDBC를 사용해서 쿼리를 실행하려면 어떻게 해야 할까?

언제나처럼 코드를 통해 알아보자. 먼저 기반 클래스를 하나 작성하자. 클래스의 이름은 CourseJdbcRepository라고 하자.

Spring JDBC를 사용해서 쿼리를 실행하려면 JdbcTemplate를 필요로 한다.

@Repository
public void CourseJdbcRepository {

    private JdbcTemplate springJdbcTemplate;
}

이제 이 클래스는 데이터베이스와 연결된다.

실행할 쿼리도 작성해보자. 변수나 그런 거 없이 하드코딩 해보자.

@Repository
public void CourseJdbcRepository {

    @Autowired
    private JdbcTemplate springJdbcTemplate;

    private static String INSERT_QUERY = 
        """
            insert into course (id, name, author)
            values(,1, 'Learn AWS', 'in28minutes');
        """;

    public void insert() {
        springJdbcTemplate.update(INSERT_QUERY);
    }
}

작성했다. 이제 쿼리를 실행하고 싶은데, 이때 Spring Boot가 제공하는 CommandLineRunner라는 개념을 사용할 수 있다. course.jdbc 패키지에 가서 새 클래스를 생성해보자. 이름은 CourseJdbcCommandLineRunner라고 하자. 이 클래스가 시작될 때 실행되게 하고 싶다면 다음과 같이 코드를 작성할 수 있다.

@Component
public class CourseJdbcCommandLineRunner implements CommandLineRunner {

    @Autowired
    private CourseJdbcRepository repository;

    @Override
    public void run(String... args) throws Execption {
        repository.insert();
    }

}

이제 어플리케이션으로 돌아가 어플리케이션을 실행해보자. 그리고 H2 콘솔로 돌아가면 데이터가 삽입된 것을 확인할 수 있다.

위에서 배운 방법을 응용해보자.

이번에는 Course를 삽입한 다음, 값을 넘겨서 세부항목만 수정해보자.

이걸 위해서 먼저 Course 클래스를 만들어보자.

public class Course {

    private long id;
    private String name;
    private String author;

    // constructor with arguments
    // constructor
    // getters and setters
    // toString
}

주석으로 작성해놓은 건 IDE 기능을 이용해서 알아서 생성하자. 쓰기 귀찮다.

이제 아까 만든 CourseJdbcRepository 클래스로 다시 돌아가보자.

아까는 값을 하드코딩해서 넣었지만, 사실 여기에는 값 대신 물음표를 넣으면, 나중에 매개변수로 값을 넘길 수 있다.

방법은 아래와 같다.

@Repository
public void CourseJdbcRepository {

    @Autowired
    private JdbcTemplate springJdbcTemplate;

    private static String INSERT_QUERY = 
        """
            insert into course (id, name, author)
            values(?, ?, ?);
        """;

    public void insert() {
        springJdbcTemplate.update(INSERT_QUERY, course.getId(), course.getName(), course.getAuthor());
    }
}
@Component
public class CourseJdbcCommandLineRunner implements CommandLineRunner {

    @Autowired
    private CourseJdbcRepository repository;

    @Override
    public void run(String... args) throws Execption {
        repository.insert(1, "Learn new AWS", "in28minutes");
        repository.insert(2, "Learn Azure", "in28minutes");
        repository.insert(3, "Learn DevOps", "in28minutes");
    }

}

이렇게 하면 기존의 값이 삭제되고, 새로 삽입된 값만 테이블에 남는다.

이번엔 id를 이용해 특정 행을 지우는 쿼리를 작성해보자.


private static String DELETE_QUERY = 
    """
        delete from course where id = ?
    """;

public void deleteById(long id) {
    springJdbcTemplate.update(DELETE_QUERY, id);
}

@Component
public class CourseJdbcCommandLineRunner implements CommandLineRunner {

    @Autowired
    private CourseJdbcRepository repository;

    @Override
    public void run(String... args) throws Execption {
        repository.insert(1, "Learn new AWS", "in28minutes");
        repository.insert(2, "Learn Azure", "in28minutes");
        repository.insert(3, "Learn DevOps", "in28minutes");

        repository.deleteById(1);
    }

}

해서 데이터베이스의 데이터를 조작하는 건 해보았으니, 이제 데이터베이스에서 데이터를 가져오는 작업을 해보자.


private static String SELECT_QUERY = 
    """
        select * from course where id = ?
    """;

public Course selectById(long id) {

    // ResultSet -> Bean => Row Mapper =>

    return springJdbcTemplate.queryForObject(SELECT_QUERY,
         new BeanPropertyRowMapper<>(Course.class), id);

    // The second parameter you see here maps the result set to a bean, which is called the Row Mapper, 
    // which associates each row in the result set to a specific bean
}

해서 이렇게 JDBC를 이용해 쿼리를 작성해보았다.

그런데 이렇게 쿼리를 이용해 테이블을 가져오는 건 좋은데, 많이 가져오려고하다보면 쿼리 작성이 꽤 복잡해지지 않겠는가?

그래서 이후에 배울 JPA는 다른 접근 방식을 사용한다.

JPA를 활용하게 되면 Course Bean을 데이터베이스에 존재하는 테이블(엔터티)로 직접 매핑하게 된다.

엔터티는 데이터베이스에서의 객체라고 이해하면 편하다.

이 매핑은 @Entity 어노테이션과 그외 여러 어노테이션을 통해 이루어진다.

@Entity (name="Course_Details")
public class Course {

    @Id
    private long id;

    @Column(name="name")
    private String name;

    @Column(name="author") // 'that corresponds to the 'author' column.
    private String author;

    ....
}

이렇게 하면 Java Bean과 테이블 사이에 매핑을 생성하고 그 매핑을 이용해 값을 삽입하고 검색하는 등 테이블에서 작업을 수행한다.

어노테이션 옆에 보면 일일이 대응을 해주고 있는데, 사실 처음이라 이렇게 적은 거고, 이름이 똑같으면 자동으로 매핑이 되기 때문에 안 적어도 상관없다.

여기까지가 첫 단계이고, 이제 엔터티를 정의했으니, 엔터티를 활용할 Repository를 생성하고, JDBC로 했던 작업을 할 수 있도록 메소드까지 작성해서 실행까지 해보자.

@Repository
@Transactional // Allow transactions when running queries with JPA
public class CourseJpaRepository {

    @PersistenceContext // instead of @Autowired
    private EntityManager entiryManager;

    public void insert(Course course) {
        entityManager.merge(course) // merge method insert row
    }

    public void findById(long id) {
        entityManager.find(Course.class, id);
    }

    public void deleteById(long id) {
        Course course = entityManager.find(Course.class, id);
        entityManager.remove(course);
    }
}

@Component
public class CourseCommandLineRunner implements CommandLineRunner {

    // @Autowired
    // private CourseJdbcRepository repository;

    @Autowired
    private CourseJpaRepository repository;

    @Override
    public void run(String... args) throws Execption {
        repository.insert(1, "Learn AWS JPA", "in28minutes");
        repository.insert(2, "Learn Azure JPA", "in28minutes");
        repository.insert(3, "Learn DevOps JPA", "in28minutes");

        repository.deleteById(1);
    }

}

설정 파일에 spring.jpa.show-sql=true라는 코드를 추가로 적으면 SQL 쿼리를 로그로 출력해주기 때문에 디버깅이 편하다. 우리가 JPA를 이용해 실행할 때 쿼리를 신경쓰지 않았음에도 로그를 보면 쿼리가 출력되는 것을 확인할 수 있다. 이를 통해 내부적으로 알아서 쿼리를 작성하고 실행해주는 것을 알 수 있다.

해서 우리는 쿼리를 신경쓰지 않고도 데이터를 다룰 수 있는데, 그렇다면 곧 이어 배울 Spring Data JPA는 왜 배우는 걸까?

그건 바로

더 편하게 일하려고...이다

Spring Data JPA를 사용하면 이제 EntityManager도 신경쓸 필요가 없다.

새로운 인터페이스를 만들어보자. 이름은 CourseSpringJpaRepository라고 하자.

Spring Data JPA를 사용할 때는 JpaRepository라는 인터페이스를 상속받아야만 한다.

public interface CourseSpringDataJpaRepository extends JpaRepository<Course, Long> {}

그리고 아래와 같이 작성하여 아까와 같은 작업을 수행할 수 있다.

@Autowired
private CourseSpringDataJpaRepository repository;

@Override
public void run(String... args) thorws Exception {
    repository.save(new Course(1, "Learn AWS Jpa!", "in28minutes"));
    repository.save(new Course(2, "Learn Azure JPA!", "in28minutes"));
    repository.save(new Course(3, "Learn DevOps JPA!", "in28minutes"));

    repository.deleteById(1l);

    System.out.println(repository.findById(2l));
    System.out.println(repository.findById(3l)); 

    // method expects a long type, so it adds an l at the end

}

이제 Spring Data JPA를 사용함으로써 EntityManager에 연결할 필요도 없어졌다. 이 또한 백그라운드에서 알아서 이루어진다.

외에도 이런 메소드들이 있는데 궁금한 건 알아서 문서 읽어보면서 찾아 보시고,

System.out.println(repository.findAll());
System.out.println(repository.count());

중요한 특징은 커스텀 메소드를 만들 수 있다는 것이다.

public interface CourseSpringDataJpaRepository extends JpaRepository<Course, Long> {

    List<Course> findByAuthor(String author);
    List<Course> findByName(String name);
}
System.out.println(repository.findByAuthor(""));
System.out.println(repository.findByAuthor("in28minutes"));

System.out.println(repository.findByName("Learn AWS JPA!"));
System.out.println(repository.findByName("Learn DevOps JPA!"));

이름 명명규칙을 따라야 한다.

해서 대략적인 걸 모두 배워보았다.

마지막으로 Hibernate와 JPA의 차이점을 생각해보며 복습하는 시간을 가져보자.

일단 먼저 말할게 있다면, 글의 앞부분에서는 Hibernate를 사용해서도 코드를 작성하겠다고 했었지만 그러지 않았다. 그럼에도 글을 잘 읽었다면 지금부터 말하는 바를 이해하는 데에는 문제가 없을 것이다. 이유는 차근차근 알아가보자.

먼저 CourseJpaRepository에서 사용한 import를 알아보자.

import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.transaction.Transactional;

그리고 Course 클래스에서는 import jakarta.persistence.Id;도 사용했었다.

이제 Maven 종속성을 보면 jakarta.persistence-api-3.3.2.jar을 볼 수 있다. 이게 JPA JAR이다. 그리고 동시에 Hibernate도 클래스 경로에 있음을 알 수 있다.

어떻게 Hibernate와 JPA 종속성이 생겼을까?

pom.xml을 열면, spring-boot-starter-data-jpa라는 게 있다.

종속성 계층 구조로 가서 hibernate를 쳐보면 hibernate 종속성 두 가지가 보이는데, 이것이 spring-boot-starter-data-jpa로 들어가고 있는 것이다. jpa 또한 동일한 과정을 통해 그러한 것을 확인할 수 있다.

그런데 아까 말했듯이 우리는 hibernate를 사용하지 않고 jpa만 사용했다.

여기서 jpa와 hibernate에 대해 알아보자.

jpa는 API로 일종의 기술명세라고 볼 수 있다. 인터페이스와 비슷하게 엔터티가 무엇인지 정의한다. 그리고 hibernate는 그러한 jpa의 구현체이다. 우리가 JPA를 사용하기 위해 @Entity, @Column을 사용했던 것처럼 Hibernate 어노테이션을 사용하면 Hibernate를 직접 사용할 수 있다.

즉, jpa를 쓰면 hibernate, Toplink와 같은 다양한 jpa의 구현체를 사용할 수 있지만, hibernate를 사용해버리면 다른 jpa의 구현체를 사용할 수 없게 된다. 우리가 첫 글에서 강한 결합을 사용하지 않고 약한 결합을 사용하려 했던 것과 비슷하다.