[Spring] 스프링 복기 (1)

seongwop·2026년 4월 5일

Spring

목록 보기
12/15

대학원 졸업과 군대 이슈로 긴 기간동안 스프링을 별로 접하지 못했는데, MSA와 대용량 트래픽 처리에 대해 공부하기 앞서 스프링 복기 차원에서 진행한 간단한 미니 프로젝트에서 겪은 개념들을 간단하게 돌이켜보고자 한다.

크게 다섯 항목으로 나눌 수 있다.

  1. 엔티티 연관관계
  2. @Transactional
  3. JpaRepository
  4. N+1 문제
  5. 원자성 보장

1. 엔티티 연관관계

주문 엔티티와 상품 엔티티가 있다고 치자. 한 주문에 여러 개의 상품이 들어갈 수 있고, 한 상품에 여러 개의 주문이 할당될 수 있다. 이러한 연관관계는 1:N, N:1, N:N 처럼 다양하게 분류될 수 있다.

중요한 점은 현재 JPA라는 ORM을 사용 중인 것인데, ORM(Object-Relational Mapping, 객체 관계 매핑)은 객체 지향 프로그래밍 언어의 객체와 관계형 데이터베이스(RDB)의 테이블을 자동으로 매핑(연결)해 주는 기술이다.

이게 중요한 이유는 DB에는 객체를 넣을 수 없는 반면에, 객체는 필드로 객체를 가지고 참조 가능하기 때문에 이러한 불일치에서 오는 문제들이 발생한다는 점이다.

따라서 연관관계 설정이 잘 되어야 하는데,

  • 1:N의 관계에서는 @OneToMany와 @ManyToOne 중에서 @ManyToOne을 써서 단방향으로 설정하는게 좋고, 필요에 따라 양방향 설정을 한다.

처음부터 모든 연관관계를 설정하기보다는 필요에 따라 추가하는 방향이 좋은데, FK(외래키)를 중심으로 DB에 최대한 매칭이 잘될 수 있도록 설정하는게 좋다.

@ManyToOne을 쓰는게 좋은 이유는 대부분 DB에서는 N쪽 테이블이 FK를 가지니까, 자연스럽게 @ManyToOne 쪽이 주인(FK를 가진 쪽)이 되고 DB의 관계 표현에 적합하다.

그렇다면 @OneToMany는 언제 쓰냐, 반대편에서 컬렉션으로 조회할 필요가 있을 때 쓴다. 객체 내부에 List를 필드로 두고 목록이 필요하거나 하는 경우에 쓴다. 하지만, 충분히 쿼리로도 목록 조회가 가능하기 때문에 필수가 아니다. 애초에 DB에는 양방향이라는 개념이 없듯이.

또한, 양방향의 경우 객체 참조가 양쪽에 생기므로 mappedBy 설정을 통해 주인을 명시해야만 문제가 발생하지 않는다.

  • N:N 관계에서는 @ManyToMany를 쓰기보단 중간 엔티티를 만들고 @OneToMany와 @ManyToOne를 사용해서 1:N + N:1 관계로 설정하는게 좋다.

@ManyToMany를 쓰면 JPA는 두 테이블만으로는 N:N 관계를 표현할 수 없어서,
중간에 조인 테이블(join table) 을 만든다. 그렇게 되면 중간 테이블을 엔티티로 다룰 수 없어서 컬럼을 넣기도 애매해진다. 단순한 연결이고 중간 테이블에 컬럼을 넣을 필요가 없다면 괜찮지만, 대부분의 경우에는 그렇지 않기 때문에 중간 엔티티를 만들고 1:N + N:1 관계로 푸는 것이 좋다.

2. @Transactional

특정 동작 또는 비즈니스 로직을 하나의 트랜잭션으로 관리할 때 쓴다. 하지만 남발할 수 없기 때문에 적용되는 개념을 알고 가야한다.

  • @Transactional 설정이 필요한 이유

트랜잭션이 없어도 조회도 가능하고 save() 메소드 호출을 통해 저장도 가능하다. 그 이유는 JPA는 기본적으로 트랜잭션 없이도 조회를 위해 아주 잠깐 영속성 컨텍스트를 열고 DB 커넥션을 빌려온다. 또한, save() 메소드에도 이미 @Transactional이 설정되어 있기 때문에 save() 메소드만 써도 저장이 가능하다. 그런데 왜 쓰는 것일까?

  1. 비즈니스 로직에 무결성이 깨질 수 있다.
    -> 예를 들어, 서비스 컴포넌트 내부에서 특정 메소드가 save() 메소드를 호출한다고 치자. 만약 다른 로직없이 save() 메소드만 한번 호출한다면 해당 메소드에 @Transactional 설정이 필요 없을 수 있다. 이유는 save() 메소드에 이미 @Transactional이 설정되어 있기 때문이다.
    -> 하지만 save() 메소드가 두 번 호출된다던가, 그 외에 같이 묶여야 할 작업들이 존재한다면 해당 메소드에 @Transactional을 설정함으로써 해당 로직 자체에 트랜잭션을 전파시켜야한다. (트랜잭션 전파의 기본값은 REQUIRED 이다. 즉, 기존 트랜잭션이 있을 때 다른 트랜잭션이 발생한다면 기존 트랜잭션에 참여한다는 뜻이다.
    )
    그렇지 않으면, 하나의 save() 메소드 또는 특정 작업만 완료되고 나머진 실패하는 상황이 발생하여 비즈니스 무결성이 깨질 수 있기 때문이다.

  2. 변경 감지(Dirty Checking)이 되지 않는다.
    -> 트랜잭션 설정에 의해 영속성 컨텍스트가 열려있어야 객체 수정 시 업데이트가 가능한데, 설정이 되어 있지 않으면 DB에 반영되지 않는다.
    -> readOnly 설정이 되어있는 경우에도 마찬가지다. JPA의 영속성 컨텍스트는 조회 시점에 엔티티의 초기 상태를 복사해서 스냅샷을 만들어 두는데, readOnly 설정 시 만들지 않을 뿐더러 스냅샷이 없어서 엔티티와 비교도 하지 않고, 마지막으로 변경사항이 없다고 가정하기 때문에 Flush 조차 하지 않는다. 하지만, 이 뜻은 조회만 필요할 경우엔 readOnly 설정을 통해 자원을 아끼고 성능을 최적화할 수 있다.

  3. OSIV가 없으면 지연 로딩(Lazy Loading)이 되지 않는다.
    -> 스프링의 기본 설정인 OSIV(Open Session In View)란 요청의 생성부터 응답까지 영속성 컨텍스트를 열어둠으로써 지연 로딩을 서비스 외 다른 계층에서도 가능하게 하는 패턴이다. 하지만 이는 트랜잭션 주기와 영속성 컨텍스트 주기를 다르게 하기 때문에 상황에 따라 사용하는 것이 좋다. 따라서, OSIV 없이 지연 로딩이 필요할 경우 @Transactional이나 readOnly 설정과 함께 사용하여 직접 영속성 컨텍스트를 열고 조회를 하는 것이 좋다.

0개의 댓글