WEB 커리큘럼 2주차 - 과제

이은지·2023년 9월 17일
0

GDSC-Web

목록 보기
3/7

✔️"점프 투 스프링 부트" 교재 2-01부터 2-04까지를 복습한 후에, 2-05까지 학습

우리가 최종적으로 구현 해야할 것은 질문 답변 게시판으로, 데이터를 저장하거나 조회하거나 수정하는 등의 기능을 구현해야 한다.

1. h2 데이터베이스

JPA를 사용하기 전에 데이터를 저장할 데이터베이스를 설치한다.

💡 H2 데이터베이스

H2 데이터베이스는 주로 개발용이나 소규모 프로젝트에서 사용되는 파일 기반의 경량 데이터베이스이다. 개발시에는 H2를 사용하여 빠르게 개발하고 실제 운영시스템은 좀 더 규모있는 DB를 사용하는 것이 일반적인 개발 패턴이다.

💡 H2 데이터베이스 설치

  1. build.gradle파일에 들어가 H2 데이터 베이스를 설치
(... 생략 ...)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
}

(... 생략 ...)
  1. 그리고 "Refresh Gradle Project"를 실행하여 필요한 라이브러리를 설치
  2. 설치한 H2 데이터베이스 사용을 위해 설정을 해야함. application.properties 파일을 다음과 같이 수정
# DATABASE
spring.h2.console.enabled=true //H2 콘솔의 접속을 허용할지의 여부이다. true로 설정한다.
spring.h2.console.path=/h2-console //콘솔 접속을 위한 URL 경로이다.
spring.datasource.url=jdbc:h2:~/local //데이터베이스 접속을 위한 경로이다.
spring.datasource.driverClassName=org.h2.Driver //데이터베이스 접속시 사용하는 드라이버이다.
spring.datasource.username=sa //데이터베이스의 사용자명이다. (사용자명은 기본 값인 sa로 설정한다.)
spring.datasource.password= //데이터베이스의 사용자명이다. (사용자명은 기본 값인 sa로 설정한다.)
  1. spring.datasource.url에 설정한 경로에 해당하는 데이터베이스 파일을 생성한다. 위에서 spring.datasource.url을 jdbc:h2:~/local 로 설정했기 때문에 사용자의 홈디렉터리(~ 에 해당하는 경로) 밑에 local.mv.db 라는 파일을 생성

    ⭐️ window에서 local.mv.db 파일 생성하기
    - copy con local.mv.db

  1. 여기까지 마무리 되었으면, 이제 H2 콘솔을 통해 데이터베이스에 접속할 수 있음. 브라우저에서 http://localhost:8080/h2-console 주소로 H2 콘솔에 접속

2. JPA 환경 설정

이제 자바 프로그램에서 H2 데이터베이스를 사용할 수 있게 해야함. 자바 프로그램에서 데이터베이스에 데이터를 저장하거나 조회하려면 JPA를 사용해야함

💡JPA 사용을 위한 준비 작업

  1. build.gradle 파일 수정
(... 생략 ...)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
}

(... 생략 ...)
  1. "Refresh Gradle Project"로 변경사항을 적용하면 JPA 라이브러리가 설치
  2. JPA 설정을 위해 application.properties 파일에 해당 코드 추가
# JPA
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect //데이터베이스 엔진 종류를 설정한다.
spring.jpa.hibernate.ddl-auto=update 
//데이터베이스 엔진 종류를 설정한다. 
// 개발 환경에서는 보통 update 모드를 사용하고, 운영환경에서는 none 또는 validate 모드를 사용한다.

3. 엔티티 생성

이제 질문답변 게시판에서 사용할 질문과 답변에 해당하는 엔티티를 만들어보자.

  • 질문 엔티티 속성: id(질문의 고유번호), subject(질문의 제목), content(질문의 내용), create_date(질문을 작성한 일시)
  • 답변 엔티티 속성: id(답변의 고유 번호), question(질문), content(답변 내용), create_date(답변 작성 일시)

💡질문 엔티티 작성하기

  • 경로: src\main\java\com\gdsc\webboard\entity\Question.java
package com.gdsc.webboard.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

import java.time.LocalDateTime;
import java.util.List;

