ORM & JPA

Sirius·2024년 7월 9일

ORM

ORM은 자바의 객체와 데이터베이스를 연결하는 프로그래밍 기법이다.
ORM이 있다면 데이터베이스 값을 객체처럼 사용할 수 있다. = SQL을 몰라도 자바 언어로만 데이터베이스에 접근해서 원하는 데이터를 받아올 수 있다.

  • ORM의 장점
    1) SQL을 직접 작성하지 않고 사용하는 언어로 바로 DB에 접근할 수 있다.
    2) 객체지향적으로 코드를 작성할 수 있기에 비즈니스 로직에만 집중할 수 있다.
    3) 데이터베이스 시스템이 추상화되어 있기 때문에 MySQL에서 PostgreSQL로 전환한다고 해도 추가로 드는 작업이 없다.(DB 시스템에 대한 종속성이 줄어든다)
    4) 매핑하는 정보가 명확하기 때문에 ERD에 대한 의존도를 낮출 수 있다.(코드만으로 DB구조를 쉽게 알 수 있다)
  • ORM의 단점
    1) 프로젝트의 복잡성이 커질수록 사용 난이도가 올라간다.
    2) 복잡하고 무거운 쿼리는 ORM으로 해결이 불가능한 경우가 있다.

JPA와 하이버네이트

ORM에도 여러 종류가 있다. 자바에서는 JPA를 표준으로 사용한다.

  • JPA: 자바에서 관계형 DB를 사용하는 방식을 정의한 인터페이스
  • 하이버네이트: JPA인터페이스를 구현한 구현체이자 자바용 ORM 프레임워크
  • 하이버네이트의 목표: 자바 객체를 통해 DB 종류에 상관없이 DB를 자유자재로 사용

엔티티 매니저와 영속성 컨텍스트

1) 엔티티(자바 객체)

DB테이블과 매핑되는 객체(쿼리를 실행하는 객체)

2) 엔티티 매니저

엔티티를 관리해 데이터베이스와 애플리케이션 사이에서 객체를 생성, 수정, 삭제하는 등의 역할을 한다.

  • 엔티티 매니저 팩토리: 엔티티 매니저를 만드는 곳

회원 2명이 동시에 가입하면 엔티티 매니저 팩토리는
엔티티 매니저1과 엔티티 매니저2를 생성하여 각각의 커넥션을 통해 DB에 접근한다.

그러나 스프링 부트는 엔티티 매니저 팩토리를 하나만 생성해서 관리한다.
또한 @PersistenceContext 또는 @Autowired 어노테이션을 사용해 엔티티 매니저를 사용한다.

@PersistenceContext
EntityManager em;

또한 스프링부트는 기본적으로 빈을 하나만 생성해서 공유하므로 동시성문제가 발생할 수 있다. 그래서 실제로는 엔티티 매니저가 아닌 실제 엔티티 매니저와 연결하는 프록시(가짜) 엔티티 매니저를 사용한다.
필요할 때 데이터베이스 트랜잭션과 관련된 실제 엔티티 매니저를 호출한다.

3) 영속성 컨텍스트

엔티티 매니저는 엔티티를 영속성 컨텍스트에 저장한다.
영속성 컨텍스트는 엔티티를 관리하는 가상의 공간이다.
영속성 컨텍스트 덕분에 DB에서 데이터를 효과적으로 가져오고, 엔티티를 편하게 사용할 수 있다.
다음은 영속성 컨텍스트의 4가지 특징이다.
이 특징들을 통해 DB접근을 최소화하고 성능을 높일 수 있다.

1> 1차캐시

영속성 컨텍스트는 내부에 1차 캐시를 가지고 있다.
캐시의 프라이머리키는 엔티티의 @Id 어노테이션이 달린 값이다.


본격적으로 엔티티를 조회하면
1) 1차 캐시에서 데이터를 조회하고 값이 있으면 반환한다.
2) 값이 없으면 DB에 조회하여 1차캐시에 저장한 다음 반환한다.
즉 DB를 거치지 않고 캐시를 통해 빠르게 조회할 수 있다.

2> 쓰기지연

트랜잭션을 커밋하기 전까지는 DB에 실제로 질의문을 보내지 않고 쿼리를 모았다가 트랜잭션을 커밋하면 모았던 쿼리를 한번에 실행하는 것이다.
ex> 데이터추가쿼리:3 으로 설정하면, 트랜잭션 커밋시 3개 쿼리를 한꺼번에 전송한다.

3> 변경감지

