[내일배움캠프 Spring 4기] 66일차 TIL - Raw JPA 연관관계 매핑 | 복합키 | Cascade | orphanRemoval | Fetch

서예진·2024년 3월 8일
0

오늘의 학습 키워드 📕

▸ 오늘의 코드카타
▸ Raw JPA 연관관계 매핑
▸ 복합키
▸ Cascade(영속성 전이)
▸ orphanRemoval(고아 객체 제거)
▸ Fetch(조회시점)


✅ 오늘의 코드카타

2024년 3월 7일 - [프로그래머스 - 자바(JAVA)] 34 : 이웃한 칸 | 올바른 괄호


✅ Raw JPA 연관관계 매핑

도메인 모델 연관관계 매핑

  • User : 유저 정보로 채널과 관계만 양방향이고, 다른 도메인과는 단방향 관계를 가진다.
  • Channel : 대화 채널은 유저와 다대다 관계를 가진다.
  • Thread : 채널 내 쓰레드로 Post와 같이 댓글, 이모지, 멘션과 관계를 가진다.

@OneToOne

  • 일대일 관계를 나타내는 매핑 정보
  • 1:1 관계를 지정하기에 앞서 이것이 꼭 물리적으로 테이블이 분리되어야 하는지에 대해 생각해 봐야 한다.
  • 1:1 관계로 구성 한다는 것은 결국 하나의 목적에 부합되는 공통된 데이터를 관리한다고 볼 수 있으며 이것은 하나의 테이블에서 관리 할 수 있는 데이타일 가능성이 높다는 의미이다.
  • 즉, 의도적 중복이 아니라면 사용할일이 없다는 말이다.
    • 의도적 중복 예시) 버블 구독상품을 사서 채팅방이 생길경우. 구독상품과 채팅방은 1:1 관계
  • @OneToOne 은 굳이 사용하지 않아도 된다. 불필요하다.

@OneToMany

  • 일대다 관계를 나타내는 매핑 정보
  • @OneToMany가 단방향으로 쓰이면 문제가 발생할 수 있다.
  • 일대다 양방향 관계는 없다 -> 다대일 양방향 관계일 때 사용한다.
  • 속도를 위해 기본적으로 FetchType 설정이 LAZY 로 설정되어 있다.
  • 속성
    • mappedBy : 연관관계의 주인 필드를 선택한다.
    • fetch : 글로벌 페치 전략 설정
    • cascade : 영속성 전이 기능을 사용한다.
    • targetEntity : 연관된 엔티티의 타입 정보를 설정한다.
  • @OneToMany 단방향 매핑 이슈

    • 엔티티가 관리하는 외래 키가 다른 테이블에 있다.
      => 작업한 Entity가 아닌 다른 Entity에서 쿼리문이 나가는 경우가 있어 헷갈린다.
  • 불필요한 쿼리문이 발생(update 등)한다.
    • 객체 저장시 update 쿼리문 추가 발생
    • 객체 삭제시 update 쿼리문 추가 발생

@ManyToOne

  • 다대일 관계를 나타내는 매핑 정보
  • 속성
    • optional (default true) : false로 설정하면 연관된 엔티티가 반드시 있어야 한다.
    • fetch : 글로벌 패치 전략 설정
      • 기본이 EGEAR 로 설정되어있으나 실무에서는 기본 LAZY로 설정하는것 추천!
    • cascade : 영속성 전이 기능 사용
    • targetEntity : 연관된 엔티티의 타입 정보 설정 (targetEntity = Member.class 식으로 사용)

@JoinColumn

  • 외래 키 매핑 시 사용 (Join 을 요청하기 위한 매핑정보로 쓰인다.)
  • @ManyToOne 어노테이션과 주로 함께 쓰인다. (조인대상 컬럼 지정기능을 안쓸거면 생략해도 됨)
  • name 속성은 매핑할 외래키의 이름
  • 어노테이션을 생략해도 외래 키가 생성됨.
    • 생략 시 외래키의 이름이 기본 전략을 활용하여 생성된다.
  • 속성
    • name : 매핑할 외래 키의 이름
    • referencedColumnName : 외래 키가 참조하는 대상 테이블의 컬럼명
    • foreignKey : 외래 키 제약조건 지정 (테이블 생성 시에만 적용됨)
    • unique/nullable/insertable/updateable/columnDefinition/table : @Column의 속성과 같음

N : 1 관계 예시

  • Thread 와 Channel 는 N : 1 관계를 가진다.
  • 양방향 관계

Channel

// lombok
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)

