Spring
JPA
JPA Quick Start

JDBC

자바 언어로 데이터베이스 프로그래밍을 하기위한 라이브러리

JPA

Java ORM(Object Relational Mapping) 표준 기술

  • ORM : 객체와 데이터베이스의 관계를 맵핑 하는 방법

등장배경

Spring Data JPA의 등장배경은 JPA ORM을 사용하기 위해서 다음과 같은 라이브러리를 사용하고 있다가

  • Hibernate
  • EclipseLink
  • DataNucleus
  • OpenJPA
  • TopLink

아래는 위와 같은 라이브러리 코드의 일부인데 모든 라이브러리들이 아래와 같은

public class User {
  private String name;
  private int age;
  private String email;
}
 
EnttiyManager em = emf.createEntityManager();
 
var user = new User("홍길동", 20, "hong@gmail.com");
 
em.persist(user);
em.getTransaction().commit();
em.close();

반복적인 작업이 일어난다는 것을 알아차리고 이를 "그냥 처음부터 Spring에서 관리해줄게!!!" 하면서 등장한 것이 바로 Spring Data JPA이다.

  • 도식도 👇

How To Use

설정

Spring initializer에서 MySQL(사용시), JPA, Web, Lombok를 선택하고 프로젝트를 생성한다.
처음 프로젝트를 생성하면 resources 폴더에 application.properties 파일이 생성이 되는데 이 파일을 application.yaml로 변경하고 아래 내용을 작성한다.

application.yml
spring:
  jpa:
    # 실행되는 코드 log 찍어줌
    show-sql: true
    properties:
      format_sql: true
      # 사용하는 MySQL 버전에 따라서 달라짐
      # MySQL8 << 이런식으로 class에서 검색해서 최상단에 import한 값 + class name으로 지정
      dialect: org.hibernate.dialect.MySQL8Dialect
    hibernate:
      # validate - 연결할려는 DB와 Entity가 있는지 확인하고 없으면 Error
      # create - 연결하려는 DB나 Entity가 없으면 생성
      # update - 데이터 베이스와 내 Entity를 비교 후 다른 점이 있다면 업데이트 
      # drop - Spring 종료시 삭제
      ddl-auto: validate  
 
  datasource:
    # 연결하고자 하는 DB url 
    # 3306/[] << DB 이름이 들어감 ex) data_store, user ...
    url: jdbc:mysql://localhost:3306/[DB이름]?userSSL=[ssl설정]&useUnicode=true&allowPublicKeyRetrieval=true
    driver-class-name: com.mysql.jdbc.Driver
    username: root
    password: "비밀번호"

Entity 자동 생성

Entity가 자동으로 생성되게 할려면(Database의 테이블이 없는 경우) spring.jpa.hibernate.ddl-autocreate로 설정해주면 된다.

🚫

위에서 사용한 설정은 validate로 설정되어 있어서 Database에 테이블이 없으면 Error를 뱉어준다.

예시
spring:
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: create

Config 파일 생성

@EntityScan은 basePackages 속성에 정의된 패키지에서 JPA Entity class를 찾아서 Spring Data JPA가 사용할 수 있도록 등록한다.
@EnableJpaRepositories Spring Data JPA Repository 인터페이스를 구현한 클래스를 찾아서 Spring Data JPA가 사용할 수 있도록 설정한다. basePackages에 정의된 패키지에서 Repository를 찾아서 등록한다.

@Configuration
@EntityScan(basePackages = {"org.delivery.db"})
@EnableJpaRepositories(basePackages = {"org.delivery.db"})
public class JpaConfig {
}

Entity 생성

Entity를 ORM과 연결시에 주의할점은 Entity Annotation의 name에 테이블 이름을 지정해줘야 한다는 점과 @ID를 반드시 지정해줘야 한다는 것이다.

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity(name = "user") // 테이블 이름
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public class UserEntity {
    @Id
    // 해당 데이터는 MySQL에 의해서 Auto Increment가 된다는 것을 의미한다.
    // strategy의 종류는 DB에 따라서 달라진다.
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private Integer age;
    private String email;
}

@JoinColumn

만약에 Entity의 변수명과 DB의 Column명이 다르다면 @JoinColumn Annotation의 name 옵션을 사용해서 매핑해줄 수 있다.

🚫

@JoinColumn Annotation은 DB와 매핑시에 기본적으로 변수명_id와 같은 식으로 매핑되게 된다. 예를 들어서 변수명이 storeEntity라면 'store_entity_id'와 같은 형식으로 매핑되게 된다.

  @ManyToOne
  @JoinColumn(nullable = false, name = "store")
  /// 여기를 store로 바꿔서 매핑해줄 수도 있음
  private StoreEntity storeEntity; 

