본문 바로가기
TIL

2023-11-01-TIL | Spring

by raphael3 2023. 11. 3.
반응형

이 노트에서는 기존의 반복되는 코드 부분 뿐만 아니라, 쿼리문조차도 필요없는 JPA 방식에 대해 다룬다.

JPA를 통해 SQL보다는 객체 중심의 설계가 가능해진다.

JPA

Java Persistence API의 약자

'Persistence'는 데이터나 상태가 지속되거나 영구적으로 유지되는 특성을 나타낸다. 프로그래밍 컨텍스트에서는 'persistence'가 데이터베이스나 파일 시스템과 같은 저장 매체에 데이터를 저장하여 프로그램이 종료되거나 시스템이 재부팅되더라도 데이터가 유지되도록 하는 능력을 의미한다. 예를 들어, 데이터베이스에서 데이터를 영구적으로 저장하면 이 데이터는 persistence를 가지고 있다고 말할 수 있다. Java Persistence API(JPA)도 이러한 persistence를 제공하기 위한 자바의 기술 중 하나다. 즉, 데이터베이스와 상호작용하여 객체를 데이터베이스에 저장하고 가져올 수 있게 해주는 기술로 persistence를 달성한다.


@Transactional

모든 데이터 변경은 트랜잭션 안에서 수행되어져야 한다.

따라서, JPA로 데이터를 저장하거나 변경하고자 할 땐 항상 @Transactional이 필요하다.

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

@Transactional
public class MemberService {
    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    /**
     * 회원 가입
     */
    public Long join(Member member) {
        validateDuplicateMember(member);

        Member savedMember = memberRepository.save(member);
        return savedMember.getId();
    }

    private void validateDuplicateMember(Member member) {
        memberRepository.findByName(member.getName())
                .ifPresent(m -> {
                    System.out.println("EXCERPTION!");
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });
    }

    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

