[점프 투 스프링부트] 2장 part1

이현경·2026년 4월 7일

1. 스프링부트 프로젝트의 구조 이해하기

src/main/java 디렉터리 살펴보기

src/main/java 디렉터리는 자바 파일을 저장하는 공간이다.

com.mysite.sbb 패키지

이 패키지는 SBB의 자바 파일을 저장하는 공간이다. HelloController.java와 같은 스프링 부트의 컨트롤러, 폼과 DTO, 데이터베이스 처리를 위한 엔티티, 서비스 등의 자바 파일이 이 곳에 위치한다.

컨트롤러는 URL 요청을 처리하고 폼은 사용자의 입력을 검증한다. DTO, 엔티티, 서비스 파일은 데이터베이스를 처리하기 위해 필요한 파일이다.

SbbApplication.java 파일

모든 프로그램에는 프로그램의 시작을 담당하는 파일이 있다. 스프링 부트로 만든 프로그램인 스프링 부트 애플리케이션의 시작 담당 파일은 ‘프로젝트명 + Application.java’ 파일이다. 스프링 부트 프로젝트를 생성할 때 프로젝트명으로 'sbb'라는 이름을 입력하면 다음과 같이 SbbApplication.java 파일이 자동으로 생성된다.

package com.mysite.sbb;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SbbApplication {

	public static void main(String[] args) {
		SpringApplication.run(SbbApplication.class, args);
	}
}

SbbApplication 클래스에는 반드시 @SpringBootApplication 애너테이션이 적용되어 있어야 한다. @SpringBootApplication 애너테이션을 통해 스프링 부트 애플리케이션을 시작할 수 있다.


src/main/resources 디렉터리 살펴보기

src/main/resources 디렉터리는 자바 파일을 제외한 HTML, CSS, 자바스크립트, 환경 파일 등을 저장하는 공간이다.

templates 디렉터리

src/main/resources 디렉터리의 하위 디렉터리인 templates에는 템플릿 파일을 저장한다. 템플릿 파일은 자바 코드를 삽입할 수 있는 HTML 형식의 파일로, 스프링 부트에서 생성한 자바 객체를 HTML 형태로 출력할 수 있다.

static 디렉터리

static 디렉터리에는 sbb 프로젝트의 스타일시트(css 파일), 자바스크립트(js 파일) 그리고 이미지 파일(jpg 파일, png 파일 등) 등을 저장한다.

application.properties 파일

application.properties 파일은 sbb 프로젝트의 환경을 설정한다. sbb 프로젝트의 환경 변수, 데이터베이스 등의 설정을 이 파일에 저장한다.


src/test/java 디렉터리 살펴보기

src/test/java 디렉터리는 sbb 프로젝트에서 작성한 파일을 테스트하는 코드를 저장하는 공간이다. JUnit과 스프링 부트의 테스트 도구를 사용하여 서버를 실행하지 않은 상태에서 src/main/java 디렉터리에 작성한 코드를 테스트할 수 있다.

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


build.gradle 파일 살펴보기

build.gradle은 그레이들이 사용하는 환경 파일이다. 그레이들은 그루비(Groovy)를 기반으로 한 빌드 도구로 Ant, Maven과 같은 이전 세대의 단점을 보완하고 장점을 취합하여 만들었다. build.gradle 파일에는 프로젝트에 필요한 플러그인과 라이브러리를 설치하기 위한 내용을 작성한다.

그루비는 그레이들 빌드 스크립트를 작성하는 데 사용하는 스크립트 언어로, 문법이 간결하고 가독성이 높다.

빌드 도구는 소스 코드를 컴파일하고 필요한 라이브러리를 내려받을 때 사용한다. SBB 프로젝트를 완성하면 단 한 개의 jar 파일로 패키징하여 서버에 배포할 수 있는데 이때에도 역시 빌드 도구를 사용한다.




2. 간단한 웹 프로그램 만들기

URL 매핑과 컨트롤러 이해하기

