들어가기 전에

사실 스프링 공부를 여러번 시도 했었다.
HTTP, WAS, DB 등 백앤드에 관련된 지식이 없을 때 무작정 인프런에서 유명하다는 강사님의 인강을 보면서 따라 쳤는데 강의 내용의 반의 반도 습득하지 못했었다.
그렇게 두 번, 세 번 전체강의를 돌려보았고 결국 완벽한 이해는 못한 채 다른 프로젝트들을 시작했다.
Flask, Nest.js 등의 다른 프레임워크를 사용하다가 현업에서 가장 많이 쓰고 강력한 무기인 Spring을 다 씹어먹지 못한 게 너무 한이되어 다시 처음부터 개념을 씹어먹을거라는 각오로 블로그 글을 작성하면서 다시 공부하기로 했다.
그래서, Code-based인 다른 포스트랑은 다르게 Spring은 내가 이해하고 추가적으로 궁금했었던 개념들을 위주로 글을 써내려가려고 한다.

추가로, 제목 앞에 붙는 넘버링은 Spring 공부 순서에 따라 정리하는 순으로 정리되며, 객관적으로 중요한 내용이더라도 필자가 기존에 제대로 이해하고 있었고 다시 공부하면서 배운 점이 없다면 굳이 포스팅으로 남기지 않으려고 한다.

JPA는 왜 써야할까?

실제 프로젝트를 하다보면 DB Table들이 계속 많아지고 OOP를 준수하면서 코딩을 할 수록 매핑작업이 끝없이 늘어나게 된다.
그래서 ORM (Object Relational Mapping) 기술을 활용하여 코딩을 하면 조금 더 개발자답게(?) 코딩할 수 있게 된다.

레거시하게 JDBC에서 유저를 조회하는 쿼리문을 작성하는 예시를 보면 아래와 같다.


public class LegacyJdbcExample {
    public User getUserById(long userId) {
        User user = null;
        Connection connection = null;
        PreparedStatement preparedStatement = null;
        ResultSet resultSet = null;

        try {
            String url = "jdbc:mysql://localhost:3306/database_name";
            String username = "username";
            String password = "password";

            connection = DriverManager.getConnection(url, username, password);
            String sql = "SELECT * FROM users WHERE id = ?";
            preparedStatement = connection.prepareStatement(sql);
            preparedStatement.setLong(1, userId);

            resultSet = preparedStatement.executeQuery();
            if (resultSet.next()) {
                user = new User();
                user.setId(resultSet.getLong("id"));
                user.setName(resultSet.getString("name"));
                // ... set other fields
            }
        } catch (SQLException e) {
            e.printStackTrace();
        } finally {
            try {
                if (resultSet != null) resultSet.close();
                if (preparedStatement != null) preparedStatement.close();
                if (connection != null) connection.close();
            } catch (SQLException e) {
                e.printStackTrace();
            }
        }

        return user;
    }
}

반면에 JPA로 같은 기능을 하는 코드를 작성하면,

public class JpaExample {
    
    @PersistenceContext
    private EntityManager entityManager;

    public User getUserById(long userId) {
        return entityManager.find(User.class, userId);
    }
}

이런식으로 Spring에서 제공해주는 EntityManager를 사용해 빠른 개발을 할 수 있다.

JPA는 Java애플리케이션과 JDBC 사이에서 동작하며, 이를 정확히 이해하려면 엔티티 매니저와 영속성 컨텍스트를 이해해야 한다.

엔티티 매니저 팩토리와 엔티티 매니저

사용자가 요청을 보낼 때 마다 EntityManagerFactory가 EntityManager 객체를 생성한다.
그렇게 생성된 EntityManager 객체는 ConnectionPool을 통해 DB에 저장, 조회된다.

영속성 컨텍스트

엔티티 매니저를 통해서 영속성 컨텍스트에 접근할 수 있다.
Spring과 같은 프레임워크에서는 엔티티 매니저와 영속성 컨텍스트가 N:1 관계로,
각각의 EntityManager들이 하나의 PersistenceContext와 매핑되어 있는 구조이다.

엔티티의 생명주기에는 아래와 같은 상태가 존재한다.

  • 비영속 (new/transient)
    영속성 컨텍스트와 전혀 관계가 없는 새로운 상태
  • 영속 (managed)
    영속성 컨텍스트에 관리되는 상태
  • 준영속 (detached)
    영속성 컨텍스트에 저장되었다가 분리된 상태
  • 삭제 (removed)
    삭제된 상태

비영속

// 단순히 객체를 생성하기만 한 상태
Member member = new Member();
member.setId("member1");
member.setUsername("회원1");

영속