    public Optional<Member> findOne(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

만약 특정 메서드에서만 트랜잭션이 필요하다면, 특정 메서드를 특정하여 @Transactional을 붙여주면 된다.

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Optional;

public class MemberService {
    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    /**
     * 회원 가입
     */
    @Transactional
    public Long join(Member member) {
        validateDuplicateMember(member);

        Member savedMember = memberRepository.save(member);
        return savedMember.getId();
    }

    private void validateDuplicateMember(Member member) {
        memberRepository.findByName(member.getName())
                .ifPresent(m -> {
                    System.out.println("EXCERPTION!");
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });
    }

    public List<Member> findMembers() {
        return memberRepository.findAll();
    }

    public Optional<Member> findOne(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

build.gradle

JPA를 사용하기 위해서 필요한 설정은 다음과 같다.

plugins {
	id 'java'
	id 'org.springframework.boot' version '2.7.17'
	id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'hello'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '17'
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
//	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	runtimeOnly 'com.h2database:h2'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
	useJUnitPlatform()
}

build.gradle에서 기존의 jdbc API를 지우고, 새롭게 data-jpa를 추가해준다. data-jpa는 JDBC API를 포함한다.

Spring이 JPA가 DB에 날리는 SQL을 보게 하기 위해서 application.properties에 다음의 내용을 추가한다.

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.jpa.show-sql=true

JPA는 도메인 객체를 참고하여 테이블을 알아서 만든다. 이 노트에서는 이미 만들어진 테이블을 쓸 것이기 때문에 관련 설정을 꺼야한다.

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none

만약 테이블을 만들고 싶다면, none 대신 create이라고 적는다.


ORM

Object Relational Mapping

어노테이션을 이용해서 DB와 적절히 mapping해주는 기술이다.

@Entity

@Entity가 붙은 객체는 JPA가 관리하는 객체가 된다.

package hello.hellospring.domain;

import javax.persistence.Entity;

@Entity
public class Member {
    private Long id;
    private String name;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

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

@Id

PK 매핑을 위해 @Id 어노테이션을 붙인다.

package hello.hellospring.domain;

import javax.persistence.Entity;
import javax.persistence.Id;

@Entity
public class Member {
    @Id
    private Long id;
    private String name;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

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

@GeneratedValue(…)

id는 별다른 조치 없이도 알아서 값이 증가하도록 하기 위해 IDENTITY 전략을 부여한다. (참고)

package hello.hellospring.domain;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Member {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

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

@Column(name = ?)

각 컬럼별로 @Column 어노테이션으로 설정해줄 수 있다. name 파라미터에 적어주는 내용이 컬럼의 이름이 된다.

package hello.hellospring.domain;

import javax.persistence.*;

@Entity
public class Member {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Column(name = "name")
    private String name;

    public Long getId() {
        return id;
    }
 
    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

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

리포지토리를 새로 만든다.

// src/main/java/hello/hellospring/repository/JpaMemberRepository.java

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import java.util.List;
import java.util.Optional;

public class JpaMemberRepository implements MemberRepository {
    ...
}

EntityManager

JPA는 EntityManager를 통해 동작한다.

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import javax.persistence.EntityManager;
import java.util.List;
import java.util.Optional;

public class JpaMemberRepository implements MemberRepository {
    private final EntityManager entityManager;

    public JpaMemberRepository(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    ...
}

EntityManager는 스프링 부트에 의해 자동 생성되기 때문에(→스프링 컨테이너에 스프링 빈으로 등록되어 있어야 한다: 직접 등록해줘야 한다.), JpaMemberRepository는 이를 생성자 주입 방식으로 외부에서 주입받는다. 또한 위와 같이 생성자가 하나만 있는 경우에는 자동으로 @Autowired가 붙는다는 점을 유의한다.

EntityManager.persist(…)

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import javax.persistence.EntityManager;
import java.util.List;
import java.util.Optional;

public class JpaMemberRepository implements MemberRepository {
    ...

    @Override
    public Member save(Member member) {
        entityManager.persist(member);
        return member;
    }

    ...
}

별다른 SQL 작성 없이 EntityManager의 persist 메서드만 쓰면 된다. persist는 “영구 저장하라”는 의미다.

EntityManager.find(조회할 type, PK)

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import javax.persistence.EntityManager;
import java.util.List;
import java.util.Optional;

public class JpaMemberRepository implements MemberRepository {
    ...

    @Override
    public Optional<Member> findById(Long id) {
        Member member = entityManager.find(Member.class, id); // 조회할 타입, 식별자:PK
        return Optional.ofNullable(member);
    }

    ...
}

조회는 find만으로 수행된다. 조회할 타입과 식별자로 쓰이는 PK(:id)를 넣어주면 되며, 메서드의 반환 타입에 맞추기 위해 Optional로 감싸준다.

EntityManager.createQuery(쿼리, type)

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import javax.persistence.EntityManager;
import java.util.List;
import java.util.Optional;

public class JpaMemberRepository implements MemberRepository {
    ...

		@Override
    public Optional<Member> findByName(String name) {
        List<Member> result = entityManager.createQuery("select m from Member m where m.name= :name", Member.class)
                .setParameter("name", name)
                .getResultList();

        return result.stream().findAny();
    }

    ...
}

name은 PK가 아니기 때문에 find 메서드로 조회할 수 없다. 전용 쿼리문(JPQL)을 사용해서 조회하기 위해 createQuery 메서드를 이용한다.

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import javax.persistence.EntityManager;
import java.util.List;
import java.util.Optional;

public class JpaMemberRepository implements MemberRepository {
    ...

    @Override
    public List<Member> findAll() {
        List<Member> result = entityManager.createQuery("select m from Member m", Member.class).getResultList();
        return result;
    } --> Cmd + Opt + N:인라인

		@Override
    public List<Member> findAll() {
        return entityManager.createQuery("select m from Member ~~as~~ m", Member.class).getResultList();
    }
    ...
}

JPQL은 쿼리의 대상이 테이블이 아니라 Entity(객체)다. 즉, Entity를 대상으로 쿼리를 날리고, 이것이 SQL로 번역된다. 또한, 객체 자체를 select한다.

참고로, as는 제외 가능하다.

package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import javax.persistence.EntityManager;
import java.util.List;
import java.util.Optional;

public class JpaMemberRepository implements MemberRepository {
    private final EntityManager entityManager;

    public JpaMemberRepository(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Override
    public Member save(Member member) {
        entityManager.persist(member);
        return member;
    }

    @Override
    public Optional<Member> findById(Long id) {
        Member member = entityManager.find(Member.class, id); // 조회할 타입, 식별자:PK
        return Optional.ofNullable(member);
    }

    @Override
    public Optional<Member> findByName(String name) {
        List<Member> result = entityManager.createQuery("select m from Member m where m.name= :name", Member.class)
                .setParameter("name", name)
                .getResultList();

        return result.stream().findAny();
    }

    @Override
    public List<Member> findAll() {
        return entityManager.createQuery("select m from Member m", Member.class).getResultList();
    }
}

EntityManager를 스프링 빈으로 등록해준다.

백링크:

package hello.hellospring;

import hello.hellospring.repository.*;
import hello.hellospring.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.persistence.EntityManager;

@Configuration
public class SpringConfig {
    private EntityManager entityManager;

    @Autowired
    public SpringConfig(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository());
    }

    @Bean
    public MemberRepository memberRepository() {
        return new JpaMemberRepository(entityManager);
    }
}

EntityManager를 주입받기 위해 @Autowired가 사용되었다.


설정(build.gradle, application.properties), Service, 도메인 객체, Repository, SpringConfig까지의 작업이 모두 완료되면 끝났다. 스프링 통합 테스트를 통해 테스트를 수행하여 확인한다.


스프링 데이터 JPA

JPQL 마저도 사용하지 않게 해주는 게 스프링 데이터 JPA다.

단, JPA를 먼저 알아야 한다.

스프링 데이터 JPA를 사용하기 위해서는 리포지토리 인터페이스가 필요하다.

package hello.hellospring.repository;

public interface SpringDataJpaMemberRepository {
	...
}

다중 상속을 통해 JpaRepository와 우리가 만든 MemberRepository 인터페이스를 상속받는다.

JpaRepository를 상속받을 땐 Member 타입과 PK인 id의 타입인 Long을 명시해준다.

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {
    ...
}

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

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

Repository에 대한 구현은 이게 끝이다. 나머지는 구현할 필요 없다.

백링크: [이렇게 전부 만들어져 있기 때문에 그냥 가져다 쓰면 된다. 다만, 인터페이스에서 제공해주지 않는 것들은 findByName을 만들었듯이 직접 만들어야 한다.](https://www.notion.so/findByName-35e8a8e39ed141d4ae77ab2d4fc8f1dc?pvs=21)


SpringDataJpaMemberRepository의 구현체는? JpaRepository를 상속받으면 알아서 구현체가 만들어진다. 게다가 이 구현체는 스프링 컨테이너에 스프링 빈으로 알아서 등록된다.

package hello.hellospring;

import hello.hellospring.repository.*;
import hello.hellospring.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SpringConfig {
    private final MemberRepository memberRepository;

    @Autowired
    public SpringConfig(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository~~()~~);
    }

    @Bean
    public MemberRepository memberRepository() {
			...
    }
}

SpringConfig.java에서는 위와 같이만 해주어, MemberRepository를 스프링 빈으로 등록시켜준다. 그럼 @Autowired에 의해 스프링 컨테이너가 MemberRepository를 찾아서 연결해준다. 하지만, MemberRepository는 현재 코드 어디에서도 따로 스프링 빈으로 등록된 것이 없다. → **SpringDataJpaMemberRepository가 쓰인다.**

이제, 스프링 통합 테스트 코드를 그대로 돌려본다. 이 때, 오류가 난다면 다음을 고려해본다.

  • JdbcTemplateMemberRepository.java를 지운다. → 스프링이 인식하는 MemberRepository가 SpringDataJpaMemberRepository만을 가리키도록 하기 위해
  • application.properties에서 spring.jpa.hibernate.ddl-auto를 none이 아닌 create으로 설정한다. → DB에 아무 내용이 없을 경우엔

JpaRepository

package org.springframework.data.jpa.repository;

import java.util.List;

import javax.persistence.EntityManager;

import org.springframework.data.domain.Example;
import org.springframework.data.domain.Sort;
import org.springframework.data.repository.NoRepositoryBean;
import org.springframework.data.repository.PagingAndSortingRepository;
import org.springframework.data.repository.query.QueryByExampleExecutor;

@NoRepositoryBean
public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {

	@Override
	List<T> findAll();

	@Override
	List<T> findAll(Sort sort);

	@Override
	List<T> findAllById(Iterable<ID> ids);

	@Override
	<S extends T> List<S> saveAll(Iterable<S> entities);

	void flush();

	<S extends T> S saveAndFlush(S entity);

	<S extends T> List<S> saveAllAndFlush(Iterable<S> entities);

	@Deprecated
	default void deleteInBatch(Iterable<T> entities) {
		deleteAllInBatch(entities);
	}

	void deleteAllInBatch(Iterable<T> entities);

	void deleteAllByIdInBatch(Iterable<ID> ids);

	void deleteAllInBatch();

	@Deprecated
	T getOne(ID id);

	@Deprecated
	T getById(ID id);

	T getReferenceById(ID id);

	@Override
	<S extends T> List<S> findAll(Example<S> example);

	@Override
	<S extends T> List<S> findAll(Example<S> example, Sort sort);
}

JpaRepository 코드를 보면 기본적인 메서드들이 이미 제공되고 있다. 이 클래스는 PagingAndSortingRepository를 상속받고 있고, PagingAndSortingRepository는 CrudRepository를 상속받고 있다.

CrudRepository

package org.springframework.data.repository;

import java.util.Optional;

import org.springframework.dao.OptimisticLockingFailureException;

@NoRepositoryBean
public interface CrudRepository<T, ID> extends Repository<T, ID> {

	<S extends T> S save(S entity);

	<S extends T> Iterable<S> saveAll(Iterable<S> entities);

	Optional<T> findById(ID id);

	boolean existsById(ID id);

	Iterable<T> findAll();

	Iterable<T> findAllById(Iterable<ID> ids);

	long count();

	void deleteById(ID id);

	void delete(T entity);

	void deleteAllById(Iterable<? extends ID> ids);

	void deleteAll(Iterable<? extends T> entities);

	void deleteAll();
}

CrudRepository 클래스에서는 기본적인 CRUD가 제공되고 있다.

이렇게 전부 만들어져 있기 때문에 그냥 가져다 쓰면 된다. 다만, 인터페이스에서 제공해주지 않는 것들은 findByName을 만들었듯이 직접 만들어야 한다.

findByName?

실은 findByName이라는 것은 네이밍 컨벤션인데, 이와 같은 메서드 이름을 정해야 한다.

findByName은 곧 JPQL로 “select m from Member m where m.name = ?”과 같은 의미가 되기 때문이다.

따라서, 다음과 같은 케이스가 가능하다.

  • findBy + {Name}
  • findBy + {Name} And {Id}

즉, 인터페이스 이름을 지어주는 것만으로도 개발이 끝난 것이다.


하지만 이렇게 인터페이스 이름만으로 해결하기 어려운 쿼리(복잡한 동적 쿼리: Querydsl 라이브러리를 이용한다.)를 처리해야 할 경우가 있으며, JPA 또는 순수 SQL(네이티브 쿼리)을 조합하여 사용해야 할 경우도 있다. 즉, 네이티브 쿼리, JPA 모두 언제든지, 얼마든지 사용될 수 있기 때문에 알아야 한다.

반응형

'TIL' 카테고리의 다른 글

2023-11-06-TIL | Spring  (0) 2023.11.07
2023-11-04-TIL | Spring  (0) 2023.11.04
2023-10-31-TIL  (1) 2023.11.01
2023-10-30-TIL  (2) 2023.10.31
2023-10-28-TIL  (1) 2023.10.31