브라우저와 같은 클라이언트의 페이지 요청이 발생하면 스프링 부트는 가장 먼저 컨트롤러에 등록된 URL 매핑을 찾고, 해당 URL 매핑을 발견하면 URL 매핑과 연결된 메서드를 실행한다.

URL 매핑이란 URL과 컨트롤러의 메서드를 일대일로 연결하는 것을 말한다. 컨트롤러의 메서드에 @GetMapping 또는 @PostMapping과 같은 애너테이션을 적용하면 해당 URL과 메서드가 연결된다.


컨트롤러 만들어서 URL 매핑하기

웹 브라우저와 같은 클라이언트의 요청이 발생하면 서버 역할을 하는 스프링 부트가 응답해야 한다. 그러기 위해서는 URL이 스프링 부트에 매핑되어 있어야 하고 이를 위해서는 먼저 컨트롤러를 만들어야 한다.

  1. src/main/java 디렉터리의 com.mysite.sbb 패키지에 MainController.java 파일을 작성

    package com.mysite.sbb;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.GetMapping;
    
    @Controller
    public class MainController {
        @GetMapping("/sbb")
        public void index() {
            System.out.println("index");
        }
    }

    MainController 클래스에 @Controller 애너테이션을 적용하면 MainController 클래스는 스프링 부트의 컨트롤러가 된다. 그리고 index 메서드의 @GetMapping 애너테이션은 요청된 URL(/sbb)과의 매핑을 담당한다. 브라우저가 URL을 요청하면 스프링 부트는 요청 페이지와 매핑되는 메서드를 찾아 실행한다.

    즉, 스프링 부트는 웹 브라우저로부터 http://localhost:8080/sbb 요청이 발생하면 /sbb URL과 매핑되는 index 메서드를 MainController 클래스에서 찾아 실행한다.

  1. http://localhost:8080/sbb URL을 호출

URL과 매핑된 메서드는 결괏값을 리턴해야 하는데 아무 값도 리턴하지 않아 이와 같은 오류가 발생하였다.

  1. MainController.java 수정

    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에 오신 것을 환영합니다.";
        }
    }

    여기서 @ResponseBody 애너테이션은 URL 요청에 대한 응답으로 문자열을 리턴하라는 의미로 쓰였다.

  1. http://localhost:8080/sbb URL을 호출




3. JPA로 데이터베이스 사용하기

ORM(Object Relational Mapping)이라는 도구를 사용하면 자바 문법으로도 데이터베이스를 다룰 수 있다.


ORM과 JPA 이해하기

ORM이란?

ORM은 SQL을 사용하지 않고 데이터베이스를 관리할 수 있는 도구이다. ORM은 데이터베이스의 테이블을 자바 클래스로 만들어 관리할 수 있다.

ORM을 사용하면 아래 쿼리문을 자바 코드로 작성할 수 있다.

-- SQL 쿼리문
insert into question (id, subject, content) values (1, '안녕하세요', '가입 인사드립니다 ^^');
insert into question (id, subject, content) values (2, '질문 있습니다', 'ORM이 궁금합니다');
// java 코드
Question q1 = new Question();
q1.setId(1);
q1.setSubject("안녕하세요");
q1.setContent("가입 인사드립니다 ^^");
this.questionRepository.save(q1);

Question q2 = new Question();
q2.setId(2); 
q2.setSubject("질문 있습니다"); 
q2.setContent("ORM이 궁금합니다"); 
this.questionRepository.save(q2);

SQL의 쿼리문과 ORM 코드를 단순히 비교해 보면 ORM 코드의 양이 더 많아 보이지만 별도의 SQL 문법을 배우지 않아도 데이터베이스를 사용할 수 있기 때문에 매우 편리하다. ORM 코드를 간단히 살펴보면 Question은 자바 클래스이며, 이처럼 데이터를 관리하는 데 사용하는 ORM의 자바 클래스를 엔티티(entity)라고 한다. 엔티티는 데이터베이스의 테이블과 매핑되는 자바 클래스를 말한다.