EntityListeners

EntityListeners를 활용해서 LocalDateTime을 자동으로 생성하는 방법은 다음과 같다.

@Entity
@EntityListeners(AuditingEntityListener.class)
public class User {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  @Column(length = 100)
  private String email;
  @Column(length = 30)
  private String name;
  @Column(name = "created_at")
  // 객체가 생성될 때 자동으로 생성
  @CreatedDate
  private LocalDateTime createdAt;
  @Column(name = "updated_at")
  // 데이터가 업데이트 될 때 자동으로 생성
  @LastModifiedDate
  private LocalDateTime updatedAt;
}

이 때 EntityListeners를 사용하기 위해서는 Spring Boot Main 부분에 @EnableJpaAuditing Annotation을 붙여줘야 한다.

@EnableJpaAuditing
@SpringBootApplication
public class SpringDataRedisApplication {
 
	public static void main(String[] args) {
		SpringApplication.run(SpringDataRedisApplication.class, args);
	}
}

Repository 생성

Memory DB 프로젝트 (opens in a new tab)에서 생성한 Repository의 구조를 보면 Repository 인터페이스를 DataRepository가 implements하고 DataRepository를 SimpleDataRepository가 implements하고 각 Repository에 대한 구현체가 있었다.
그러나 Spring Data에서는 JpaRepository\<[Entity], [ID Type]\> 인터페이스를 상속받으면 된다. JpaRepository를 뜯어보면 이미 내부적으로 JpaRepository -> PagingAndSortingRepository -> CrudRepository 순으로 구현이 되어있다.

public interface UserRepository extends JpaRepository<UserEntity, Long> /* Entity / Id Type */{
}

Controller

Find All

@RequestMapping("/api/user")
public class UserApiController {
 
    private final UserRepository userRepository;
 
    @GetMapping("/find-all")
    public List<UserEntity> findAll() {
        return userRepository.findAll();
    }
    ...
}

Create

    @GetMapping("/add/{name}")
    public void autoSave(
            @PathVariable String name
    ) {
        /*
        INSERT INTO user.user (name) VALUES (value)
        위의 Query와 동일한 코드이다.
         */
        var user = UserEntity.builder().name(name).build();
        userRepository.save(user);
    }

JPA Query Method

JPA로 기존에 생성되어 있는 쿼리 외에 작성하는 방법은 크게 3가지가 있다.

JPA Query Method

형식은 쿼리메서드By[변수-필드]조건문와 같고 조건문은 공식문서 (opens in a new tab)에서 확인할 수 있다.

public interface UserRepository extends JpaRepository<UserEntity, Long> {
    // select * from user where score > [??]
    // SELECT * FROM book_store.`user` WHERE score > 95;
    // 쿼리메서드By[변수]조건문 : JPA에서는 메서드의 명에 따라서 Query를 자동으로 생성해준다.
    public List<UserEntity> findAllByScoreGreaterThan(Integer sc);
}

Native Query

@Query에 nativeQuery를 true로 지정하고 Value 옵션을 사용해서 직접 Query를 작성할 수 있다. 이 때, Parameter 앞에 @Param Annotation을 사용해서 인자를 지정해주는 방식과 아니면 그냥 들어온 순서대로 ?1, ?2 ... 이런식으로 매칭하는 방식이 있다.

Native Query
    @Query(
            // form user u : user는 Entity를 뜻하고 u는 user의 별칭
            // u.score : user entity 안에 있는 score 필드(변수)
            // ?1, ?2 : 들어오는 첫 번째 인자, 두 번째 인자
            // named parameter 매칭 방식 : @Param으로 등록해준 이름으로 :[이름]을 사용해서 매칭한다.
            value = "select * from user u where u.score <= :high and u.score >= :low",
            nativeQuery = true
    )
    public List<UserEntity> scoreBetween(
            @Param(value = "high") Integer high,
            @Param(value = "low") Integer low);
 

JPQL Query

사실 Native Query와 크게 다르지는 않은데 별칭을 필수로 사용한다는 부분에서 차이가 있는 것(* 사용 불가)같다.

    @Query(
            // form user u : user는 Entity를 뜻하고 u는 user의 별칭
            // u.score : user entity 안에 있는 score 필드(변수)
            // ?1, ?2 : 들어오는 첫 번째 인자, 두 번째 인자
            "select u from user u where u.score <= ?1 and u.score >= ?2"
    )
    public List<UserEntity> scoreBetween(
            Integer high,
            Integer low);