// jpa
@Entity
@Table(name = "TB_CHANNEL")
public class Channel {
	/**
	 * 컬럼 - 연관관계 컬럼을 제외한 컬럼을 정의합니다.
	 */
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "id", nullable = false)
	private Long id;

	private String name;

	@Enumerated(EnumType.STRING)
	private Type type;

	public enum Type {
		PUBLIC, PRIVATE;
	}

	/**
	 * 생성자 - 약속된 형태로만 생성가능하도록 합니다.
	 */

	@Builder
	public Channel (String name, Type type) {
		this.name = name;
		this.type = type;
	}


	/**
	 * 연관관계 - Foreign Key 값을 따로 컬럼으로 정의하지 않고 연관 관계로 정의합니다.
	 */
	@OneToMany(mappedBy = "channel")
	private Set<Thread> threads = new LinkedHashSet<>();

	/**
	 * 연관관계 편의 메소드 - 반대쪽에는 연관관계 편의 메소드가 없도록 주의합니다.
	 */
	public void addThread(Thread thread) {
		this.threads.add(thread);
	}

	/**
	 * 서비스 메소드 - 외부에서 엔티티를 수정할 메소드를 정의합니다. (단일 책임을 가지도록 주의합니다.)
	 */
}
  • @OneToMany(mappedBy = "channel") 를 통해 Thread 와 매핑했다.
  • 하나의 Channel에 N개의 Thread를 가지므로 Set의 threads를 필드로 가진다.

Thread

// lombok
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)

// jpa
@Entity
@Table(name = "TB_THREAD")
public class Thread {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "id", nullable = false)
	private Long id;

	/**
	 * 컬럼 - 연관관계 컬럼을 제외한 컬럼을 정의합니다.
	 */
	@Column(length = 500)
	private String message;

	/**
	 * 생성자 - 약속된 형태로만 생성가능하도록 합니다.
	 */
	@Builder
	public Thread (String message) {
		this.message = message;
	}

	/**
	 * 연관관계 - Foreign Key 값을 따로 컬럼으로 정의하지 않고 연관 관계로 정의합니다.
	 */
	@ManyToOne
	@JoinColumn(name = "channel_id")
	private Channel channel;


	/**
	 * 연관관계 편의 메소드 - 반대쪽에는 연관관계 편의 메소드가 없도록 주의합니다.
	 */
	public void setChannel (Channel channel) {
		this.channel = channel;
		channel.addThread(this);
	}


	/**
	 * 서비스 메소드 - 외부에서 엔티티를 수정할 메소드를 정의합니다. (단일 책임을 가지도록 주의합니다.)
	 */
}
  • @ManyToOne @JoinColumn(name = "channel_id") 를 통해 Channel과 매핑했다.
  • 위에서 설명한 것과 같이 @ManyToOne@JoinColumn 은 같이 사용된다.

Repository

  • RawJPA는 @PersistenceContext 를 주입받아와서 EntityManager를 직접 사용하고 있다.

  • ChannelRepository

    @Repository
    public class ChannelRepository {
    
        @PersistenceContext
        EntityManager entityManager;
    
        public Channel insertChannel (Channel channel) {
            entityManager.persist(channel);
            return channel;
        }
    
        public Channel selectChannel (Long id) {
            return entityManager.find(Channel.class, id);
        }
    }
  • ThreadRepository

    @Repository
    public class ThreadRepository {
    
        @PersistenceContext
        EntityManager entityManager;
    
        public Thread insertThread (Thread thread) {
            entityManager.persist(thread);
            return thread;
        }
    
        public Thread selectThread (Long id) {
            return entityManager.find(Thread.class, id);
        }
    
    }

테스트

  • @SpringBootTest@Transactional , @Rollback 을 사용하는 의미는 무엇일까?
  • @SpringBootTest
    • Spring Boot에서 제공하는 어노테이션으로 통합 테스트를 할 때 사용한다. -> 실제 애플리케이션이 실행되는 것과 유사한 환경을 제공한다.
    • 이 어노테이션을 사용하면 컨트롤러, 서비스, 리포지토리 등의 모든 구성 요소가 애플리케이션 컨텍스트에 로드되어 이러한 구성 요소간의 상호 작용을 테스트 할 수 있다.
  • @Transactional
    • Spring 프레임워크에서 제공하는 어노테이션이다.
    • 테스트 메서드 또는 테스트 클래스에 @Transactional을 표시하면 테스트 작업이 트랜잭션 컨텍스트 내에서 실행된다.
    • 트랜잭션은 테스트 실행 중에 발생하는 변경 사항이 테스트가 완료된 후에 롤백되어 데이터베이스가 테스트 시작 전과 동일한 상태로 유지되도록한다.
    • 이는 테스트 환경의 일관성을 유지하고 테스트 데이터가 실제 데이터베이스에 영향을 미치지 않도록 도와준다.
  • @Rollback
    • @Rollback(value = false)는 테스트에서 기본 롤백 동작을 재정의하는 데 사용된다. 기본적으로 Spring은 각 테스트 메서드 실행 후 트랜잭션을 롤백한다.
    • 그러한 경우에 따라 테스트 중에 수행된 변경 사항을 유지하고 추가 분석이나 디버깅을 위해 이러한 변경사항을 유지하고 싶을 수 있다.
    • 이러한 상황에서 @Rollback(value = false)를 설정하면 트랜잭션이 롤백되지 않고 테스트 중에 발생한 변경 사항이 데이터베이스에 유지된다.

ChannelRepositoryTest

@SpringBootTest
@Transactional
@Rollback(value = false)
class ChannelRepositoryTest {

	@Autowired
	private ChannelRepository channelRepository;

