[스프링부트] 익명 블로그 V6 - 연관 관계 ORM

익명 블로그 V6 - 1
이나겸's avatar
Dec 01, 2024
[스프링부트] 익명 블로그 V6 - 연관 관계 ORM
💡

ORM(Object-Relational Mapping)

객체 지향 프로그래밍 언어의 객체를 데이터베이스의 테이블과 매핑하여 데이터베이스와의 상호 변환을 자동화하는 기술 또는 개념
데이터베이스를 사용할 때 SQL문을 직접 작성하지 않아도 객체를 조작하는 것만으로 데이터베이스 작업을 수행 가능
객체 지향 프로그래밍과 관계형 데이터베이스 간의 불일치를 해결하기 위해 사용

특징

  • 객체와 테이블의 매핑, 속성과 컬럼의 매핑
    • 클래스와 데이터베이스 테이블, 클래스의 필드와 테이블의 컬럼을 매핑해서 데이터베이스를 객체 지향적으로 다룰 수 있음
      • 예) User 클래스가 데이터베이스의 user-tb 테이블에 매핑
      • 예) User 클래스의 username 속성이 user_tb 테이블의 username 컬럼에 매핑
  • 데이터베이스 독립성
    • SQL 문법에 의존하지 않고 ORM이 제공하는 메서드를 통해 작업
    • 데이터베이스 변경시 코드 수정 최소화
  • 생산성 향상
    • 반복적인 SQL 작성을 줄이고 객체지향적인 코드로 데이터베이스를 다룰 수 있어 개발 속도 높임
  • 자동화된 SQL 생성
    • 기본적인 CRUD(Create, Read, Update, Delete) 쿼리를 자동으로 생성하고 실행
    • 개발자는 SQL 쿼리를 직접 작성할 필요 없이 객체를 조작하는 코드만 작성하면 됨
  • 트랜잭션 관리
    • ORM 프레임워크는 데이터베이스 트랜잭션을 관리하여 데이터의 일관성과 무결성 유지

장점

  • 유지보수 용이성
    • 코드에서 객체만 수정하면 돼서 데이터베이스 구조 변경 시 영향 최소화
  • 효율적인 협업
    • 데이터베이스에 익숙하지 않은 개발자도 객체 지향적으로 데이터베이스 사용 가능
  • 보안
    • SQL Injection 같은 보안 문제를 방지하는데 도움

단점

  • 성능 문제
    • 복잡한 쿼리나 대량 데이터 처리가 필요한 경우, 직접 작성한 SQL보다 느릴 수 있음
  • 학습 곡선
    • ORM 도구(JPA, Hibernate 등)의 동작 방식을 이해하고 최적화 하려면 학습 필요
  • 제한된 기능
    • 데이터베이스 특정 기능을 사용하는 데 제약 있을 수 있음

Java 환경에서의 ORM (Spring Boot)

  • Spring Boot에서는 대표적인 ORM 프레임워크로 JPA를 사용하며, 그 구현체로 Hibernate 주로 사용
    • JPA : 표준 ORM 스펙(API)
    • Hibernate: JPA를 구현한 대표적인 프레임워크

ORM을 구현하기 위해 사용한 기술 참고

Hibernate

💡

Hibernate

Java 언어를 위한 ORM(Object-Relational Mapping) 프레임워크
객체 지향 프로그래밍에서 데이터베이스와 상호작용할 수 있도록 도움
데이터베이스의 테이블과 Java 객체 간의 매핑을 자동으로 처리하므로 개발자는 SQL 쿼리를 직접 작성하지 않고도 데이터베이스 작업을 수행