참고

Memory DB를 Jpa Repository로 변경 후 MySQL 연동 + JPA Query 작성 #1 (opens in a new tab)

JPA 연관

Board와 Post를 Board(1) : Post(N)로 설정하고자 한다고 할 때 Board 쪽에는 OneToMany Annotation을 사용해줄 수 있다. 이 때 지정될 PostEntity의 변수명을 입력해주면 된다.

Board Entity
public class BoardEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String boardName;
    private String status;
 
    @OneToMany(
        mappedBy = "board"
    )
    private List<PostEntity> postList = List.of();
}

PostEntity 측에선 ManyToOne Annotation을 통해서 어떤 변수와 연관있는지를 지정해준다.
그러면 이 부분에 대해서 자동으로 _id를 붙여주게 되는데 실제 데이터 베이스의 해당 부분이 board_id 이므로 변수명을 board라고 해준 것이다.

PostEntity
public class PostEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    // 이 Annotation을 붙여주면 해당 변수를 Column으로 인식을 한다.
    // 자동으로 뒤에다가 _id를 붙여주게 된다.
    @ManyToOne
    private BoardEntity board;
    private String userName;
    private String password;
    private String email;
    private String status;
    private String title;
    // Database의 sql 유형이 TEXT인 경우
    // 아래의 Annotation을 통해서 데이터 타입을 맞춰줄 수 있음
    @Column(columnDefinition = "TEXT")
    private String content;
    private LocalDateTime postedAt;
    // Entity Annotation이 붙어있으면 기본적으로 이곳에 선언된 변수들은 DB의 Column으로 인식한다.
    // Transient Annotation을 붙여주면 DB의 Column으로 인식하지 않는다.(Container 등록시에 무시함)
    @Transient
    private List<ReplyEntity> replyList = List.of();
    @Transient
    private Integer replyCount;
}

무한 참조 오류와 DTO

위의 코드에서 오류가 발생할 수 있다. 그 이유는 Json Mapping과 toString 때문이다.
Json Mapper는 getter를 참조해서 Json Mapping을 해주게 되는데 PostEntity의 아래 부분과

PostEntity
    private BoardEntity board;

BoardEntity의 아래 부분이 계속 참조를 반복하면서

BoardEntity
    private List<PostEntity> postList = List.of();

PostEntity를 참조하라고? 알았어. > PostEntity로 이동 > BoardEntity를 참조하라고? 알았어. > BoardEntity로 이동 > PostEntity를 참조하라고? 알았어.

를 무한히 반복하면서 발생하는 오류이다. toString도 역시 위와 비슷한 이유로 무한 참조 오류가 발생한다.
이를 끊어내기 위해서는 한쪽에서 참조의 순회를 끊어줘야 하는데 더 이상 참조할 필요가 없는 곳에서 @JsonIgnore@ToString.Exclude Annotation을 사용해준다.
나는 이 순회를 PostEntity에서 끊어주기로 했다. 왜냐하면 JsonIgnore로 인해서 BoardID가 전달되지 않게 되더라도 Reponse를 받는 입장에서는 BoardID를 전달받을 이유가 없기 때문이다.

public class PostEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    // 이 Annotation을 붙여주면 해당 변수를 Column으로 인식을 한다.
    // 자동으로 뒤에다가 _id를 붙여주게 된다.
    @ManyToOne
    // Object Mapper가 BoardEntity에서 참조를 하고 BoardEntity에서 또 이곳을 참조하고 ... 하는 식으로
    // 상호 참조가 반복되어서 무한 루프가 돌게되는데 이를 방지하기 위해서 @JsonIgnore를 붙여준다.
    @JsonIgnore
    // toString끼리 상호참조로 인한 무한 루프를 방지하기 위해서 사용한다.
    @ToString.Exclude
    private BoardEntity board;
    private String userName;
    private String password;
    private String email;
    private String status;
    private String title;
    // Database의 sql 유형이 TEXT인 경우
    // 아래의 Annotation을 통해서 데이터 타입을 맞춰줄 수 있음
    @Column(columnDefinition = "TEXT")
    private String content;
    private LocalDateTime postedAt;
    // Entity Annotation이 붙어있으면 기본적으로 이곳에 선언된 변수들은 DB의 Column으로 인식한다.
    // Transient Annotation을 붙여주면 DB의 Column으로 인식하지 않는다.(Container 등록시에 무시함)
    @Transient
    private List<ReplyEntity> replyList = List.of();
    @Transient
    private Integer replyCount;
}

