Spring Data JPA

8주 JPA 저장소 교체·9주 회원과 과제 관계·조회·rollback을 실행하고 검사하기

이번 문서에서 만드는 것

8주는 7주의 메모리 명단을 JPA와 H2로 교체합니다. 9주는 회원과 과제의 관계·트랜잭션을 추가합니다. Controller·MemberInput·MemberResponse·ApiExceptionHandler는 그대로 이어서 사용합니다.

JPA는 객체와 관계형 DB 사이의 영속성을 다루는 규약이며 Spring Data JPA는 Repository 구현을 편리하게 제공합니다. SQL과 관계 설계를 몰라도 된다는 뜻은 아닙니다.

8주: 저장소 교체 준비

build.gradle의 기존 dependencies 블록에 추가합니다. 기존 의존성은 유지합니다.

Groovy
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'runtimeOnly 'com.h2database:h2'

src/main/resources/application.properties에 다음을 추가합니다.

Plain text
spring.datasource.url=jdbc:h2:mem:lionspring.jpa.hibernate.ddl-auto=create-dropspring.jpa.open-in-view=falsespring.jpa.show-sql=true

이 설정은 로컬 실습 전용 인메모리 DB입니다. 서버 재시작 때 데이터가 사라집니다. 영구 저장·운영 환경에는 그대로 사용하지 않습니다. create-drop을 운영 DB에 적용하면 데이터 손실 위험이 있습니다. 실제 배포는 DB 선택과 migration 도구로 스키마 변경을 관리합니다.

Entity와 Repository

파일 위치는 모두 src/main/java/com/example/lion/입니다. Member.java를 만듭니다. 앞 주 콘솔 Member 파일을 그대로 복사하지 않습니다.

Java
package com.example.lion;
import jakarta.persistence.*;
@Entity@Table(name = "members")public class Member {    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)    private Long id;
    @Column(nullable = false, length = 40)    private String name;
    protected Member() {}    public Member(String name) { rename(name); }    public Long getId() { return id; }    public String getName() { return name; }
    public void rename(String name) {        if (name == null || name.isBlank() || name.strip().length() > 40) {            throw new IllegalArgumentException("이름은 1~40자여야 합니다.");        }        this.name = name.strip();    }}

JPA를 위한 기본 생성자는 protected로 두고 정상 생성에는 이름 검증을 거칩니다. 입력 DTO 검증과 Entity 규칙은 적용 위치가 다릅니다. HTTP 외부의 호출에서도 규칙을 지켜야 한다면 Entity에도 필요한 불변 조건을 둡니다.

MemberRepository.java:

Java
package com.example.lion;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, Long> {}

Service 전체 교체

기존 메모리 MemberService를 아래로 교체합니다. Controller는 변경하지 않습니다.

Java
package com.example.lion;
import java.util.List;import java.util.NoSuchElementException;import org.springframework.data.domain.Sort;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;
@Service@Transactional(readOnly = true)public class MemberService {    private final MemberRepository repository;    public MemberService(MemberRepository repository) { this.repository = repository; }
    public List<MemberResponse> findAll() {        return repository.findAll(Sort.by("id")).stream().map(this::response).toList();    }
    public MemberResponse findOne(long id) { return response(requireMember(id)); }
    @Transactional    public MemberResponse create(MemberInput input) {        return response(repository.save(new Member(input.name())));    }
    @Transactional    public MemberResponse update(long id, MemberInput input) {        Member member = requireMember(id);        member.rename(input.name());        return response(member);    }
    @Transactional    public void delete(long id) { repository.delete(requireMember(id)); }
    private Member requireMember(long id) {        return repository.findById(id).orElseThrow(NoSuchElementException::new);    }
    private MemberResponse response(Member member) {        return new MemberResponse(member.getId(), member.getName());    }}

트랜잭션 안에서 조회한 관리 상태 Entity의 변경은 커밋 시 반영됩니다. update에 명시적 save가 없어도 작동하는 이유를 SQL 로그로 확인합니다. Entity를 응답으로 직접 노출하지 않아 DB 필드와 API 계약을 분리합니다.

심화: @Transactional은 일반적인 프록시 기반 호출에서 적용됩니다. 같은 객체 내부의 메서드 직접 호출에 새 트랜잭션 애너테이션이 항상 적용된다고 가정하지 않습니다. 또한 readOnly는 모든 쓰기를 물리적으로 금지하는 보안 장치가 아닙니다.

8주 권장 110분: DB·Entity 25분, Service 교체 35분, 7주 API 재검사 30분, SQL 설명 20분. 완료 기준은 동일한 CRUD 요청이 통하고 insert·select·update·delete가 언제 발생하는지 확인하는 것입니다.