	@Test
	void 삽입_조회_성공() {
		// given
		Channel newChannel = Channel.builder().name("newGrooup").build();

		// when
		Channel savedChannel = channelRepository.insertChannel(newChannel);

		// then
		Channel foundChannel = channelRepository.selectChannel(savedChannel.getId());
		assert foundChannel.getId().equals(savedChannel.getId());
	}
}

  • insert 쿼리 하나만 날라간 이유? (select 쿼리가 날라가지 않은 이유)
    • 1차 캐시, 영속성 컨텍스트에서 조회해오기 때문에

ThreadRepositoryTest

@SpringBootTest
@Transactional
@Rollback(value = false)
class ThreadRepositoryTest {

	@Autowired
	private ThreadRepository threadRepository;

	@Test
	void Thread_삽입_조회_성공() {
		// given
		Channel newChannel = Channel.builder().name("new-channel").build();
		Thread newThread = Thread.builder().message("new-message").build();
		newThread.setChannel(newChannel);

		// when
		Thread savedThread = threadRepository.insertThread(newThread);

		// then
		Thread foundThread = threadRepository.selectThread(savedThread.getId());
		assert foundThread.getId().equals(savedThread.getId());
	}

}

오류 발생

org.springframework.dao.InvalidDataAccessApiUsageException: org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing : me.springstudy.jpastudy.thread.Thread.channel -> me.springstudy.jpastudy.channel.Channel

⇒ channel 이 flushing 이 안되어있다고 나옴

newChannel을 만들었는데 flushing이 안됨

해결방법

  • save 상태에서 persist 상태로 바꾸기 → 아래 코드 추가
  • 같이 영속성 컨텍스트로 넘겨줘야함
@Autowired
	private ChannelRepository channelRepository;
@Test
	void Thread_삽입_조회_성공() {
		// when
		Channel savedChannel = channelRepository.insertChannel(newChannel);
	}

  • insert 쿼리가 2개 넘어간 것을 확인할 수 있음

다대일 관계 테스트 - ThreadRepositoryTest

@SpringBootTest
@Transactional
@Rollback(value = false)
class ThreadRepositoryTest {

	@Autowired
	private ThreadRepository threadRepository;

	@Autowired
	private ChannelRepository channelRepository;

	@Test
	void Thread_삽입_조회_성공() {
		// given
		Channel newChannel = Channel.builder().name("new-channel").build();
		Thread newThread1 = Thread.builder().message("new-message").build();
		Thread newThread2 = Thread.builder().message("new-message1").build();
		newThread1.setChannel(newChannel);
		newThread2.setChannel(newChannel);

		// when
		Thread savedThread1 = threadRepository.insertThread(newThread1);
		Thread savedThread2 = threadRepository.insertThread(newThread2);
		Channel savedChannel = channelRepository.insertChannel(newChannel);

		// then
		Channel foundChannel = channelRepository.selectChannel(savedChannel.getId());
		assert foundChannel.getThreads().containsAll(Set.of(savedThread1, savedThread2));
	}

}

@ManyToMany

  • 다대다 관계를 나타내는 매핑 정보 (N:M)
  • 다대다 설정을 하게되면 중간 매핑테이블(JoinTable)이 자동으로 생성된다.
  • 중간 매핑 테이블은 JPA상에서 숨겨져서(Entity 정의 없이) 관리된다.
  • 매핑 테이블 관리가 불가능하여 @ManyToMany를 사용하는 것이 아니라 중간 테이블을 새로 하나 만든다.
    • TableA(@OneToMany) > MappingTable(@ManyToOne, @ManyToOne) > TableB(@OneToMany)

N : M 관계 예시

  • User 와 Channel 은 다대다 관계를 가진다.
  • 따라서 중간에 UserChannel 테이블을 만들어 주어야 한다.
  • User - UserChannel : 일대다 관계
  • UserChannel - Channel : 다대일 관계

User

// lombok
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)

// jpa
@Entity
@Table(name = "TB_USER")
public class User {
	/**
	 * 컬럼 - 연관관계 컬럼을 제외한 컬럼을 정의합니다.
	 */
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "id", nullable = false)
	private Long id;

	@Column(length = 25)
	private String username;

	@Column(length = 25)
	private String password;

	/**
	 * 생성자 - 약속된 형태로만 생성가능하도록 합니다.
	 */
	@Builder
	public User (String username, String password) {
		this.username = username;
		this.password = password;
	}

	/**
	 * 연관관계 - Foreign Key 값을 따로 컬럼으로 정의하지 않고 연관 관계로 정의합니다.
	 */
	 @OneToMany(mappedBy = "user")
	Set<UserChannel> userChannels = new LinkedHashSet<>();

	/**
	 * 연관관계 편의 메소드 - 반대쪽에는 연관관계 편의 메소드가 없도록 주의합니다.
	 */


	/**
	 * 서비스 메소드 - 외부에서 엔티티를 수정할 메소드를 정의합니다. (단일 책임을 가지도록 주의합니다.)
	 */


}

Channel

// lombok
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)

