• 포스트
  • 도구
  • 작업
  • 포스트
  • 도구
  • 작업
로그인회원가입

© 2005 kaisa.co.kr. All Rights Reserved.

소개개인정보처리방침7083620@hanmail.net
← 목록으로
2024-12-04

Spring Data JPA와 영속성 계층

Spring Data JPA와 영속성 계층

요약: JPA 엔티티와 트랜잭션 경계를 명확히 하고, N+1·OSIV·스키마 마이그레이션 함정을 피한다.


1. 왜 JPA인가

JPA(Jakarta Persistence API) 는 객체를 테이블에 매핑하는 표준이다. Hibernate가 구현체고, Spring Data JPA는 리포지토리 인터페이스로 반복 쿼리를 줄인다.

계층 책임
Entity 상태와 불변식
Repository 영속성 추상화
Service (@Transactional) 유스케이스 단위 트랜잭션
Controller HTTP만

컨트롤러에 save를 두거나, 리포지토리에 비즈니스 분기를 넣지 않는다.


2. 엔티티

기본 생성자는 JPA용으로 protected, 변경은 setter 대신 도메인 메서드로 둔다.

@Entity
@Table(name = "users")
@EntityListeners(AuditingEntityListener.class)
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true, length = 100)
    private String email;

    @Column(nullable = false, length = 50)
    private String name;

    @CreatedDate
    @Column(nullable = false, updatable = false)
    private Instant createdAt;

    @LastModifiedDate
    private Instant updatedAt;

    protected User() {}

    public static User create(String email, String name) {
        User user = new User();
        user.email = email;
        user.name = name;
        return user;
    }

    public void rename(String name) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("name required");
        }
        this.name = name;
    }
}

@EnableJpaAuditing을 켜야 @CreatedDate가 동작한다. equals/hashCode를 전 필드 Lombok으로 만들면 영속 전후 동등성이 깨지기 쉽다. 식별자 기준으로 신중히 구현한다.

공통 시각 필드는 @MappedSuperclass로 빼고, 소프트 삭제는 deletedAt 또는 Hibernate @SoftDelete로 명시한다.


3. 리포지토리

파생 메서드 → JPQL @Query → Specification / QueryDSL 순으로 복잡도를 올린다.

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
    boolean existsByEmail(String email);

    @Query("select u from User u where u.name like %:keyword%")
    List<User> searchByName(@Param("keyword") String keyword);

    @Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query("update User u set u.name = :name where u.id = :id")
    int rename(@Param("id") Long id, @Param("name") String name);
}

벌크 @Modifying은 영속성 컨텍스트를 건너뛴다. clearAutomatically 없이 같은 트랜잭션에서 엔티티를 다시 쓰면 DB와 객체가 어긋난다.


4. 트랜잭션

한 줄 정의: 트랜잭션은 한 유스케이스가 전부 성공하거나 전부 롤백되는 경계다. 클래스를 readOnly = true로 두고 쓰기 메서드만 @Transactional을 연다.

@Service
@Transactional(readOnly = true)
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public UserResponse get(Long id) {
        return userRepository.findById(id)
                .map(UserMapper::toResponse)
                .orElseThrow(() -> new UserNotFoundException(id));
    }

    @Transactional
    public UserResponse create(CreateUserRequest request) {
        if (userRepository.existsByEmail(request.email())) {
            throw new DuplicateEmailException(request.email());
        }
        User saved = userRepository.save(User.create(request.email(), request.name()));
        return UserMapper.toResponse(saved);
    }
}

전파·격리 수준은 기본값으로 대부분 충분하다. 긴 트랜잭션 안에서 외부 HTTP를 호출하면 커넥션을 붙잡은 채 대기한다.


5. 연관과 지연 로딩

연관은 LAZY가 기본 전략이다. EAGER는 목록 조회마다 조인이 붙는 사고의 주범이다.

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
private User user;

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();

public void addLine(OrderLine line) {
    lines.add(line);
    line.setOrder(this);
}

양방향은 편의 메서드로 양쪽을 맞춘다.


6. N+1과 OSIV

N+1: 목록 1번 조회 후 연관에 접근할 때마다 쿼리가 N번 더 나간다.

@Query("select o from Order o join fetch o.user where o.id = :id")
Optional<Order> findWithUser(@Param("id") Long id);

@EntityGraph(attributePaths = {"lines"})
Optional<Order> findWithLinesById(Long id);

@Query("""
        select new com.example.demo.order.OrderSummary(o.id, u.email, o.totalAmount)
        from Order o join o.user u
        where o.status = :status
        """)
List<OrderSummary> findSummaries(OrderStatus status);

읽기 전용 화면에 DTO 프로젝션이 가장 싸다. fetch join은 컬렉션을 두 개 이상 동시에 끌어오면 카테시안 곱이 난다.

OSIV(Open Session In View) 는 뷰 렌더까지 영속성 컨텍스트를 열어 두는 설정이다. Boot 기본은 true이고, 운영은 false를 권장한다. 컨트롤러에서 지연 로딩이 터지면 쿼리를 고친다.

spring:
  jpa:
    open-in-view: false

7. Flyway와 낙관적 락

ddl-auto: update는 로컬 실험용이다. 운영 스키마의 진실은 Flyway/Liquibase 마이그레이션 파일이다.

spring:
  jpa:
    hibernate:
      ddl-auto: validate
  flyway:
    enabled: true
db/migration/
  V1__create_users.sql
CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email VARCHAR(100) NOT NULL UNIQUE,
    name VARCHAR(50) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL,
    updated_at TIMESTAMPTZ
);

동시 수정 충돌은 @Version 낙관적 락으로 잡는다. 실패 시 OptimisticLockException → 409 또는 재조회 후 재시도.

@Version
private Long version;

8. 로컬에서 SQL 보기

spring:
  datasource:
    hikari:
      maximum-pool-size: 10
      connection-timeout: 3000
  jpa:
    properties:
      hibernate:
        jdbc.batch_size: 50
        order_inserts: true
        order_updates: true

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE

show-sql=true만으로는 바인딩 파라미터가 안 보인다. Hibernate 6 로거는 orm.jdbc.bind다.


9. 흔한 실수

실수 대안
전역 EAGER LAZY + 필요한 조회만 fetch/DTO
OSIV에 의존 open-in-view: false + 명시 조회
ddl-auto: update 운영 Flyway + validate
트랜잭션 안에서 외부 API 커밋 후 이벤트/아웃박스
벌크 업데이트 후 엔티티 재사용 clear/flush 또는 재조회

연습

  1. User 1:N Post 엔티티와 리포지토리를 만든다.
  2. 목록 + 작성자 이메일을 fetch join 또는 DTO로 N+1 없이 조회한다.
  3. Flyway로 테이블을 만들고 ddl-auto: validate로 맞춘다.
  4. open-in-view: false에서 지연 로딩 예외가 안 나는지 확인한다.

댓글

0

아직 댓글이 없습니다. 첫 댓글을 남겨보세요.

이전 글REST API 설계 — Validation, Problem Details, RestClient다음 글Spring Security 6 — 인증·인가·OAuth2·JWT
← 목록으로