엔티티 매니저는 엔티티가 처음 로드될 때의 상태(1차 캐시에 저장된 상태)와 트랜잭션이 커밋될 때의 현재 상태(변경)를 비교하여 변경된 값을 DB에 자동으로 반영한다.

4> 지연로딩

데이터베이스에서 데이터를 즉시 로드하지 않고 실제로 사용할 때까지 로딩을 지연시키는 방법이다. 이를 통해 불필요한 데이터베이스 쿼리를 피하고, 성능을 최적화할 수 있다.

4) 엔티티의 4가지 상태

public class EntityManagerTest{
	@Autowired
    EntityManager em;
    
    public class example(){
    
    	// 1) 엔티티 매니저가 엔티티를 관리하지 않는 상태(비영속 상태)
    	Member member = new Member(1L, "홍길동");
        
        // 엔티티가 관리되는 상태
        em.persist(member);
        
        // 엔티티 객체가 분리된 상태
        em.detach(member);
        
        // 엔티티 객체가 삭제된 상태
        em.remove(memeber);
    
    }

}

1) 엔티티를 처음만들면 비영속 상태기 된다.
2) persist() 메소드로 관리 상태로 만들수 있다.(Member 객체가 영속성 컨텍스트에서 상태가 관리됨)
3) detach() 메소드로 영속성 컨텍스트로 부터 관리를 포기(분리)할 수 있다.
4) remove() 메소드로 엔티티(객체)를 영속성 컨텍스트와 데이터베이스에서 삭제할 수 있다.

스프링 데이터와 스프링 데이터 JPA

스프링 데이터는 비즈니스 로직에 더 집중할 수 있도록 DB 사용 기능을 클래스 레벨에서 추상화 하였다.
스프링 데이터에서 제공하는 인터페이스를 통해 스프링 데이터를 사용할 수 있다.
이 인터페이스에서는 CRUD를 포함하여 여러 메소드가 포함되어 있고 알아서 쿼리를 만들어준다.

1) 스프링 데이터 JPA

스프링 데이터의 공통적인 기능에서 JPA의 유용한 기술이 추가됨
-> PagingAndSortingRepository(스프링 데이터의 인터페이스)를 상속받아 JpaRepository(인터페이스)를 만들었다.
이는 JPA를 더 편리하게 사용하는 메소드를 제공한다.

  • 기존방법
@PersisteContext
EntityManager em;
public void join(){
	// 기존에 엔티티 상태를 바꾸는 방법(메소드를 호출해서 상태 변경)
	Member member = new Member(1L, "홍길동");
    em.persist(member);
}

하지만 스프링 데이터 JPA를 사용하면 레포지토리 역할을 하는 인터페이스를 만들어 DB 테이블 조회, 수정, 생성, 삭제 같은 작업을 간단히 할 수 있다.

  • JpaRepository를 상속하는 방법
public interface MemberRepository extends JpaRepository<Member, Long>{
}

JpaRepository를 상속해 인터페이스를 만들고, 제네릭에는 관리할 <엔티티 이름, 엔티티 기본키의 타입>을 입력하면 기본 CRUD 메소드를 사용할 수 있다.

2) 스프링 데이터 JPA 메소드 사용해보기

테스트 코드 환경구축

가. 테스트 파일 생성

MembersRepository.java 파일에서 Alt+Enter로 [Create Test]를 한다.

나. test/resources에 insert-members.sql 추가

INSERT INTO member (id, name) VALUES(1, 'A')
INSERT INTO member (id, name) VALUES(2, 'B')
INSERT INTO member (id, name) VALUES(3, 'C')

다. test/resources에 application.yml 추가

spring:
  sql:
    init:
      mode: never

이는 src/main/resources 폴더내에 있는 data.sql 파일을 자동으로 실행하지 않도록 하기 위함이다.

1> findAll() 메소드 사용

MemberRepositoryTest.java 파일에 코드를 다음과 같이 작성한다.

@DataJpaTest
class MemberRepositoryTest {
    @Autowired
    MemberRepository memberRepository;
    @Sql("/insert-members.sql")
    @Test
    void getAllMembers(){
        List<Member> members = memberRepository.findAll();
        assertThat(members.size()).isEqualTo(3);
    }
}

1) 테스트 설정: @DataJpaTest 애노테이션을 통해 JPA 관련 설정이 로드된다.
2) 의존성 주입: @Autowired를 통해 MemberRepository 빈이 주입된다.
3) SQL 스크립트 실행: @Sql("/insert-members.sql") 애노테이션을 통해 테스트 전에 insert-members.sql 스크립트가 실행된다. 이 스크립트는 테스트 데이터베이스에 멤버 데이터를 삽입한다.
4) 테스트 메서드 실행: getAllMembers 메서드가 실행되며, memberRepository.findAll()을 호출하여 데이터베이스에서 모든 멤버를 조회한다.
5) 단언: 조회된 멤버의 개수가 3개인지 assertThat 메서드를 사용하여 검증한다.