JPA란?

스프링 부트는 JPA(Java Persistence API)를 사용하여 데이터베이스를 관리한다. 스프링 부트는 JPA를 ORM(Object-Relational Mapping) 기술의 표준으로 사용한다. JPA는 인터페이스 모음이므로, 이 인터페이스를 구현한 실제 클래스가 필요하다. JPA를 구현한 실제 클래스에는 대표적으로 하이버네이트(Hibernate)가 있다. 정리하자면, 하이버네이트는 JPA의 인터페이스를 구현한 실제 클래스이자 자바의 ORM 프레임워크로, 스프링 부트에서 데이터베이스를 관리하기 쉽게 도와준다.


H2 데이터베이스 설치하기

H2 데이터베이스는 주로 개발 환경에서 사용하는 자바 기반의 경량 DBMS이다.


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'** 
    }
  2. application.yaml 수정

    # DATABASE & JPA
    spring:
      h2:
        console:
          enabled: true
          path: /h2-console
      datasource:
        url: jdbc:h2:~/local
        driverClassName: org.h2.Driver
        username: sa
        password: 
    
      # JPA
      jpa:
        hibernate:
          ddl-auto: update
        properties:
          hibernate:
            dialect: org.hibernate.dialect.H2Dialect




4. 엔티티로 테이블 매핑하기

데이터베이스 구성 요소 살펴보기

데이터베이스는 기본적으로 2차원 표 형태로 저장하고 관리한다. 표 형태의 데이터 저장 공간을 테이블(table)이라고 하는데, 테이블은 가로줄과 세로줄 형태로 구성되어 있다. 이때 가로줄을 행(row), 세로줄을 열(column)이라고 한다.

또한 데이터베이스에서 중요한 용어 중 하나가 바로 기본키(primary key)이다. 기본키는 테이블의 데이터가 중복되어 저장되지 않게 한다. 어떤 열을 기본키로 설정하면 해당 열에는 동일한 값을 저장하지 못한다.


엔티티 속성 구성하기

엔티티는 데이터베이스 테이블과 매핑되는 자바 클래스를 말한다. SBB는 질문과 답변을 할 수 있는 게시판 서비스이므로 SBB의 질문과 답변 데이터를 저장할 데이터베이스 테이블과 매핑되는 질문과 답변 엔티티가 있어야 한다.

엔티티를 모델 또는 도메인 모델이라고도 한다.

질문 엔티티

속성 이름설명
id질문 데이터의 고유 번호
subject질문 데이터의 제목
content질문 데이터의 내용
createDate질문 데이터를 작성한 일시

답변 엔티티

속성 이름설명
id답변 데이터의 고유 번호
question질문 데이터 (어떤 질문의 답변인지 알아야 하므로 이 속성이 필요하다.)
content답변 데이터의 내용
createDate답변 데이터를 작성한 일시

질문 엔티티 만들기

// 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 애너테이션을 적용해야 스프링 부트가 Question 클래스를 엔티티로 인식한다.

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


@Id 애너테이션

id 속성에 적용한 @Id 애너테이션은 id 속성을 기본키로 지정한다. id 속성을 기본키로 지정한 이유는 id 속성의 고유 번호들은 엔티티에서 각 데이터들을 구분하는 유효한 값으로, 중복되면 안 되기 때문이다.


@GeneratedValue 애너테이션

@GeneratedValue 애너테이션을 적용하면 데이터를 저장할 때 해당 속성에 값을 일일이 입력하지 않아도 자동으로 1씩 증가하여 저장된다. strategy = GenerationType.IDENTITY는 고유한 번호를 생성하는 방법을 지정하는 부분으로, GenerationType.IDENTITY는 해당 속성만 별도로 번호가 차례대로 늘어나도록 할 때 사용한다.