@Getter
@Setter
@Entity //해당 어노테이션을 적용해야 JPA가 엔티티로 인식 
public class Question {

    @Id //DB에서 id를 뜻함
    @GeneratedValue(strategy = GenerationType.IDENTITY) //id를 어떤 방식으로 생성할건지
    private Integer id;

    @Column(length=200) //최대 길이가 200으로 지정 , 속성 사용 안하면 Column 어노테이션 사용하지 않음
    private String subject;

    @Column(columnDefinition = "TEXT")
    private String content;

    private LocalDateTime createDate;

    @OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE) //질문에서 대답으로 One to Many
    private List<Answer> answerList; //여러개의 답변이니까 list

}

⭐️소스 설명

1. @Entity

  • 엔티티로 만들기 위해 Question 클래스에 @Entity 애너테이션을 적용

2. @Getter, @Setter

  • Getter, Setter 메서드를 자동으로 생성하기 위해 롬복의 @Getter, @Setter 애너테이션을 적용

3. @Id

  • id 속성을 기본 키로 지정
  • 기본 키로 지정하면 이제 id 속성의 값은 데이터베이스에 저장할 때 동일한 값으로 저장할 수 없음

4. @GeneratedValue

  • 데이터를 저장할 때 해당 속성에 값을 따로 세팅하지 않아도 1씩 자동으로 증가하여 저장
  • strategy는 고유번호를 생성하는 옵션으로 GenerationType.IDENTITY는 해당 컬럼만의 독립적인 시퀀스를 생성하여 번호를 증가시킬 때 사용

5. @Column

  • 엔티티의 속성은 테이블의 컬럼명과 일치하는데 컬럼의 세부 설정을 위해 @Column 애너테이션을 사용
  • length: 컬럼의 길이를 설정할때 사용
  • columnDefinition: 컬럼의 속성을 정의할 때 사용
    • columnDefinition = "TEXT"은 "내용"처럼 글자 수를 제한할 수 없는 경우에 사용

6. @OneToMany

  • 질문과 답변은 1:N의 관계
  • mappedBy는 참조 엔티티의 속성명을 의미
  • Answer 엔티티에서 Question 엔티티를 참조한 속성명 question을 mappedBy에 전달해야 함
  • CascadeType.REMOVE: 질문 하나에는 여러개의 답변이 작성될 수 있. 이때 질문을 삭제하면 그에 달린 답변들도 모두 함께 삭제하기 위해서 @OneToMany의 속성으로 cascade = CascadeType.REMOVE를 사용

💡답변 엔티티 생성하기

경로: src\main\java\com\gdsc\webboard\entity\Answer.java

package com.gdsc.webboard.entity;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

import java.time.LocalDateTime;

@Getter
@Setter
@Entity
public class Answer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    @Column(columnDefinition = "TEXT")
    private String content;

    private LocalDateTime createDate;

    @ManyToOne //Many-답변 One-질문 //답변에서 질문으로 -> Many to One
    private Question question;

}

⭐️소스 설명

  1. @ManyToOne
  • 답변은 하나의 질문에 여러개가 달릴 수 있는 구조
  • 따라서 답변은 Many(많은 것)가 되고 질문은 One(하나)이 됨(N:1 관계)
  • @ManyToOne 애너테이션을 설정하면 Answer 엔티티의 question 속성과 Question 엔티티가 서로 연결 → 실제 데이터베이스에서는 ForeignKey 관계가 생성

엔티티 생성 후, H2 콘솔에 접속하면 Question과 Answer 테이블이 자동으로 생성되어 있음



4. 리포지토리 생성

  • 데이터 처리를 위해서는 실제 데이터베이스와 연동하는 JPA 리포지터리가 필요함

💡리포지토리 인터페이스 생성하기

1. QuestionRepository.java

경로: src\main\java\com\gdsc\webboard\repository\QuestionRepository.java

package com.gdsc.webboard.repository;

import com.gdsc.webboard.entity.Question;
import org.springframework.data.jpa.repository.JpaRepository;

public interface QuestionRepository extends JpaRepository<Question, Integer> {
}


2. AnswerRepository.java

경로: src\main\java\com\gdsc\webboard\repository\AnswerRepository.java