특징

  • 객체와 테이블 매핑
    • Java 클래스와 데이터베이스 테이블 간의 매핑 자동 처리
  • 자동 SQL 생성
    • 데이터베이스 작업을 위해 필요한 SQL 쿼리 자동 생성하고 실행
  • 지연 로딩(Lazy Loading)
    • 필요한 시점까지 데이터베이스에서 데이터를 로드하지 않아 성능 최적화 가능
  • 캐시 지원
    • 1차 캐시(세션 캐시)와 2차 캐시(전역 캐시)를 통해 데이터베이스의 접근 최소화하고 성능 향상시킴
  • 트랜잭션 관리
    • 데이터의 일관성과 무결성 유지 위해 트랜잭션 관리
 

익명 블로그에 ORM 활용하기

[User 테이블]

  • @Builder
    • 풀 생성자 만들고 Builder의 형태 잡음 ⇒ @AllArgsConstructor 어노테이션 지우고 풀 생성자에 @Builder 어노테이션 달기
    • @Builder는 디폴트 생성자가 있어야 사용 가능 ⇒ @NoArgsConstructor 어노테이션은 제외하지 않음
    • 컬렉션은 빌더 생성 불가 주의 ⇒ 컬렉션 포함되어있으면 빌더에서 제외됨
  • @NoArgsConstructor(access = AccessLevel.PROTECTED)
    • 외부에서 빈(디폴트) 생성자 생성하지 못하도록 제한(protected)
  • @GeneratedValue(strategy = GenerationType.IDENTITY)
    • Autoincrement
  • @Column(unique = true, nullable = false)
    • 컬럼제약조건 unique(index 알아서 만들어짐)와 not null
// Builder 문법 // @Builder는 @AllArgsConstructor(디폴트 생성자)가 있어야 사용 가능 // 컬렉션은 빌더 생성 불가 => 포함돼있으면 빌더에서 제외 // 풀 생성자 생성해놓고 @Builder 달아서 빌더 만듦 => 풀생성자 어노테이션 지우기 @NoArgsConstructor(access = AccessLevel.PROTECTED) // 외부에서 디폴트 생성자 생성하지 못하게 제한(protected) @Table(name = "user_tb") // 테이블명 @Getter // private에 접근 하려면 getter 필요 @Entity // 엔티티, 이거 달아놔야 테이블 생성 가능 public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) // Autoincrement private Integer id; // Null일것도 감안해서 Integer @Column(unique = true, nullable = false) // 컬럼제약조건 unique(index 알아서 만들어질것), not null private String username; // 유저 아이디는 username으로 약속되어있음, id 아님 @Column(nullable = false) private String password; @Column(nullable = false) private String email; @CreationTimestamp private Timestamp createdAt; // 풀 생성자 만들고 @Builder 달아서 빌더의 형태 잡음 @Builder public User(Integer id, String password, String username, String email, Timestamp createdAt) { this.id = id; this.password = password; this.username = username; this.email = email; this.createdAt = createdAt; } }
 

[Board 클래스]

  • @Builder
    • 풀 생성자 만들고 Builder의 형태 잡음 ⇒ @AllArgsConstructor 어노테이션 지우고 풀 생성자에 @Builder 어노테이션 달기
    • @Builder는 디폴트 생성자가 있어야 사용 가능 ⇒ @NoArgsConstructor 어노테이션은 제외하지 않음
  • @NoArgsConstructor(access = AccessLevel.PROTECTED)
    • 외부에서 빈(디폴트) 생성자 생성하지 못하도록 제한(protected)
  • @GeneratedValue(strategy = GenerationType.IDENTITY)
    • AutoIncrement
  • 기존 Board 객체의 필드에 User user가 추가됨
  • @ManyToOne(fetch = FetchType.LAZY)
    • @ManyToOne : JPA에서 객체 간의 관계 설정 시 사용
    • 현재 엔티티가 다른 엔티티와 N:1 관계에 있음을 나타냄
      • 여러 Board(N)가 하나의 User(1)와 연결되는 관계
      • DB에는 User 객체가 들어가지 못하기 떄문에 user_tb의 PK(user_id)를 board_tb의 FK로 설정
    • fetch = FetchType.LAZY : 관련된 User 엔티티를 지연 로딩(Lazy Loading)
      • 실제로 User 데이터가 필요할 때까지 데이터베이스에서 로드되지 않음
      • 밑의 지연 로딩 VS 즉시 로딩 이론 참고
    • 연관 관계 관련 Hibernate 문법 규칙
      • 연관 관계 설정해놓은 User 객체(user_tb) 자체를 들고오는 것이 아니라 user_tb 테이블의 pk(userId)를 들고옴 ⇒ board_tb 테이블의 fk