strategy 옵션을 생략한다면 @GeneratedValue 애너테이션이 지정된 모든 속성에 번호를 차례로 생성하므로 순서가 일정한 고유 번호를 가질 수 없게 된다. 이러한 이유로 보통 strategy = GenerationType.IDENTITY를 많이 사용한다.


@Column 애너테이션

엔티티의 속성은 테이블의 열 이름과 일치하는데 열의 세부 설정을 위해 @Column 애너테이션을 사용한다. length는 열의 길이를 설정할 때 사용하고, columnDefinition은 열 데이터의 유형이나 성격을 정의할 때 사용한다. 여기서 columnDefinition = "TEXT"는 말 그대로 ‘텍스트’를 열 데이터로 넣을 수 있음을 의미하고, 글자 수를 제한할 수 없는 경우에 사용한다.

엔티티의 속성은 @Column 애너테이션을 사용하지 않더라도 테이블의 열로 인식한다. 테이블의 열로 인식하고 싶지 않다면 @Transient 애너테이션을 사용한다. @Transient 애너테이션은 엔티티의 속성을 테이블의 열로 만들지 않고 클래스의 속성 기능으로만 사용하고자 할 때 쓴다.

  • 엔티티의 속성 이름과 테이블의 열 이름 차이
    Question 엔티티에서 작성 일시에 해당하는 createDate 속성의 이름은 데이터베이스의 테이블에서는 create_date라는 열 이름으로 설정된다. 즉, createDate처럼 카멜 케이스(camel case) 형식의 이름은 create_date처럼 모두 소문자로 변경되고 단어가 언더바(_)로 구분되어 데이터베이스 테이블의 열 이름이 된다.

  • 엔티티를 만들 때 Setter 메서드는 사용하지 않는다.
    일반적으로 엔티티를 만들 때에는 Setter 메서드를 사용하지 않기를 권한다. 왜냐하면 엔티티는 데이터베이스와 바로 연결되므로 데이터를 자유롭게 변경할 수 있는 Setter 메서드를 허용하는 것이 안전하지 않다고 판단하기 때문이다. 그렇다면 Setter 메서드 없이 어떻게 엔티티에 값을 저장할 수 있을까?
    엔티티는 생성자에 의해서만 엔티티의 값을 저장할 수 있게 하고, 데이터를 변경해야 할 경우에는 메서드를 추가로 작성하면 된다. 다만, 이 책은 복잡도를 낮추고 원활한 설명을 위해 엔티티에 Setter 메서드를 추가하여 진행한다.


답변 엔티티 만들기

// 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; 

    @ManyToOne 
    private Question question;  // 답변 엔티티를 참조하기 위해 추가
}

답변을 통해 질문의 제목을 알고 싶다면 answer.getQuestion().getSubject()를 사용해 접근할 수 있다. 하지만 question 속성만 추가하면 안 되고 질문 엔티티와 연결된 속성이라는 것을 답변 엔티티에 표시해야 한다. 즉, 위와 같이 Answer 엔티티의 question 속성에 @ManyToOne 애너테이션을 추가해 질문 엔티티와 연결한다.

게시판 서비스에서는 하나의 질문에 답변은 여러 개가 달릴 수 있다. 따라서 답변은 Many가 되고 질문은 One이 된다. 즉, @ManyToOne 애너테이션을 사용하면 N:1 관계를 나타낼 수 있다. 이렇게 @ManyToOne 애너테이션을 설정하면 Answer 엔티티의 question 속성과 Question 엔티티가 서로 연결된다.

질문에서 답변을 참조할 경우에는 @OneToMany 애너테이션을 사용한다. 질문 하나에 답변은 여러 개이므로 Question 엔티티에 추가할 Answer 속성은 List 형태로 구성해야 한다.

// Question.java
package 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; 
}

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

  • CascadeType.REMOVE
    게시판 서비스에서는 질문 하나에 답변이 여러 개 작성될 수 있다. 그런데 보통 게시판 서비스에서는 질문을 삭제하면 그에 달린 답변들도 함께 삭제된다. SBB도 질문을 삭제하면 그에 달린 답변들도 모두 삭제되도록 cascade = CascadeType.REMOVE를 사용했다.

