Spring Boot: 스프링 부트 기초 [3]

hyeppy·2025년 9월 7일

Spring Boot

목록 보기
4/6
post-thumbnail

2-05 리포지터리로 데이터베이스 관리하기


리포지토리 (Repository)

리포지토리(Repository)는 엔티티가 생성한 데이터베이스 테이블의 데이터를 저장, 조회, 수정, 삭제 등을 할 수 있도록 도와주는 인터페이스이다.

테이블에 접근하고 데이터를 관리하는 메서드를 제공하여, 개발자가 복잡한 SQL을 작성하지 않고도 데이터베이스 작업을 수행할 수 있게 해 준다.

엔티티 vs 리포지토리

  • 엔티티: 데이터베이스 테이블 구조를 정의
  • 리포지토리: 정의된 테이블의 데이터를 조작

리포지토리 생성

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 인터페이스를 상속한다. JpaRepository는 JPA가 제공하는 기본 인터페이스 중 하나로, CRUD 메서드를 이미 내장하고 있어 데이터 관리 작업을 편리하게 처리할 수 있다.

제네릭 타입을 JpaRepository<엔티티 클래스, 해당 엔티티의 기본키 타입> 방식으로 선언하여 사용할 수 있다.

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

  • JUnit 테스트 메서드임을 표시
  • 해당 메서드가 단위 테스트로 실행됨

@DisplayName("테스트 설명")

  • 테스트 메서드의 목적을 명확하게 설명
  • 테스트 실행 결과에서 가독성 향상

@ActiveProfiles("test")

  • 테스트 전용 설정 프로필 활성화
  • 운영 환경과 테스트 환경을 분리했을 경우 사용

@Autowired

  • 스프링의 의존성 주입(DI) 기능
  • 스프링 컨테이너가 자동으로 객체를 생성하여 주입
  • 주의사항: 일반 코드에서는 생성자가 하나만 있을 경우 생성자 주입 방식을 권장하나, 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
		@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);
		}
}

save() 메서드의 동작 방식

  • 새로운 엔티티: INSERT 쿼리 실행
  • 기존 엔티티: UPDATE 쿼리 실행 (ID가 존재할 경우)
  • 자동 ID 생성: @GeneratedValue로 설정된 ID는 자동으로 할당

질문 데이터 조회하기

findAll() - 전체 조회

@Test
@DisplayName("전체 질문 조회")
void testFindAll() {
    List<Question> all = this.questionRepository.findAll();
    assertEquals(2, all.size());

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

findById() - ID로 조회

@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 등의 키워드를 조합하여 복합 조건 쿼리나 다양한 조회 조건을 구현할 수 있다.

findBySubject() - 제목으로 조회

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

findBySubjectAndContent() - 복합 조건 조회

// 리포지토리에 메서드 추가
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());
}

findBySubjectLike() - 패턴 매칭 조회

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'를 포함하는 문자열

Query Method 키워드 정리

SQL 연산자리포지터리의 메서드 예시설명
AndfindBySubjectAndContent(String subject, String content)SubjectContent 열과 일치하는 데이터를 조회
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 열을 오름차순으로 정렬하여 반환
Asc/DescfindAllByOrderByCreateDateDesc() / 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());
}

save() 메서드의 동작 원리

  1. 엔티티의 ID 확인
  2. ID가 없거나 새로운 값: INSERT 실행
  3. ID가 이미 존재: UPDATE 실행
  4. 영속성 컨텍스트에서 변경 감지 후 자동으로 적절한 쿼리 생성

질문 데이터 삭제

@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);

삭제 시 주의사항

  • CASCADE 설정 확인: 연관된 엔티티의 삭제 정책 고려
  • 외래키 제약조건: 참조되고 있는 데이터 삭제 시 오류 발생 가능
  • 트랜잭션 처리: 삭제 작업의 원자성 보장

답변 데이터 저장하기

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

답변 저장 시 save() 동작 원리

  1. 답변 엔티티 생성 및 필수 속성 설정
  2. 연관관계 설정: 기존 질문 엔티티를 question 속성에 할당
  3. save() 호출: 새로운 엔티티이므로 INSERT 쿼리 실행
  4. 외래키 자동 설정: question_id 컬럼에 질문의 ID 값 저장
-- 실제 실행되는 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());
}

LazyInitializationException 발생 가능성

Question q = oq.get(); // 질문 조회 후 데이터베이스 세션 종료

List<Answer> answerList = q.getAnswerList(); 
// ❌ LazyInitializationException 발생
// 세션이 종료된 상태에서 지연 로딩 시도

JPA의 지연 로딩(LAZY Loading)은 실제 데이터가 필요한 시점에 데이터베이스에 조회한다. 하지만 데이터베이스 세션이 종료된 후에는 추가 데이터를 가져올 수 없어 LazyInitializationException와 같은 에러가 발생할 수 있다.

데이터베이스에서 하나의 논리적 작업 단위를 뜻하는 트랜잭션(Transaction)은 작업의 성공, 실패를 가리지 않고 모든 작업이 동일한 결과값을 가져야 하는 원자성(Atomicity)을 보장한다.

@Transactional

  • 트랜잭션 경계 설정
    • 메서드 시작 시: 트랜잭션 시작
    • 메서드 정상 종료 시: 트랜잭션 커밋 (변경사항 확정)
    • 예외 발생 시: 트랜잭션 롤백 (변경사항 취소)
  • 영속성 컨텍스트 유지
    • 트랜잭션 동안 1차 캐시와 데이터베이스 세션 유지
    • 더티 체킹(Dirty Checking) 기능으로 엔티티 변경 자동 감지
  • 데이터 일관성 보장

일반적인 상황에서는 비즈니스 로직 메서드가 실행되는 동안 트랜잭션이 유지되지만 테스트 환경에서는 각 메서드가 호출마다 세션이 종료되어 지연 로딩이 불가능하다.

따라서 테스트에서 지연 로딩을 테스트하려면 @Transactional로 전체 테스트 메서드를 트랜잭션으로 감싸야 한다.

Fetch 전략 (로딩 전략)

연관된 엔티티를 언제 로딩할 것인가를 결정하는 전략으로, 지연 로딩(LAZY) / 즉시 로딩(EAGER) 두 가지 옵션이 있다.

FetchType.LAZY (지연 로딩) [기본값, 권장]

@OneToMany(mappedBy = "question", fetch = FetchType.LAZY, 
           cascade = CascadeType.REMOVE)
private List<Answer> answerList;
  • 실제 데이터가 필요한 시점에 추가 쿼리 실행
  • 메모리 효율적: 불필요한 데이터 로딩 방지
  • N+1 문제 발생 가능성 있음
  • @Transactional 또는 세션 유지 필요

FetchType.EAGER (즉시 로딩)

@OneToMany(mappedBy = "question", fetch = FetchType.EAGER)
private List<Answer> answerList;
  • 엔티티 조회 시 연관된 모든 데이터를 함께 조회
  • 불필요한 데이터까지 항상 로딩하여 성능상 비효율적
  • N+1 문제 발생 가능성
  • 트랜잭션 유지 불필요

profile
Backend

0개의 댓글