package com.gdsc.webboard.repository;

import com.gdsc.webboard.entity.Answer;
import org.springframework.data.jpa.repository.JpaRepository;

public interface AnswerRepository extends JpaRepository<Answer, Integer> {
}
  • 리포지토리로 만들기 위해 JpaRepository 인터페이스를 상속
  • JpaRepository를 상속할 때는 제네릭 타입으로 <Question, Integer> 처럼 리포지터리의 대상이 되는 엔티티의 타입(Question)과 해당 엔티티의 PK의 속성 타입(Integer)을 지정해야 함


💡데이터 저장하기

  • 작성한 리포지토리 테스트를 위해 JUnit 기반의 스프링부트 테스트 프레임워크를 사용

1. 먼저, 테스트 파일을 생성

경로: src\test\java\com\gdsc\webboard\SbbApplicationTests.java

package com.gdsc.webboard;

import com.gdsc.webboard.entity.Question;
import com.gdsc.webboard.repository.QuestionRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.time.LocalDateTime;

@SpringBootTest
public class SbbApplicationTests {
    @Autowired
    private QuestionRepository questionRepository;

    @Test
    void testJpa() {
        Question q1 = new Question(); //q1 엔티티 객체 생성
        q1.setSubject("sbb가 무엇인가요?");
        q1.setContent("sbb에 대해서 알고 싶습니다.");
        q1.setCreateDate(LocalDateTime.now());
        this.questionRepository.save(q1);  // 첫번째 질문 저장

        Question q2 = new Question();//q2 엔티티 객체 생성
        q2.setSubject("스프링부트 모델 질문입니다.");
        q2.setContent("id는 자동으로 생성되나요?");
        q2.setCreateDate(LocalDateTime.now());
        this.questionRepository.save(q2);  // 두번째 질문 저장
    }
}

⭐️소스 설명

  • @SpringBootTest: SbbApplicationTests 클래스가 스프링부트 테스트 클래스임을 의미

  • @Autowired: 스프링의 DI 기능으로 questionRepository 객체를 스프링이 자동으로 생성해 준다.

  1. SbbApplicationTests 클래스 실행
  1. H2 Console에 접속하여 데이터가 저장되었는지 확인

💡데이터 조회하기

📌 Junit assertEquals 메서드

  • assertEquals는 assertEquals(기대값, 실제값)와 같이 사용하고 기대값과 실제값이 동일한지를 조사
  • 만약 기대값과 실제값이 동일하지 않다면 테스트는 실패로 처리

1. findAll

@SpringBootTest
class SbbApplicationTests {

    @Autowired
    private QuestionRepository questionRepository;

    @Test
    void testJpa() {
        List<Question> all = this.questionRepository.findAll();
        assertEquals(2, all.size());

        Question q = all.get(0);
        assertEquals("sbb가 무엇인가요?", q.getSubject());
    }
}
  • 테이블에 저장된 모든 데이터를 조회

2. findById

@SpringBootTest
class SbbApplicationTests {

    @Autowired
    private QuestionRepository questionRepository;

    @Test
    void testJpa() {
        Optional<Question> oq = this.questionRepository.findById(1);
        if(oq.isPresent()) {
            Question q = oq.get();
            assertEquals("sbb가 무엇인가요?", q.getSubject());
        }
    }
}
  • 엔티티의 Id값으로 데이터를 조회
  • findById의 리턴 타입은 Optional
  • Optional은 null 처리를 유연하게 처리하기 위해 사용하는 클래스로 위와 같이 isPresent로 null이 아닌지를 확인한 후에 get으로 실제 Question 객체 값을 얻어야 함

3. findBySubject

  • findBySubject와 같은 메서드는 기본적으로 제공되지 않음
  • findBySubject 메서드를 사용하려면 다음처럼 QuestionRepository 인터페이스를 변경 해아함.
package com.gdsc.webboard.repository;

import com.gdsc.webboard.entity.Question;
import org.springframework.data.jpa.repository.JpaRepository;

public interface QuestionRepository extends JpaRepository<Question, Integer> {
    Question findBySubject(String subject);
}
  • 그러면 다음처럼 제목으로 테이블 데이터를 조회할 수 있음