테이블 확인하기

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




5. 리포지터리로 데이터베이스 관리하기

리포지터리 생성하기

리포지터리는 생성된 데이터베이스 테이블의 데이터들을 저장, 조회, 수정, 삭제 등을 할 수 있도록 도와주는 인터페이스이다. 이때 리포지터리는 테이블에 접근하고, 데이터를 관리하는 메서드를 제공한다.

  1. QuestionRepository 인터페이스 생성

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

    생성한 QuestionRepository 인터페이스를 리포지터리로 만들기 위해 JpaRepository 인터페이스를 상속한다. JpaRepository는 JPA가 제공하는 인터페이스 중 하나로 CRUD 작업을 처리하는 메서드들을 이미 내장하고 있어 데이터 관리 작업을 좀 더 편리하게 처리할 수 있다. JpaRepository<Question, Integer>는 Question 엔티티로 리포지터리를 생성한다는 의미이고, Question 엔티티의 기본키가 Integer임을 지정해야 한다.

  2. AnswerRepository 인터페이스 생성

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

JUnit 설치하기

리포지터리를 이용하여 데이터를 저장하려면 질문을 등록하는 화면과 사용자가 입력한 질문 관련 정보를 저장하는 컨트롤러, 서비스 파일 등이 필요하다. 하지만 JUnit을 사용하면 이러한 프로세스를 따르지 않아도 리포지터리만 개별적으로 실행해 테스트해볼 수 있다.

JUnit은 테스트 코드를 작성하고, 작성한 테스트 코드를 실행할 때 사용하는 자바의 테스트 프레임워크이다. JUnit은 리포지터리뿐만 아니라 소프트웨어 개발 시 테스트 작업을 수행할 때 많이 사용한다.


질문 데이터 저장하기

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의 객체를 주입했다.

  • @Autowired 애너테이션
    앞서 작성한 테스트 코드를 보면 questionRepository 변수는 선언만 되어 있고 그 값이 비어 있다. 하지만 @Autowired 애너테이션을 해당 변수에 적용하면 스프링 부트가 questionRepository 객체를 자동으로 만들어 주입한다. 객체를 주입하는 방식에는 @Autowired 애너테이션을 사용하는 것 외에 Setter 메서드 또는 생성자를 사용하는 방식이 있다. 순환 참조 문제와 같은 이유로 개발 시 @Autowired보다는 생성자를 통한 객체 주입 방식을 권장한다. 하지만 테스트 코드의 경우 JUnit이 생성자를 통한 객체 주입을 지원하지 않으므로 테스트 코드 작성 시에만 @Autowired를 사용하고 실제 코드 작성 시에는 생성자를 통한 객체 주입 방식을 사용한다.

@Test 애너테이션은 testJpa 메서드가 테스트 메서드임을 나타낸다. SbbApplicationTests 클래스를 JUnit으로 실행하면 @Test 애너테이션이 붙은 testJpa 메서드가 실행된다. testJpa 메서드는 q1, q2라는 질문 엔티티의 객체를 생성하고 QuestionRepository를 이용하여 그 값을 데이터베이스에 저장한다.


질문 데이터 조회하기

findAll 메서드

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> all = this.questionRepository.findAll();
        assertEquals(2, all.size());

        Question q = all.get(0);
        assertEquals("sbb가 무엇인가요?", q.getSubject());
    }
}

데이터 사이즈가 2인지 확인하기 위해 JUnit의 assertEquals 메서드를 사용하는데, 이 메서드는 테스트에서 예상한 결과와 실제 결과가 동일한지를 확인하는 목적으로 사용한다. assertEquals(기댓값, 실젯값)와 같이 작성하고 기댓값과 실젯값이 동일한지를 조사한다. 만약 기댓값과 실젯값이 동일하지 않다면 테스트는 실패로 처리된다.

