박종훈 기술블로그

[Spring] Spring In Action - 데이터로 작업하기 (3장)

최근에 K-DEVCON 에서 Spring 스터디를 시작하였다. Spring In Action 이라는 책을 함께 공부하기로 하였다. 책에 있는 내용들도 다루지만, 그 외에도 관련된 다양한 내용들을 함께 다뤄보고자 한다.

https://k-devcon.web.app/spring2025

데이터로 작업하기

관련 개념 정리하기

domain 과 entity 의 차이

  • domain : 핵심 비즈니스 개념을 추상화한 객체, 비즈니스 로직과 규칙을 표현
  • entity : 데이터베이스 테이블에 대응하는 클래스

각 entity 인스턴스는 테이블 row 와 연결된다.

도메인 객체는 반드시 데이터베이스와 매핑될 필요는 없다. 도메인 객체는 엔티티일 수도 있고 아닐 수도 있다.

객체 영속성 (Object Persistence)

영속성 永續性 Persistence : (명사) 영원히 계속되는 성질이나 능력.

개별 객체가 애플리케이션 프로세스보다 오래 유지될 수 있음을 의미한다. 각 객체는 데이터 저장소에 저장했다가 나중에 특정 시점에서 다시 생성할 수 있다. 일반적으로 SQL을 사용해 데이터베이스의 객체 인스턴스를 매핑하고 데이터베이스에 저장하는 것을 의미한다.

Repository 와 DAO

repository 와 dao 는 거의 같다.

좀 더 깊이있게 차이를 설명하면, repository는 엔티티 객체를 보관하고 관리하는 저장소이고, dao는 데이터에 접근하도록 DB접근 관련 로직을 모아둔 객체이다.

둘은 개념의 차이일 뿐 실제로 개발할 때는 비슷하게 사용된다.

https://www.inflearn.com/community/questions/111159/domain%EA%B3%BC-repository-%EC%A7%88%EB%AC%B8

자바 개발자가 관계형 데이터를 사용하는 대표적인 방법

  • JDBC 기반 : SQL을 직접 제어
  • JPA 기반 : 객체 지향적인 방식, ORM (Object-Relational Mapping) 방식 사용.
    • 자동으로 SQL이 생성되어 편리하나, 최적화하는데 어려움이 있다.

두 가지를 상황과 필요에 따라 잘 사용하는 것이 좋다.

JDBC

https://en.wikipedia.org/wiki/Java_Database_Connectivity

JDBC(Java DataBase Connectivity)는 클라이언트가 데이터베이스에 액세스하는 방법을 정의하는 Java API 이다.

JDBC Driver

JDBC 드라이버는 JDBC API를 기반으로 구현된 소프트웨어이다. 애플리케이션이 개별 데이터베이스와 상호 작용할 수 있도록 돕는다. 각 데이터베이스에 맞는 전용 드라이버가 필요하다. JDBC 드라이버에는 데이터베이스에 대한 연결 관리 기능과 클라이언트와 데이터베이스 간에 쿼리 및 결과를 전송하기 위한 프로토콜이 구현되어 있다.

순수 JDBC 를 이용해서 데이터 읽어오기 (코드 예시)

아래 코드는 JDBC를 이용하여 데이터를 읽어오는 코드의 예시이다.

@Override
public Optional<Ingredient> findById(String id) {
  Connection connection = null;
  PreparedStatement statement = null;
  ResultSet resultSet = null;
  try {
    connection = dataSource.getConnection();
    statement = connection.prepareStatement(
        "select id, name, type from Ingredient where id=?");
    statement.setString(1, id);
    resultSet = statement.executeQuery();
    Ingredient ingredient = null;
    if(resultSet.next()) {
      ingredient = new Ingredient(
          resultSet.getString("id"),
          resultSet.getString("name"),
          Ingredient.Type.valueOf(resultSet.getString("type")));
  }
    return Optional.of(ingredient);
  } catch (SQLException e) {
    // ??? What should be done here ???
  } finally {
    if (resultSet != null) {
      try {
        resultSet.close();
      } catch (SQLException e) {}
    }
    if (statement != null) {
      try {
        statement.close();
      } catch (SQLException e) {}
    }
    if (connection != null) {
      try {
        connection.close();
      } catch (SQLException e) {}
    }
  }
  return Optional.empty();
}

select id, name, type from Ingredient where id=? 한 줄을 수행하기 위해 굉장히 많은 코드가 사용되었다는 것을 볼 수 있다.

Spring 에서는 JDBC를 좀 더 쉽게 사용할 수 있는 방법을 제시한다.

Spring JDBC

https://spring.io/projects/spring-data-jdbc