@NoArgsConstructor(access = AccessLevel.PROTECTED) // 외부에서 빈 생성자 생성하지 못하게 제한(protected) @Getter @Table(name = "board_tb") // 테이블 명 @Entity // Board 객체를 테이블로 생성하며, 데이터베이스 테이블과 매핑하여 데이터베이스 작업(insert, update, delete 등)을 수행 public class Board { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) // AutoIncrement private Integer id; private String title; private String content; // 241129 User 필드 추가 // 하이버네이트 문법 규칙 // 연관 관계 설정해놓은 User 객체(user_tb 테이블) 자체를 들고오는 것이 아니라 // user_tb 테이블의 PK (user_id(필드로는 userId))를 들고옴 => board_tb 테이블의 fk // 연관 관계 설정 (User 쪽이 1이니까 ManyToOne) @ManyToOne(fetch = FetchType.LAZY) // LAZY와 EAGER일때 콘솔 출력되는 쿼리 다름 private User user; @CreationTimestamp private Timestamp createdAt; // 풀 생성자 만들고 @Builder 달아서 빌더의 형태 잡음 @Builder public Board(Integer id, String title, String content, User user, Timestamp createdAt) { this.id = id; this.title = title; this.content = content; this.user = user; this.createdAt = createdAt; } // update(글 수정)를 setter로 만들어서 활용하기로 함 // setter는 명확한 의도가 있는것만 해놓는게 좋기 때문에 public void update(String title, String content) { this.title = title; this.content = content; } }

[지연 로딩(Lazy Loading) vs 즉시 로딩(EAGER Loading) 이론 참고]

지연 로딩(Lazy Loading)

  • 관련된 엔티티 데이터를 실제로 사용하기 전까지 로드하지 않음
  • Board 엔티티를 조회할 때 관련된 User 엔티티를 즉시 로드하지 않고, User 데이터가 필요할 때 user.getUser() 등을 데이터베이스에서 가져옴
    • Board 엔티티가 로드될 떄 User 엔티티는 프록시 객체로 대체 ⇒ getter 가져오면 프록시 객체로 대체해서 select 발생
    • User 엔티티의 데이터를 가지지 않지만 User 엔티티에 접근하는 시점에 데이터베이스 조회(select) 발생하여 실제 데이터 로드 ⇒ 프록시 객체는 실제 User 엔티티로 대체
💡
조회할 board_tb 테이블에 user_id가 fk로 존재하기 떄문에 User 객체의 id값은 초기화됨
User 객체의 나머지 값들은 전부 null

장점

  • 초기 조회 시 불필요한 데이터를 로드하지 않음으로써 성능 향상
  • 필요한 시점에만 데이터를 로드해서 메모리 사용량 감소

단점

  • 연관된 데이터를 사용할 때 추가적인 데이터베이스 쿼리가 발생할 수 있음

