ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 231218_점프 투 스프링부트, JPA (2)
    카테고리 없음 2023. 12. 18. 17:43

    점프 투 스트링부트

     

    점프 투 스프링부트

    점프 투 스프링부트는 Spring Boot Board(SBB)라는 이름의 게시판 서비스를 만들어가는 과정을 설명한 스프링부트 입문서이다. 자바 설치부터 시작하여 서비스 운…

    wikidocs.net


    2-04 엔티티

    SBB가 사용할 엔티티(Entity) 는 데이터베이스 테이블과 매핑되는 자바 클래스를 말한다. SBB는 질문과 답변을 할 수 있는 게시판 서비스이다. 따라서 SBB에는 질문과 답변에 해당하는 엔티티가 있어야 한다.


    엔티티의 속성 구상하기

    질문과 답변 엔티티에 필요한 속성은 아래와 같다. 아래 속성을 바탕으로 엔티티를 작성할 것이다.

    질문(Question) 엔티티 답변(Answer) 엔티티
    속성명 설명 속성명 설명
    id 질문의 고유 번호 id 답변의 고유 번호
    subject 질문의 제목 question 질문 (어떤 질문의 답변인지 알아야하므로 질문 속성이 필요하다)
    content 질문의 내용 content 답변의 내용
    create_date 질문을 작성한 일시 create_date 답변을 작성한 일시

    질문 엔티티 작성하기

    [파일명:/sbb/src/main/java/com/mysite/sbb/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;
    }

    엔티티로 만들기 위해 Question 클래스에 @Entity 애너테이션을 적용했다. @Entity 애너테이션을 적용해야 JPA가 엔티티로 인식한다. 그리고 Getter, Setter 메서드를 자동으로 생성하기 위해 롬복의 @Getter, @Setter 애너테이션을 적용했다.

    컨트롤러에 @Controller 애너테이션을 적용하는 것과 마찬가지로 엔티티는 @Entity 애너테이션을 적용해야 한다.

    그리고 엔티티의 속성으로 고유번호(id), 제목(subject), 내용(content), 작성일시(createDate)를 추가했다. 각 속성에는 Id, GeneratedValue, Column과 같은 애너테이션이 적용되어 있다.각 속성별 에너테이션에 대한 내용은 더보기에 작성했다.

    더보기

    @Id

    고유 번호 id 속성에 적용한 @Id 애너테이션은 id 속성을 기본 키로 지정한다. 기본 키로 지정하면 이제 id 속성의 값은 데이터베이스에 저장할 때 동일한 값으로 저장할 수 없다. 고유 번호를 기본 키로 한 이유는 고유 번호는 엔티티에서 각 데이터를 구분하는 유효한 값으로 중복되면 안 되기 때문이다.

    데이터베이스에서는 id와 같은 특징을 가진 속성을 기본 키(primary key)라고 한다.

    @GeneratedValue

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

    strategy 옵션을 생략할 경우에 @GeneratedValue 애너테이션이 지정된 컬럼들이 모두 동일한 시퀀스로 번호를 생성하기 때문에 일정한 순서의 고유번호를 가질수 없게 된다. 이러한 이유로 보통 GenerationType.IDENTITY를 많이 사용한다.

    @Column

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

    엔티티의 속성은 @Column 애너테이션을 사용하지 않더라도 테이블 컬럼으로 인식한다. 테이블 컬럼으로 인식하고 싶지 않은 경우에만 @Transient 애너테이션을 사용한다.


    답변 엔티티 생성하기

    [파일명:/sbb/src/main/java/com/mysite/sbb/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 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;
    
        private Question question;
    }

    Id, content, createDate 속성은 질문 엔티티와 동일하다. question 속성은 답변 엔티티에서 질문 엔티티를 참조하기 위해 추가했다. 예를 들어 답변 객체(예:answer)를 통해 질문 객체의 제목을 알고 싶다면 answer.getQuestion().getSubject()처럼 접근할 수 있다. 하지만 이렇게 속성만 추가하면 안되고 질문 엔티티와 연결된 속성이라는 것을 명시적으로 표시해야 한다.

    즉, 다음과 같이 question 속성에 @ManyToOne 애너테이션을 추가해야 한다.

    (... 생략 ...)
    import jakarta.persistence.ManyToOne;
    (... 생략 ...)
    
    @Getter
    @Setter
    @Entity
    public class Answer {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Integer id;
    
        @Column(columnDefinition = "TEXT")
        private String content;
    
        @CreatedDate
        private LocalDateTime createDate;
    
        @ManyToOne
        private Question question;
    }

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

    @ManyToOne은 부모 자식 관계를 갖는 구조에서 사용한다. 여기서 부모는 Question, 자식은 Answer라고 할 수 있다.

    반대로, Question 엔티티에서 Answer 엔티티를 참조하는것도 가능하다. 답변과 질문이 N:1의 관계라면 질문과 답변은 1:N의 관계라고 할 수 있다. 이런경우에는 @ManyToOne이 아닌 @OneToMany애너테이션을 사용한다. Question 하나에 Answer는 여러개이므로 Question 엔티티에 추가할 답변의 속성은 List 형태로 구성해야 한다.

    이를 구현하기 위해 Question 엔티티를 아래와 같이 수정한다.

    [파일명:/sbb/src/main/java/com/mysite/sbb/Question.java]

    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 {// Question 엔티티는 데이터베이스의 question 테이블과 매핑됨.
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Integer id; // id는 자동 생성되는 기본 키입니다.
    
        @Column(length = 200)
        private String subject; // 제목을 나타내는 필드로, 최대 길이는 200자
    
        @Column(columnDefinition = "TEXT")
        private String content; // 내용을 나타내는 필드로, 긴 텍스트 형식으로 저장됨
    
        private LocalDateTime createDate; // 질문 생성 일시를 나타내는 필드
    
        // Question과 Answer 간에 일대다 관계를 설정
        // mappedBy 속성은 양방향 매핑에서 연관 관계의 주인을 나타냄
        // cascade는 Question 엔티티가 삭제될 때 연결된 Answer 엔티티도 함께 삭제됨을 나타냄
        @OneToMany(mappedBy = "question", cascade = CascadeType.REMOVE)
        private List<Answer> answerList; // 질문에 대한 답변 목록을 나타내는 필드
    }

    Answer 엔티티 객체로 구성된 answerList를 속성으로 추가하고 @OneToMany 애너테이션을 설정했다. 이제 질문 객체(예:question)에서 답변을 참조하려면 question.getAnswerList()를 호출하면 된다. @OneToMany 애너테이션에 사용된 mappedBy는 참조 엔티티의 속성명을 의미한다. 즉, Answer 엔티티에서 Question 엔티티를 참조한 속성명 question을 mappedBy에 전달해야 한다.


    테이블 확인하기

    h2 접속이 서버 연결할때만 되는 방식으로, 스프링부트가 작동할땐 H2 를 끄고 진행해야 하며, 서버가 끊어지면 h2 사이트가 안들어가지는게 정상이다 (이미지 참고). 서버를 연결 한 뒤 H2 에 접속할 땐 http://localhost:8080/h2-console로 접속해서 확인이 가능하다. 

    위와 같이 설정했기 때문임.

    Question과 Answer 테이블이 자동으로 생성된 것을 확인할 수 있다.


    2-05 리포지터리

    JPA를 사용하여 데이터를 처리할것이다.


    리포지터리

    엔티티만으로는 데이터베이스에 데이터를 저장하거나 조회 할 수 없다. 데이터 처리를 위해서는 실제 데이터베이스와 연동하는 JPA 리포지터리가 필요하다.

    여기서 지포지터리란, 엔티티에 의해 생성된 데이터베이스 테이블에 접근하는 메서드들(예: findAll, save 등)을 사용하기 위한 인터페이스이다. 데이터 처리를 위해서는 테이블에 어떤 값을 넣거나 값을 조회하는 등의 CRUD(Create, Read, Update, Delete)가 필요하다. 이 때 이러한 CRUD를 어떻게 처리할지 정의하는 계층이 바로 리포지터리이다.

    QuestionRepository 인터페이스를 생성하기 위해 아래와 같이 작성한다.

    [파일명:/sbb/src/main/java/com/mysite/sbb/QuestionRepository.java]

    package com.mysite.sbb;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface QuestionRepository extends JpaRepository<Question, Integer> {
    
    }

    QuestionRepository는 리포지터리로 만들기 위해 JpaRepository 인터페이스를 상속했다. JpaRepository를 상속할 때는 제네릭스 타입으로 <Question, Integer> 처럼 리포지터리의 대상이 되는 엔티티의 타입(Question)과 해당 엔티티의 PK의 속성 타입(Integer)을 지정해야 한다. 이것은 JpaRepository를 생성하기 위한 규칙이다.

    Question 엔티티의 PK(Primary Key) 속성인 id의 타입은 Integer 이다.

     AnswerRepository도 아래와 같이 생성한다.

    [파일명:/sbb/src/main/java/com/mysite/sbb/AnswerRepository.java]

    package com.mysite.sbb;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface AnswerRepository extends JpaRepository<Answer, Integer> {
    
    }

    이제 QuestionRepository, AnswerRepository를 이용하여 question, answer 테이블에 데이터를 저장하거나 조회할 수 있다.


    데이터 저장하기

    작성한 리포지터리를 테스트하기 위해서 JUnit 기반의 스프링부트의 테스트 프레임워크를 작성한다.

    [파일명:/sbb/src/test/java/com/mysite/sbb/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);  // 두번째 질문 저장
        }
    }

    @SpringBootTest 애너테이션은 SbbApplicationTests 클래스가 스프링부트 테스트 클래스임을 의미한다. 그리고 @Autowired 애너테이션은 스프링의 DI 기능으로 questionRepository 객체를 스프링이 자동으로 생성해 준다.

    DI(Dependency Injection) - 스프링이 객체를 대신 생성하여 주입한다.

    @Autowired

    객체를 주입하기 위해 사용하는 스프링의 애너테이션이다. 객체를 주입하는 방식에는 @Autowired 외에 Setter 또는 생성자를 사용하는 방식이 있다. 순환참조 문제와 같은 이유로 @Autowired 보다는 생성자를 통한 객체 주입방식이 권장된다. 하지만 테스트 코드의 경우에는 생성자를 통한 객체의 주입이 불가능하므로 테스트 코드 작성시에만 @Autowired를 사용하고 실제 코드 작성시에는 생성자를 통한 객체 주입방식을 사용할 것이다.

    testJpa 메서드 위의 @Test 애너테이션은 testJpa 메서드가 테스트 메서드임을 나타낸다. 위 클래스를 JUnit으로 실행하면 @Test 애너테이션이 붙은 메서드가 실행된다.

    JUnit은 테스트코드를 작성하고 작성한 테스트코드를 실행하기 위해 사용하는 자바의 테스트 프레임워크이다.

    testJpa 메서드의 내용을 잠시 살펴보자. testJpa 메서드는 q1, q2 라는 Question 엔티티 객체를 생성하고 QuestionRepository를 이용하여 그 값을 데이터베이스에 저장하는 코드이다.

    이제 작성한 SbbApplicationTests 클래스를 실행해 보자. 다음처럼 [Run -> Run As -> JUnit Test]를 선택하면 SbbApplicationTests 클래스를 실행할수 있다.

    로컬서버가 이미 구동중이라면 The file is locked: nio:/Users/pahkey/local.mv.db 와 비슷한 오류가 발생할 수 있다. H2 데이터베이스는 파일 기반의 데이터베이스이기 때문에 이미 로컬서버가 점유하고 있기 때문에 이러한 오류가 발생하는 것이므로 테스트를 하기 위해서는 로컬 서버를 중지해야 한다. 로컬서버를 중지하고 다시 테스트를 실행하면 오류 없이 잘 실행된다. 

    실제 데이터베이스에 값이 잘 들어갔는지 확인해 보기 위해 다시 로컬서버를 시작하고 H2 콘솔에 접속하여 SELECT * FROM QUESTION 을 실행하면, 저장한 저장한 Question 객체의 값이 데이터베이스에 저장된 것을 확인한 수 있다.

    id는 Question 엔티티의 기본 키(Primary Key)이다. id는 앞에서 엔티티를 생성할 때 설정했던대로 데이터를 생성할 때 속성값이 자동으로 1씩 증가하는 것을 확인할 수 있다.

    Question 엔티티의 id는 @GeneratedValue 설정을 했다.


    데이터 조회하기

     코드는 모두 테스트 코드 [파일명:/sbb/src/test/java/com/mysite/sbb/SbbApplicationTests.java] 에 작성했다.

     

    findAll

    저장된 데이터를 조회하는 테스트 코드는 아래와 같다.

    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() {
            // Question 엔티티의 모든 데이터를 조회합니다.
            List<Question> all = this.questionRepository.findAll();
    
            // 조회된 데이터의 개수가 예상된 개수와 일치하는지 확인합니다.
            assertEquals(2, all.size());
    
            // 조회된 데이터 중 첫 번째 Question 엔티티를 가져옵니다.
            Question q = all.get(0);
    
            // 첫 번째 Question 엔티티의 제목이 예상 값과 일치하는지 확인합니다.
            assertEquals("sbb가 무엇인가요?", q.getSubject());
        }
    }

    question 테이블에 저장된 모든 데이터를 조회하기 위해서 리포지터리의 findAll 메서드를 사용했다.

    findAll은 데이터를 조회할때 사용하는 메서드이다.

    우리는 총 2건의 데이터를 저장했기 때문에 데이터의 사이즈는 2가 되어야 한다. 데이터 사이즈가 2인지 확인하기 위해 JUnit의 assertEquals 메서드를 사용했다. assertEquals는 assertEquals(기대값, 실제값)와 같이 사용하고 기대값과 실제값이 동일한지를 조사한다. 만약 기대값과 실제값이 동일하지 않다면 테스트는 실패로 처리된다. 그리고 우리가 저장한 첫번째 데이터의 제목이 "sbb가 무엇인가요?"와 일치하는지도 테스트했다.

     

    findById

    Question 엔티티의 Id값으로 데이터를 조회해보고자 한다. 테스트코드는 아래와 같이 수정한다.

    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 엔티티의 Id 값이 1인 데이터를 조회합니다.
            Optional<Question> oq = this.questionRepository.findById(1);
    
            // 만약 조회 결과가 존재한다면
            if(oq.isPresent()) {
                // 조회된 Question 엔티티를 가져옵니다.
                Question q = oq.get();
    
                // 가져온 Question 엔티티의 제목이 예상 값과 일치하는지 확인합니다.
                assertEquals("sbb가 무엇인가요?", q.getSubject());
            }
        }
    }

    id 값으로 데이터를 조회하기 위해서는 리포지터리의 findById 메서드를 사용해야 한다. 하지만 findById의 리턴 타입은 Question이 아닌 Optional임에 주의하자. Optional은 null 처리를 유연하게 처리하기 위해 사용하는 클래스로 위와 같이 isPresent로 null이 아닌지를 확인한 후에 get으로 실제 Question 객체 값을 얻어야 한다.

     

    findBySubject

    Question 엔티티의 subject 값으로 데이터를 조회하기 위해 아래와 같이 테스트 코드를 수정한다. 하지만 테스트 코드를 변경하기 전, Question 리포지터리는 findBySubject와 같은 메서드를 기본적으로 제공하지는 않는다. findBySubject 메서드를 사용하려면 다음처럼 QuestionRepository 인터페이스를 변경해야 한다.

    [파일명:/sbb/src/main/java/com/mysite/sbb/QuestionRepository.java]

    import org.springframework.data.jpa.repository.JpaRepository;
    
    // JpaRepository를 상속받는 인터페이스 QuestionRepository
    // Question 엔티티와 Integer 타입의 Id를 사용하여 JPA 기능을 활용하는 데에 사용됩니다.
    public interface QuestionRepository extends JpaRepository<Question, Integer> {
    
        // findBySubject 메서드 선언
        // 이 메서드는 Question 엔티티에서 subject 값을 사용하여 조회하는 기능을 제공합니다.
        // Spring Data JPA는 이 메서드의 이름을 분석하여 자동으로 쿼리를 생성해줍니다.
        Question findBySubject(String subject);
    }

    이후 다음처럼 제목으로 테이블 데이터를 조회할 수 있다.

    [파일명:/sbb/src/test/java/com/mysite/sbb/SbbApplicationTests.java]

    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를 사용하여 테스트를 진행하는 클래스
    @SpringBootTest
    class SbbApplicationTests {
    
        // QuestionRepository를 주입받는다.
        @Autowired
        private QuestionRepository questionRepository;
    
        // JUnit 테스트 메서드인 testJpa
        @Test
        void testJpa() {
            // Question 엔티티에서 subject가 "sbb가 무엇인가요?"인 데이터를 조회
            Question q = this.questionRepository.findBySubject("sbb가 무엇인가요?");
    
            // 조회한 Question 엔티티의 id 값이 1인지 확인
            assertEquals(1, q.getId());
        }
    }

    인터페이스에 findBySubject 라는 메서드를 선언만 하고 구현은 하지 않았는데 실행이 되는 이유는, JpaRepository를 상속한 QuestionRepository 객체가 생성되기 때문이다. (DI에 의해 스프링이 자동으로 QuestionRepository 객체를 생성한다. 이 때 프록시 패턴이 사용된다고 한다.) 리포지터리 객체의 메서드가 실행될때 JPA가 해당 메서드명을 분석하여 쿼리를 만들고 실행한다. 

    즉, findBy + 엔티티의 속성명(예:findBySubject)과 같은 리포지터리 메서드를 작성하면 해당 속성의 값으로 데이터를 조회할수 있다. findBySubject 메서드를 호출할때 실제 어떤 쿼리가 실행되는지 실행되는 쿼리를 로그에서 보려면 application.properties 파일을 다음과 같이 수정해야 한다.

    [파일명:/sbb/src/main/resources/application.properties]

    # DATABASE
    spring.h2.console.enabled=true
    spring.h2.console.path=/h2-console
    spring.datasource.url=jdbc:h2:~/local
    spring.datasource.driverClassName=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

    이후 다시한번 테스트코드를 실행하면 다음과 같이 콘솔로그에서 실행된 쿼리를 확인할 수 있다.

    findBySubjectAndContent

    제목과 내용을 함께 조회하려면 두 개의 속성을 And 조건으로 조회할때는 리포지터리에 다음과 같은 메서드를 추가해야 한다.

    [파일명:/sbb/src/main/java/com/mysite/sbb/QuestionRepository.java]

    package com.mysite.sbb;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface QuestionRepository extends JpaRepository<Question, Integer> {
        Question findBySubject(String subject);
        Question findBySubjectAndContent(String subject, String content);
    }

     

    테스트코드는 아래와 같이 작성한다.

    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를 사용하여 테스트를 진행하는 클래스
    @SpringBootTest
    class SbbApplicationTests {
    
        // QuestionRepository를 주입받는다.
        @Autowired
        private QuestionRepository questionRepository;
    
        // JUnit 테스트 메서드인 testJpa
        @Test
        void testJpa() {
            // Question 엔티티에서 subject가 "sbb가 무엇인가요?"이고 
            // content가 "sbb에 대해서 알고 싶습니다."인 데이터를 조회
            Question q = this.questionRepository.findBySubjectAndContent(
                    "sbb가 무엇인가요?", "sbb에 대해서 알고 싶습니다.");
    
            // 조회한 Question 엔티티의 id 값이 1인지 확인
            assertEquals(1, q.getId());
        }
    }

    테스트를 실행하면, 실제 쿼리는 다음과 같이 실행되는 것을 콘솔 로그에서 확인할 수 있다.

    select
        question0_.id as id1_1_,
        question0_.content as content2_1_,
        question0_.create_date as create_d3_1_,
        question0_.subject as subject4_1_ 
    from
        question question0_ 
    where
        question0_.subject=? 
        and question0_.content=?

    subject, content 컬럼이 and 조건으로 where문에 사용되었다.  리포지터리의 메서드명은 데이터를 조회하는 쿼리문의 where 조건을 결정하는 역할을 한다. 그 외 다양한 조합은 더보기를 참고할 것.

    더보기
    항목예제설명
    And findBySubjectAndContent(String subject, String content) 여러 컬럼을 and 로 검색
    Or findBySubjectOrContent(String subject, String content) 여러 컬럼을 or 로 검색
    Between findByCreateDateBetween(LocalDateTime fromDate, LocalDateTime toDate) 컬럼을 between으로 검색
    LessThan findByIdLessThan(Integer id) 작은 항목 검색
    GreaterThanEqual findByIdGraterThanEqual(Integer id) 크거나 같은 항목 검색
    Like findBySubjectLike(String subject) like 검색
    In findBySubjectIn(String[] subjects) 여러 값중에 하나인 항목 검색
    OrderBy findBySubjectOrderByCreateDateAsc(String subject) 검색 결과를 정렬하여 전달

    단, 응답 결과가 여러건인 경우에는 리포지터리 메서드의 리턴 타입을 Question이 아닌 List<Question> 으로 해야 한다.

    보다 자세한 내용은 쿼리 생성 규칙에 대한 다음의 공식문서를 참고하자.

     

    findBySubjectLike

    제목에 특정 문자열이 포함되어 있는 데이터를 조회하기 위해 Question 리포지터리를 다음과 같이 수정한다.

    [파일명:/sbb/src/main/java/com/mysite/sbb/QuestionRepository.java]

    import java.util.List;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    
    // Spring Data JPA에서 JpaRepository를 상속받아 Question 엔티티에 대한 데이터 액세스를 제공하는 인터페이스
    public interface QuestionRepository extends JpaRepository<Question, Integer> {
    
        // subject 필드를 이용하여 Question 엔티티를 조회하는 메서드
        Question findBySubject(String subject);
    
        // subject와 content 필드를 이용하여 Question 엔티티를 조회하는 메서드
        Question findBySubjectAndContent(String subject, String content);
    
        // subject 필드를 키워드로 포함하는 Question 엔티티들을 조회하는 메서드
        List<Question> findBySubjectLike(String subject);
    }

     

    테스트코드는 아래와 같이 수정한다.

    [파일명:/sbb/src/test/java/com/mysite/sbb/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 어노테이션은 스프링 부트 테스트를 지원하는 어노테이션입니다.
    @SpringBootTest
    class SbbApplicationTests {
    
        // QuestionRepository를 주입받습니다.
        @Autowired
        private QuestionRepository questionRepository;
    
        // JPA 테스트 메서드
        @Test
        void testJpa() {
            // subject이 "sbb"로 시작하는 데이터를 조회합니다.
            List<Question> qList = this.questionRepository.findBySubjectLike("sbb%");
            
            // 조회된 데이터 중 첫 번째 데이터를 가져옵니다.
            Question q = qList.get(0);
            
            // 가져온 데이터의 subject이 "sbb가 무엇인가요?"와 일치하는지 확인합니다.
            assertEquals("sbb가 무엇인가요?", q.getSubject());
        }
    }

    Like 검색을 위해서는 findBySubjectLike 메서드의 입력 문자열로 "sbb%"와 같이 "%"를 적어주어야 한다. % 표기는 다음과 같은 의미를 갖는다.

    • sbb%: "sbb"로 시작하는 문자열
    • %sbb: "sbb"로 끝나는 문자열
    • %sbb%: "sbb"를 포함하는 문자열

    데이터 수정하기

    질문 데이터를 수정하는 테스트 코드를 아래와 같이 작성한다.

    [파일명:/sbb/src/test/java/com/mysite/sbb/SbbApplicationTests.java]

    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() {
            // Id가 1인 Question 엔티티를 조회합니다.
            Optional<Question> oq = this.questionRepository.findById(1);
    
            // 조회한 Question 엔티티가 존재하는지 확인합니다.
            assertTrue(oq.isPresent());
            Question q = oq.get();
    
            // Question 엔티티의 제목을 수정합니다.
            q.setSubject("수정된 제목");
    
            // 수정된 Question 엔티티를 저장합니다.
            // save 메서드는 엔티티가 이미 존재하면 업데이트를 수행하고, 없으면 새로운 엔티티를 추가합니다.
            this.questionRepository.save(q);
        }
    }

    assertTrue(값)은 값이 true인지를 테스트한다.

    질문 데이터를 조회한 다음 subject를 "수정된 제목" 이라는 값으로 수정했다. 변경된 Question 데이터를 저장하기 위해서는 this.questionRepository.save(q) 처럼 리포지터리의 save 메서드를 사용한다.

    테스트를 수행해 보면 콘솔 로그에서 다음과 같은 update 문이 실행되었음을 확인할 수 있다.


    데이터 삭제하기

    데이터를 삭제하는 코드는 아래와 같이 작성한다.

    [파일명:/sbb/src/test/java/com/mysite/sbb/SbbApplicationTests.java]

    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() {
            // 현재 question 테이블의 레코드 수를 확인하여 초기 상태를 검증합니다.
            assertEquals(2, this.questionRepository.count());
    
            // Id가 1인 Question 엔티티를 조회합니다.
            Optional<Question> oq = this.questionRepository.findById(1);
    
            // 조회한 Question 엔티티가 존재하는지 확인합니다.
            assertTrue(oq.isPresent());
            Question q = oq.get();
    
            // 조회한 Question 엔티티를 삭제합니다.
            this.questionRepository.delete(q);
    
            // Question 엔티티가 삭제된 후 테이블의 레코드 수를 확인하여 삭제 여부를 검증합니다.
            assertEquals(1, this.questionRepository.count());
        }
    }

    리포지터리의 count() 메서드는 해당 리포지터리의 총 데이터건수를 리턴한다.

    Question 리포지터리의 delete 메서드를 사용하여 데이터를 삭제했다. 삭제하기 전에는 데이터 건수가 2, 삭제한 후에는 데이터 건수가 1인지를 테스트했다. 테스트는 잘 통과된다다.


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

    [파일명:/sbb/src/test/java/com/mysite/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.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);// 질문(Id가 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);
        }
    }

    답변 데이터 처리를 위해서는 답변 리포지터리가 필요하므로 AnswerRepository 객체를 @Autowired로 주입했다. 답변 데이터를 생성하려면 질문 데이터가 필요하므로 우선 질문 데이터를 구해야 한다. id가 2인 질문 데이터를 가져온 다음 Answer 엔티티의 question 속성에 방금 가져온 질문 데이터를 대입해(a.setQuestion(q)) 답변 데이터를 생성했다. Answer 엔티티에는 어떤 질문에 해당하는 답변인지 연결할 목적으로 question 속성이 필요하다.

     

     

     

     

     

     

     

     

     

     


    답변 조회하기

    [파일명:/sbb/src/test/java/com/mysite/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;
    
        @Autowired
        private AnswerRepository answerRepository;
    
        @Test
        void testJpa() {
            // 데이터베이스에서 Id가 1인 답변을 조회
            Optional<Answer> oa = this.answerRepository.findById(1);
    
            // 조회된 답변이 존재하는지 확인
            assertTrue(oa.isPresent());
    
            // 조회된 답변 객체 얻기
            Answer a = oa.get();
    
            // 조회된 답변의 질문의 Id와 기대하는 값(2)이 같은지 확인
            assertEquals(2, a.getQuestion().getId());
        }
    }

    -- answer 테이블과 question 테이블을 LEFT JOIN하여
    -- Id 값이 1인 answer의 정보와 그에 해당하는 question의 정보를 모두 가져오는 쿼리입니다.
    SELECT a.*, q.*
    FROM answer a
    LEFT JOIN question q ON a.question_id = q.id
    
    -- WHERE 절에서는 answer의 Id가 1이고, 해당하는 question의 Id가 2인 조건을 지정합니다.
    -- 즉, Id가 1인 answer를 찾아 그에 해당하는 question의 Id가 2인 데이터를 검색합니다.
    WHERE a.id = 1 AND q.id = 2;

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

    답변에 연결된 질문 찾기는 Answer 엔티티에 question 속성이 정의되어 있어서 위에서 찾은 것 처럼 찾을 수있다. 반대로 질문에서 답변을 찾으려면 질문 엔티티에 정의한 answerList를 사용하면 된다. 

    [파일명:/sbb/src/test/java/com/mysite/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.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;
    
    @SpringBootTest
    class SbbApplicationTests {
    
        @Autowired
        private QuestionRepository questionRepository;
    
        @Test
        void testJpa() {
            // QuestionRepository를 통해 Id 값이 2인 Question을 조회함
            Optional<Question> oq = this.questionRepository.findById(2);
            
            // 조회된 Question이 존재하는지 확인
            assertTrue(oq.isPresent());
            
            // 조회된 Question 객체를 얻어옴
            Question q = oq.get();
    
            // Question에 연관된 Answer 목록을 가져옴
            List<Answer> answerList = q.getAnswerList();
    
            // Answer 목록의 크기가 1이어야 함
            assertEquals(1, answerList.size());
            
            // Answer 목록에서 첫 번째 Answer의 내용이 "네 자동으로 생성됩니다."와 같아야 한다.
            assertEquals("네 자동으로 생성됩니다.", answerList.get(0).getContent());
        }
    }

    id가 2인 질문에 답변을 한 개 등록했으므로 위와 같이 검증할 수 있지만, 위 코드를 실행하면 에러가 뜬다. 

    Question 리포지터리가 findById를 호출하여 Question 객체를 조회하고 나면 DB세션이 끊어지기 때문이다. 세션이 끊어진다는건, 보통 DB뿐만 아니라 서버들은 클라이언트가 연결하면 세션을 맺어주는데 (데이터를 주고받아야 하기 떄문에 통로를 만드는 것) 서버들은 보통 그때그때 세션을 맺어주고 끊어준다. 여기서도 비슷한 맥락으로 Question 리포지터리가 findById를 호출하여 Question 객체를 조회하고 나면 DB세션이 끊어지기 때문에 오류가 발생한다. 

    이 문제는 테스트 코드에서만 발생한다. 실제 서버에서 JPA 프로그램들을 실행할 때는 DB 세션이 종료되지 않기 때문에 위와 같은 오류가 발생하지 않는다. 테스트 코드를 수행할 때 위와 같은 오류를 방지할 수 있는 가장 간단한 방법은 다음처럼 @Transactional 애너테이션을 사용하는 것이다. (별로 권장하지는 않는다.) 

    package com.mysite.sbb;
    
    import static org.junit.jupiter.api.Assertions.assertEquals;
    import static org.junit.jupiter.api.Assertions.assertTrue;
    
    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 org.springframework.transaction.annotation.Transactional; //추가
    
    
    @SpringBootTest
    class SbbApplicationTests {
    
        @Autowired
        private QuestionRepository questionRepository;
    
        @Transactional //추가
        @Test
        void testJpa() {
            // QuestionRepository를 통해 Id 값이 2인 Question을 조회함
            Optional<Question> oq = this.questionRepository.findById(2);
            
            // 조회된 Question이 존재하는지 확인
            assertTrue(oq.isPresent());
            
            // 조회된 Question 객체를 얻어옴
            Question q = oq.get();
    
            // Question에 연관된 Answer 목록을 가져옴
            List<Answer> answerList = q.getAnswerList();
    
            // Answer 목록의 크기가 1이어야 함
            assertEquals(1, answerList.size());
            
            // Answer 목록에서 첫 번째 Answer의 내용이 "네 자동으로 생성됩니다."와 같아야 한다.
            assertEquals("네 자동으로 생성됩니다.", answerList.get(0).getContent());
        }
    }

    h2에서는 결과를 어떻게 확인해야 할지 잘 모르겠다.


    2-06 도메인 별로 분류하기

    본격적으로 SBB를 만들기 전에 앞서 패키지 정리를 했다. 지금까지 우리가 작성한 파일은 다음처럼 com.mysite.sbb 패키지 안에 모두 모여 있었으나, 하나의 패키지 안에 모든 자바파일을 넣고 관리하는 것은 바람직하지 않다. SBB는 도메인별로 패키지를 나누어 자바파일을 관리하기 위해 도메인을 분류했다.

    com.mysite.sbb.question 패키지를 생성하고 Question.java, QuestionRepository.java 파일을 해당 패키지로 이동하였다. 그리고 com.mysite.sbb.answer 패키지를 생성하고 Answer.java, AnswerRepository.java 파일을 해당 패키지로 이동하였다. 이때 Answer.java에서 Question 클래스를 import하는 위치가 변경되기 때문에 파일의 import 구문이 변경된다.

    이클립스의 리팩토링 기능인 파일 Move를 사용하면 쉽게 파일을 해당 패키지로 이동할 수 있다.

    나머지 파일들은 특정 도메인에 속하지 않는 파일들이므로 com.mysite.sbb 패키지에 그대로 놔두었다.


    2-07 질문 목록과 템플릿

    질문 목록은 http://localhost:8080/question/list 에 접속할 때 동작해야 한다. 하지만 해당 페이지로 접속하면 404 오류페이지가 나타날 것이다. 404 오류를 해결하려면 /question/list URL에 대한 매핑이 있는 컨트롤러가 필요하다. QuestionController.java 파일을 아래와 같이 작성했다.

    [파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionController.java]

    package com.mysite.sbb.question;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.ResponseBody;
    
    @Controller
    public class QuestionController {
    
        @GetMapping("/question/list")
        @ResponseBody
        public String list() {
            return "question list";
        }
    }

    수정하고 다시 http://localhost:8080/question/list에 접속하면 화면에 "question list" 문자열이 출력된다.


    템플릿 설정하기

    보통 브라우저에 응답하는 문자열은 위의 예처럼 자바 코드에서 직접 만들지는 않고 탬플릿 방식으로 사용한다. 템플릿은 자바 코드를 삽입할 수 있는 HTML 형식의 파일이다. 여기에서는 타임리프(Thymleaf) 템플릿 엔진을 사용할 것이다. 

    [파일명: /sbb/build.gradle]

    plugins {
    	id 'java'
    	id 'org.springframework.boot' version '3.2.0'
    	id 'io.spring.dependency-management' version '1.1.4'
    }
    
    group = 'com.mysite'
    version = '0.0.1-SNAPSHOT'
    
    java {
    	sourceCompatibility = '17'
    }
    
    repositories {
    	mavenCentral()
    }
    
    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'
        implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' // Thymeleaf 추가
        implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect' // Thymeleaf 추가
    }
    
    tasks.named('test') {
    	useJUnitPlatform()
    }

    위와 같이 수정하고 "Refresh Gradle Project"로 필요한 라이브러리를 설치한 뒤, 로컬 서버를 재시작 해 타임리프 탬플릿 엔진을 적용한다.


    템플릿 사용하기

    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="UTF-8">
    <title>Insert title here</title>
    </head>
    <body>
    <h2>Hello Template</h2>
    </body>
    </html>
    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";
        }
    }

     


    데이터 조회하여 템플릿에 전달하기

    package com.mysite.sbb.question;
    
    import java.util.List;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.GetMapping;
    
    import lombok.RequiredArgsConstructor;
    
    @RequiredArgsConstructor
    @Controller
    public class QuestionController {
    
        // QuestionRepository를 주입받음
        private final QuestionRepository questionRepository;
    
        // "/question/list" 경로로 GET 요청이 오면 실행되는 메서드
        @GetMapping("/question/list")
        public String list(Model model) {
            // QuestionRepository를 사용하여 모든 질문 데이터를 가져옴
            List<Question> questionList = this.questionRepository.findAll();
            
            // Model에 "questionList"라는 이름으로 질문 목록을 추가
            model.addAttribute("questionList", questionList);
            
            // "question_list"라는 뷰를 반환
            return "question_list";
        }
    }

    @RequiredArgsConstructor 애너테이션으로 questionRepository 속성을 포함하는 생성자를 생성하였다. @RequiredArgsConstructor는 롬복이 제공하는 애너테이션으로 final이 붙은 속성을 포함하는 생성자를 자동으로 생성하는 역할을 한다. 롬복의 @Getter, @Setter가 자동으로 Getter, Setter 메서드를 생성하는 것과 마찬가지로 @RequiredArgsConstructor는 자동으로 생성자를 생성한다. 따라서 스프링 의존성 주입 규칙에 의해 questionRepository 객체가 자동으로 주입된다.

    Question 리포지터의 findAll 메서드를 사용하여 질문 목록 데이터인 questionList를 생성하고 Model 객체에 "questionList" 라는 이름으로 값을 저장했다. Model 객체는 자바 클래스와 템플릿 간의 연결고리 역할을 한다. Model 객체에 값을 담아두면 템플릿에서 그 값을 사용할 수 있다.

     

    <table>
        <thead>
            <tr>
                <th>제목</th>
                <th>작성일시</th>
            </tr>
        </thead>
        <tbody>
            <tr th:each="question : ${questionList}">
                <td th:text="${question.subject}"></td>
                <td th:text="${question.createDate}"></td>
            </tr>
        </tbody>
    </table>

    템플릿 파일에 입력된 th:each="question : ${questionList}"와 같은 표현이 있다. th: 로 시작하는 속성은 타임리프 템플릿 엔진이 사용하는 속성이며, 이 부분이 자바 코드와 연결된다. 

    더보기
    <tr th:each="question : ${questionList}">
    

    QuestionController의 list 메서드에서 조회한 질문 목록 데이터를 "questionList"라는 이름으로 Model 객체에 저장했다. 타임리프는 Model 객체에 저장된 값을 읽을 수 있으므로 템플릿에서 questionList를 사용할수 있게 되는 것이다. 위의 코드는 <tr> ... </tr> 엘리먼트를 questionList의 갯수만큼 반복하여 출력하는 역할을 한다. 그리고 questionList에 저장된 데이터를 하나씩 꺼내 question 객체에 대입하여 반복구간 내에서 사용할수 있게 한다. 자바의 for each 문을 떠올리면 쉽게 이해할 수 있을 것이다.

    다음 코드는 바로 앞의 for 문에서 얻은 question 객체의 제목을 <td> 엘리먼트의 텍스트로 출력한다.

    <td th:text="${question.subject}"></td>
    

    다음 코드도 같은 맥락으로 이해할 수 있다.

    <td th:text="${question.createDate}"></td>


    2-08 ROOT URL

    루트 URL은 http://localhost:8080 처럼 도메인명과 포트 뒤에 아무것도 붙이지 않은 URL을 말한다. 아직 루트 URL에 대한 매핑을 만들지 않았기 때문에 브라우저에서 루트 URL에 접속하면 404 에러가 나타난다. 루트 URL 호출시 404 페이지 대신 질문 목록을 출력하기 위해 아래와 같이 MainController를 수정했다.

    package com.mysite.sbb;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.ResponseBody;
    
    @Controller
    public class MainController {
    
        @GetMapping("/sbb")
        @ResponseBody
        public String index() {
            return "안녕하세요 sbb에 오신것을 환영합니다.";
        }
    
        @GetMapping("/")
        public String root() {
            // 이 메서드는 웹 애플리케이션의 루트 경로("/")에 대한 GET 요청을 처리하는 핸들러다.
    
            // 반환 타입은 String이며, 뷰의 이름을 나타냄. 
            // 여기서는 "/question/list"로 리다이렉트할 것이라는 것을 나타냄.
            return "redirect:/question/list";
        }
    }

    root 메서드를 추가하고 / URL을 매핑했다. 리턴 문자열 redirect:/question/list /question/list URL로 페이지를 리다이렉트 하라는 명령어이다.  스프링부트는 리다이렉트 또는 포워딩을 다음과 같이 할 수 있다.

    • redirect:<URL> - URL로 리다이렉트 (리다이렉트는 완전히 새로운 URL로 요청이 된다.)
    • forward:<URL> - URL로 포워드 (포워드는 기존 요청 값들이 유지된 상태로 URL이 전환된다.)

    이제 http://localhost:8080 페이지 접속을 하면 root 메서드가 실행되어 질문 목록이 표시되는 것을 확인할 수 있다.


    2-09 서비스

    이제 질문 목록의 제목 링크를 누르면 질문 상세 화면이 보이게 할 것이다. 기능을 추가하기 전, 대부분의 규모있는 스프링부트 프로젝트는 컨트롤러에서 리포지터리를 직접 호출하지 않고 중간에 서비스(Service)를 두어 데이터를 처리하는걸 생각해보자. 서비스는 스프링에서 데이터 처리를 위해 작성하는 클래스이다. 서비스가 필요한 이유로 크게 3가지가 있다. 

    모듈화: 어떤 컨트롤러가 여러개의 리포지터리를 사용하여 데이터를 조회한후 가공하여 리턴한다고 가정했을 때,  이러한 기능을 서비스로 만들어 두면 컨트롤러에서는 해당 서비스를 호출하여 사용하면 된다. 하지만 서비스로 만들지 않고 컨트롤러에서 구현하려 한다면 해당 기능을 필요로 하는 모든 컨트롤러가 동일한 기능을 중복으로 구현해야 한다. 이러한 이유로 서비스는 모듈화를 위해서 필요하다.

    보안: 컨트롤러는 리포지터리 없이 서비스를 통해서만 데이터베이스에 접근하도록 구현하는 것이 보안상 안전하다. 이렇게 하면 어떤 해커가 해킹을 통해 컨트롤러를 제어할 수 있게 되더라도 리포지터리에 직접 접근할 수는 없게 된다.

    엔티티 객체와 DTO 객체의 변환: 작성한 Question, Answer 클래스는 엔티티(Entity) 클래스이다. 엔티티 클래스는 데이터베이스와 직접 맞닿아 있는 클래스이기 때문에 컨트롤러나 타임리프 같은 템플릿 엔진에 전달하여 사용하는 것은 좋지 않다. 컨트롤러나 타임리프에서 사용하는 데이터 객체는 속성을 변경하여 비즈니스적인 요구를 처리해야 하는 경우가 많은데 엔티티를 직접 사용하여 속성을 변경한다면 테이블 컬럼이 변경되어 엉망이 될수도 있기 때문이다.

    이러한 이유로 Question, Answer 같은 엔티티 클래스는 컨트롤러에서 사용할수 없게끔 설계하는 것이 좋다. 그러기 위해서는 Question, Answer 대신 사용할 DTO(Data Transfer Object) 클래스가 필요하다. 그리고 Question, Answer 등의 엔티티 객체를 DTO 객체로 변환하는 작업도 필요하다. 여기서 엔티티 객체를 DTO 객체로 변환하는 일을 서비스가 한다. 서비스는 컨트롤러와 리포지터리의 중간자적인 입장에서 엔티티 객체와 DTO 객체를 서로 변환하여 양방향에 전달하는 역할을 하는 것이다.


    QuestionService

    [파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionService.java]

    package com.mysite.sbb.question;
    
    import java.util.List;
    
    import org.springframework.stereotype.Service;
    
    import lombok.RequiredArgsConstructor;
    
    @RequiredArgsConstructor
    @Service
    public class QuestionService {
    
        private final QuestionRepository questionRepository;
    
        public List<Question> getList() {
            return this.questionRepository.findAll();
        }
    }

    스프링의 서비스로 만들기 위해서는 위와 같이 클래스명 위에 @Service 애너테이션을 붙이면 된다. @Controller, @Entity 등과 마찬가지로 스프링부트는 @Service 애너테이션이 붙은 클래스는 서비스로 인식한다.


    QuestionController

    QuestionController는 리포지터리 대신 서비스를 사용하도록 수정한다.

    [파일명:/sbb/src/main/java/com/mysite/sbb/question/QuestionController.java]

    package com.mysite.sbb.question;
    
    import java.util.List;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.GetMapping;
    
    import lombok.RequiredArgsConstructor;
    
    @RequiredArgsConstructor
    @Controller
    public class QuestionController {
    
    	private final QuestionService questionService; // 수정
    
        @GetMapping("/question/list")
        public String list(Model model) {
            List<Question> questionList = this.questionService.getList(); // 수정
            model.addAttribute("questionList", questionList);
            return "question_list";
        }
    }

    브라우저로 http://localhost:8080/question/list 페이지에 접속하면 이전과 동일한 화면을 볼수 있다. 앞으로 작성할 컨트롤러들도 리포지터리를 직접 사용하지 않고 Controller -> Service -> Repository 구조로 데이터를 처리할 것이다.


    스프링부트의 프로젝트를 해봤는데 사실 아직 감은 잘 안 잡힌다.

    데이타베이스 H2 와 연결해서 조작 처리를 하는건 알겠는데, 코드를 작성하는게 아직 헷갈린다. 주석을 달아놓은걸 툼툼히 다시 보면서 이해할 필요가 있다. 

Designed by Tistory.