Member member = new Member();
member.setId("member1");
member.setUsername("회원1");

EntitiyManager em = emf.createEntityManager();
em.getTransaction().begin();

// 객체를 저장한 상태 (영속)
em.persist(member);

준영속, 삭제

// 영속성 컨텍스트에서 분리 (준영속)
em.detach(member);
// 객체를 삭제한 상태 (삭제)
em.detach(member);

결국 영속성 컨텍스트가 프레임워크 내에서 객체를 관리해주는 역할을 한다.

영속성 컨텍스트의 이점

  • 1차 캐시
  • 동일성 보장
  • 트랜잭션을 지원하는 쓰기 지연
  • 변경 감지(Dirty Checking)
  • 지연로딩(Lazy Loading)

1차 캐시

// 1차 캐시에 저장됨
em.persist(member);
// 1차 캐시에서 조회
Member findMember = em.find(Member.class, "member1");

이런식으로 사용했을 때 DB에서 find 하는 게 아니라, 1차 캐시에서 조회하기 때문에 빠른 응답을 제공할 수 있다.

만약 1차 캐시에 없다면, 이후 DB에서 조회를 하고 1차 캐시에 저장 후 반환하게 된다.

처음 공부할 때 Spring이 알아서 한번 조회했던 객체를 entityManager 내에 1차 캐시에 저장하면,
Redis (REmote Dictionary Server)는 Spring을 쓰면서 MSA로 구성할 때 메인노드로 쓸 때를 제외하면 굳이 Spring과 레디스를 같이 쓸 필요는 없겠네..? 라는 생각을 했었다.

그리고 의문이 1분만에 풀리는데, entityManager는 보통 한번 사용되고 바로 close 되는데, close되면서 1차 캐시에 있던 key-value가 날라가는 것이다.

그러니까 객체가 호출되면서 잠깐만 존재했다가 사라지는 존재였던 것이다.

동일성 보장

Member a = em.find(Member.class, "member1");
Member b = em.find(Member.class, "member1");
System.out.println(a == b); 
// 동일성 비교 true

실제로 여러 번의 조회를 하더라도 트랜잭션 격리 수준을 DB가 아닌 Java 애플리케이션 차원에서 제공하기 때문에 동일성을 보장 받는다.

트랜잭션을 지원하는 쓰기 지연

엔티티 등록 : 트랜잭션을 지원하는 쓰기 지연

EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
// 엔티티 매니저는 데이터 변경시 트랜잭션을 시작해야 한다.
transaction.begin(); // [트랜잭션] 시작
em.persist(memberA);
em.persist(memberB);
// 여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.
// 커밋하는 순간 데이터베이스에 INSERT SQL을 보낸다.
transaction.commit(); // [트랜잭션] 커밋

트랜잭션이 커밋되기 전까지는 SQL을 보내지 않음으로 한번에 모아서 SQL을 호출해서 성능적인 부분에서 이점을 챙길 수 있다.

em.persist(memberA);
em.persist(memberB);
// 이때까지는 영속성 컨텍스트 내에 있는 1차 캐시에 저장만 하고 있다.

추가로 commit()을 하더라도 영속 컨텍스트에 있는 1차 캐시가 날라가지는 않고, em.close를 해서 entityManager를 삭제해야 캐시가 날라간다.

변경 감지(Dirty Checking)

EntityManager em = emf.createEntityManager();
EntityTransaction transaction = em.getTransaction();
transaction.begin(); // [트랜잭션] 시작
// 영속 엔티티 조회
Member memberA = em.find(Member.class, "memberA");
// 영속 엔티티 데이터 수정
memberA.setUsername("hi");
memberA.setAge(10);
//em.update(member) 이런 코드가 있어야 하지 않을까?
transaction.commit(); // [트랜잭션] 커밋

1차 캐시에 있는 member Entity와 스냅샷을 비교하여 UPDATE SQL을 쓰기지연 SQL 저장소에서 생성하기 때문에 UPDATE문이 필요가 없다.

플러시 Flush

  • 영속성 컨텍스트의 변경내용을 데이터베이스에 동기화

영속성 컨텍스트를 플러시하는 방법

  • em.flush() - 직접 호출
  • 트랜잭션 커밋 - 플러시 자동 호출
  • JPQL 쿼리 실행 - 플러시 자동 호출

플러시 특성

  • 영속성 컨텍스트를 비우지 않음
  • 영속성 컨텍스트의 변경내용을 데이터베이스에 동기화
  • 트랜잭션이라는 작업 단위가 중요 -> 커밋 직전에만 동기화 하면 됨

플러시 모드 옵션은 Default 상태에서 크게 건들 일이 없다.