테스트가 성공했다고 표시된다.


findById 메서드

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() {
        Optional<Question> oq = this.questionRepository.findById(1);
        if(oq.isPresent()) {
            Question q = oq.get();
            assertEquals("sbb가 무엇인가요?", q.getSubject());
        }
    }
}

id값으로 데이터를 조회하기 위해서는 리포지터리의 findById 메서드를 사용해야 한다. 여기서는 questionRepository를 사용하여 데이터베이스에서 id가 1인 질문을 조회한다. 이때 findById의 리턴 타입은 Question이 아닌 Optional이다. findById로 호출한 값이 존재할 수도 있고, 존재하지 않을 수도 있어서 리턴 타입으로 Optional이 사용된 것이다. Optional은 null값을 유연하게 처리하기 위한 클래스로, isPresent() 메서드로 값이 존재하는지 확인할 수 있다. 만약 isPresent()를 통해 값이 존재한다는 것을 확인했다면, get() 메서드를 통해 실제 Question 객체의 값을 얻는다. 즉, 여기서는 데이터베이스에서 ID가 1인 질문을 검색하고, 이에 해당하는 질문의 제목이 ‘sbb가 무엇인가요?’인 경우에 JUnit 테스트를 통과하게 된다.


findBySubject 메서드

리포지터리는 findBySubject 메서드를 기본적으로 제공하지는 않는다. 그래서 findBySubject 메서드를 사용하려면 다음과 같이 QuestionRepository 인터페이스를 변경해야 한다.

  1. QuestionRepository.java 수정

    package com.mysite.sbb;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface QuestionRepository extends JpaRepository<Question, Integer> {
        Question findBySubject(String subject);
    }
  2. subject 값으로 테이블에 저장된 데이터를 조회할 수 있도록 수정

    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.findBySubject("sbb가 무엇인가요?");
            assertEquals(1, q.getId());
        }
    }

테스트 코드를 실행해 보면 성공적으로 통과된다.

인터페이스에 findBySubject라는 메서드를 선언만 하고 구현하지 않았는데 실행될 수 있는 이유는, JPA에 리포지터리의 메서드명을 분석하여 쿼리를 만들고 실행하는 기능이 있기 때문이다. 즉, ‘findBy + 엔티티의 속성명’과 같은 리포지터리의 메서드를 작성하면 입력한 속성의 값으로 데이터를 조회할 수 있다.


findBySubjectAndContent 메서드

SQL을 활용해 데이터베이스에서 두 개의 열을 조회하기 위해서는 And 연산자를 사용한다. subject와 content 속성을 조회하기 위해 findBySubject와 마찬가지로 먼저 리포지터리에 findBySubjectAndContent 메서드를 추가해야 한다.

  1. 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);
    }
  2. 테스트 코드 수정

    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());
        }
    }

이렇듯 리포지터리의 메서드명은 데이터를 조회하는 쿼리문의 where 조건을 결정하는 역할을 한다.

SQL 연산자리포지터리의 메서드 예시설명
AndfindBySubjectAndContent(String subject, String content)Subject, Content 열과 일치하는 데이터를 조회
OrfindBySubjectOrContent(String subject, String content)Subject열 또는 Content 열과 일치하는 데이터를 조회
BetweenfindByCreateDateBetween(LocalDateTime fromDate, LocalDateTime toDate)CreateDate 열의 데이터 중 정해진 범위 내에 있는 데이터를 조회
LessThanfindByIdLessThan(Integer id)Id 열에서 조건보다 작은 데이터를 조회
GreaterThanEqualfindByIdGreaterThanEqual(Integer id)Id 열에서 조건보다 크거나 같은 데이터를 조회
LikefindBySubjectLike(String subject)Subject 열에서 문자열 ‘subject’와 같은 문자열을 포함한 데이터를 조회
InfindBySubjectIn(String[] subjects)Subject 열의 데이터가 주어진 배열에 포함되는 데이터만 조회
OrderByfindBySubjectOrderByCreateDateAsc(String subject)Subject 열 중 조건에 일치하는 데이터를 조회하여 CreateDate 열을 오름차순으로 정렬하여 반환

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