// jpa
@Entity
@Table(name = "TB_CHANNEL")
public class Channel {
	/**
	 * 컬럼 - 연관관계 컬럼을 제외한 컬럼을 정의합니다.
	 */
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "id", nullable = false)
	private Long id;

	private String name;

	@Enumerated(EnumType.STRING)
	private Type type;

	public enum Type {
		PUBLIC, PRIVATE;
	}

	/**
	 * 생성자 - 약속된 형태로만 생성가능하도록 합니다.
	 */

	@Builder
	public Channel (String name, Type type) {
		this.name = name;
		this.type = type;
	}


	/**
	 * 연관관계 - Foreign Key 값을 따로 컬럼으로 정의하지 않고 연관 관계로 정의합니다.
	 */
	@OneToMany(mappedBy = "channel")
	private Set<Thread> threads = new LinkedHashSet<>();

	@OneToMany(mappedBy = "channel")
	private Set<UserChannel> userChannels = new LinkedHashSet<>();

	/**
	 * 연관관계 편의 메소드 - 반대쪽에는 연관관계 편의 메소드가 없도록 주의합니다.
	 */
	public void addThread(Thread thread) {
		this.threads.add(thread);
	}

	public UserChannel joinUser (User user) {
		UserChannel userChannel = UserChannel.builder().user(user).channel(this).build();
		this.userChannels.add(userChannel);
		user.getUserChannels().add(userChannel);
		return userChannel;
	} // 이 부분 잘 생각해보기

	/**
	 * 서비스 메소드 - 외부에서 엔티티를 수정할 메소드를 정의합니다. (단일 책임을 가지도록 주의합니다.)
	 */
}

UserChannel

// lombok
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)

// jpa
@Entity
@Table(name = "TB_USERCHANNEL")
public class UserChannel {
	/**
	 * 컬럼 - 연관관계 컬럼을 제외한 컬럼을 정의합니다.
	 */
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "id", nullable = false)
	private Long id;

	/**
	 * 생성자 - 약속된 형태로만 생성가능하도록 합니다.
	 */
	@Builder
	public UserChannel(User user, Channel channel) {
		this.user = user;
		this.channel = channel;
	}

	/**
	 * 연관관계 - Foreign Key 값을 따로 컬럼으로 정의하지 않고 연관 관계로 정의합니다.
	 */
	@ManyToOne
	@JoinColumn(name = "user_id")
	User user;

	@ManyToOne
	@JoinColumn(name = "channel_id")
	Channel channel;

	/**
	 * 연관관계 편의 메소드 - 반대쪽에는 연관관계 편의 메소드가 없도록 주의합니다.
	 */


	/**
	 * 서비스 메소드 - 외부에서 엔티티를 수정할 메소드를 정의합니다. (단일 책임을 가지도록 주의합니다.)
	 */
}

UserChannelRepository

@Repository
public class UserChannelRepository {

	@PersistenceContext
	EntityManager entityManager;

	public UserChannel insertUserChannel (UserChannel userchannel) {
		entityManager.persist(userchannel);
		return userchannel;
	}

	public UserChannel selectUserChannel (Long id) {
		return entityManager.find(UserChannel.class, id);
	}
}

UserChannelRepositoryTest

@SpringBootTest
@Transactional
@Rollback(value = false)
class UserChannelRepositoryTest {

	@Autowired
	private UserRepository userRepository;

	@Autowired
	private ChannelRepository channelRepository;

	@Autowired
	private UserChannelRepository userChannelRepository;

	@Test
	void userJoinChannelTest() {
		// given
		Channel newChannel = Channel.builder().name("new-channel").build();
		User newUser = User.builder().username("new-user").password("new-pass").build();
		UserChannel newUserChannel = newChannel.joinUser(newUser);

		// when
		Channel savedChannel = channelRepository.insertChannel(newChannel);
		User savedUser = userRepository.insertUser(newUser);
		UserChannel savedUserChannel = userChannelRepository.insertUserChannel(newUserChannel);

		// then
		Channel foundChaneel = channelRepository.selectChannel(savedChannel.getId());
		assert foundChaneel.getUserChannels().stream()
			.map(UserChannel::getChannel)
			.map(Channel::getName)
			.anyMatch(name -> name.equals(newChannel.getName()));
	}
}

✅ 복합키 사용하기

  • PK 1개 - 단일키
  • FK 2개 - 복합키 (PK 없이) → 중간 테이블에서 복합키가 기본키의 역할을 한다.
  • 복합키를 선언하는 방법은 2가지가 있다.