Spring JDBC는 Spring Data의 일부로 JDBC 기반의 레포지토리를 쉽게 구현할 수 있도록 돕는다.

JdbcTemplate를 사용해서 데이터베이스 쿼리하기

Spring JDBC의 JdbcTemplate 을 사용하면 상용구(boilerplate) 코드가 확 줄일 수 있다.

private JdbcTemplate jdbcTemplate;

public Optional<Ingredient> findById(String id) {
    List<Ingredient> results = jdbcTemplate.query(
        "select id, name, type from Ingredient where id=?",
        this::mapRowToIngredient,
        id);
    return results.size() == 0 ?
        Optional.empty() :
        Optional.of(results.get(0));
}

JdbcTemplate을 사용하는 Repository 만들기

JdbcTemplate을 사용하는 Repository는 다음과 같이 구현할 수 있다.

  1. Repository Interface 정의
  2. JdbcTemplate을 사용하는 Repository 구현

1. Repository Interface 정의

필요한 인터페이스를 정의한다.

public interface IngredientRepository {
  Iterable<Ingredient> findAll();
  Optional<Ingredient> findById(String id);
  Ingredient save(Ingredient ingredient);
}

2. JdbcTemplate을 사용하는 Repository 구현

이전 단계에서 정의한 인터페이스를 구현한다.

@Repository
public class JdbcIngredientRepository implements IngredientRepository {
    private JdbcTemplate jdbcTemplate;

    public JdbcIngredientRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public Iterable<Ingredient> findAll() {
        return jdbcTemplate.query(
            "select id, name, type from Ingredient",
            this::mapRowToIngredient);
    }

    @Override
    public Optional<Ingredient> findById(String id) {
        List<Ingredient> results = jdbcTemplate.query(
            "select id, name, type from Ingredient where id=?",
            this::mapRowToIngredient,
            id);
        return results.size() == 0 ?
                Optional.empty() :
                Optional.of(results.get(0));
    }

    @Override
    public Ingredient save(Ingredient ingredient) {
        jdbcTemplate.update(
            "insert into Ingredient (id, name, type) values (?, ?, ?)",
            ingredient.getId(),
            ingredient.getName(),
            ingredient.getType().toString());
        return ingredient;
    }

    private Ingredient mapRowToIngredient(ResultSet row, int rowNum) throws SQLException {
        return new Ingredient(
            row.getString("id"),
            row.getString("name"),
            Ingredient.Type.valueOf(row.getString("type")));
    }
}

@Repository 어노테이션을 달면 Component Scan 시 자동으로 검색되어 Spring Appliaction Context에서 빈으로 등록된다.

(질문) 왜 굳이 인터페이스와 실제 구현클래스를 나눠뒀을까?

  • Repository의 인터페이스를 통해 의존성을 주입받음으로써, 실제 구현체의 변경이 클라이언트 코드에 영향을 미치지 않도록 할 수 있다.
    • 다른 구현체로도 변경하기 용이하다.
  • 테스트에 용이하다.
    • 인터페이스를 Mocking 하면 실제 Object를 사용하는것보다 세부 구현에 대한 의존성을 줄일 수 있다.

또 다른 findById 구현 방법

queryForObject 를 사용하는 것도 한가지 방법이다.

@Override
public Ingredient findById(String id) {
    return jdbcTemplate.queryForObject(
        "select id, name, type from Ingredient where id=?",
        this::mapRowToIngredient,
        id);
}

이 코드는 좀 더 간결하긴 하지만 값이 없거나, 단일 row가 아닐 경우 예외가 발생될 수 있다.

INSERT 시 Generated Key 받아오기 (KeyHolder)

우리는 데이터 스키마를 작성할 때 DB에서 생성된 값을 사용하기도 한다. DB 에서 생성된 값을 자바 어플리케이션으로 받아오려면 어떻게 해야할까?

그럴 때 사용하는 것이 Keyholder 이다.

예시 코드는 다음과 같다.

PreparedStatementCreatorFactory pscf =
    new PreparedStatementCreatorFactory(
      "insert into Taco_Order "
      + "(delivery_name, delivery_street, delivery_city, "
      + "delivery_state, delivery_zip, cc_number, "
      + "cc_expiration, cc_cvv, placed_at) "
      + "values (?,?,?,?,?,?,?,?,?)",
      Types.VARCHAR, Types.VARCHAR, Types.VARCHAR,
      Types.VARCHAR, Types.VARCHAR, Types.VARCHAR,
      Types.VARCHAR, Types.VARCHAR, Types.TIMESTAMP
);
pscf.setReturnGeneratedKeys(true);