findBySubjectLike 메서드

SQL에서는 특정 문자열을 포함한 데이터를 열에서 찾을 때 Like를 사용한다.

  1. findBySubjectLike 메서드를 QuestionRepository.java에 추가

    package com.mysite.sbb;
    
    import java.util.List;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface QuestionRepository extends JpaRepository<Question, Integer> {
        Question findBySubject(String subject);
        Question findBySubjectAndContent(String subject, String content);
        List<Question> findBySubjectLike(String subject);
    }
  2. 테스트 코드 수정

    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());
        }
    }


질문 데이터 수정하기

질문 엔티티의 데이터를 수정하는 테스트 코드 작성

package com.mysite.sbb;

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()); // 괄호 안의 값이 참인지 테스트
        Question q = oq.get();
        q.setSubject("수정된 제목");
        this.questionRepository.save(q);
    }
}

질문 엔티티의 데이터를 조회한 다음, subject 속성을 '수정된 제목'이라는 값으로 수정했다. 변경된 질문을 데이터베이스에 저장하기 위해서 this.questionRepository.save(q)와 같이 리포지터리의 save 메서드를 사용했다.


질문 데이터 삭제하기

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() {
        assertEquals(2, this.questionRepository.count()); // 테이블 행 개수 리턴
        Optional<Question> oq = this.questionRepository.findById(1);
        assertTrue(oq.isPresent());
        Question q = oq.get();
        this.questionRepository.delete(q);
        assertEquals(1, this.questionRepository.count());
    }
}


답변 데이터 저장하기

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);
        assertTrue(oq.isPresent());
        Question q = oq.get();

        Answer a = new Answer();
        a.setContent("네 자동으로 생성됩니다.");
        a.setQuestion(q);  // 어떤 질문의 답변인지 알기위해서 Question 객체가 필요하다.
        a.setCreateDate(LocalDateTime.now());
        this.answerRepository.save(a);
    }
}

답변 데이터를 저장할 때에도 리포지터리가 필요하므로 AnswerRepository의 객체를 @Autowired를 통해 주입했다. 답변을 생성하려면 질문이 필요하므로 우선 질문을 조회해야 한다. questionRepository의 findById 메서드를 통해 id가 2인 질문 데이터를 가져와 답변의 question 속성에 대입해 답변 데이터를 생성했다.


답변 데이터 조회하기

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> oa = this.answerRepository.findById(1);
        assertTrue(oa.isPresent());
        Answer a = oa.get();
        assertEquals(2, a.getQuestion().getId());
    }
}


답변 데이터를 통해 질문 데이터 찾기 vs 질문 데이터를 통해 답변 데이터 찾기

질문 데이터에서 답변 데이터를 찾을 수 있을까? 다음과 같이 질문 엔티티에 정의한 answerList를 사용하면 해결할 수 있다.

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() {
        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());
    }
}

테스트 코드를 실행해보면 위와 같은 에러가 뜨는데, 이는 QuestionRepository가 findById 메서드를 통해 Question 객체를 조회하고 나면 DB 세션이 끊어지기 때문이다.

그래서 그 이후에 실행되는 q.getAnswerList() 메서드는 세션이 종료되어 오류가 발생한다. answerList는 앞서 q 객체를 조회할 때가 아니라 q.getAnswerList() 메서드를 호출하는 시점에 가져오기 때문에 이와 같이 오류가 발생한 것이다.

이 문제는 테스트 코드에서만 발생한다. 실제 서버에서 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() {
        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());
    }
}

메서드에 @Transactional 애너테이션을 추가하였더니 오류 없이 잘 수행되었다.

profile
커피 한 잔의 여유를 아는 품격있는 여자

0개의 댓글