[Spring 강의 작성] Team - Member / OneToMany
이번 글에선 최~~~대한 쉽게
1:N 연관관계를 갖는 Entity 실습을 설명할 것이다.
Spring data JPA와 MySQL을 사용할 것이기에, 다음 dependency를 build.gradle에 추가해준다.
// 추가
implementation 'org.springframework.boot:spring-boot-starter-data-jpa:2.7.6'
implementation 'mysql:mysql-connector-java:8.0.31'
MySQL은 데이터베이스를 목적으로 사용하는 데이터베이스 서버이고
Spring data JPA는, ORM 라이브러리이다.
ORM
어플리케이션의 객체와 관계형 데이터베이스의 데이터를 자동으로 매핑해주는 것
- 객체가 테이블이 되도록 매핑 시켜주는 프레임워크이다.
- 쉽게말해, 테이블의 세상인 관계형 데이터베이스와, 객체의 세상인 JAVA 사이의 변환을 쉽게 도와준다.
MySQL을 실행하고, 데이터베이스를 만들어준다.
MySQL 서버가 설치되어 있지 않다면 여기를 보고 오면 된다.
CREATE DATABASE TEAM
TEAM 이라는 이름의 데이터베이스를 생성해주었다.
그럼 이제 ORM 라이브러리 Spring data JPA를 이용해서, 테이블을 간단하게 만들어보자.
Member.java
package io.spring.hevton.Team.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Entity
@Getter
@Builder
@NoArgsConstructor // 파라미터가 없는 기본 생성자를 생성
@AllArgsConstructor // 모든 필드값을 파라미터로 갖는 생성자를 생성
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // Auto_increment
private Long id;
private String name;
@ManyToOne
@JoinColumn(name = "team_id") // team_id 컬럼이 생긴다.
private Team team;
}
Team.java
package io.spring.hevton.Team.entity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // Auto_increment
private Long id;
@Builder.Default // https://multifrontgarden.tistory.com/221
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY) // 주인은 다 쪽이다. One에서는 데이터를 담고 있지 않으므로, 가져올 때만 가져오게끔 LAZY
private List<Member> memberList = new ArrayList<>();
public String toString() {
return "id : " + id + ", List : " + memberList.toString() + "\n";
}
}
@Builder.Default는, 초깃값을 지정해준 경우에 달아주어야 한다.
이렇게 하면,
1 : N = One to Many = Member : Team 관계가 생성된다.
아직까진 데이터가 없는데, 스프링부트를 컴파일하여 Entity들이 잘 만들어졌는지 확인한다.
테이블이 두개 생성된 것을 확인할 수 있다.
구조도 잘 실행된 것을 볼 수 있다.
여기서 볼 수 있듯이, 다 대 일 관계에서 주인은 '다' 이다.
일에는 필드 자체에 List 가 없다. 그래서 LAZY를 통해서 List가 필요할 때만 가져오는 옵션을 권장한다.
이제, 수행하기 위해 Controller부터 코딩을 진행한다.
MemberController.java
package io.spring.hevton.Team.controller;
import io.spring.hevton.Team.dto.MemberRequestDto;
import io.spring.hevton.Team.dto.MemberResponseDto;
import io.spring.hevton.Team.dto.Message;
import io.spring.hevton.Team.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@PostMapping("/member")
public ResponseEntity<Message<MemberResponseDto>> CreateMember(@RequestBody MemberRequestDto member) {
MemberResponseDto dto = memberService.createMember(member);
Message<MemberResponseDto> message = Message.<MemberResponseDto>builder()
.body(dto)
.message("OK")
.code(HttpStatus.OK)
.build();
return new ResponseEntity<>(message, HttpStatus.OK);
}
}
Member는 생성방식만 정의했다.
TeamController.java
package io.spring.hevton.Team.controller;
import io.spring.hevton.Team.dto.Message;
import io.spring.hevton.Team.dto.TeamResponseDto;
import io.spring.hevton.Team.service.TeamService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
public class TeamController {
private final TeamService teamService;
@PostMapping("/team")
public ResponseEntity<Message<TeamResponseDto>> createTeam() {
TeamResponseDto dto = teamService.createTeam();
Message<TeamResponseDto> message = Message.<TeamResponseDto>builder()
.body(dto)
.message("OK")
.code(HttpStatus.OK)
.build();
return new ResponseEntity<>(message, HttpStatus.OK);
}
@GetMapping("/team/{id}")
public ResponseEntity<Message<TeamResponseDto>> getTeam(@PathVariable Long id) {
TeamResponseDto dto = teamService.findById(id);
Message<TeamResponseDto> message = Message.<TeamResponseDto>builder()
.body(dto)
.message("OK")
.code(HttpStatus.OK)
.build();
return new ResponseEntity<>(message, HttpStatus.OK);
}
}
Team은 생성과 조회를 정의했다.
HttpEntity
HTTP 요청(Request) 또는 응답(Response)에 해당하는 HttpHeader와 HttpBody를 포함하는 클래스이다.
ResponoseEntity
HttpEntity 클래스를 상속받아 구현한 클래스가 RequestEntity, ResponseEntity 클래스이다. ResponseEntity는 사용자의 HttpRequest에 대한 응답 데이터를 포함하는 클래스이다. 따라서 HttpStatus, HttpHeaders, HttpBody를 포함한다.
Message는, Responses 데이터 형식을 정하기 위해 만들었다.
Message.java
package io.spring.hevton.Team.dto;
import lombok.*;
import org.springframework.http.HttpStatus;
@Data
@Builder
@AllArgsConstructor
@Setter(AccessLevel.NONE)
public class Message<T> {
private HttpStatus code;
private String message;
private T body;
}
그리고 두 Controller에서 쓰인 Dto도 정의해준다.
MemberRequestDto
package io.spring.hevton.Team.dto;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Data;
import lombok.Setter;
@Data
@Builder
@Setter(AccessLevel.NONE)
public class MemberRequestDto {
private Long id;
private String name;
private Long team_id;
}
MemberResponseDto.java
package io.spring.hevton.Team.dto;
import lombok.*;
@Data // Data == Getter, Setter, RequiredArgsConstructor, ToString, EqualsAndHashCode
@Builder
@NoArgsConstructor // https://athena7.tistory.com/entry/Lombok-NoArgsConstructor-AllArgsConstructor-RequiredArgsConstructor
@AllArgsConstructor
@Setter(AccessLevel.NONE) // https://velog.io/@bwjhj1030/DTO-만들-때-Lombok-꿀팁-대방출
public class MemberResponseDto {
private Long id;
}
TeamRequestDto.java
package io.spring.hevton.Team.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TeamRequestDto {
private Long id;
}
TeamResponseDto.java
package io.spring.hevton.Team.dto;
import io.spring.hevton.Team.entity.Member;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TeamResponseDto {
private Long id;
private List<MemberResponseDto> memberList; // 무한루프 참조 방지, Dto 사용
}
@Data 어노테이션은, 주로 Dto에 쓰인다.
Data == Getter, Setter, RequiredArgsConstructor, ToString, EqualsAndHashCode
이러한 모든 것을 포함하기 때문에, 간편하게 사용할 수 있지만
Setter가 정의되어있기 때문에 @Setter(AccessLevel.NONE) 를 통해, Setter는 방지시키기도 한다.
MemberRepository.java
package io.spring.hevton.Team.repository;
import io.spring.hevton.Team.entity.Member;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, Long> {
}
TeamRepository.java
package io.spring.hevton.Team.repository;
import io.spring.hevton.Team.entity.Team;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface TeamRepository extends JpaRepository<Team, Long> {
}
MemberService.java
package io.spring.hevton.Team.service;
import io.spring.hevton.Team.dto.MemberRequestDto;
import io.spring.hevton.Team.dto.MemberResponseDto;
import io.spring.hevton.Team.entity.Member;
import io.spring.hevton.Team.repository.MemberRepository;
import io.spring.hevton.Team.repository.TeamRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class MemberService {
private final MemberRepository memberRepository;
private final TeamRepository teamRepository;
public MemberResponseDto createMember(MemberRequestDto dto) {
Member member = memberRepository.save(Member.builder()
.name(dto.getName())
.team(teamRepository.findById(dto.getTeam_id()).orElseThrow())
.build());
return MemberResponseDto.builder().id(member.getId()).build();
}
}
MemberService에서, Member를 생성할 때에는
MemberRequestDto에서 받은 Team_id를 통해 Team객체를 먼저 가져온 뒤에 member를 저장한다.
TeamService.java
package io.spring.hevton.Team.service;
import io.spring.hevton.Team.dto.MemberResponseDto;
import io.spring.hevton.Team.dto.TeamResponseDto;
import io.spring.hevton.Team.entity.Team;
import io.spring.hevton.Team.repository.TeamRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.stream.Collectors;
@RequiredArgsConstructor
@Service
public class TeamService {
private final TeamRepository teamRepository;
public TeamResponseDto createTeam() {
Team team = teamRepository.save(Team.builder().build());
return TeamResponseDto.builder().id(team.getId()).build();
}
public TeamResponseDto findById(Long id) {
Team team = teamRepository.findById(id).orElseThrow();
System.out.println(team.toString());
// TeamResponseDto에서 Member를 List<Member>로 쓰면, 양방향 순환참조가 되면서 이 리턴 부분에서 오류가 생긴다.
// 따라서 TeamResponseDto에서 Member를 List<MemberResponseDto> 등 새로운 dto로 만들어서 순환참조를 없애야 이 부분이 잘 진행된다.
return TeamResponseDto.builder().id(team.getId()).memberList(team.getMemberList().stream().map(h -> MemberResponseDto.builder().id(h.getId()).build()).collect(Collectors.toList())).build();
}
}
신기한 점은, 우리가 Entity를 정의할 때에는
Team 내에 Member들의 List를 두고 정의했지만, 실제 관계형 데이터베이스 상에는 Member 관련 필드가 전혀 없었고
하지만 또 JPA를 이용해 Team 객체를 가져오면, Member도 같이 가져와진다.
이렇게, 우리가 코드 상에서의 구현 개념을 관계형데이터베이스에 잘 녹여주는게 JPA의 역할이다.
주의할 점 : DTO 사용
: Entity 자체를 response로 리턴하는 것을 주의한다. entity 자체를 return 하지 말고, DTO 객체를 만들어 필요한 데이터만 옮겨담아 Client로 리턴하면 순환 참조 관련 문제는 애초에 방지 할 수 있다.
TeamService에서 주목해봐야할점은, findById이다.
Team을 가져오고, 이를 ResponseDto로 리턴하는 과정에서
Team과 Member는 양방향 참조 상태이기 때문에, 순환참조 상태이다.
따라서
Team에서 찾은 Member 객체 안에서의 Team의 Member의 Team의 ...
이렇게 될 수 있다.
이는 Dto를 사용하지 않고 Entity 그 자체를 사용하여 리턴할 때 발생할 수 있는 문제이다
따라서 Dto 사용을 권장하는 이유가 추가된다.
이제 Insomnia라는 Rest Client를 이용해 실행을 테스트해본다.
팀을 먼저 추가한다. auto_increment로 인해서 id가 1인 팀이 생성된 것을 확인할 수 있다.
Member 생성도 잘 된다. 방금 생성한 team_id 기반으로 넣어준다.
데이터를 더 추가한 뒤에, 결과를 보자.
잘 동작함을 확인할 수 있다!
기억할 점은 다음과 같다.
- 양방향 매핑 시에 순환참조가 발생할 수 있다. 그렇기 떄문에서라도 Dto 사용을 더욱 적극 권장한다.
- 1 : N = Team : Member 관계에서, Member 객체를 insert할 때에는, Team객체에 대한 id를 통해 Team객체를 먼저 찾은 뒤에, 그 객체를 담아서 Member데이터를 추가한다.
참고
OneToMany 관계에서, 지금처럼 FetchType.LAZY 처리가 되어 있으면
Team에서 findById() 같은 함수를 호출하게 되면, Hibernate 같은 쿼리를 통해 확인해보면 select를 통해 Member 데이터를 곧바로 가져오지 않는 것을 확인할 수 있다.
그러다가 team = findById() 이후 team.getMembers() 같은 함수를 호출하게 되면 그제서야 DB에서 member를 가져오게 된다.
이게 바로 LAZY이다.
FetchType.EAGER로 설정했다면, 아예 쿼리를 날릴 때 부터 Fetch join으로 함께 가져오게 된다.
@EntityGraph는 FetchType.EAGER와 같은 방식이다.