order.setPlacedAt(new Date());
PreparedStatementCreator psc =
    pscf.newPreparedStatementCreator(
        Arrays.asList(
            order.getDeliveryName(),
            order.getDeliveryStreet(),
            order.getDeliveryCity(),
            order.getDeliveryState(),
            order.getDeliveryZip(),
            order.getCcNumber(),
            order.getCcExpiration(),
            order.getCcCVV(),
            order.getPlacedAt()));

GeneratedKeyHolder keyHolder = new GeneratedKeyHolder();
jdbcOperations.update(psc, keyHolder);
long orderId = keyHolder.getKey().longValue();
order.setId(orderId);

PreparedStatementCreator 와 KeyHolder 를 사용하여 update를 하면 key 값을 받아올 수 있다.

해당 기능의 동작 방식은 분석하여 별도의 글로 정리해본다. [spring] jdbctemplate - keyholder 동작 분석

좀 더 편리하게 Insert 하기 (SimpleJdbcInsert)

Insert 과정을 좀 더 편리하게 할 수 있다.

SimpleJdbcInsert orderInsert = new SimpleJdbcInsert(jdbcTemplate)
	.withTableName("order")
	.usingGeneratedKeyColumns("id");

Map<String, Object> values = convertValueToMap(order);
long orderId =
	orderInserter
		.executeAndReturnKey(values)
		.longValue();

테이블과 자동 생성 key 컬럼을 지정하고 paramMap 을 이용하여 insert와 동시에 id 값을 받아올 수 있다. 물론 그냥 execute만 하는것도 가능하다.

데이터베이스 초기화 및 버전 관리하기

spring doc - Database Initialization

  • Spring boot 자체 제공 기능 사용
    • classpath (src/main/resources) 에 schema.sql, data.sql 이 있을 경우, Spring boot 실행시 SQL 문을 수행함.
    • 단 Embedded Database일 경우에만 자동으로 실행되며, spring.sql.init.mode 값을 변경하여 다른 데이터베이스에서도 사용할 수 있음.
  • 전문 도구를 활용하여 Database를 버저닝 하여 관리할 수도 있음.
    • Flyway
    • Liquibase

JdbcClient

Spring 6.1 에서 새로 나온 방식이다.

Document 에서는 다음과 같이 소개하고 있다.

NOTE: As of 6.1, there is a unified JDBC access facade available in the form of JdbcClient. JdbcClient provides a fluent API style for common JDBC queries/updates with flexible use of indexed or named parameters. It delegates to a JdbcTemplate/NamedParameterJdbcTemplate for actual execution.

통일된 방식으로 다양한 쿼리들을 수행할 수 있는 것이 특징이다. 또한 method chaining 방식을 채용하였다.

사용 예시들은 다음과 같다.

참고 : A Guide to Spring JdbcClient API

List<Student> getStudentsOfGradeStateAndGenderWithPositionalParams(int grade, String state, String gender) {
    String sql = "select student_id, student_name, age, grade, gender, state from student"
            + " where grade = ? and state = ? and gender = ?";
    return jdbcClient.sql(sql)
      .param(grade)
      .param(state)
      .param(gender)
      .query(new StudentRowMapper()).list();
}

int getCountOfStudentsOfGradeStateAndGenderWithNamedParam(int grade, String state, String gender) {
    String sql = "select student_id, student_name, age, grade, gender, state from student"
      + " where grade = :grade and state = :state and gender = :gender";
    RowCountCallbackHandler countCallbackHandler = new RowCountCallbackHandler();
    jdbcClient.sql(sql)
      .param("grade", grade)
      .param("state", state)
      .param("gender", gender)
      .query(countCallbackHandler);
    return countCallbackHandler.getRowCount();
}

Integer insertWithSetParamWithNamedParamAndSqlType(Student student) {
    String sql = "INSERT INTO student (student_name, age, grade, gender, state)"
      + "VALUES (:name, :age, :grade, :gender, :state)";
    Integer noOfrowsAffected = this.jdbcClient.sql(sql)
      .param("name", student.getStudentName(), Types.VARCHAR)
      .param("age", student.getAge(), Types.INTEGER)
      .param("grade", student.getGrade(), Types.INTEGER)
      .param("gender", student.getStudentGender(), Types.VARCHAR)
      .param("state", student.getState(), Types.VARCHAR)
      .update();
    return noOfrowsAffected;
}

앞으로 이 방식이 많이 소개되고, 사용되지 않을까 생각된다.

JPA

이 책에서는 다루지 않지만 짧게 다루고 넘어가보자면 다음과 같다.