@SpringBootTest
class SbbApplicationTests {

    @Autowired
    private QuestionRepository questionRepository;

    @Test
    void testJpa() {
        Question q = this.questionRepository.findBySubject("sbb가 무엇인가요?");
        assertEquals(1, q.getId());
    }
}

📌 인터페이스에 findBySubject라는 메서드를 선언만 하고 구현은 하지 않았는데 어떻게 실행이 되는 것인가?
→ JpaRepository를 상속한 QuestionRepository 객체가 생성되고, 메서드가 실행될 때 JPA가 해당 메서드명을 분석하여 쿼리를 만들고 실행한다.
→ 즉, findBy+엔티티의 속성명과 같은 리포지토리 메서드를 작성하면 해당 속성의 값으로 데이터를 조회할 수 있다.

📌 실행되는 쿼리 로그에서 보는 방법

  • application.properties 파일에 해당 코드 추가
# 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

4. findBySubjectAndContent

  • 제목과 내용 함께 조회하기
  • 리포지토리 메서드 추가
package com.gdsc.webboard.repository;

import com.gdsc.webboard.entity.Question;
import org.springframework.data.jpa.repository.JpaRepository;

public interface QuestionRepository extends JpaRepository<Question, Integer> {
    Question findBySubject(String subject);
    Question findBySubjectAndContent(String subject, String content);
}
  • 테스트코드
@SpringBootTest
class SbbApplicationTests {

    @Autowired
    private QuestionRepository questionRepository;

    @Test
    void testJpa() {
        Question q = this.questionRepository.findBySubjectAndContent(
                "sbb가 무엇인가요?", "sbb에 대해서 알고 싶습니다.");
        assertEquals(1, q.getId());
    }
}
  • 실행 결과

    → subject, content 컬럼이 and 조건으로 where문에 사용

5. findBySubjectLike

  • 제목에 특정 문자열이 포함되어 있는 데이터 조회
  • Question 리포지토리에 메소드 추가
package com.gdsc.webboard.repository;

import com.gdsc.webboard.entity.Question;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface QuestionRepository extends JpaRepository<Question, Integer> {
    Question findBySubject(String subject);
    Question findBySubjectAndContent(String subject, String content);
    List<Question> findBySubjectLike(String subject);
}
  • 테스트코드
@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());
    }
}
  • % 표기 의미
    • sbb%: "sbb"로 시작하는 문자열
    • %sbb: "sbb"로 끝나는 문자열
    • %sbb%: "sbb"를 포함하는 문자열

💡데이터 수정하기

package com.gdsc.webboard;

import com.gdsc.webboard.entity.Question;
import com.gdsc.webboard.repository.QuestionRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.Optional;

@SpringBootTest
public 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 객체에 저장
        q.setSubject("수정된 제목"); //subject를 "수정된 제목"으로 변경
        this.questionRepository.save(q); //변경된 Question 데이터를 save 메서드를 사용해 저장
    }

}

📌 Junit의 Assertion 클래스

모든 Assertion은 Assert 클래스가 있음

public class Assert extends java.lang.Object

이 클래스는 테스트 작성에 유용한 Assertion 메서드 집합을 제공하고, 실패한 Assertion 만 기록된다. Assert는 프로그램에 대한 가정을 테스트할 수 있는 것으로, 프로그램의 오류를 감지하고 수정하는 효과적인 방법을 제공함.

  • 테스트 수행
    - 콘솔 로그에서 다음과 같은 update 문이 실행되었음을 확인 가능
  • h2-console 접속
    - 수정된 subject 확인 가능

💡데이터 삭제하기

package com.gdsc.webboard;

import com.gdsc.webboard.entity.Question;
import com.gdsc.webboard.repository.QuestionRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