주의점

  • N+1 문제
    • Lazy Loading을 사용할 때 가장 흔히 발생하는 문제 ⇒ 하나의 쿼리로 N개의 엔티티를 가져온 후, 각 엔티티의 연관된 데이터를 각각 추가 쿼리로 가져오는 경우가 발생
    • 데이터베이스 쿼리가 불필요하게 많이 발생하게 되어 성능 저하될 수 있음
  • 프록시 객체 사용
    • Lazy Loading은 프록시 객체를 사용하여 연관된 데이터를 필요한 시점에 로드
    • 프록시 객체는 실제 데이터가 로드되기 전까지는 원본 객체와 다르게 동작할 수 있음 ⇒ 프록시 객체의 특정 메서드 호출 시 데이터베이스 쿼리가 발생할 수 있음 주의
  • 데이터 접근 시점 (트랜잭션 관리)
    • Lazy Loading된 데이터를 접근하는 시점에 데이터베이스 세션이 닫혀 있으면(트랜잭션이 종료돼있으면) 예외 발생 ⇒ 서비스 계층에서 필요한 데이터 미리 로드하는것이 좋음
    • 필요 시점에 미리 데이터 로딩하는 것이 중요
  • 성능 고려
    • 성능에 영향을 미칠 수 있어서 대량의 데이터나 연관된 데이터가 많을 때는 주의
    • Fetch 전략을 상황에 따라 적절하게 조정하고 필요한 경우 Eager Loading으로 전환하는 것이 좋음

지연 로딩일 때의 쿼리문

  • board_tb만 조회하고 user_tb는 board_tb가 들고 있는 fk인 user_id만 조회함 ⇒ 단순한 select문으로만 조회함
  • 상황에 따라 불필요한 데이터를 빼는게 좋을 경우에 Lazy를 쓰면 좋음 ⇒ 게시글 목록 출력할 때는 굳이 유저의 정보가 필요 없기 때문에 Lazy Loading 전략 사용
notion image
 

즉시 로딩(Eager Loading)

  • 연관된 엔티티 데이터를 즉시 로드함
  • Board 엔티티를 조회할 때 관련된 User 엔티티를 함께 로드함
💡
User 객체의 모든 값이 초기화됨
불필요한 select가 일어날 수 있어서 용도에 맞춰서 사용해야함

장점

  • 연관된 데이터를 사용할 때 추가적인 데이터 베이스 쿼리가 발생하지 않음

단점

  • 초기 조회 시 성능이 저하될 수 있으며 메모리 사용량 증가할 수 있음

즉시 로딩일때의 쿼리문

  • 연관된 정보들을 모두 들고오기 위해 join문 사용됨 ⇒ left join 사용해서 board_tb 전부 조회(left join 기준 왼쪽)하고 user_tb도 게시글과 상관있는 회원 정보 전부 다 조회됨
notion image

DB에서의 동작

  • 지연 로딩(Lazy Loading)
    • Board 엔티티를 검색할 떄, 관련된 User 엔티티는 로드되지 않음
    • 관련된 User 엔티티에 접근할 때 별도의 쿼리(select)가 발생하여 User 데이터 로드
    • Board board = boardRepository.findById(1).orElse(null); User user = board.getUser(); // 이 시점에 User 데이터베이스 조회 쿼리 발생
  • 즉시 로딩(EAGER Loading)
    • Board 엔티티를 검색할 때 관련된 User 엔티티도 함께 로드됨
    • Board 엔티티를 조회하는 시점에 User 데이터가 이미 로드되어 있어서 추가 쿼리 발생하지 않음 (join으로 다 가져오니까)
    • Board board = boardRepository.findById(1).orElse(null); User user = board.getUser(); // 이 시점에 이미 User 데이터가 로드되어 있음

테스트로 지연로딩(Lazy Loading) 실행 시점 확인하기

[BoardRepositoryTest 테스트 클래스의 findById_test 메서드]

  • Board는 ManyToOne 연관 관계에 의해 userId만 들고 있고 username은 없는 상태
    • username을 달라고 하면 연관 관계 속성이 Lazy여도 username을 select해서 줌 ⇒ 결론은 Lazy Loading은 getter를 달라고할 때 실행됨