JPA는 Java Persistence API 의 약자이다. JDBC와 마찬가지로 인터페이스이며, 동작은 구현에 따른다. 영속성을 관리하는데 사용된다.

다음과 같이 엔티티를 정의한다.

@Entity
public class MyEntity {

    @Id
    private Long id;
    private String name;
    private int age;

    // Getters and setters
}

다음과 같이 레포지토리를 구현한다.

엔티티 매니저는 엔티티를 관리하고 영속성을 처리하는 역할을 담당한다.

@PersistenceContext
private EntityManager entityManager;


public MyEntity findById(Long id) {
    return entityManager.find(MyEntity.class, id);
}

public void save(String name, int age) {
    MyEntity entity = new MyEntity();
    entity.setName(name);
    entity.setAge(age);

    entityManager.persist(entity);
}

Spring Data JPA

Spring Data JPA 는 인터페이스를 통해 데이터 작업을 쉽게 처리할 수 있도록 돕는다.

대표적으로 아래와 같은 기능을 제공한다.

  • 기본 인터페이스를 통한 기능 제공
  • 쿼리 메소드 기능 제공
    • 메소드 이름으로 쿼리 생성
    • @Query 어노테이션으로 SQL 직접 정의

기본 인터페이스

JPA Repository는 자주 사용되는 데이터 접근 기능(예: CRUD, 페이징, 정렬 등)을 기본적으로 제공한다. 기본 제공 인터페이스는 다음과 같다.

public interface JpaRepository<T, ID>
		extends ListCrudRepository<T, ID>,
		ListPagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {
    void flush();

    <S extends T> S saveAndFlush(S entity);

    <S extends T> List<S> saveAllAndFlush(Iterable<S> entities);

    void deleteAllInBatch(Iterable<T> entities);

    void deleteAllByIdInBatch(Iterable<ID> ids);

    void deleteAllInBatch();

    T getReferenceById(ID id);

    <S extends T> List<S> findAll(Example<S> example);

    <S extends T> List<S> findAll(Example<S> example, Sort sort);
}

개발자가 별도로 구현하지 않고도 바로 사용할 수 있다. 실제 구현은 JPA의 구현체(Hibernate, EclipseLink 등)에 의해 이루어진다.

메소드 이름으로 쿼리 생성

Spring 공식 문서 - JPA Query Methods

기본 인터페이스 외에도 간단하게 필요한 쿼리가 있을 때 이 방법을 사용하면 좋다. 인터페이스에 정해진 규칙에 따라 메소드의 이름을 지어주면 그에 따라 쿼리가 생성된다.

query-method-naming-rule

예를 들면 아래와 같이 메소드를 만들 수 있다.

List<TacoOrder> findByDeliveryNameAndDeliveryCityAllIgnoresCase(
        String deliveryName, String deliveryCity);

List<TacoOrder> findByDeliveryCityOrderByDeliveryName(String city);

이 메소드는 다음과 같은 느낌으로 쿼리가 생성될 것이다.

SELECT *
FROM taco_order
WHERE LOWER(delivery_name) = LOWER(:deliveryName)
  AND LOWER(delivery_city) = LOWER(:deliveryCity);

SELECT *
FROM taco_order
WHERE delivery_city = :city
ORDER BY delivery_name;

이외에도 다양한 문법들을 제공한다.

  • IsAfter, After, IsGreaterThan, GreaterThan
  • IsGreaterThanEqual, GreaterThanEqual
  • IsBefore, Before, IsLessThan, LessThan
  • IsLessThanEqual, LessThanEqual
  • IsBetween, Between
  • IsNull, Null
  • IsNotNull, NotNull
  • IsIn, In
  • IsNotIn, NotIn
  • IsStartingWith, StartingWith, StartsWith
  • IsEndingWith, EndingWith, EndsWith
  • IsContaining, Containing, Contains
  • IsLike, Like
  • IsNotLike, NotLike
  • IsTrue, True
  • IsFalse, False
  • Is, Equals
  • IsNot, Not
  • IgnoringCase, IgnoresCase

@Query 어노테이션으로 SQL 직접 정의

이름 기반으로 작성하기 어려울 경우나, 최적화가 필요하다든지, 다양한 이유로 직접 쿼리를 정의해야 할 때 사용할 수 있다.

@Query("SELECT o FROM Order o WHERE o.deliveryCity = :city")
List<TacoOrder> readOrdersDeliveredInCity(@Param("city") String city);

마무리

이 책에서는 JDBC에 대한 설명이 더 많은 편이다. 추후 스터디에서 JPA 책을 집중적으로 분석해볼 예정이니 그 때 더 자세히 알아볼 수 있도록 한다.