2> findById() 메소드 사용

@DataJpaTest
class MemberRepositoryTest {
    @Autowired
    MemberRepository memberRepository;
    @Sql("/insert-members.sql")
    @Test
    void getMemberById(){
    	Member member = memberRepository.findById(2L).get();
        
        assertThat(member.getName()).isEqualTo("B");
    
    }
}

id로 멤버를 찾을 수 있다.

3> findByName() 메소드 사용 (쿼리 메소드)

id는 모든 테이블에서 기본키로 사용하므로 값이 없을 수가 없다.
그러나 name은 값이 있을수도 있고 없을수도 있다.
하지만 JPA는 메소드 이름으로 쿼리를 작성하는 기능을 제공한다.
따라서 MemberRepsitory 인터페이스에 findByName()이라는 메소드를 추가한다.

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    Optional<Member> findByName(String name);

}

Spring Data JPA는 리포지토리 인터페이스에서 메서드 이름을 분석하여, 해당 메서드 이름에 따라 적절한 쿼리를 자동으로 생성한다. 이 기능을 통해 복잡한 쿼리를 직접 작성하지 않고도 데이터를 조회할 수 있다.

  • 쿼리 메소드 프로세스
    1) 메소드 이름 파싱(Name)
    2) 파싱결과로 JPQL 쿼리 생성
    3) JPA 엔티티 매니저에 의해 쿼리 실행

이제 테스트코드를 작성해본다.

@DataJpaTest
class MemberRepositoryTest {
    @Autowired
    MemberRepository memberRepository;
    @Sql("/insert-members.sql")
    @Test
    void getMemberByName(){
        Member member = memberRepository.findByName("C").get();
        assertThat(member.getId()).isEqualTo(3);
    }
}

JPA를 이용하여 표현하기 너무 복잡한 쿼리인 경우 혹은 성능이 중요하여 직접 쿼리문을 사용해야 하는 경우

@Query 메소드를 쓴다.

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
    @Query("select m from Member m where m.name = ?1")
    Optional<Member> findByNameQuery(String name);
}

@Query: 이 애노테이션은 리포지토리 메서드에 JPQL 쿼리를 직접 정의한다.

4> save() 메소드 사용

JPA는 save()를 통해 데이터를 추가할 수 있다.

 @Test
    void saveMember(){
        Member member = new Member(1L, "A");
        memberRepository.save(member);    assertThat(memberRepository.findById(1L).get().getName()).isEqualTo("A");
    }

5> saveAll() 메소드 사용

saveAll() 메소드를 통해 여러 엔티티를 한꺼번에 저장할 수 있다.

 @Test
    void saveMembers(){
        List<Member> members = List.of(new Member(2L, "B"), new Member(3L, "C"));
        memberRepository.saveAll(members);
        assertThat(memberRepository.findAll().size()).isEqualTo(2);
    }

6> deleteById() 메소드 사용

deleteById()를 사용하면 Id로 레코드를 삭제할 수 있다.

@Sql("/insert-members.sql")
    @Test
    void deleteMemberById(){
        memberRepository.deleteById(2L);
        assertThat(memberRepository.findById(2L).isEmpty()).isTrue();
    }

6> deleteAll() 메소드 사용: cleanUp()

deleteAll()을 사용하면 모든 데이터를 삭제할 수 있다.
따라서 실제 서비스 코드에서는 거의 사용하지 않는다.
이런 원리를 사용하는 곳이 하나 있다.
바로 테스트간의 격리를 보장하기 위한곳에 쓰인다.

@Sql("/insert-members.sql")
    @Test
    void deleteAll(){
        memberRepository.deleteAll();
        assertThat(memberRepository.findAll().size()).isZero();
    }

따라서 @AfterEach 어노테이션을 붙여 cleanUp()메소드와 같은 형태로 사용한다.

@DataJpaTest
class MemberRepositoryTest {
    @Autowired
    MemberRepository memberRepository;  
    @AfterEach
    public void cleanUp(){
        memberRepository.deleteAll();
    }
    ....
}

7> 수정 메소드 만들기

JPA에서 데이터를 수정하려면 Member.java(엔티티)에 수정하는 메소드를 자체적으로 추가해야한다.
다음은 name의 필드값을 바꾸는 단순한 메소드이다.

public class Member{
	...
    public void changeName(String name){
    	this.name = name;
    }
}