@SpringBootTest
public class SbbApplicationTests {
    @Autowired
    private QuestionRepository questionRepository;
		@Test
    void delete(){
        assertEquals(2, this.questionRepository.count()); //삭제 전 데이터 건수가 2인지 테스트
        Optional<Question> oq = this.questionRepository.findById(1); //findById 메서드로 데이터를 찾음
        assertTrue(oq.isPresent()); //데이터가 있는지 테스트
        Question q = oq.get(); //데이터가 있으면 q 객체에 대입
        this.questionRepository.delete(q); //delete 메서드를 사용하여 데이터를 삭제
        assertEquals(1, this.questionRepository.count()); //삭제 후 데이터 건수가 1인지 테스트
    }
  • 테스트 성공
  • h2-console에 접속하여 데이터 삭제 확인

💡답변 데이터 생성 후 저장하기

@SpringBootTest
public class SbbApplicationTests {
    @Autowired
    private QuestionRepository questionRepository;
    @Autowired
    private AnswerRepository answerRepository; //AnswerRepository 객체를 @Autowired로 주입

    @Test
    void 답변저장(){
        //답변 데이터를 생성하려면 질문 데이터가 필요
        Optional<Question> oq = this.questionRepository.findById(2); //id가 2인 질문데이터 가져옴
        assertTrue(oq.isPresent()); //데이터가 있는지 확인
        Question q = oq.get(); //있으면, q 객체에 저장

        Answer a = new Answer(); //Answer 객체 생성
        a.setContent("네 자동으로 생성됩니다."); //데이터 세팅
        a.setQuestion(q);//데이터 세팅
        a.setCreateDate(LocalDateTime.now());//데이터 세팅
        this.answerRepository.save(a); //save 메서드를 통한 데이터 저장

    }
  • 테스트 성공
  • h2-console 접속
    - 답변이 잘 생성됨을 확인

💡답변 조회하기

  • 답변의 id 값을 이용해 데이터를 조회해보자
@Test
    void 답변조회(){
        Optional<Answer> oa = this.answerRepository.findById(1); //id가 1인 answer데이터 조회
        assertTrue(oa.isPresent()); //데이터가 있는지 확인
        Answer a = oa.get(); //데이터가 있으면 a 객체에 저장
        assertEquals(2, a.getQuestion().getId()); //질문의 id가 2인지 확인
    }
  • 테스트 성공


💡답변에 연결된 질문 찾기 vs 질문에 달린 답변 찾기

답변에 연결된 질문 찾기는 Answer 엔티티에 question 속성이 정의되어 있어서 매우 쉽다. 반대의 경우(질문에서 답변을 찾는 경우), 질문 엔티티에 정의한 answerList를 사용하면 쉽게 구할 수 있다.

@Test
    void 질문에서답변찾기(){
        Optional<Question> oq = this.questionRepository.findById(2); //id가 2인 question을 조회
        assertTrue(oq.isPresent()); //데이터가 있는지 확인
        Question q = oq.get(); //q 객체에 저장

        List<Answer> answerList = q.getAnswerList(); //q 객체에서 answer list 조회

        assertEquals(1, answerList.size()); //answer list 데이터 개수 확인
        assertEquals("네 자동으로 생성됩니다.", answerList.get(0).getContent()); //첫번째 데이터 내용 확인
    }
  • 해당 코드를 실행하면 LazyInitializationException오류가 발생함
  • 그 이유는?
    • Question 리포지토리가 findById를 호출하여 Question 객체를 조회하고 나면, DB 세션이 끊어지기 때문
    • 그 이후에 실행되는 q.getAnswerList() 메서드는 세션이 종류되어 오류가 발생한다

사실 이 문제는 테스트 코드에서만 발생함 → 실제 서버에서 JPA 프로그램을 실행할 때는 DB 세션이 종료되지 않기 때문

  • 오류 해결 방법
    • @Transcational 어노테이션 추가: 메서드가 종료될 때까지 DB 세션이 유지됨
@Transactional
    @Test
    void 질문에서답변찾기(){
        Optional<Question> oq = this.questionRepository.findById(2); //id가 2인 question을 조회
        assertTrue(oq.isPresent()); //데이터가 있는지 확인
        Question q = oq.get(); //q 객체에 저장

        List<Answer> answerList = q.getAnswerList(); //q 객체에서 answer list 조회

        assertEquals(1, answerList.size()); //answer list 데이터 개수 확인
        assertEquals("네 자동으로 생성됩니다.", answerList.get(0).getContent()); //첫번째 데이터 내용 확인
    }
  • 테스트 성공!
profile
소통하는 개발자가 꿈입니다!

0개의 댓글