1. @IdClass를 활용하는 복합키는 복합키를 사용할 엔티티 위에 @IdClass(식별자 클래스) 사용

  • 예시 코드 - @IdClass

    • UserChannel

      // lombok
      @Getter
      @NoArgsConstructor(access = AccessLevel.PROTECTED)
      
      // jpa
      @Entity
      @IdClass(UserChannelId.class)
      @Table(name = "TB_USERCHANNEL")
      public class UserChannel {
          /**
           * 컬럼 - 연관관계 컬럼을 제외한 컬럼을 정의합니다.
           */
      
          /**
           * 생성자 - 약속된 형태로만 생성가능하도록 합니다.
           */
          @Builder
          public UserChannel(User user, Channel channel) {
              this.user = user;
              this.channel = channel;
          }
      
          /**
           * 연관관계 - Foreign Key 값을 따로 컬럼으로 정의하지 않고 연관 관계로 정의합니다.
           */
          @Id
          @ManyToOne
          @JoinColumn(name = "user_id")
          User user;
      
          @Id
          @ManyToOne
          @JoinColumn(name = "channel_id")
          Channel channel;
      
          /**
           * 연관관계 편의 메소드 - 반대쪽에는 연관관계 편의 메소드가 없도록 주의합니다.
           */
      
          /**
           * 서비스 메소드 - 외부에서 엔티티를 수정할 메소드를 정의합니다. (단일 책임을 가지도록 주의합니다.)
           */
      }
      
    • UserChannelId

      @Getter
      @NoArgsConstructor(access = AccessLevel.PROTECTED)
      public class UserChannelId implements Serializable {
         private Long user;   // UserChannel 의 user 필드명과 동일해야함
         private Long channel; // UserChannel 의 channel 필드명과 동일해야함
      
         @Override
         public boolean equals(Object o) {
             if (this == o) return true;
             if (o == null || getClass() != o.getClass()) return false;
             UserChannelId userChannelId = (UserChannelId) o;
             return Objects.equals(getUser(), userChannelId.getUser()) && Objects.equals(getChannel(), userChannelId.getChannel());
         }
      
         @Override
         public int hashCode() {
             return Objects.hash(getUser(), getChannel());
         }
      }
      


2. @EmbeddedId를 활용하는 복합키는 복합키 위에 @EmbeddedId 사용

  • 예시 코드 - @EmbeddedId
    • UserChannel
      // lombok
      @Getter
      @NoArgsConstructor(access = AccessLevel.PROTECTED)
      
      // jpa
      @Entity
      @Table(name = "TB_USERCHANNEL")
      public class UserChannel {
      	/**
      	 * 컬럼 - 연관관계 컬럼을 제외한 컬럼을 정의합니다.
      	 */
      	@EmbeddedId
      	private UserChannelId userChannelId;
      
      	/**
      	 * 생성자 - 약속된 형태로만 생성가능하도록 합니다.
      	 */
      	@Builder
      	public UserChannel(User user, Channel channel) {
      		this.user = user;
      		this.channel = channel;
      	}
      
      	/**
      	 * 연관관계 - Foreign Key 값을 따로 컬럼으로 정의하지 않고 연관 관계로 정의합니다.
      	 */
      	@ManyToOne
      	@MapsId("user_id")
      	User user;
      
      	@ManyToOne
      	@MapsId("channel_id")
      	Channel channel;
      
      	/**
      	 * 연관관계 편의 메소드 - 반대쪽에는 연관관계 편의 메소드가 없도록 주의합니다.
      	 */
      
      	/**
      	 * 서비스 메소드 - 외부에서 엔티티를 수정할 메소드를 정의합니다. (단일 책임을 가지도록 주의합니다.)
      	 */
      }
      
    • UserChannelId
      @Getter
      @NoArgsConstructor(access = AccessLevel.PROTECTED)
      @Embeddable
      public class UserChannelId implements Serializable {
      
      	@Serial
      	private static final long serialVersionUID = 932813899396663626L;
      
      	@Column(name = "user_id")
      	private Long userId;
      
      	@Column(name = "channel_id")
      	private Long channelId;
      
      	@Override
      	public boolean equals(Object o) {
      		if (this == o) {
      			return true;
      		}
      		if (o == null || getClass() != o.getClass()) {
      			return false;
      		}
      		UserChannelId userChannelId = (UserChannelId) o;
      		return Objects.equals(getUserId(), userChannelId.getUserId()) && Objects.equals(
      			getChannelId(), userChannelId.getChannelId());
      	}
      
      	@Override
      	public int hashCode() {
      		return Objects.hash(getUserId(), getChannelId());
      	}
      }
      

✅ Cascade(영속성 전이)

  • 사용 위치
    • 연관관계의 주인 반대편 - 부모 엔티티(다대일에서 )
    • 즉, @OneToMany 가 있는 쪽 또는 @OneToOne 도 가능
    • 예를들어, 게시글과 첨부파일이라면 에 해당하는 게시글에 설정한다.
  • 사용 조건
    • 양쪽 엔티티의 라이프사이클이 동일하거나 비슷해야한다.
      • 예를들어, 게시글이 삭제되면 첨부파일도 같이 삭제 되어야 한다.
    • 대상 엔티티로의 영속성 전이는 현재 엔티티에서만 전이 되어야 한다. (다른곳에서 또 걸면 안됨)
      • 예를들어, 첨부파일을 게시글이 아닌 다른곳에서 영속성 전이를 하면 안된다.
      • 하나의 관계에서만 영속성 전이를 해야함
  • 옵션 종류
    • ALL : 전체 상태 전이
    • PERSIST : 저장 상태 전이
    • REMOVE : 삭제 상태 전이
    • MERGE : 업데이트 상태 전이
    • REFERESH : 갱신 상태 전이
    • DETACH : 비영속성 상태 전이

