
JPA란?
- 자바 애플리케이션에서 관계형 데이터베이스와 상호작용하기 위한 표준 API
- 스프링부트는 JPA(Jav Persistence API)를 사용하여 데이터베이스를 관리한다.
- 스프링부트는 JPA를 ORM의 기술 표준으로 사용한다.
- JPA는 인터페이스 모음이므로 이 인터페이스를 구현한 실제 클레스가 필요하다.
- JPA를 구현한 실제 클래스에는 대표적으로 하이버네이트(Hibernate)가 있다.
- 하이버네이트는 JPA의 인터페이스를 구현한 실제 클래스이자 자바의 ORM 프레임워크로 부트에서 데이터베이스를 관리하기 쉽게 도와준다.
ORM이란?
- ORM은 SQL을 사용하지 않고 데이터베이스를 관리할 수 있는 도구
- ORM은 데이터베이스의 테이블을 자바 클래스로 만들어 관리할 수 있다.
SQL로 작성시
insert into question (id, subject, content) values (1, '안녕하세요', '가입 인사드립 니다 ^^); insert into question (id, subject, content) values (2, '질문 있습니다', 'ORM이 궁금합니다');ORM으로 작성시
Question q1 = new Question(); q1.setId(1); q1.setSubject("안녕하세요"); q1.setContent("가입 인사드립니다 ^^); this.questionRepository.save(q1); Question g2 = new Question(); q2.setId(2); q2.setSubject("질문 있습니다"); q2.setContent("ORM이 궁금합니다"); this.questionRepository.save(q2);
ORM의 장점
- ORM을 이용하면 MySQL, 오라클, db, MSSQL과 같은 DBMS의 종류에 관계없이 일관된 자바코드로 사용할 수 있어서 프로그램의 유지보수가 편리하다.
DBMS란?
- DBMS(Database Management System) 데이터베이스를 관리하는 소프트웨어이다.
- DB와 DBMS를 구분하지 않고 사용하는 경우가 많은데 엄밀히 말하면 DB는 데이터를 담은 통이라고 할 수 있고, DBMS는 이 통을 관리하는 소프트웨어이다.
- 사용자의 질문이나 답변을 작성하면 데이터가 생성되는데 이러한 데이터를 관리하려면, 저장, 조회, 수정하는 등의 기능을 구현해야 한다.
-> 데이터를 모으고 관리하는 저장소를 데이터베이스라고 한다.- 데이터베이스를 관리하려면 SQL이라는 언어를 사용해야 한다.
-> 스프링부트와 달리 데이터베이스는 자바를 이해하지 못한다.
-> 하지만 ORM(Object Relational Mapping)이라는 도구를 사용하면 자바 문법으로도 데이터베이스를 다룰 수 있다. ORM을 사용하면 개발자는 SQL을 직접 작성하지 않아도 데이터베이스의 데이터를 처리할 수 있다.
- build.gradle에
runtimeOnly 'com.h2database:h2'추가하고 build.gradle refresh하기
- application.properties 수정
spring.application.name=sbb # DATABASE #h2콘솔로 접속할 것인지 여부 spring.h2.console.enabled=true #h2콜솔로 접속하기 위한 경로 spring.h2.console.path=/h2-console #데이터베이스에 접속하기 위한 경로 spring.datasource.url=jdbc:h2:~/local #데이터베이스에 접속할때 사용하는 드라이버 클래스 명 spring.datasource.driver-class-name=org.h2.Driver #데이터베이스에 접속할때 사용하는 아이디 spring.datasource.username=sa #데이터베이스에 접속할때 사용하는 패스워드 spring.datasource.password=
- database 만들기
- build.gradle refresh
- 결과
- 변경 후 연결 클릭
- 데이터베이스 연결된 결과
- build.gradle에
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'-> 추가후 refreash
- application.properties 에 JPA추가
spring.application.name=sbb # DATABASE #h2콘솔로 접속할 것인지 여부 spring.h2.console.enabled=true #h2콜솔로 접속하기 위한 경로 spring.h2.console.path=/h2-console #데이터베이스에 접속하기 위한 경로 spring.datasource.url=jdbc:h2:~/local #데이터베이스에 접속할때 사용하는 드라이버 클래스 명 spring.datasource.driver-class-name=org.h2.Driver #데이터베이스에 접속할때 사용하는 아이디 spring.datasource.username=sa #데이터베이스에 접속할때 사용하는 패스워드 spring.datasource.password= # JPA spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update
- none: 엔티티가 변경되더라도 데이터베이스를 변경하지 않는다.
- update: 엔티티의 변경된 부분만 데이터베이스에 적용한다.
- validate: 엔티티와 테이블 간에 차이점이 있는지 검사만 한다.
- create: 스프링 부트 서버를 시작할 때 테이블을 모두 삭제한 후 다시 생성한다.
- create-drop: create와 동일하지만 스프링 부트 서버를 종료할 때에도 테이블을 모두 삭제한다.
Question(id, subject, content, createDate)
Answer
- Question.java 생성
package com.mysite.sbb; import java.time.LocalDateTime; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import lombok.Getter; import lombok.Setter; @Getter @Setter @Entity public class Question { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @Column(length = 200) private String subject; @Column(columnDefinition = "TEXT") private String content; private LocalDateTime createDate; }
- Answer.java 생성
package com.mysite.sbb; import java.time.LocalDateTime; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; import lombok.Getter; import lombok.Setter; @Getter @Setter @Entity public class Answer { @Id // 기본키. 각 데이터들을 구분하는 유효한 값.(중복 불가능) @GeneratedValue(strategy = GenerationType.IDENTITY) //고유한 번호를 생성하는 방법. private Integer id; //열 이름 텍스트를 열 데이터로 넣을 수 있다. 글자수를 제한할 수 없는 경우에 사용. @Column(columnDefinition = "TEXT") private String content; private LocalDateTime createDate; // Question을 참조해야 하기 위해. @ManyToOne private Question question; }
- 결과
답변을 통해 질문의 제목을 알고 싶다
- answer.getQuestion().getSubject() -> 접근할 수 있다.
@ManyToOne private Question question;-> 하나의 질문에 답변은 여러개가 달릴 수 있다.
따라서 답변은 Many가 되고 질문은 One이 된다.
@ManyToOne을 통해서 N:1관계를 나타낼 수 있다.
이렇게 @ManyToOne을 설정하면 Answer 엔티티의 question속성과 Question 엔티티가 서로 연결된다(실제 데이터베이스에서는 (외래키_foreign key)관계가 생성된다.)@ManyToOne은 부모 자식 관계를 갖는 구조에서 사용한다.
- 여기서 부모는 Question, 자식은 Answer라고 할 수 있다.
-> 외래키란 테이블과 테이블 사이의 관계를 구성할때 연결되는 열을 의미한다.반대로 질문에서 답변을 참조할 수 없을까? -> 있다
- 답변과 질문이 N:1관계라면 질문과 답변은 1:N관계라고 할 수 있다.
-> @OneToMany를 사용하면 된다
질문 하나에 답변은 여러개 이므로 Question 추가할 Answer속성 타입은 List형태로 구성해야 한다.- Question.java에
@OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE) private List<Answer> answerList;추가
Qustion.javapackage com.mysite.sbb; import java.time.LocalDateTime; import java.util.List; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; import lombok.Getter; import lombok.Setter; @Getter @Setter @Entity public class Question { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @Column(length = 200) private String subject; @Column(columnDefinition = "TEXT") private String content; private LocalDateTime createDate; @OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE) private List<Answer> answerList; // question.getAnswerList() }-> 질문에 답변을 참조하려면 question.getAnswerList()를 호출하면 된다.
@OneToMany에 사용된 mappedBy 속성은 참조 엔티티의 속성명을 정의한다.
-> 즉, Answer 엔티티에서 Question 엔티티를 참조한 속성인 question을 mappedBy에 전달해야 한다.
- Cascade = CascadeType.REMOVE
게시판에서는 질문 하나에 답변이 여러개 작성될 수 있다. 그런데 보통 게시판 서비스에서는 질문을 삭제하면 그에 달린 답변들도 함께 삭제된다. 그렇게 만들기 위해 적용하는 속성이다.
- entity만으로는 테이블의 데이터를 저장, 조회, 수정, 삭제 등을 할 수 없다.
이런 데이터를 관리하려면 데이터베이스와 연동하는 JPA repository가 있어야 한다.- QuestionRepository.java(interface) 생성
package com.mysite.sbb; import org.springframework.data.jpa.repository.JpaRepository; // CRUD함수를 JpaRepository가 들고 있음. // @Repository라는 에너테이션이 없어도 IoC된다. 이유는 JpaRepository를 상속했기 때문 public interface QuestionRepository extends JpaRepository<Question, Integer>{ }-> Question 엔티티를 리포지터리로 생성한다는 의미. Question 엔티티의 기본키가 integer타입.
- AnswerRepository.java(interface) 생성
package com.mysite.sbb; import org.springframework.data.jpa.repository.JpaRepository; public interface AnswerRepository extends JpaRepository<Answer, Integer>{ }
- build.gradle에
testImplementation 'org.junit.jupiter:junit-jupiter' testRuntimeOnly 'org.junit.platform:junit-platform-launcher'추가 후 refresh
- IoC(Inversoin of Control) : Spring에서 객체의 생성과 관리를 Spring 컨테이너가 담당
(이전에는 개발자가 객체의 생성과 생명주기를 직접 관리)- DI(Dependency Injection) : 필요로하는 객체를 생성하거나 검색하지 않고 외부로부터 주입.
Spring의 IoC컨테이너는 애플리케이션의 객체를 생성하고 관리하는데, 이때 의존성주입(DI)를 통해 필요한 Bean을 주입한다.- SbbApplicationTests.java 수정
package com.mysite.sbb; import java.time.LocalDateTime; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class SbbApplicationTests { @Autowired private QuestionRepository questionRepository; @Test void testJpa() { Question q1 = new Question(); q1.setSubject("sbb가 무엇인가요?"); q1.setContent("sbb에 대해서 알고 싶습니다"); q1.setCreateDate(LocalDateTime.now()); this.questionRepository.save(q1); Question q2 = new Question(); q2.setSubject("스프링 부트 모델 질문입니다"); q2.setContent("id는 자동으로 생성되나요?"); q2.setCreateDate(LocalDateTime.now()); this.questionRepository.save(q2); } }
-> 서버가 이미 켜져있기 때문에 에러
-> 서버 종료 후 다시 실행하면 에러 없음 -> JUnit Test 실행 후 서버 다시 실행
- 입력 및 실행 결과
- SbbApplicationTests.java 수정
package com.mysite.sbb; import static org.junit.jupiter.api.Assertions.assertEquals; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class SbbApplicationTests { @Autowired private QuestionRepository questionRepository; @Test void testJpa() { List<Question> all = this.questionRepository.findAll(); // assertEquals(기댓값, 실제값) 동일한지 조사. assertEquals(2, all.size()); // 데이터개수 Question q = all.get(0); // 첫번째 assertEquals("sbb가 무엇인가요?", q.getSubject()); } }
- SbbApplicationTests.java 수정
package com.mysite.sbb; import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class SbbApplicationTests { @Autowired private QuestionRepository questionRepository; @Test void testJpa() { // 값이 있는지 없는지에 대한 처리를 확인하는 클래스. (null값을 유연하게 처리) // isPresent로 값이 존재하는지 확인할 수 있다. // isPresent를 통해 값이 존재한다면 get()을 통해 실제 Question의 값을 얻는다. Optional<Question> oq = this.questionRepository.findById(1); if(oq.isPresent()) { Question q = oq.get(); assertEquals("sbb가 무엇인가요?", q.getSubject()); } } }
- QuestionRepository.java (interface) 수정
package com.mysite.sbb; import org.springframework.data.jpa.repository.JpaRepository; // CRUD함수를 JpaRepository가 들고 있음. // @Repository라는 에너테이션이 없어도 IoC된다. 이유는 JpaRepository를 상속했기 때문 public interface QuestionRepository extends JpaRepository<Question, Integer>{ // findBy~ // select * from question where subject = ? Question findBySubject(String subject); //Jpa Query methods }
- SbbApplicationTests.java 수정
package com.mysite.sbb; import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class SbbApplicationTests { @Autowired private QuestionRepository questionRepository; @Test void testJpa() { Question q = this.questionRepository.findBySubject("sbb가 무엇인가요?"); assertEquals(1, q.getId()); } }
- application.properties에
spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.show_sql=truespring.application.name=sbb # DATABASE #h2콘솔로 접속할 것인지 여부 spring.h2.console.enabled=true #h2콜솔로 접속하기 위한 경로 spring.h2.console.path=/h2-console #데이터베이스에 접속하기 위한 경로 spring.datasource.url=jdbc:h2:~/local #데이터베이스에 접속할때 사용하는 드라이버 클래스 명 spring.datasource.driver-class-name=org.h2.Driver #데이터베이스에 접속할때 사용하는 아이디 spring.datasource.username=sa #데이터베이스에 접속할때 사용하는 패스워드 spring.datasource.password= # JPA spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect spring.jpa.hibernate.ddl-auto=update spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.show_sql=true추가 후 결과 -> console창에 결과가 나옴
- subject와 content를 함께 조회
-> findBySubjectAndContent- QuestionRepository.java(interface) 수정
package com.mysite.sbb; import org.springframework.data.jpa.repository.JpaRepository; // CRUD함수를 JpaRepository가 들고 있음. // @Repository라는 에너테이션이 없어도 IoC된다. 이유는 JpaRepository를 상속했기 때문 public interface QuestionRepository extends JpaRepository<Question, Integer>{ // findBy~ // select * from question where subject = ? Question findBySubject(String subject); //Jpa Query methods Question findBySubjectAndContent(String subject, String content); }
- SbbApplicationTests.java 수정
package com.mysite.sbb; import static org.junit.jupiter.api.Assertions.assertEquals; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class SbbApplicationTests { @Autowired private QuestionRepository questionRepository; @Test void testJpa() { Question q = this.questionRepository .findBySubjectAndContent("sbb가 무엇인가요?", "sbb에 대해서 알고 싶습니다."); assertEquals(1, q.getId()); } }
- 실행 및 결과
- 쿼리메소드 검색사이트
https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html
- subject 열값들 중에 특정 문자열을 포함하는 데이터를 조회
-> Like를 사용
List[Question] findBySubjectLike(String subject);- QuestionRepository.java(interface) 수정
package com.mysite.sbb; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; // CRUD함수를 JpaRepository가 들고 있음. // @Repository라는 에너테이션이 없어도 IoC된다. 이유는 JpaRepository를 상속했기 때문 public interface QuestionRepository extends JpaRepository<Question, Integer>{ // findBy~ // select * from question where subject = ? Question findBySubject(String subject); //Jpa Query methods Question findBySubjectAndContent(String subject, String content); List<Question> findBySubjectLike(String subject); }
- SbbApplicationTests.java 수정
package com.mysite.sbb; import static org.junit.jupiter.api.Assertions.assertEquals; import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class SbbApplicationTests { @Autowired private QuestionRepository questionRepository; @Test void testJpa() { List<Question> qList = this.questionRepository.findBySubjectLike("sbb%"); Question q = qList.get(0); assertEquals("sbb가 무엇인가요?", q.getSubject()); } }
- 실행 및 결과
-> q1_0.subject like ? escape'\'
escape'\' 구문은 SQL쿼리에서 사용되는 LIKE 연산자와 함께 쓰이는데, 특정 문자가 와일드 카드로 작동하는 것을 방지하기 위해 사용된다. "sbb%" % 기호가 텍스트로 들어가는 것을 방지하기 위해.- sbb% : sbb로 시작하는 문자열
- %sbb : sbb로 끝나는 문자열
- %sbb% : sbb를 포함하는 문자열
- SbbApplicationTests.java 수정
package com.mysite.sbb; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class SbbApplicationTests { @Autowired private QuestionRepository questionRepository; @Test void testJpa() { Optional<Question> oq = this.questionRepository.findById(1); assertTrue(oq.isPresent()); //괄호 안의 값이 TRUE인지를 테스트 Question q = oq.get(); q.setSubject("수정된 제목"); this.questionRepository.save(q); } }
- 실행 및 결과
- SbbApplicationTests.java 수정
package com.mysite.sbb; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.LocalDateTime; import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class SbbApplicationTests { @Autowired private QuestionRepository questionRepository; @Autowired private AnswerRepository answerRepository; @Test void testJpa() { Optional<Question> oq = this.questionRepository.findById(2); assertTrue(oq.isPresent()); Question q = oq.get(); Answer a = new Answer(); a.setContent("네 자동으로 생성됩니다."); a.setQuestion(q); a.setCreateDate(LocalDateTime.now()); this.answerRepository.save(a); } }
- 실행 및 결과
- SbbApplicationTests.java 수정
package com.mysite.sbb; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class SbbApplicationTests { @Autowired private QuestionRepository questionRepository; @Autowired private AnswerRepository answerRepository; @Test void testJpa() { Optional<Answer> oq = this.answerRepository.findById(1); assertTrue(oq.isPresent()); Answer a = oq.get(); assertEquals(2, a.getQuestion().getId()); } }
- 실행 및 결과
-> left join : 연관된 데이터를 접근하기 위한 방법
Answer와 Question은 데이터베이스 내에서 연관관계를 가지고 있다.
Answer가 Question을 참조하는 외래키(question_id)를 포함한다. left join을 사용하면 Answer객체를 조회할때 연관된 Question 객체의 정보도 함께 불러올 수 있다.
- a.getQuesetion() : 답변을 통해서 질문 찾기
질문 데이터를 통해 답변 찾기 -> answerList를 사용
- org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.mysite.sbb.Question.answerList: could not initialize proxy - no Session
-> QuestionRepository가 findById메서드를 통해 Question 객체를 조회하면 DB세션이 끊어진다.
== DB세션이란 스프링부트 애플리케이션과 데이터베이스 간의 연결- answerList는 앞서 q객체를 조회할때가 아니라 q.getAnswerList()메서드를 호출하는 시점에 가져오기 때문에 이와 같은 오류가 발생하는 것이다.
== 이렇게 데이터를 필요한 시점에 가져오는 방식을 지연(Lazy)방식이라고 한다.- 이와 반대로 q객체를 조회할 때 미리 answer 리스트를 모두 가져오는 방식은 즉시(Eager)방식이라고 한다.
@OneToMany, @ManyToOne 옵션으로 fetch=FetchType.Lazy 또는 fetch=Fetchtype.EAGER 가져오는 방식을 설정할 수 있다.
-> 이러한 문제는 테스트 코드에서만 발생한다. 실제 서버에서 JPA프로그램들을 실행할때는 DB세션이 종료되지 않아 오류가 발생하지 않는다.
테스트 코드를 수행할때 이런 오류를 방지할 수 있는 방법은 @Transactional 사용하면 된다.- @Transactional은 메서드가 종료될 때까지 DB세션을 유지한다.
- SbbApplicationTests.java 수정
package com.mysite.sbb; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import jakarta.transaction.Transactional; @SpringBootTest class SbbApplicationTests { @Autowired private QuestionRepository questionRepository; @Autowired private AnswerRepository answerRepository; @Transactional @Test void testJpa() { Optional<Question> oq = this.questionRepository.findById(2); assertTrue(oq.isPresent()); Question q = oq.get(); List<Answer> answerList = q.getAnswerList(); assertEquals(1, answerList.size()); assertEquals("네 자동으로 생성됩니다.", answerList.get(0).getContent()); } }
- 실행 및 결과
- QuestionController.java 생성
package com.mysite.sbb.question; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class QuestionController { @GetMapping("/question/list") public String list() { return "question_list"; } }
- build.gradle에
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'추가 후 refresh