9주: 회원에게 연결된 과제

이번 관계는 회원 한 명이 여러 담당 과제를 가지는 단방향 ManyToOne입니다. 여러 회원의 제출 이력이 필요한 제품이라면 Submission 같은 연결 Entity를 별도로 설계합니다. 처음부터 모든 관계를 양방향으로 만들지 않습니다.

Assignment.java:

Java
package com.example.lion;
import jakarta.persistence.*;
@Entity@Table(name = "assignments")public class Assignment {    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)    private Long id;    @Column(nullable = false, length = 100)    private String title;    @ManyToOne(fetch = FetchType.LAZY, optional = false)    @JoinColumn(name = "member_id", nullable = false)    private Member member;
    protected Assignment() {}    public Assignment(String title, Member member) {        if (title == null || title.isBlank() || title.strip().length() > 100 || member == null) {            throw new IllegalArgumentException("제목과 담당 회원을 확인해 주세요.");        }        this.title = title.strip();        this.member = member;    }    public Long getId() { return id; }    public String getTitle() { return title; }    public Member getMember() { return member; }}

심화: EntityGraph로 목록 조회 최적화

AssignmentRepository.java:

Java
package com.example.lion;
import java.util.List;import org.springframework.data.jpa.repository.EntityGraph;import org.springframework.data.jpa.repository.JpaRepository;
public interface AssignmentRepository extends JpaRepository<Assignment, Long> {    @EntityGraph(attributePaths = "member")    List<Assignment> findAllByOrderByIdAsc();}

목록에서 각 과제의 회원 이름을 읽을 때 추가 조회가 반복되지 않도록 필요한 member를 함께 로드합니다. 모든 관계를 EAGER로 바꾸는 대신 실제 조회 사용처에 맞춰 선택합니다. 지연 로딩 Entity를 트랜잭션 밖에서 무작정 읽으면 오류가 날 수 있으므로 DTO 변환 위치도 함께 결정합니다.

관계와 rollback 검사

심화: TransactionTemplate로 rollback 확인

src/test/java/com/example/lion/AssignmentPersistenceTest.java를 만듭니다.

Java
package com.example.lion;
import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.transaction.PlatformTransactionManager;import org.springframework.transaction.support.TransactionTemplate;import static org.junit.jupiter.api.Assertions.*;
@SpringBootTestclass AssignmentPersistenceTest {    @Autowired MemberRepository members;    @Autowired AssignmentRepository assignments;    @Autowired PlatformTransactionManager transactionManager;
    @Test    void loadsMemberName() {        TransactionTemplate tx = new TransactionTemplate(transactionManager);        tx.executeWithoutResult(status -> {            Member member = members.save(new Member("가람"));            Assignment assignment = assignments.save(new Assignment("JPA 실습", member));            assignments.flush();            var rows = assignments.findAllByOrderByIdAsc();            assertTrue(rows.stream().anyMatch(row ->                row.getId().equals(assignment.getId()) && row.getMember().getName().equals("가람")));            status.setRollbackOnly();        });    }
    @Test    void rollsBackRelatedWrites() {        long before = members.count();        TransactionTemplate tx = new TransactionTemplate(transactionManager);        assertThrows(IllegalStateException.class, () -> tx.executeWithoutResult(status -> {            Member member = members.saveAndFlush(new Member("나래"));            assignments.saveAndFlush(new Assignment("취소될 과제", member));            throw new IllegalStateException("두 작업을 함께 취소");        }));        assertEquals(before, members.count());    }}

./gradlew test로 검사합니다. 예제는 실제 DB 저장·조회와 rollback을 확인하지만 운영 DB의 잠금·성능까지 증명하지 않습니다. 관계가 있는 회원 삭제는 FK 제약에 걸리므로 다음 프로젝트 문서에서 충돌 응답으로 처리합니다. 과제를 자동으로 지울지, 삭제를 막을지는 제품 정책입니다.

9주 권장 100분: 관계·삭제 정책 20분, Entity·조회 30분, rollback 검사 30분, 리뷰 20분. 완료 기준은 FK가 있는 쪽과 삭제 영향을 설명하고 실패 시 관련 쓰기가 함께 취소되는 것을 확인하는 것입니다.

더 읽어보기

연결된 PBL 미션과 VOD

주차·미션참고 VOD 범위
8주 · JPA 기초Spring Boot 실습 5·6장
9주 · 연관관계와 트랜잭션Spring Boot 실습 5·6장

6–10주의 팝오버에는 Spring Boot 실습 강의명·장만 있고 직접 강좌 URL이 없다. 해당 미션의 참고 VOD 안내를 사용한다. 10주 VOD의 프론트 예제는 SvelteKit이며 React 전환을 필수로 요구하지 않는다.