다음은 글은 infrean의 "자바와 스프링 부트로 생애 최초 서버 만들기, 누구나 쉽게 개발부터 배포까지! [서버 개발 올인원 패키지]" 강의의 학습 목적으로 작성된 것입니다. 강의의 디테일한 내용이나, 코드 등은 빠져있을 수 있습니다.
1. 책 생성 API 개발하기
- API 스펙
• HTTP Method: POST
• HTTP Path: /book
• HTTP Body (JSON)
{
"name": String // 책 이름
}
• 결과 반환 X (HTTP 상태 200 OK이면 충분하다)
-> book 테이블 설계
create table book
(
id bigint auto_increment,
name varchar(255),
primary key (id)
);
=> 직접 설계하고, 개발해보자!
2. 대출 기능 개발하기
- 요구사항
사용자가 책을 빌릴 수 있다.
다른 사람이 그 책을 미리 빌렸다면, 빌릴 수 없다.
- API 스펙
• HTTP Method: POST
• HTTP Path: /book/loan
• HTTP Body (JSON)
{
"userName": String
"bookName": String
}
• 결과 반환 X (HTTP 상태 200 OK이면 충분하다)
=> 테이블 설계
유저의 대출 기록을 저장하는 새로운 테이블이 필요하다!
create table user_loan_history
( id bigint auto_increment,
user_id bigint,
book_name varchar(255),
is_return tinyint(1),
primary key (id)
);
// UserLoanHistoryRepository 작성
// UserLoanHistory 엔티티 작성
// BookLoanRequest dto 작성
// BookController 내부에 PostMapping 작성
// BookService 내부에 loanBook 함수 작성
@Transactional
public void loanBook(BookLoanRequest request) {
// 1. 책 정보 가져오기
Book book = bookRepository.findByName(request.getBookName())
.orElseThrow(IllegalAccessError::new);
// 2. 대출 기록 조회, 대출 중인지 확인
if(userLoanHistoryRepository.existsByBookNameAndIsReturn(book.getName(), false)) {
// 2-1. 대출 중이라면, 예외 발생.
throw new IllegalArgumentException("이미 대출되어 있는 책입니다.");
}
// 3. 대출 중이 아니라면, 대출
// 3-1. 유저 정보 가져오기
User user = userRepository.findByName(request.getUserName())
.orElseThrow(IllegalAccessError::new);
// 3-2. 유저 정보와 책 정보를 기반으로 UserLoanHistory 저장
userLoanHistoryRepository.save(new UserLoanHistory(user.getId(), book.getName()));
}
3. 반납 기능 개발하기
- API 스펙
• HTTP Method: PUT
• HTTP Path: /book/return
• HTTP Body (JSON)
{
"userName": String
"bookName": String
}
• 결과 반환 X (HTTP 상태 200 OK이면 충분하다)
🤔 대출 API 스펙의 HTTP Body와 완전히 동일하다.
재활용? 다시 make?
-> 새로 만드는 것이 좋다.
두 기능 중 한 기능에 변화가 생겼을 때, side-effect없이 대처할 수 있다.
// BookService 내부에 returnBook 함수 작성
@Transactional
public void returnBook(BookReturnRequest request) {
// 1. 유저 정보 가져오기
User user = userRepository.findByName(request.getUserName())
.orElseThrow(IllegalAccessError::new);
// 2. 대출 기록 찾기
UserLoanHistory userLoanHistory = userLoanHistoryRepository
.findByUserIdAndBookName(user.getId(), request.getBookName())
.orElseThrow(IllegalAccessError::new);
// 3. isReturn을 true로
userLoanHistory.setReturn(true);
}
SQL 대신 ORM을 사용했다.
🤔 "DB 테이블과 객체는 패러다임이 다르기 때문"
DB 테이블에 데이터를 저장하는 것은 필수이지만,
Java는 객체지향 언어이고, 절차지향적인 설계보다 객체지향적인 설계를 지향한다.
4. 조금 더 객체지향적으로 개발할 수 없을까?
User, Book, UserLoanHisotry가 모두 분리되어 있는 객체이다.
원래는 BookService에서 Book과 User을 모두 가져와서
유저 정보와 책 정보를 기반으로 UserLoanHistory 저장 하는 방식 이었다.
하지만, BookService에서 User을 바로 가져와서 대출을 처리하는 방식으로 바꿔보자.
4-1. 구조 Refactoring 의 선행 조건
User와 UserLoanHistory가 서로를 알아야 한다.
4-2. UserLoanHistory Entity 구조 변경
간단하게 생각하면, UserLoanHistory의 멤버변수에 User을 넣어주면 끝날 것 같다.
@Entity
public class UserLoanHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
private User user; // userId 대신 변경
private String bookName;
// ... 생략
하지만, Jpa는 기존의 table과 매핑하는 과정에서 user라는 filed를 찾지 못한다. (table에 없으니까)
이때 사용하는 어노테이션이 @ManyToOne이다.
'대출기록은 여러 개이고, 대출기록을 소유하는 User는 한명이다.' 라는 소리이다.
이러한 관계를 N:1 관계라고 한다.
4-3. User Entity 구조 변경
이제 User도 UserLoanHistory와 연결될 수 있도록 구조를 바꿔줘야 한다.
이번에는 @OneToMany 어노테이션을 활용하면 된다.
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 20) // name varchar(20)
private String name;
private Integer age;
@OneToMany
private List<UserLoanHistory> userLoanHistories = new ArrayList<>();
이제 세부적으로 오류나는 부분을 수정해주면 된다.
4-4. 연관관계의 주인
두 Table을 Mapping 했을 때, 우리는 연관관계의 주인을 설정해주어야 한다.
한 문장으로 말하면, Table을 보았을 때 누가 관계의 주도권을 가지고 있는가? 이다.
조금 더 구조적으로 접근하면,
데이터베이스 테이블 간에 어떤 쪽이 외래 키(Foreign Key)를 관리할지를 나타낸다.
이렇게 되면, 주인(Entity Owner)은 데이터베이스에 영향을 주는 책임이 있다.
우리의 Table 구조를 보면서 살펴보자.
주도권은 user_loan_history에 있다. 근거는 user_loan_history에 user_id(외래키)를 가지고 있기 때문이다.
이전에 @ManyToOne 과 @OneToMany로 연결을 해주었다.
그리고 연관관계의 주인이 아닌 쪽에 mappedBy = "주인이 아닌 쪽 이름" 옵션을 달아주어야 한다.
(mappedBy를 ~에 종속되어 있다. 매달려 있다. 라는 식으로 이해하면 편하다.)
@OneToMany(mappedBy = "user")
private List<UserLoanHistory> userLoanHistories = new ArrayList<>();
* 연관관계의 주인의 효과
- 상대 테이블을 참조하고 있으면 연관관계의 주인
- 연관관계의 주인이 아니면 mappedBy를 사용
- 연관관계의 주인의 setter가 사용되어야만 테이블 연결
5. JPA 연관관계
5-1. 1:1 관계
예시 ) 한 사람은 한 개의 실주소만을 가지고 있다.
그렇다면, 서로 간에 Entity를 1:1 관계로 연결시킬 수 있다.
둘은 서로 연결되어 있기는 하지만, 한 쪽만 다른 한 쪽의 Id를 가지고 있어도 충분하다.
물론, 아무 쪽이나 가지고 있어도 상관은 없다.
Person의 Address라는 말이 어울리므로, Person에 address_id를 field로 두는 것이 일반적이다.
어찌됐든, Person에 address_id를 field로 두고, peson이 주인이 된다!
그리고 양 쪽 모두에 1:1 관계의 @OneToOne 어노테이션을 사용해주어 연결해준다.
그리고, 주인이 아닌 쪽에 mappedBy 옵션을 달아줘야 한다고 했다.
그렇다면, Address 엔티티의 Person 필드 위에 @OneToOne(mappedBy = "address") 라고 써주면 된다.
5-2. 연관 관계의 주인 효과
그렇다면 굳이 연관 관계 중에서 "주인"을 결정해줘야 하는 이유가 있을까?
결론부터 이야기하면, 사용하는 이유는 "객체가 연결되는 기준이 되기 때문"이다.
그렇다면 위와 같이 코딩을 하고 다음 Transaction을 실행시켜보자.
@Transactional
public void savePerson() {
Person person = personRepository.save(new Person());
Address address = addressRepository.save(new Address());
// 주인을 통해 set 설정
person.setAddress(address);
}
생성자를 호출하면, 모두 비어 있기 때문에
위의 두 줄은 빈 Person, Address 구현체만을 생성해줄 뿐이다.
즉, 연결은 아직 이루어지지 않았다는 이야기이다.
물론, setter를 사용하는 것은 좋지 않지만, 연관관계의 주인을 사용하는 이유를 알아보기 위한 test로 간단히 사용해보자.
=> 이렇게 실행해보면, Person 필드에 address_id가 잘 연결된 것을 볼 수 있다.
그러나,
@Transactional
public void savePerson() {
Person person = personRepository.save(new Person());
Address address = addressRepository.save(new Address());
// 비주인을 통해 set 설정
address.setPerson(person);
}
해당 방식으로 실행해보면, 연결이 되어있지 않은 모습을 볼 수 있다.
즉, 정리하면,
1) 상대 테이블을 참조하고 있으면 연관관계의 주인
2) 연관관계의 주인이 아니면 mappedBy를 사용
3) 연관관계의 주인의 setter가 사용되어야만 테이블 연결
🚨주의사항 : 트랜잭션이 끝나지 않았을 때, 한 쪽만 연결해두면 반대 쪽은 아직 알 수 없다.
@Transactional
public void savePerson() {
Person person = personRepository.save(new Person());
Address address = addressRepository.save(new Address());
person.setAddress(address);
System.out.println(address.getPerson()); // null이 나온다.
}
✅해결책 : setter 설정할 때 둘을 같이 이어주자!
@Entity
Person {
...
public void setAddress(Address address) {
this.address = address;
this.address.setPerson(this); //setAddress에서 나 자신도 연결.
}
}
5-3. N:1 관계 - @ManyToOne과 @OneToMany
* 연관관계의 주인은 무조건 N 쪽이 된다.
논리적으로 보면 N:1의 관계를 살펴볼 수 있다.
User와 UserLoanHistory가 있었다.
User 1명이 UserLoanHistory1, 2, 3.... 을 들고 있기는 부담스럽다.
UserLoanHistory 하나에 User 를 달아놓는 것이 편하다.
고로 UserLoanHistory가 N이 되고, 주인이 된다.
User 쪽에 mappedBy를 적어준다.
또한 @ManyToOne은 단방향에만 사용해도 된다.
둘다 @ManyToOne과 @OneToMany를 써줄 필요가 없다는 뜻이다. (생략 가능)
5-4. @JoinColumn
연관관계의 주인이 활용할 수 있는 어노테이션이다.
필드의 이름이나 null 여부, 유일성 여부, 업데이트 여부 등을 지정한다.
(@Column 어노테이션과 비슷하다!! 대신 사용한다고 생각하면 편하다.)
5-5. N:M 관계 - @ManyToMany
구조가 복잡하고, 테이블이 직관적으로 매핑되지 않아 사용을 비추천한다.
1:N 관계를 여러 개 설정하는 것을 추천한다.
5-6. Option - cascade
cascade의 사전적 의미를 검색하면 다음과 같다. . . 작은 폭포, 폭포처럼 쏟아지는 물, 풍성하게 늘어진 것
마치 초콜릿 분수대에서 위에서 아래로 흘러 내려오듯한 이미지를 상상하면 된다.
코딩에서의 Cascde는 영속성 전이라고 한다.
좀 더 쉽게 말하면
한 객체가 저장되거나 삭제될 때, 그 변경이 폭포처럼 흘러 연결되어 있는 객체도 함께 저장되거나 삭제되는 기능!
cascade 옵션을 정의한 엔티티가 영속화되면 연관된 엔티티도 영속화되고 삭제되면 삭제되는 것이다.
예시를 들어보자!
Ex) user A가 책 두 권을 빌렸다.
user_loan_history DB에 있는 책 1, 2 에 user A id가 있다.
그런데 A라는 user를 삭제하면 어떤 일이 벌어질까?
user A만 사라지고, user_loan_history DB의 책 1, 2는 그대로 남아있다!
논리적으로 함께 사라지는 것을 원한다면cascade = CascadeType.ALL 옵션을 사용해보자.
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 20) // name varchar(20)
private String name;
private Integer age;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<UserLoanHistory> userLoanHistories = new ArrayList<>();
...
5-6. Option - orphanRemoval
아까와 같은 상황이라고 가정해보자.
Ex) user A가 책 두 권을 빌렸다.
user_loan_history DB에 있는 책 1, 2 에 user A id가 있다.
만약에 BookService에서 책 1을 지우고 싶어서, deleteUserHistory라는 메서드를 만들고,
User user = userRpository.findByName("1").orElseThrow(~)
user.removeHisotry("1");
와 같은 코드를 실행시켰다고 해보자..
(removeHistory는 user와 연결되어 있는 UserLoanHistory List에서 책을 지우는 함수이다.)
그러나 아무런 일도 일어나지 않는다.
UserLoanHistory 레포지토리를 가져와서 지우는 작업을 따로 해줘야한다......(너무 번거롭다.)
이렇게 해서 탄생한
끊어진 관계에 해당하는 데이터를 자동으로 제거해주는 옵션이 있다.
(직역하면, 고아 를 제거...)
orphanRemoval = true
이 옵션 또한, @OneToMany 어노테이션의 옵션에 써주면 된다.
6. 책 대출/반납 기능 리펙토링과 지연로딩
6-1. 도메인 계층으로의 비지니스 로직 이동
User 엔티티에 책을 빌리는 함수를 설정해준다.
public void loanBook(String bookName) {
this.userLoanHistories.add(new UserLoanHistory(this, bookName));
}
이렇게 되면, loanBook에서는 userLoanHistory 객체를 직접적으로 사용하지 않게 된다.
도메인 계층에 있는 user와 userLoanHistory가 직접적으로 협력하게 된 것을
"도메인 계층에 비지니스 로직이 들어갔다." 라고 표현한다.
즉, Service에서 Domain(Entity)로 비지니스 로직이 이동한 것이다!
6-2. 지연로딩(Lazy Loading) - 영속성 컨텍스트의 4번째 능력!
코드가 실행될 때 데이터를 필요한 시점에 로드하는 전략!!
매우 게으른 로딩이라고 볼 수 있다... (원래 나처럼 게으른 사람이 극효율충이라는 거)
객체 지향 프로그래밍에서 주로 사용되는 skill이다.
객체의 데이터를 처음에는 로드하지 않고, 실제로 필요한 순간에 비로소 데이터를 로딩하는 방식!
직접 눈으로 보면 더 직관적이다!
// 반납 기능
@Transactional
public void returnBook(BookReturnRequest request) {
// 1. 유저 정보 가져오기
User user = userRepository.findByName(request.getUserName())
.orElseThrow(IllegalAccessError::new);
//Test
System.out.println("TEST");
user.returnBook(request.getBookName());
}
테스트를 위해 사이에 TEST 코드 한 줄을 삽입하고, 반납 기능을 사용해봤다.
User를 가장 먼저 가져오고
Test가 출력이 된 다음
UserLoanHistory를 가져온다!
코드가 돌아감에 따라 필요한 객체의 정보만을 DB에 접근해서 가져오는 것이다.
+ 추가 : @OneToMany 어노테이션을 찾아들어가보면, fetch의 default값이 LAZY로 되어 있다!
... 그러나 @ManyToOne 어노테이션을 찾아들어가보면, fetch의 default값이 EAGER로 되어 있다!
(GPT의 답변)
이는 일반적으로 각각의 관계가 어떤 상황에서 사용되는지에 따라 최적의 동작을 제공하기 위함이다.
@ManyToOne - EAGER(즉시 로딩):
@ManyToOne 관계는 많은 쪽이 하나의 쪽을 참조하는 경우입니다.
예를 들어, 여러 엔티티가 하나의 엔티티를 참조할 때 해당 관계는 주로 필수적이고 자주 사용되는 데이터일 가능성이 높습니다.
따라서 즉시 로딩을 기본으로 하는 것이 성능상 이점을 제공할 수 있습니다.
모든 필수 데이터를 즉시 로드함으로써, 연관된 엔티티에 대한 접근 시 따로 추가적인 데이터베이스 쿼리를 수행하지 않아도 됩니다.
@OneToMany - LAZY(지연 로딩):
@OneToMany 관계는 하나의 쪽이 많은 쪽을 참조하는 경우입니다.
예를 들어, 하나의 엔티티가 다수의 하위 엔티티를 가질 때 해당 관계는 일반적으로 큰 데이터 세트를 다루게 됩니다.
만약 모든 하위 엔티티를 즉시 로딩한다면, 필요하지 않은 데이터를 불필요하게 가져오게 되어 성능 문제가 발생할 수 있습니다.
따라서 기본적으로는 LAZY로 설정하여, 실제로 필요한 시점에만 데이터를 로딩하는 것이 효율적입니다.
이러한 기본 설정은 일반적인 상황에 대한 최적의 동작을 제공하며, 실제 사용 사례에 따라 FetchType을 명시적으로 지정하여 조절할 수 있습니다.
6-3. 추가적으로 생각해 볼 것들
1) 연관관계를 사용하면 무엇이 좋을까?
- 각자의 역할에 집중하게 된다! (= 응집성) Service -> Domain
- 새로운 개발자가 코드를 읽을 때 이해하기 쉬워진다.
- 테스트 코드 작성이 쉬워진다.
2) 그렇다면 연관관계를 사용하는 것이 항상 좋을까?
NO!
지나치게 사용하면, 성능상의 문제가 생길 수도 있고, 도메인 간의 복잡한 연결로 인해 시스템을 파악하기 어려워질 수 있다.
또한 너무 많이 얽혀 있으면, 한 가지를 수정했을 때, 너무 많은 부분에 영향이 퍼지게 된다.
=> 비즈니스 요구사항, 기술적인 요구사항, 도메인 아키텍처 등 여러 부분을 고민해서 연관관계 사용을 선택해야 한다.