✅ orphanRemoval(고아 객체 제거)

  • 사용 위치

    • @OneToMany 또는 @OneToOne 에서 사용 - 부모 엔티티
  • 사용법

    • Cascade.REMOVE 와 비슷한 용도로 삭제를 전파하는데 쓰인다.
    • 부모 객체에서 리스트 요소삭제를 했을경우 해당 자식 객체는 매핑정보가 없어지므로 대신 삭제해준다.
      • 요건 DB 에서는 절대 알 수 없는 행동이다. (부모가 자식의 손을 놓고 버리고 간 고아 객체)
    Parent parent1 = em.find(Parent.class, parent.getId());
    parent1.getChildList().remove(0); // delete 쿼리나간다.

    그렇다면 Cascade.REMOVEorphanRemoval 차이점은 무엇인가?

    Cascade.REMOVE의 경우 에 해당하는 부모 엔티티를 em.remove를 통해 직접 삭제할 때,그 아래에 있는 에 해당하는 자식 엔티티들이 삭제되는 것이다.

    orphanRemoval=true는 위 케이스도 포함하며,에 해당하는 부모 엔티티의 리스트에서 요소를 삭제하기만 해도 해당 에 해당하는 자식 엔티티가 delete되는 기능까지 포함하고 있다고 이해하면 된다.

    즉, orphanRemoval=true 는 리스트 요소로써의 영속성 전이도 해준다는 뜻

    ⇒ 부가설명

    orphanRemoval=true를 사용하는 경우에는 부모 엔티티의 리스트에서 요소를 삭제할 때 해당 요소에 연관된 자식 엔티티가 자동으로 삭제된다. 이를 통해 리스트에서 요소가 제거될 때 관련된 자식 엔티티도 함께 삭제되므로 데이터베이스에서 쓰레기 데이터가 생성되는 것을 방지할 수 있다.

    예시

    @Entity
    public class Parent {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true)
        private List<Child> children = new ArrayList<>();
    
        // getters and setters
    }
    
    @Entity
    public class Child {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        @ManyToOne
        private Parent parent;
    
        // getters and setters
    }
    

    위의 코드에서 Parent 엔티티에는 Child 엔티티와의 일대다 관계가 있다. 이 때 orphanRemoval=true를 설정하면 부모 엔티티에서 자식 엔티티를 삭제할 때 관련된 자식 엔티티가 자동으로 삭제된다.

    Parent parent = entityManager.find(Parent.class, parentId);
    Child child = parent.getChildren().get(0); // 첫 번째 자식 엔티티 가져오기
    parent.getChildren().remove(child); // 부모 엔티티에서 자식 엔티티 제거
    // 자식 엔티티는 자동으로 삭제됨 (orphanRemoval=true 때문에)
    

    위의 코드에서 parent 엔티티에서 자식 엔티티를 제거하면 자식 엔티티가 자동으로 삭제된다. 이것이 orphanRemoval=true가 리스트 요소로써의 영속성 전이를 해준다는 의미이다.

    Gpt

    CascadeType.REMOVEorphanRemoval은 둘 다 JPA에서 관계를 관리하는 데 사용되는 기능이지만, 목적과 동작 방식에 차이가 있습니다.

    1. CascadeType.REMOVE:
      • CascadeType.REMOVE는 부모 엔티티가 삭제될 때 자식 엔티티도 함께 삭제되도록 지정하는 기능입니다.
      • 예를 들어, 부모 엔티티를 삭제할 때 자식 엔티티도 함께 삭제하려면 CascadeType.REMOVE를 사용하여 연관 관계를 설정할 수 있습니다.
      • 이것은 부모 엔티티의 삭제가 자식 엔티티에 대한 캐스케이드 삭제를 유발합니다.
    2. orphanRemoval:
      • orphanRemoval은 연관된 엔티티가 더 이상 부모 엔티티에 속하지 않을 때 자동으로 삭제되도록 지정하는 기능입니다.
      • 이것은 단순히 부모 엔티티의 삭제와는 관련이 없으며, 자식 엔티티가 부모와의 관계에서 분리되면 자동으로 삭제됩니다.
      • orphanRemoval을 사용하면 부모와의 연관 관계가 해제될 때 자식 엔티티를 자동으로 삭제하여 데이터베이스에서 쓰레기 데이터를 방지할 수 있습니다.

    따라서 CascadeType.REMOVE은 부모 엔티티의 삭제에 따라 자식 엔티티가 함께 삭제되는 것을 제어하는 반면, orphanRemoval은 부모와의 연관 관계가 끊길 때 자식 엔티티를 삭제하는 것을 제어합니다.

  • 옵션

    • true
    • false

영속성 전이 최강 조합 : orphanRemoval=true + Cascade.ALL

  • 위 2개를 함께 설정하면 자식 엔티티의 라이프 사이클이 부모 엔티티와 동일해지며 직접 자식 엔티티의 생명주기를 관리할 수 있게 되므로 자식 엔티티의 Repository 조차 없어도 된다.
  • 따라서 매핑 테이블에서 많이 쓰인다.

