diff --git a/.gitignore b/.gitignore index c2065bc..645353a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ build/ !**/src/test/**/build/ ### STS ### +.metadata/ .apt_generated .classpath .factorypath diff --git a/README.md b/README.md index 0e54e8a..6762bb7 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,83 @@ + +# JPA 실습 - 순환 참조 문제 해결하기 + +## 현재 발생하는 문제 + +### 1. 순환 참조 오류 +```json +{ + "timestamp": "2025-04-30T15:45:14.678+09:00", + "status": 500, + "error": "Internal Server Error", + "message": "Type definition error: [simple type, class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor]", + "path": "/api/students/1" +} +``` + +### 2. Hibernate 프록시 직렬화 오류 +``` +com.fasterxml.jackson.databind.exc.InvalidDefinitionException: +No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor +and no properties discovered to create BeanSerializer +``` + +## 해결해야 할 과제 + +### 1. 엔티티 수정 +- `Student`와 `ClassRoom` 엔티티의 양방향 연관관계 설정 +- 순환 참조를 방지하기 위한 Jackson 어노테이션 추가 +- 지연 로딩 관련 설정 최적화 + +### 2. 테스트 케이스 작성 +```java +@Test +void testCircularReference() { + // 1. 반 생성 + ClassRoom classRoom = new ClassRoom(); + classRoom.setName("1반"); + classRoom.setCapacity(30); + classRoom.setTeacherName("김선생"); + ClassRoom savedClassRoom = classRoomRepository.save(classRoom); + + // 2. 학생 생성 + Student student = new Student(); + student.setName("홍길동"); + student.setAge(15); + student.setClassRoom(savedClassRoom); + Student savedStudent = studentRepository.save(student); + + // 3. API 호출 테스트 + // GET /api/students/{id} 호출 시 순환 참조 오류 발생 확인 + // GET /api/classrooms/{id} 호출 시 순환 참조 오류 발생 확인 +} +``` + +### 3. 해결 방안 고민 +1. `@JsonIgnore` 사용 +2. `@JsonManagedReference`와 `@JsonBackReference` 사용 +3. DTO 패턴 적용 +4. Jackson 설정 변경 + +## 학습 목표 +1. JPA의 지연 로딩과 프록시 객체 이해 +2. Jackson 직렬화 과정에서의 순환 참조 문제 이해 +3. 다양한 해결 방안의 장단점 비교 +4. REST API에서의 엔티티 직렬화 최적화 + +## 체크리스트 +- [ ] 엔티티 수정 +- [ ] 테스트 케이스 작성 +- [ ] 해결 방안 구현 +- [ ] API 테스트 +- [ ] 문서화 + +## 참고 자료 +- [Jackson 직렬화 문서](https://github.com/FasterXML/jackson-docs) +- [Hibernate 프록시 문서](https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#associations) +- [Spring Data JPA 문서](https://docs.spring.io/spring-data/jpa/docs/current/reference/html/) + +이 브랜치에서 순환 참조 문제를 해결하면서 JPA와 Jackson의 동작 방식을 깊이 이해할 수 있습니다. 다양한 해결 방안을 시도해보고 각각의 장단점을 비교해보세요. +======= # JPA 실습 스터디 안내 ## 스터디 목적 diff --git a/build.gradle b/build.gradle index 36513a9..249cc7d 100644 --- a/build.gradle +++ b/build.gradle @@ -27,8 +27,15 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' compileOnly 'org.projectlombok:lombok' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.5' + + // db runtimeOnly 'com.h2database:h2' annotationProcessor 'org.projectlombok:lombok' + + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/com/jpapractice/jpapractice/JpaPracticeApplication.java b/src/main/java/com/example/jpapractice/JpaPracticeApplication.java similarity index 88% rename from src/main/java/com/jpapractice/jpapractice/JpaPracticeApplication.java rename to src/main/java/com/example/jpapractice/JpaPracticeApplication.java index ec9b585..6337ee7 100644 --- a/src/main/java/com/jpapractice/jpapractice/JpaPracticeApplication.java +++ b/src/main/java/com/example/jpapractice/JpaPracticeApplication.java @@ -1,4 +1,4 @@ -package com.jpapractice.jpapractice; +package com.example.jpapractice; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; diff --git a/src/main/java/com/example/jpapractice/controller/ClassRoomController.java b/src/main/java/com/example/jpapractice/controller/ClassRoomController.java new file mode 100644 index 0000000..cc43c37 --- /dev/null +++ b/src/main/java/com/example/jpapractice/controller/ClassRoomController.java @@ -0,0 +1,80 @@ +package com.example.jpapractice.controller; + +import com.example.jpapractice.entity.ClassRoom; +import com.example.jpapractice.service.ClassRoomService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/classrooms") +@RequiredArgsConstructor +public class ClassRoomController { + + private final ClassRoomService classRoomService; + + /** + * 새로운 반을 생성합니다. + * @param classRoom 생성할 반 정보 + * @return 생성된 반 정보 + */ + @PostMapping + public ResponseEntity createClassRoom(@RequestBody ClassRoom classRoom) { + return ResponseEntity.ok(classRoomService.createClassRoom(classRoom)); + } + + /** + * 모든 반 정보를 조회합니다. + * @return 반 목록 + */ + @GetMapping + public ResponseEntity> getAllClassRooms() { + return ResponseEntity.ok(classRoomService.getAllClassRooms()); + } + + /** + * ID로 반 정보를 조회합니다. + * @param id 반 ID + * @return 반 정보 + */ + @GetMapping("/{id}") + public ResponseEntity getClassRoomById(@PathVariable Long id) { + return ResponseEntity.ok(classRoomService.getClassRoomById(id)); + } + + /** + * 반 정보를 수정합니다. + * @param id 수정할 반 ID + * @param updatedClassRoom 수정할 반 정보 + * @return 수정된 반 정보 + */ + @PutMapping("/{id}") + public ResponseEntity updateClassRoom( + @PathVariable Long id, + @RequestBody ClassRoom updatedClassRoom) { + return ResponseEntity.ok(classRoomService.updateClassRoom(id, updatedClassRoom)); + } + + /** + * 반을 삭제합니다. + * @param id 삭제할 반 ID + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteClassRoom(@PathVariable Long id) { + classRoomService.deleteClassRoom(id); + return ResponseEntity.ok().build(); + } + + /** + * 특정 정원 이상의 반을 조회합니다. + * @param minCapacity 최소 정원 + * @return 반 목록 + */ + @GetMapping("/large") + public ResponseEntity> findLargeClassRooms( + @RequestParam Integer minCapacity) { + return ResponseEntity.ok(classRoomService.findLargeClassRooms(minCapacity)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/jpapractice/controller/StudentController.java b/src/main/java/com/example/jpapractice/controller/StudentController.java new file mode 100644 index 0000000..257dc42 --- /dev/null +++ b/src/main/java/com/example/jpapractice/controller/StudentController.java @@ -0,0 +1,91 @@ +package com.example.jpapractice.controller; + +import com.example.jpapractice.entity.Student; +import com.example.jpapractice.service.StudentService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/students") +@RequiredArgsConstructor +public class StudentController { + + private final StudentService studentService; + + /** + * 새로운 학생을 생성합니다. + * @param student 생성할 학생 정보 + * @return 생성된 학생 정보 + */ + @PostMapping + public ResponseEntity createStudent(@RequestBody Student student) { + return ResponseEntity.ok(studentService.createStudent(student)); + } + + /** + * 모든 학생 정보를 조회합니다. + * @return 학생 목록 + */ + @GetMapping + public ResponseEntity> getAllStudents() { + return ResponseEntity.ok(studentService.getAllStudents()); + } + + /** + * ID로 학생 정보를 조회합니다. + * @param id 학생 ID + * @return 학생 정보 + */ + @GetMapping("/{id}") + public ResponseEntity getStudentById(@PathVariable Long id) { + return ResponseEntity.ok(studentService.getStudentById(id)); + } + + /** + * 학생 정보를 수정합니다. + * @param id 수정할 학생 ID + * @param updatedStudent 수정할 학생 정보 + * @return 수정된 학생 정보 + */ + @PutMapping("/{id}") + public ResponseEntity updateStudent( + @PathVariable Long id, + @RequestBody Student updatedStudent) { + return ResponseEntity.ok(studentService.updateStudent(id, updatedStudent)); + } + + /** + * 학생을 삭제합니다. + * @param id 삭제할 학생 ID + */ + @DeleteMapping("/{id}") + public ResponseEntity deleteStudent(@PathVariable Long id) { + studentService.deleteStudent(id); + return ResponseEntity.ok().build(); + } + + /** + * 특정 반의 모든 학생을 조회합니다. + * @param classRoomId 반 ID + * @return 학생 목록 + */ + @GetMapping("/classroom/{classRoomId}") + public ResponseEntity> getStudentsByClassRoom( + @PathVariable Long classRoomId) { + return ResponseEntity.ok(studentService.getStudentsByClassRoom(classRoomId)); + } + + /** + * 특정 나이 이상의 학생을 조회합니다. + * @param minAge 최소 나이 + * @return 학생 목록 + */ + @GetMapping("/age") + public ResponseEntity> getStudentsByAge( + @RequestParam Integer minAge) { + return ResponseEntity.ok(studentService.getStudentsByAge(minAge)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/jpapractice/entity/ClassRoom.java b/src/main/java/com/example/jpapractice/entity/ClassRoom.java new file mode 100644 index 0000000..955f683 --- /dev/null +++ b/src/main/java/com/example/jpapractice/entity/ClassRoom.java @@ -0,0 +1,137 @@ +package com.example.jpapractice.entity; + +import com.fasterxml.jackson.annotation.JsonManagedReference; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Setter +@Table(name = "class_rooms") +public class ClassRoom { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private Integer capacity; + + @Column(name = "teacher_name") + private String teacherName; + + @OneToMany(mappedBy = "classRoom") + // @JsonManagedReference // 순환 참조 해결 방법 1: 정방향 참조를 JSON 직렬화에 포함 + // @JsonIgnore // 순환 참조 해결 방법 2: 해당 필드를 JSON 직렬화에서 완전히 제외 + + @JsonManagedReference + private List students = new ArrayList<>(); + + /* + 1. 순환 참조 오류 + + ● 직렬화 + - 객체를 저장하거나 전송하기 위해 문자열이나 이진 형태로 변환하는 것. + - Java 객체 <-> JSON 문자열 + + ● 직렬화를 하는 이유 + - API 응답을 JSON으로 주려면 직렬화 필요 + - 데이터를 파일에 저장하거나 네트워크로 보낼 때 문자열로 변환해야 함 + - 객체는 Java 프로그램 안에서만 쓸 수 있음 -> 외부 시스템과 주고받으려면 문자열로 바꿔야 함 + + ● Jackson은 직렬화를 위한 라이브러리. + - @RestController의 응답 자동 변환 + - @RequestBody로 JSON 데이터를 받을 때 + - API 응답/요청 등을 처리해줌 + + + 1) @JsonIgnore + - Jackson이 직렬화할 때 무시해야 하는 필드에 붙이는 어노테이션 + - 보통 @ManyToOne 또는 @OneToOne 관계에서 사용. + ● 동작 원리 : Student 객체를 JSON으로 변환할 때 classRoom 필드를 출력하지 않음 -> 순환 구조를 끊게 됨. + ● 장점 + - 가장 간단하고 직관적임 + - 설정 하나만으로 순환 참조 방지 가능 + - 유지보수 쉬움 + ● 단점 + - classRoom 정보가 응답 JSON에 아예 포함되지 않음 -> 양방향 관계인데 한쪽 응답이 비정상적으로 비어버림 + - 필요한 정보가 날아갈 수 있음 + + + 2) @JsonManagedReference / @JsonBackReference + Jackson이 양방향 연관관꼐에서 직렬화 시 순환을 피할 수 있게 도와주는 어노테이션 쌍. + - @JsonManagedReference : 직렬화 대상(부모 -> 자식 방향) + - @JsonBackReference : 역참조 대상(자식 -> 부모 방향은 직렬화 제외됨) + ● 장점 + - 양방향 관계 그대로 유지하면서 순환 참조 해결 가능 + - 부모 기준으로 응답을 만들때는 자식까지 출력 가능 + ● 단점 + - 직렬화 방향이 한 방향으로 고정됨 -> 유연하지 않음 + - 복잡한 관계(3단계 이상 참조)에서는 적용하기 어려움 + - Jackson 내부 기능에 강하게 의존 -> 나중에 DTO로 바꾸기 어려움 + + + 3) DTO 패턴 적용 + - Entity를 그대로 반환하지 않고, 필요한 필드만 따로 뽑은 DTO 클래스를 만들어 반환 + - 컨트롤러에서 DTO로 변환 후 반환함 + ● 장점 + - 순환 참조 문제를 원천 차단한(Entity -> JSON 직렬화 안함) + - 필요한 정보만 제공 -> API 최적화 / 깔끔한 구조 / 유지보수 쉬움 / 확장성 좋음(API 응답을 쉽게 커스터마이징 가능) + ● 단점 + - DTO 클래스, 변환 코드를 직접 작성해야함 + - 구조가 복잡해질 수 있음(매번 변환 코드 필요) + + + 4) Jackson 설정 변경 + - @JsonIdentityInfo 사용 + - Jackson에서 순환 참조를 감지하고, 객체를 ID 기준으로 한번만 출력하게 만드는 어노테이션 + ● 동작 방식 : 같은 객체가 두 번 이상 등장하면, ID로만 참조해서 순환을 막음 + ● 장점 + - 순환 잠조 자동 감지 : Jackson이 객체 ID 기준으로 순환을 방지 + - JSON에 정보 모두 포함 - @JsonIgnore와 달리 정보 손실 없음 + - 엔티티 그대로 반환 가능 - DTO 없이도 일정 수준 해결 가능 + ● 단점 + - JSON 형태가 이해하기 어려움 : ID 참조 구조의 생소함 + - 객체 ID 기준으로만 판단 : ID가 없거나 중복되면 충돌 가능성 있음 + => 실무에서 거의 안 씀 : DTO 방식이 더 명확하고 유연함 + + + */ + + /* + 2. Hibernate 프록시 객체 직렬화 문제 + - JPA에서 @ManyToOne(fetch = FetchType.LAZY) 같은 지연 로딩을 쓰면 Hibernate는 실제 객체를 바로 로딩하지 않고, 가자(프록시) 객체를 먼저 만듦. + - 이 프록시 객체는 실제 DB 조회는 하지 않고, 필요할 때 진짜 객체로 바뀜(초기화) + - 이 프록시 객체를 Jackson이 JSON으로 바꾸려 하면... + => 프록시 객체는 실제 클래스가 아니라 동적으로 만들어진 가짜 객체라 Jackson은 어떤 필드를 직렬화해야 할지 몰라서 터짐 + + ● 해결책 + 1) 강제 초기화 후 DTO로 변환 + DTO 패턴 쓰면서 필요한 값만 꺼내오면 프록시 문제 없음 + ClassRoom classRoom = student.getClassRoom(); + classRoom.getName(); // LAZY 강제 초기화 + + StudentResponse response = new StudentResponse(student); + return response; + + + 2) Jackson 설정으로 프록시 무시하게 만들기 + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper() + .registerModule(new Hibernate5Module()); // Hibernate 전용 모듈 + } + + + 3) 프록시 객체를 무시하는 어노테이션 사용 + @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) + + */ +} \ No newline at end of file diff --git a/src/main/java/com/example/jpapractice/entity/Student.java b/src/main/java/com/example/jpapractice/entity/Student.java new file mode 100644 index 0000000..2b3765c --- /dev/null +++ b/src/main/java/com/example/jpapractice/entity/Student.java @@ -0,0 +1,33 @@ +package com.example.jpapractice.entity; + +import com.fasterxml.jackson.annotation.JsonBackReference; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter +@Setter +@Table(name = "students") +public class Student { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private Integer age; + + @ManyToOne + @JoinColumn(name = "class_room_id") + // @JsonBackReference // 순환 참조 해결 방법 1: 역방향 참조를 JSON 직렬화에서 제외 + // @JsonIgnore // 순환 참조 해결 방법 2: 해당 필드를 JSON 직렬화에서 완전히 제외 + +// @JsonIgnore + @JsonBackReference + private ClassRoom classRoom; +} \ No newline at end of file diff --git a/src/main/java/com/example/jpapractice/repository/ClassRoomRepository.java b/src/main/java/com/example/jpapractice/repository/ClassRoomRepository.java new file mode 100644 index 0000000..511f437 --- /dev/null +++ b/src/main/java/com/example/jpapractice/repository/ClassRoomRepository.java @@ -0,0 +1,26 @@ +package com.example.jpapractice.repository; + +import com.example.jpapractice.entity.ClassRoom; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ClassRoomRepository extends JpaRepository { + + // 기본 CRUD 메서드는 JpaRepository에서 상속받음 + + // JPQL을 사용한 쿼리 예시 + @Query("SELECT c FROM ClassRoom c WHERE c.capacity > :minCapacity") + List findByCapacityGreaterThan(@Param("minCapacity") Integer minCapacity); + + // 메서드 이름 기반 쿼리 예시 + List findByTeacherName(String teacherName); + + // @Query와 네이티브 쿼리 사용 예시 + @Query(value = "SELECT * FROM class_rooms WHERE capacity > :minCapacity", nativeQuery = true) + List findLargeClassRooms(@Param("minCapacity") Integer minCapacity); +} \ No newline at end of file diff --git a/src/main/java/com/example/jpapractice/repository/StudentRepository.java b/src/main/java/com/example/jpapractice/repository/StudentRepository.java new file mode 100644 index 0000000..234f58b --- /dev/null +++ b/src/main/java/com/example/jpapractice/repository/StudentRepository.java @@ -0,0 +1,29 @@ +package com.example.jpapractice.repository; + +import com.example.jpapractice.entity.Student; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface StudentRepository extends JpaRepository { + + // 기본 CRUD 메서드는 JpaRepository에서 상속받음 + + // 연관관계를 활용한 쿼리 예시 + List findByClassRoomId(Long classRoomId); + + // JPQL을 사용한 조인 쿼리 예시 + @Query("SELECT s FROM Student s JOIN s.classRoom c WHERE c.name = :className") + List findByClassName(@Param("className") String className); + + // 페이징 처리가 포함된 쿼리 예시 + @Query("SELECT s FROM Student s WHERE s.age >= :minAge") + List findStudentsByAge(@Param("minAge") Integer minAge); + + // 메서드 이름 기반 쿼리 예시 + List findByNameContaining(String name); +} \ No newline at end of file diff --git a/src/main/java/com/example/jpapractice/service/ClassRoomService.java b/src/main/java/com/example/jpapractice/service/ClassRoomService.java new file mode 100644 index 0000000..fd66a8c --- /dev/null +++ b/src/main/java/com/example/jpapractice/service/ClassRoomService.java @@ -0,0 +1,78 @@ +package com.example.jpapractice.service; + +import com.example.jpapractice.entity.ClassRoom; +import com.example.jpapractice.repository.ClassRoomRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ClassRoomService { + + private final ClassRoomRepository classRoomRepository; + + /** + * 새로운 반을 생성합니다. + * @param classRoom 생성할 반 정보 + * @return 저장된 반 정보 + */ + @Transactional + public ClassRoom createClassRoom(ClassRoom classRoom) { + return classRoomRepository.save(classRoom); + } + + /** + * 모든 반 정보를 조회합니다. + * @return 반 목록 + */ + public List getAllClassRooms() { + return classRoomRepository.findAll(); + } + + /** + * ID로 반 정보를 조회합니다. + * @param id 반 ID + * @return 반 정보 + */ + public ClassRoom getClassRoomById(Long id) { + return classRoomRepository.findById(id) + .orElseThrow(() -> new RuntimeException("반을 찾을 수 없습니다: " + id)); + } + + /** + * 반 정보를 수정합니다. + * @param id 수정할 반 ID + * @param updatedClassRoom 수정할 반 정보 + * @return 수정된 반 정보 + */ + @Transactional + public ClassRoom updateClassRoom(Long id, ClassRoom updatedClassRoom) { + ClassRoom existingClassRoom = getClassRoomById(id); + existingClassRoom.setName(updatedClassRoom.getName()); + existingClassRoom.setCapacity(updatedClassRoom.getCapacity()); + existingClassRoom.setTeacherName(updatedClassRoom.getTeacherName()); + return existingClassRoom; + } + + /** + * 반을 삭제합니다. + * @param id 삭제할 반 ID + */ + @Transactional + public void deleteClassRoom(Long id) { + classRoomRepository.deleteById(id); + } + + /** + * 특정 정원 이상의 반을 조회합니다. + * @param minCapacity 최소 정원 + * @return 반 목록 + */ + public List findLargeClassRooms(Integer minCapacity) { + return classRoomRepository.findByCapacityGreaterThan(minCapacity); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/jpapractice/service/StudentService.java b/src/main/java/com/example/jpapractice/service/StudentService.java new file mode 100644 index 0000000..a8eee9b --- /dev/null +++ b/src/main/java/com/example/jpapractice/service/StudentService.java @@ -0,0 +1,87 @@ +package com.example.jpapractice.service; + +import com.example.jpapractice.entity.Student; +import com.example.jpapractice.repository.StudentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StudentService { + + private final StudentRepository studentRepository; + + /** + * 새로운 학생을 생성합니다. + * @param student 생성할 학생 정보 + * @return 저장된 학생 정보 + */ + @Transactional + public Student createStudent(Student student) { + return studentRepository.save(student); + } + + /** + * 모든 학생 정보를 조회합니다. + * @return 학생 목록 + */ + public List getAllStudents() { + return studentRepository.findAll(); + } + + /** + * ID로 학생 정보를 조회합니다. + * @param id 학생 ID + * @return 학생 정보 + */ + public Student getStudentById(Long id) { + return studentRepository.findById(id) + .orElseThrow(() -> new RuntimeException("학생을 찾을 수 없습니다: " + id)); + } + + /** + * 학생 정보를 수정합니다. + * @param id 수정할 학생 ID + * @param updatedStudent 수정할 학생 정보 + * @return 수정된 학생 정보 + */ + @Transactional + public Student updateStudent(Long id, Student updatedStudent) { + Student existingStudent = getStudentById(id); + existingStudent.setName(updatedStudent.getName()); + existingStudent.setAge(updatedStudent.getAge()); + existingStudent.setClassRoom(updatedStudent.getClassRoom()); + return existingStudent; + } + + /** + * 학생을 삭제합니다. + * @param id 삭제할 학생 ID + */ + @Transactional + public void deleteStudent(Long id) { + studentRepository.deleteById(id); + } + + /** + * 특정 반의 모든 학생을 조회합니다. + * @param classRoomId 반 ID + * @return 학생 목록 + */ + public List getStudentsByClassRoom(Long classRoomId) { + return studentRepository.findByClassRoomId(classRoomId); + } + + /** + * 특정 나이 이상의 학생을 조회합니다. + * @param minAge 최소 나이 + * @return 학생 목록 + */ + public List getStudentsByAge(Integer minAge) { + return studentRepository.findStudentsByAge(minAge); + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 8226517..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=jpa-practice diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..639802f --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,36 @@ +spring: + application: + name: jpa-practice +# h2 + datasource: + url: jdbc:h2:mem:testdb + username: root + password: + driver-class-name: org.h2.Driver + + h2: + console: + enabled: true + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.H2Dialect + defer-datasource-initialization: true + +logging: + level: + org.springframework.security: DEBUG + com.ssafy.demo: DEBUG + +server: + port: ${SERVER_PORT:8080} + +springdoc: + api-docs: + path: /v3/api-docs + swagger-ui: + path: /swagger-ui.html \ No newline at end of file diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..1aad4a2 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,44 @@ +-- 반(class_rooms) 데이터 +INSERT INTO class_rooms (name, capacity, teacher_name) VALUES +('1반', 30, '김선생'), +('2반', 25, '이선생'), +('3반', 35, '박선생'), +('4반', 20, '최선생'), +('5반', 40, '정선생'); + +-- 학생(students) 데이터 +INSERT INTO students (name, age, class_room_id) VALUES +-- 1반 학생들 +('홍길동', 15, 1), +('김철수', 16, 1), +('이영희', 15, 1), +('박민수', 16, 1), +('최지우', 15, 1), + +-- 2반 학생들 +('강민준', 16, 2), +('서예준', 15, 2), +('윤서연', 16, 2), +('장하은', 15, 2), +('임지훈', 16, 2), + +-- 3반 학생들 +('한지민', 17, 3), +('송중기', 16, 3), +('김태희', 17, 3), +('이병헌', 16, 3), +('전지현', 17, 3), + +-- 4반 학생들 +('공유', 18, 4), +('김고은', 17, 4), +('이동욱', 18, 4), +('유인나', 17, 4), +('조정석', 18, 4), + +-- 5반 학생들 +('현빈', 19, 5), +('손예진', 18, 5), +('강동원', 19, 5), +('한효주', 18, 5), +('김우빈', 19, 5); diff --git a/src/test/java/com/example/jpapractice/JpaPracticeTest.java b/src/test/java/com/example/jpapractice/JpaPracticeTest.java new file mode 100644 index 0000000..e263677 --- /dev/null +++ b/src/test/java/com/example/jpapractice/JpaPracticeTest.java @@ -0,0 +1,110 @@ +package com.example.jpapractice; + +import com.example.jpapractice.entity.ClassRoom; +import com.example.jpapractice.entity.Student; +import com.example.jpapractice.repository.ClassRoomRepository; +import com.example.jpapractice.repository.StudentRepository; +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; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +class JpaPracticeTest { + + @Autowired + private ClassRoomRepository classRoomRepository; + + @Autowired + private StudentRepository studentRepository; + + /** + * 기본적인 CRUD 테스트 + */ + @Test + @Transactional + void basicCrudTest() { + // Create + ClassRoom classRoom = new ClassRoom(); + classRoom.setName("1반"); + classRoom.setCapacity(30); + classRoom.setTeacherName("김선생"); + ClassRoom savedClassRoom = classRoomRepository.save(classRoom); + + // Read + ClassRoom foundClassRoom = classRoomRepository.findById(savedClassRoom.getId()) + .orElseThrow(() -> new RuntimeException("반을 찾을 수 없습니다")); + assertEquals("1반", foundClassRoom.getName()); + + // Update + foundClassRoom.setName("2반"); + classRoomRepository.save(foundClassRoom); + ClassRoom updatedClassRoom = classRoomRepository.findById(foundClassRoom.getId()) + .orElseThrow(() -> new RuntimeException("반을 찾을 수 없습니다")); + assertEquals("2반", updatedClassRoom.getName()); + + // Delete + classRoomRepository.deleteById(updatedClassRoom.getId()); + assertFalse(classRoomRepository.existsById(updatedClassRoom.getId())); + } + + /** + * 연관관계 테스트 + */ + @Test + @Transactional + void relationshipTest() { + // 반 생성 + ClassRoom classRoom = new ClassRoom(); + classRoom.setName("1반"); + classRoom.setCapacity(30); + classRoom.setTeacherName("김선생"); + ClassRoom savedClassRoom = classRoomRepository.save(classRoom); + + // 학생 생성 + Student student = new Student(); + student.setName("홍길동"); + student.setAge(15); + student.setClassRoom(savedClassRoom); + Student savedStudent = studentRepository.save(student); + + // 연관관계 확인 + Student foundStudent = studentRepository.findById(savedStudent.getId()) + .orElseThrow(() -> new RuntimeException("학생을 찾을 수 없습니다")); + assertEquals(savedClassRoom.getId(), foundStudent.getClassRoom().getId()); + + // 반의 학생 목록 확인 + List studentsInClass = studentRepository.findByClassRoomId(savedClassRoom.getId()); + assertEquals(1, studentsInClass.size()); + assertEquals("홍길동", studentsInClass.get(0).getName()); + } + + /** + * JPQL 쿼리 테스트 + */ + @Test + @Transactional + void jpqlQueryTest() { + // 테스트 데이터 생성 + ClassRoom classRoom1 = new ClassRoom(); + classRoom1.setName("1반"); + classRoom1.setCapacity(20); + classRoom1.setTeacherName("김선생"); + classRoomRepository.save(classRoom1); + + ClassRoom classRoom2 = new ClassRoom(); + classRoom2.setName("2반"); + classRoom2.setCapacity(40); + classRoom2.setTeacherName("이선생"); + classRoomRepository.save(classRoom2); + + // 정원이 30명 이상인 반 조회 + List largeClassRooms = classRoomRepository.findByCapacityGreaterThan(30); + assertEquals(1, largeClassRooms.size()); + assertEquals("2반", largeClassRooms.get(0).getName()); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/jpapractice/controller/StudentControllerTest.java b/src/test/java/com/example/jpapractice/controller/StudentControllerTest.java new file mode 100644 index 0000000..8741c0b --- /dev/null +++ b/src/test/java/com/example/jpapractice/controller/StudentControllerTest.java @@ -0,0 +1,52 @@ +package com.example.jpapractice.controller; + +import com.example.jpapractice.entity.ClassRoom; +import com.example.jpapractice.entity.Student; +import com.example.jpapractice.repository.ClassRoomRepository; +import com.example.jpapractice.repository.StudentRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +public class StudentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ClassRoomRepository classRoomRepository; + + @Autowired + private StudentRepository studentRepository; + + @Test + void testCircularReferenceResolved() throws Exception { + // 1. 반 생성 + ClassRoom classRoom = new ClassRoom(); + classRoom.setName("1반"); + classRoom.setCapacity(30); + classRoom.setTeacherName("김선생"); + ClassRoom savedClassRoom = classRoomRepository.save(classRoom); + + // 2. 학생 생성 + Student student = new Student(); + student.setName("홍길동"); + student.setAge(15); + student.setClassRoom(savedClassRoom); + Student savedStudent = studentRepository.save(student); + + // 3. API 호출 - 순환 참조 없이 잘 응답되는지 확인 + mockMvc.perform(get("/api/students/" + student.getId())) + .andExpect(status().isOk()); // 200 OK → 순환 참조 해결됨 + + mockMvc.perform(get("/api/classrooms/" + classRoom.getId())) + .andExpect(status().isOk()); // 역시 OK면 성공 + } +}