
리포지토리(Repository)는 엔티티가 생성한 데이터베이스 테이블의 데이터를 저장, 조회, 수정, 삭제 등을 할 수 있도록 도와주는 인터페이스이다.
테이블에 접근하고 데이터를 관리하는 메서드를 제공하여, 개발자가 복잡한 SQL을 작성하지 않고도 데이터베이스 작업을 수행할 수 있게 해 준다.
package com.mysite.sbb;
import org.springframework.data.jpa.repository.JpaRepository;
// QuestionRepository의 경우
public interface QuestionRepository extends JpaRepository<Question, Integer> {
}
// AnswerRepository의 경우
public interface AnswerRepository extends JpaRepository<Answer, Integer> {
}
생성한 인터페이스를 리포지토리로 만들기 위해 JpaRepository 인터페이스를 상속한다. JpaRepository는 JPA가 제공하는 기본 인터페이스 중 하나로, CRUD 메서드를 이미 내장하고 있어 데이터 관리 작업을 편리하게 처리할 수 있다.
제네릭 타입을 JpaRepository<엔티티 클래스, 해당 엔티티의 기본키 타입> 방식으로 선언하여 사용할 수 있다.
// 저장 및 수정
save(entity) // 엔티티 저장 또는 수정
saveAll(entities) // 여러 엔티티 일괄 저장
// 조회
findById(id) // ID로 엔티티 조회
findAll() // 모든 엔티티 조회
existsById(id) // ID 존재 여부 확인
count() // 전체 엔티티 개수
// 삭제
delete(entity) // 엔티티 삭제
deleteById(id) // ID로 엔티티 삭제
deleteAll() // 모든 엔티티 삭제
JUnit은 자바의 대표적인 테스트 프레임워크로, 테스트 코드를 작성하고 실행할 때 사용하며, 소프트웨어 개발 시 코드의 정상 동작을 검증하는 데 필수적인 도구이다.
@SpringBootTest
class SbbApplicationTests {
@Autowired
private QuestionRepository questionRepository;
@Test
@DisplayName("질문 데이터 저장 테스트")
void saveQuestionTest() {
// 테스트 코드 작성
}
}
@SpringBootTest@Test@DisplayName("테스트 설명")@ActiveProfiles("test")@Autowiredpackage 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
@DisplayName("질문 데이터 저장")
void testSaveQuestion() {
// 첫 번째 질문 생성 및 저장
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);
}
}
@GeneratedValue로 설정된 ID는 자동으로 할당@Test
@DisplayName("전체 질문 조회")
void testFindAll() {
List<Question> all = this.questionRepository.findAll();
assertEquals(2, all.size());
Question firstQuestion = all.get(0);
assertEquals("sbb가 무엇인가요?", firstQuestion.getSubject());
}
@Test
@DisplayName("ID로 질문 조회")
void testFindById() {
Optional<Question> oq = this.questionRepository.findById(1);
if(oq.isPresent()) {
Question q = oq.get();
assertEquals("sbb가 무엇인가요?", q.getSubject());
}
}
findById로 조회된 데이터가 존재할 수도, 존재하지 않을 수도 있기에 findById의 리턴 타입이 Question이 아닌 Optional<Question>임을 주의해야 한다.
isPresent() 메서드로 존재 확인한 후 get() 메서드를 통해 실제 객체를 획득할 수 있다
// 방법 1: isPresent() 확인 후 get()
Optional<Question> oq = questionRepository.findById(1);
if(oq.isPresent()) {
Question q = oq.get();
// 처리 로직
}
// 방법 2: orElse() 사용
Question q = questionRepository.findById(1).orElse(null);
// 방법 3: orElseThrow() 사용
Question q = questionRepository.findById(1)
.orElseThrow(() -> new RuntimeException("질문을 찾을 수 없습니다."));
JPA는 메서드 이름을 분석하여 자동으로 쿼리를 생성하는 Query Method 기능을 제공한다. 이 기능을 통해 리포지토리가 기본적으로 제공하지 않는 메서드라도 인터페이스에 선언만 하면 사용할 수 있다.
findBy + 엔티티 속성명 패턴으로 메서드를 작성하면 해당 속성 값으로 데이터를 조회할 수 있으며, SQL 연산자처럼 And, Or, Like 등의 키워드를 조합하여 복합 조건 쿼리나 다양한 조회 조건을 구현할 수 있다.
// QuestionRepository 인터페이스에 메서드 추가
public interface QuestionRepository extends JpaRepository<Question, Integer> {
Optional<Question> findBySubject(String subject);
}
@Test
@DisplayName("제목으로 질문 조회")
void testFindBySubject() {
Question q = this.questionRepository
.findBySubject("sbb가 무엇인가요?").get();
assertEquals(1, q.getId());
}
// 리포지토리에 메서드 추가
public interface QuestionRepository extends JpaRepository<Question, Integer> {
Optional<Question> findBySubject(String subject);
Optional<Question> findBySubjectAndContent(String subject, String content);
}
@Test
@DisplayName("제목과 내용으로 질문 조회")
void testFindBySubjectAndContent() {
Question q = this.questionRepository.findBySubjectAndContent(
"sbb가 무엇인가요?", "sbb에 대해서 알고 싶습니다.").get();
assertEquals(1, q.getId());
}
public interface QuestionRepository extends JpaRepository<Question, Integer> {
Optional<Question> findBySubject(String subject);
Optional<Question> findBySubjectAndContent(String subject, String content);
List<Question> findBySubjectLike(String pattern);
}
@Test
@DisplayName("제목 패턴으로 질문 조회")
void testFindBySubjectLike() {
List<Question> qList = this.questionRepository.findBySubjectLike("sbb%");
Question q = qList.get(0);
assertEquals("sbb가 무엇인가요?", q.getSubject());
}
| 표기 예 | 표기 위치에 따른 의미 |
|---|---|
sbb% | 'sbb'로 시작하는 문자열 |
%sbb | 'sbb'로 끝나는 문자열 |
%sbb% | 'sbb'를 포함하는 문자열 |
| SQL 연산자 | 리포지터리의 메서드 예시 | 설명 |
|---|---|---|
| And | findBySubjectAndContent(String subject, String content) | Subject, Content 열과 일치하는 데이터를 조회 |
| Or | findBySubjectOrContent(String subject, String content) | Subject열 또는 Content 열과 일치하는 데이터를 조회 |
| Between | findByCreateDateBetween(LocalDateTime fromDate, LocalDateTime toDate) | CreateDate 열의 데이터 중 정해진 범위 내에 있는 데이터를 조회 |
| LessThan | findByIdLessThan(Integer id) | Id 열에서 조건보다 작은 데이터를 조회 |
| GreaterThanEqual | findByIdGreaterThanEqual(Integer id) | Id 열에서 조건보다 크거나 같은 데이터를 조회 |
| Like | findBySubjectLike(String subject) | Subject 열에서 문자열 ‘subject’와 같은 문자열을 포함한 데이터를 조회 |
| In | findBySubjectIn(String[] subjects) | Subject 열의 데이터가 주어진 배열에 포함되는 데이터만 조회 |
| OrderBy | findBySubjectOrderByCreateDateAsc(String subject) | Subject 열 중 조건에 일치하는 데이터를 조회하여 CreateDate 열을 오름차순으로 정렬하여 반환 |
| Asc/Desc | findAllByOrderByCreateDateDesc() / findBySubjectContainingOrderByCreateDateDesc(String keyword) | Asc → 오름차순 정렬 |
| Desc → 내림차순 정렬 |
@Test
@DisplayName("질문 데이터 수정")
void testUpdateQuestion() {
Optional<Question> oq = this.questionRepository.findById(1);
assertTrue(oq.isPresent());
Question q = oq.get();
q.setSubject("수정된 제목");
// save() 메서드로 수정 (기존 ID가 있으면 UPDATE 수행)
this.questionRepository.save(q);
// 수정 결과 검증
Question updatedQuestion = this.questionRepository.findById(1).get();
assertEquals("수정된 제목", updatedQuestion.getSubject());
}
@Test
@DisplayName("질문 데이터 삭제")
void testDeleteQuestion() {
// 삭제 전 전체 개수 확인
assertEquals(2, this.questionRepository.count());
Optional<Question> oq = this.questionRepository.findById(1);
assertTrue(oq.isPresent());
Question q = oq.get();
// delete() 메서드로 엔티티 삭제
this.questionRepository.delete(q);
// 삭제 후 개수 확인
assertEquals(1, this.questionRepository.count());
}
// 1. 엔티티 객체로 삭제
Question q = questionRepository.findById(1).get();
questionRepository.delete(q);
// 2. ID로 직접 삭제
questionRepository.deleteById(1);
// 3. 전체 삭제
questionRepository.deleteAll();
// 4. 조건부 삭제 (사용자 정의 메서드)
void deleteBySubject(String subject);
삭제 시 주의사항
@Autowired
private QuestionRepository questionRepository;
@Autowired
private AnswerRepository answerRepository;
@Test
@DisplayName("답변 데이터 저장")
void testSaveAnswer() {
// 1. 답변을 달 질문 조회
Optional<Question> oq = this.questionRepository.findById(2);
assertTrue(oq.isPresent());
Question q = oq.get();
// 2. 답변 엔티티 생성 및 속성 설정
Answer a = new Answer();
a.setContent("네 자동으로 생성됩니다.");
a.setQuestion(q); // 연관관계 설정: 어떤 질문의 답변인지 명시
a.setCreateDate(LocalDateTime.now());
// 3. 답변 저장
this.answerRepository.save(a);
}
-- 실제 실행되는 SQL
INSERT INTO answer (content, create_date, question_id)
VALUES ('네 자동으로 생성됩니다.', '2024-01-01 12:00:00', 2);
@Test
@DisplayName("답변을 통한 질문 조회")
void testFindQuestionByAnswer() {
Optional<Answer> oa = this.answerRepository.findById(1);
assertTrue(oa.isPresent());
Answer a = oa.get();
// 답변에서 연결된 질문 조회
assertEquals(2, a.getQuestion().getId());
}
답변 엔티티의 question 속성을 이용하면 @ManyToOne 관계로 설정된 질문 객체에 직접 접근할 수 있으며, a.getQuestion() 메서드는 답변에 연결된 질문 객체를 반환한다.
답변에서 질문을 찾는 것이 가능한 것처럼, 질문에서 해당 질문의 모든 답변을 조회하는 것 또한 가능하다.
@Transactional // 지연 로딩을 위한 트랜잭션 유지
@Test
@DisplayName("질문을 통한 답변 목록 조회")
void testFindAnswersByQuestion() {
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());
}
Question q = oq.get(); // 질문 조회 후 데이터베이스 세션 종료
List<Answer> answerList = q.getAnswerList();
// ❌ LazyInitializationException 발생
// 세션이 종료된 상태에서 지연 로딩 시도
JPA의 지연 로딩(LAZY Loading)은 실제 데이터가 필요한 시점에 데이터베이스에 조회한다. 하지만 데이터베이스 세션이 종료된 후에는 추가 데이터를 가져올 수 없어 LazyInitializationException와 같은 에러가 발생할 수 있다.
데이터베이스에서 하나의 논리적 작업 단위를 뜻하는 트랜잭션(Transaction)은 작업의 성공, 실패를 가리지 않고 모든 작업이 동일한 결과값을 가져야 하는 원자성(Atomicity)을 보장한다.
@Transactional일반적인 상황에서는 비즈니스 로직 메서드가 실행되는 동안 트랜잭션이 유지되지만 테스트 환경에서는 각 메서드가 호출마다 세션이 종료되어 지연 로딩이 불가능하다.
따라서 테스트에서 지연 로딩을 테스트하려면 @Transactional로 전체 테스트 메서드를 트랜잭션으로 감싸야 한다.
연관된 엔티티를 언제 로딩할 것인가를 결정하는 전략으로, 지연 로딩(LAZY) / 즉시 로딩(EAGER) 두 가지 옵션이 있다.
FetchType.LAZY (지연 로딩) [기본값, 권장]
@OneToMany(mappedBy = "question", fetch = FetchType.LAZY,
cascade = CascadeType.REMOVE)
private List<Answer> answerList;
FetchType.EAGER (즉시 로딩)
@OneToMany(mappedBy = "question", fetch = FetchType.EAGER)
private List<Answer> answerList;