예시 코드

  • 리펙토링

    • 위의 코드를 보면 사용하지 않는 변수가 존재하는 것을 알 수 있다.

    • Cascade 설정을 통하여 위와 같은 코드를 조금 더 간결하게 짜고자 했다.

    • UserChannel을 저장하지 않더라도 저장이 되도록 하기

    • Channel

      // lombok
      @Getter
      @NoArgsConstructor(access = AccessLevel.PROTECTED)
      
      // jpa
      @Entity
      @Table(name = "TB_CHANNEL")
      public class Channel {
      	/**
      	 * 컬럼 - 연관관계 컬럼을 제외한 컬럼을 정의합니다.
      	 */
      	@Id
      	@GeneratedValue(strategy = GenerationType.IDENTITY)
      	@Column(name = "id", nullable = false)
      	private Long id;
      
      	private String name;
      
      	@Enumerated(EnumType.STRING)
      	private Type type;
      
      	public enum Type {
      		PUBLIC, PRIVATE;
      	}
      
      	/**
      	 * 생성자 - 약속된 형태로만 생성가능하도록 합니다.
      	 */
      
      	@Builder
      	public Channel (String name, Type type) {
      		this.name = name;
      		this.type = type;
      	}
      
      	/**
      	 * 연관관계 - Foreign Key 값을 따로 컬럼으로 정의하지 않고 연관 관계로 정의합니다.
      	 */
      	@OneToMany(mappedBy = "channel")
      	private Set<Thread> threads = new LinkedHashSet<>();
      
      	@OneToMany(mappedBy = "channel", cascade = CascadeType.ALL)
      	private Set<UserChannel> userChannels = new LinkedHashSet<>();
      
      	/**
      	 * 연관관계 편의 메소드 - 반대쪽에는 연관관계 편의 메소드가 없도록 주의합니다.
      	 */
      	public void addThread(Thread thread) {
      		this.threads.add(thread);
      	}
      
      	public UserChannel joinUser (User user) {
      		UserChannel userChannel = UserChannel.builder().user(user).channel(this).build();
      		this.userChannels.add(userChannel);
      		user.getUserChannels().add(userChannel);
      		return userChannel;
      	}
      
      	/**
      	 * 서비스 메소드 - 외부에서 엔티티를 수정할 메소드를 정의합니다. (단일 책임을 가지도록 주의합니다.)
      	 */
      }
      
    • UserChannel

      // lombok
      @Getter
      @NoArgsConstructor(access = AccessLevel.PROTECTED)
      
      // jpa
      @Entity
      @Table(name = "TB_USERCHANNEL")
      public class UserChannel {
      	/**
      	 * 컬럼 - 연관관계 컬럼을 제외한 컬럼을 정의합니다.
      	 */
      	@Id
      	@GeneratedValue(strategy = GenerationType.IDENTITY)
      	@Column(name = "id", nullable = false)
      	private Long id;
      
      	/**
      	 * 생성자 - 약속된 형태로만 생성가능하도록 합니다.
      	 */
      	@Builder
      	public UserChannel(User user, Channel channel) {
      		this.user = user;
      		this.channel = channel;
      	}
      
      	/**
      	 * 연관관계 - Foreign Key 값을 따로 컬럼으로 정의하지 않고 연관 관계로 정의합니다.
      	 */
      	@ManyToOne
      	@JoinColumn(name = "user_id")
      	User user;
      
      	@ManyToOne
      	@JoinColumn(name = "channel_id")
      	Channel channel;
      
      	/**
      	 * 연관관계 편의 메소드 - 반대쪽에는 연관관계 편의 메소드가 없도록 주의합니다.
      	 */
      
      	/**
      	 * 서비스 메소드 - 외부에서 엔티티를 수정할 메소드를 정의합니다. (단일 책임을 가지도록 주의합니다.)
      	 */
      }
      
    • UserChannelRepositoryTest

      @Test
      	void userJoinChannelWithCascadeTest() {
      		// given
      		Channel newChannel = Channel.builder().name("new-channel").build();
      		User newUser = User.builder().username("new-user").password("new-pass").build();
      		newChannel.joinUser(newUser);
      
      		// when
      		Channel savedChannel = channelRepository.insertChannel(newChannel);
      		User savedUser = userRepository.insertUser(newUser);
      
      		// then
      		Channel foundChaneel = channelRepository.selectChannel(savedChannel.getId());
      		assert foundChaneel.getUserChannels().stream()
      			.map(UserChannel::getChannel)
      			.map(Channel::getName)
      			.anyMatch(name -> name.equals(newChannel.getName()));
      	}

      • Cascade.All 설정을 하기 전에는 UserChannel도 저장을 해줬어야 했는데, 설정을 통해서 insertChannel 이 호출 될 때, 이 channel에 연관되어 있는 userChannels에도 Cascade가 전파되었다.
    • orphanRemoval 설정

      • ThreadRepositoryTest - orphanRemoval 설정 x

        @Test
            void deleteThreadWithoutOrphanRemovalTest() {
                // given
                Channel newChannel = Channel.builder().name("new-channel").build();
                Thread newThread1 = Thread.builder().message("new-message").build();
                Thread newThread2 = Thread.builder().message("new-message1").build();
                newThread1.setChannel(newChannel);
                newThread2.setChannel(newChannel);
                Thread savedThread1 = threadRepository.insertThread(newThread1);
                Thread savedThread2 = threadRepository.insertThread(newThread2);
                Channel savedChannel = channelRepository.insertChannel(newChannel);
        
                // when
                Channel foundChannel = channelRepository.selectChannel(savedChannel.getId());
                foundChannel.getThreads().remove(savedThread1);
        
            }
      • ThreadRepositoryTest - orphanRemoval 설정 o

        • Channel

          @OneToMany(mappedBy = "channel", cascade = CascadeType.ALL, orphanRemoval = true)
              private Set<Thread> threads = new LinkedHashSet<>();
        • ThreadRepositoryTest

          @Test
              void deleteThreadWithoutOrphanRemovalTest() {
                  // given
                  Channel newChannel = Channel.builder().name("new-channel").build();
                  Thread newThread1 = Thread.builder().message("new-message").build();
                  Thread newThread2 = Thread.builder().message("new-message1").build();
                  newThread1.setChannel(newChannel);
                  newThread2.setChannel(newChannel);
                  Thread savedThread1 = threadRepository.insertThread(newThread1);
                  Thread savedThread2 = threadRepository.insertThread(newThread2);
                  Channel savedChannel = channelRepository.insertChannel(newChannel);
          
                  // when
                  Channel foundChannel = channelRepository.selectChannel(savedChannel.getId());
                  foundChannel.getThreads().remove(savedThread1);
          
              }

          • delete 쿼리가 날아간 것을 확인할 수 있다.
    • User 와 Channel 이 다대다 관계 → 여기에도 적용할 수 있음

      • UserChannelRepository 가 없어도 됨

      • User

        /**
        	 * 연관관계 - Foreign Key 값을 따로 컬럼으로 정의하지 않고 연관 관계로 정의합니다.
        	 */
        	 @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
        	Set<UserChannel> userChannels = new LinkedHashSet<>();
      • Channel

        	/**
        	 * 연관관계 - Foreign Key 값을 따로 컬럼으로 정의하지 않고 연관 관계로 정의합니다.
        	 */
        	@OneToMany(mappedBy = "channel", cascade = CascadeType.ALL, orphanRemoval = true)
        	private Set<Thread> threads = new LinkedHashSet<>();
        
        	@OneToMany(mappedBy = "channel", cascade = CascadeType.ALL, orphanRemoval = true)
        	private Set<UserChannel> userChannels = new LinkedHashSet<>();
      • UserChannelRepositoryTest

        @SpringBootTest
        @Transactional
        @Rollback(value = false)
        class UserChannelRepositoryTest {
        
        	@Autowired
        	private UserRepository userRepository;
        
        	@Autowired
        	private ChannelRepository channelRepository;
        	
        	@Test
        	void userJoinChannelTest() {
        		// given
        		Channel newChannel = Channel.builder().name("new-channel").build();
        		User newUser = User.builder().username("new-user").password("new-pass").build();
        		UserChannel newUserChannel = newChannel.joinUser(newUser);
        
        		// when
        		Channel savedChannel = channelRepository.insertChannel(newChannel);
        		User savedUser = userRepository.insertUser(newUser);
        
        		// then
        		Channel foundChaneel = channelRepository.selectChannel(savedChannel.getId());
        		assert foundChaneel.getUserChannels().stream()
        			.map(UserChannel::getChannel)
        			.map(Channel::getName)
        			.anyMatch(name -> name.equals(newChannel.getName()));
        	}
        
        	@Test
        	void userJoinChannelWithCascadeTest() {
        		// given
        		Channel newChannel = Channel.builder().name("new-channel").build();
        		User newUser = User.builder().username("new-user").password("new-pass").build();
        		newChannel.joinUser(newUser);
        
        		// when
        		Channel savedChannel = channelRepository.insertChannel(newChannel);
        		User savedUser = userRepository.insertUser(newUser);
        
        		// then
        		Channel foundChaneel = channelRepository.selectChannel(savedChannel.getId());
        		assert foundChaneel.getUserChannels().stream()
        			.map(UserChannel::getChannel)
        			.map(Channel::getName)
        			.anyMatch(name -> name.equals(newChannel.getName()));
        	}
        }

✅ Fetch(조회시점)

  • 사용 위치
    • Entity 에 FetchType 으로 설정할 수 있다.
      • @ElementCollection, @ManyToMany, @OneToMany, @ManyToOne, @OneToOne
    • Query 수행시 fetch Join 을 통해서 LAZY 인 경우도 즉시 불러올 수 있다.
  • 사용법
    • 기본 LAZY를 설정한 뒤에 필요할때만 fetch Join 을 수행한다.
    • 항상 같이 쓰이는 연관관계 일 경우만 EAGER 를 설정한다.
  • 옵션(FetchType)
    • EAGER : 즉시 로딩 (부모 조회 시 자식도 같이 조회)
    • LAZY : 지연 로딩 (자식은 필요할때 따로 조회)
profile
안녕하세요

0개의 댓글