DTO

위와 같은 오류와 클라이언트 측에 보내줄 필요가 없는 부분을 관리하기 위해서 Response에 사용할 DTO(Data Transfer Object)를 설계하게 된다.
고로 지금까지의 UseCase를 따지자면 RequestModel > ModelEntity > RequestDto순으로 동작하게 되며 이는 Api에서 Request 호출 > Entity 참조 > Reponse(DTO) 전송과 같은 역할을 하게 된다.

ManyToOne

내가 다수이고 상대방이 하나인 경우에 사용하는 옵션

  @ManyToOne
  @JoinColumn(nullable = false, name = "store_id")
  /// 여기를 store로 바꿔줘도 됨
  private StoreEntity storeEntity;  

Error

Jpa 변수명과 Entity의 연관관계

Jpa Query는 Entity의 변수명을 기준으로 쿼리를 작성하는데 변수명을 userOrderMenuEntity로 작성하고

UserOrderMenuEntity
public class UserOrderMenuEntity extends BaseEntity {
    @ManyToOne
    @JoinColumn(nullable = false, name = "user_order_id")
    private UserOrderEntity userOrderEntity;   // UserOrderID 1 : n UserOrderMenuEntity
    ...
}

쿼리에다가 UserOrderId를 써놔서 발생한 에러

a
interface UserOrderMenuRepository: JpaRepository<UserOrderMenuEntity, Long> {
	// select * from user_order_menu where user_order_id = ? status = ?
	fun findAllByUserOrderIdAndStatus(userOrderId: Long?, status: UserOrderMenuStatus?): List<UserOrderMenuEntity>
}

해결방법은 두 가지이다.

  1. 변수명을 UserOrder로 변경한다.
UserOrderMenuEntity
public class UserOrderMenuEntity extends BaseEntity {
    @ManyToOne
    @JoinColumn(nullable = false, name = "user_order_id")
    private UserOrderEntity userOrder;   // UserOrderID 1 : n UserOrderMenuEntity
    ...
}
  1. 쿼리에 들어가는 변수명을 UserOrderEntityId로 변경한다.
a
interface UserOrderMenuRepository: JpaRepository<UserOrderMenuEntity, Long> {
	// select * from user_order_menu where user_order_id = ? status = ?
	fun findAllByUserOrderEntityIdAndStatus(userOrderId: Long?, status: UserOrderMenuStatus?): List<UserOrderMenuEntity>
}

그런데 2번 방법을 택할경우 뭔가 쿼리가 이상해지므로 1번 방법을 선택하기로 한다.

에러 메시지

🚫

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'userOrderBusiness' defined in file [/Users/rookedsysc/Documents/delivery-app/delivery-java-project/api/build/classes/java/main/org/delivery/api/domain/userorder/business/UserOrderBusiness.class]: Unsatisfied dependency expressed through constructor parameter 5; nested exception is org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'userOrderMenuService' defined in file [/Users/rookedsysc/Documents/delivery-app/delivery-java-project/api/build/classes/java/main/org/delivery/api/domain/userordermenu/service/UserOrderMenuService.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'userOrderMenuRepository' defined in org.delivery.db.userordermenu.UserOrderMenuRepository defined in @EnableJpaRepositories declared on JpaConfig: Invocation of init method failed; nested exception is org.springframework.data.repository.query.QueryCreationException: Could not create query for public abstract java.util.List org.delivery.db.userordermenu.UserOrderMenuRepository.findAllByUserOrderIdAndStatus(java.lang.Long,org.delivery.db.userordermenu.enums.UserOrderMenuStatus); Reason: Failed to create query for method public abstract java.util.List org.delivery.db.userordermenu.UserOrderMenuRepository.findAllByUserOrderIdAndStatus(java.lang.Long,org.delivery.db.userordermenu.enums.UserOrderMenuStatus)! No property 'userOrderId' found for type 'UserOrderMenuEntity'; nested exception is java.lang.IllegalArgumentException: Failed to create query for method public abstract java.util.List org.delivery.db.userordermenu.UserOrderMenuRepository.findAllByUserOrderIdAndStatus(java.lang.Long,org.delivery.db.userordermenu.enums.UserOrderMenuStatus)! No property 'userOrderId' found for type 'UserOrderMenuEntity'

Reference

참조할 Commit (opens in a new tab) - 변경내역 중에서 Entity.java 파일 참조