만약 위의 changeName메소드가 @Transactional 어노테이션이 포함된 메소드에서 호출되면 JPA는 Dirty Checking(변경감지) 기능을 통해 엔티티의 필드값이 변경됨에 따라 그 변경사항을 DB에 자동으로 반영한다.

이제 테스트코드를 작성해보자.

@DataJpaTest
class MemberRepositoryTest {
...
	@Sql("/insert-members.sql")
    	@Test
    	void update(){
        	Member member = memberRepository.findById(2L).get();
        	member.changeName("BC");
        assertThat(memberRepository.findById(2L).get().getName()).isEqualTo("BC");
    }
    ...
}

위 코드에서 @Transactional 어노테이션은 존재하지 않는다.
어떻게 업데이트가 반영이 된것일까? 그것은 @DataJpaTest 어노테이션에 @Transactional이 포함되어 있기 때문이다.
따라서 서비스 메소드에서 이 기능을 사용하려면 반드시 @Transactional을 붙여야 한다.

엔티티(Member.java) 살펴보기

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", updatable = false)
    private Long id;

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

    public void changeName(String name){
        this.name = name;
    }
}

1) @Entiity 어노테이션은 Member 객체를 JPA가 관리하는 엔티티로 지정한다.
= Member 클래스와 실제 DB의 테이블을 매핑시킨다.
여기서는 테이블 이름을 지정하지 않았으므로 클래스 이름과 같은 테이블인 member테이블과 매칭된다.
만약 특정 테이블을 지정하고 싶다면 다음과 같이 작성한다.

@Entity(name="member_list")
public class Article{
...
}

2) @NoArgsConstructor는 엔티티의 기본 생성자다.(파라미터가 없음)
접근 제어자로 protected를 선택하였다(public도 가능)
@AllArgsConstructor는 파라미터가 있는 생성자이다.


3) @Id 는 long타입의 id필드를 PK로 지정하겠다는 뜻이다.


4) @GeneratedValue는 기본키의 생성 방식을 결정한다.
여기서는 strategy = GenerationType.IDENTITY를 통해 자동으로 기본키가 증가하도록 설계하였다.


5) @Column 어노테이션은 데이터베이스의 Column과 필드를 매핑해준다.

model vs domain

  • model: 모델은 애플리케이션의 데이터를 나타내며, 일반적으로 데이터 전송 객체(Data Transfer Object, DTO), 폼 객체(Form Object), 뷰 모델(View Model) 등을 포함한다. 모델 객체는 주로 데이터의 표현 및 전송에 중점을 둡니다.

  • domain: 도메인은 비즈니스 로직과 관련된 개념이다. 도메인 객체 또는 도메인 모델은 애플리케이션이 해결하고자 하는 문제 영역의 핵심 개념과 규칙을 나타낸다. 이는 주로 비즈니스 규칙, 정책, 엔터티(Entity) 및 밸류 오브젝트(Value Object)를 포함한다.

DAO vs DTO

  • DTO (Data Transfer Object): 계층 간 데이터 전송을 위한 객체로, 주로 컨트롤러와 서비스 간의 데이터 전송에 사용됩니다.
  • DAO (Data Access Object): 데이터베이스에 대한 CRUD 작업을 캡슐화하는 객체로, 데이터 접근을 처리합니다.(ex> 스프링 데이터 JPA를 사용하면 리포지토리가 DAO의 역할을 대신함)

DTO의 흐름

1> 컨트롤러에서 DTO를 서비스로 전달한다.
2> 서비스에서는 DTO를 엔티티로 변환하여 리포지토리로 전달한다.
3> 리포지토리는 데이터베이스와 상호작용하여 엔티티를 저장하거나 조회한다.
4> 서비스에서 필요한 경우, 엔티티를 다시 DTO로 변환하여 컨트롤러로 반환한다.

정리

1) 레포지토리는 엔티티 매핑정보(DB를 자바객체로 표현)를 바탕으로 DB에 쿼리를 보낸다.
= JPA는 리포지토리 메서드를 호출하면 적절한 JPQL (Java Persistence Query Language) 또는 SQL 쿼리를 생성하여 데이터베이스에서 데이터를 조회한다.


2) 데이터베이스는 요청된 데이터를 조회하여 응답한다. 이 데이터는 엔티티 객체로 변환되어 레포지토리로 반환된다.


3) 레포지토리는 데이터베이스로부터 받은 엔티티 객체를 사용자에게 반환한다.
반환된 데이터는 일반적으로 서비스 계층이나 컨트롤러를 통해 사용자에게 전달된다.

0개의 댓글