@Import(BoardRepository.class) // BoardRepository 클래스를 사용 @DataJpaTest // DB 관련된 자원들을 메모리(IoC)에 올림(JPA 관련 컴포넌트만 초기화) public class BoardRepositoryTest { @Autowired private BoardRepository boardRepository; @Test public void findById_test() { // given Integer id = 1; // when Optional<Board> boardOP = boardRepository.findById(id); Board board = boardOP.get(); // eye System.out.println("Lazy Loading 직전"); // Board는 manyToOne 연관 관계에 의해 userId만 들고있고 userName은 없는 상태 // userName을 달라고 하면 연관 관계 속성이 Lazy지만 // userName을 select해서 줌 // 결론은 레이지로딩은 getter를 달라고할때 실행됨 String username = board.getUser().getUsername(); System.out.println("Lazy Loading 직후"); } }
  • 테스트 실행 후 출력 확인
    • notion image
 

 

[data.sql 파일]

  • 더미데이터 추가
  • user_tb 추가하고 user_id를 board_tb의 fk로 둠
insert into user_tb(username, password, email) values ('ssar', '1234', 'ssar@nate.com'); insert into user_tb(username, password, email) values ('cos', '1234', 'cos@nate.com'); -- User의 userId가 fk insert into board_tb(title, content, user_id, created_at) values('제목1', '내용1', 1, now()); insert into board_tb(title, content, user_id, created_at) values('제목2', '내용2', 1, now()); insert into board_tb(title, content, user_id, created_at) values('제목3', '내용3', 1, now()); insert into board_tb(title, content, user_id, created_at) values('제목4', '내용4', 2, now()); insert into board_tb(title, content, user_id, created_at) values('제목5', '내용5', 2, now());
 

[application.properties 파일]

  • OSIV 설정을 false
    • Service 객체를 기준으로 데이터베이스 세션이 열리고 닫힘
    • 컨트롤러에서 반환 받은 Model을 그대로 return 할 때의 문제 발생 방지
      • jackson 라이브러리가 전달 받은 객체를 json으로 변환을 시도할 때, username이 없는 상태라면 username을 다시 조회하여 들고 오기 전에 데이터 파싱을 시도하여 예외 발생 할 수 있기 때문
      • Service 객체에서 DTO로 변환하여 컨트롤러로 전달하게 되면 문제 발생 방지 가능
    • 데이터베이스 세션이 닫힌 이후에는 getter를 이용한 조회 불가 주의
      • OSIV 설정을 false로 두고 컨트롤러에서 getter를 사용하는 경우 예외 발생
notion image
 

[BuilderTest 테스트 클래스]

  • 테스트 파일에서는 롬복 적용 안됨
  • 테스트 클래스나 메서드는 @Test 어노테이션을 달고 이름뒤에 test를 붙이는것이 약속
  • Builder를 이용한 테스트
    • 특정 필드 한 개 또는 몇 개만 필요한 경우 그에 따른 생성자를 또 만들어야되거나 풀 생성자를 그대로 가져오면 불필요한 정보도 가져야함
    • 빌더를 쓰면 필요한 것만 뽑아 쓸 수 있음
// 테스트 파일에서는 롬복 적용 안됨 import org.junit.jupiter.api.Test; class Member { private Integer id; private String name; private String addr; private Member() {} // builder로 빈(디폴트)생성자 만들기 public static Member builder() { return new Member(); } public Member id(Integer id) { this.id = id; return this; // 자기자신 반환, 나중에 new되면 이걸로 되는것 } public Member name(String name) { this.name = name; return this; } public Member addr(String addr) { this.addr = addr; return this; } } public class BuilderTest { @Test public void new_test() { // 풀 생성자 안 쓰고 빌더 쓰는 이유? // 전체 필드가 필요한게 아니라 특정만 필요하거나 몇 개만 필요한 경우 // 그에 따른 생성자를 또 만들어야되거나 풀 생성자 가져와서 불필요한 정보도 가져야하기 때문 // 빌더 쓰면 필요한것만 뽑아 쓸 수 있음 // private Member() {}로 막아놔서 그냥 new는 안되고 // builder로 빈생성자 만들어놓은거 이용하면 됨 Member m = Member.builder() .id(1) .name("이름") .addr("주소"); } }
Share article

Nakyeom's Study