요청 → Controller → Service → Repository → DB 저장
| HTTP Method | HTTP path | HTTP body | API return 값 |
|---|---|---|---|
POST | /book | { "name": String } | 200 OK |
create table book
(
id bigint auto_increment,
name varchar(255),
primary key (id)
);
name 필드의 길이를 varchar(255)로 설정한 이유: JPA에서는 기본적으로 @Column 어노테이션을 사용하게 되는데, @Column 어노테이션의 기본 length가 255이므로 주로 이렇게 설정함domain 폴더 안에 아래와 같이 book 객체 생성하기package com.group.libraryapp.domain.book;
import javax.persistence.*;
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false) // , length = 255, name = "name" 이 둘은 기본값이므로 생략 가능
private String name;
protected Book(){};
public Book(String name) {
if (name == null || name.isEmpty()) {
throw new IllegalArgumentException(String.format("잘못된 name (%s)이 들어옴", name));
}
this.name = name;
}
}
@Entity: JPA가 이 클래스를 엔티티(테이블로 저장할 수 있는 클래스)로 인식
@Id: 기본 키
@GeneratedValue(...): 기본 키 자동 생성 (DB가 자동으로 증가시키는 방식)
@Column(nullable = false): name은 NOT NULL이어야 함
기본 생성자 (protected Book()): JPA는 객체를 생성할 때 reflection을 사용하므로 기본 생성자가 꼭 필요함 (보통 protected로 막아두고 외부에서는 new Book(name) 생성자 사용)
reflection이란?
자바에서 클래스나 객체를 runtime에 분석하고 조작해서 클래스 이름만 알아도, new 없이 객체를 만들 수 있게 해주는 기술
JPA는 DB에서 데이터를 조회하고, 조회 결과를 기반으로 Java 객체(Book)를 생성한 후 필드(id, name 등)에 값을 채우는 과정을 실행함.
이를 위해 JPA는 먼저 빈 Book 객체를 만들어야 하는데, 이때 기본 생성자가 필요하다. 이후에 reflection을 통해 필드에 값을 주입함.
기본생성자를 public이 아니라 protected로 만드는 이유: public으로 생성 시 외부에서 실수로 호출할 수 있는 위험을 방지하기 위해 같은 패키지나 하위 클래스에서는 접근 가능하지만 외부에서는 접근 불가한 protected로 생성함
(즉, JPA가 내부적으로 사용하는 건 가능하지만 일반 사용자는 못 쓰도록 하기 위함)
interface 타입으로 레포지토리도 함께 생성해줘야 함package com.group.libraryapp.domain.book;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BookRepository extends JpaRepository<Book, Long> {
}
JpaRepository<Book, Long>: Book 엔티티를 Long 타입의 id로 관리하며, 해당 인터페이스를 생성함으로서 CRUD 메서드를 자동으로 제공받아 사용할 수 있게 됨!save(), findById(), findAll(), delete() 등package com.group.libraryapp.dto.book.request;
public class BookCreateRequest {
private String name;
public String getName() {
return name;
}
}
package com.group.libraryapp.controller.book;
import com.group.libraryapp.dto.book.request.BookCreateRequest;
import com.group.libraryapp.service.book.BookService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@PostMapping("/book")
public void createBook(@RequestBody BookCreateRequest request) {
bookService.createBook(request);
}
}
@RestController: JSON 형태로 응답하는 컨트롤러임을 의미/book으로 POST 요청이 오면 createBook() 메서드가 호출됨@RequestBody: JSON 데이터를 Java 객체(BookCreateRequest)로 변환package com.group.libraryapp.service.book;
import com.group.libraryapp.domain.book.Book;
import com.group.libraryapp.domain.book.BookRepository;
import com.group.libraryapp.dto.book.request.BookCreateRequest;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
@Service
public class BookService {
private final BookRepository bookRepository;
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@Transactional
public void createBook(BookCreateRequest request) {
bookRepository.save(new Book(request.getName()));
}
}
@Service: 이 클래스가 서비스임을 스프링에게 알려줌@Transactional: 이 메서드에서 DB 작업을 트랜잭션으로 처리하며, 하나라도 작동 실패 시 롤백됨bookRepository.save(...): 새 책 객체를 생성하고 DB에 저장| HTTP Method | HTTP path | HTTP body | API return 값 |
|---|---|---|---|
POST | /book/loan | { "userName": String, "bookName": String } | 200 OK |
user_loan_history 테이블 추가하기User, Book 인데, 이 두개의 개별 테이블만으로는 누가 어떤 책을 대출했는지 저장하기 어려움.user_loan_history 라는 테이블을 추가하자.create table user_loan_history
(
id bigint auto_increment,
user_id bigint,
book_name varchar(255),
is_return tinyint(1), -- 0이면 대출중, 1이면 반납됨
primary key (id)
)

user_loan_history 객체 생성하기package com.group.libraryapp.domain.user.loanhistory;
import javax.persistence.*;
@Entity
public class UserLoanHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id = null;
@Column
private Long userId;
@Column
private String bookName;
@Column
private boolean isReturn; // 0 == false, 1 == true로 자동으로 매핑!
protected UserLoanHistory() {};
public UserLoanHistory(Long userId, String bookName) {
this.userId = userId;
this.bookName = bookName;
this.isReturn = false;
}
}
user_loan_history 레포지토리 생성하기package com.group.libraryapp.domain.user.loanhistory;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserLoanHistoryRepository extends JpaRepository<UserLoanHistory, Long> {
}
dto 만들 때에는 항상 입력받을 필드 (API 명세서의 body 부분) 변수를 private 로 만들고, getter 만들어서 마무리. package com.group.libraryapp.dto.book.request;
public class BookLoanRequest {
private String userName;
private String bookName;
public String getUserName() {
return userName;
}
public String getBookName() {
return bookName;
}
}
user_loan_history 컨트롤러와 서비스 클래스 만들기 @PostMapping("/book/loan")
public void loanBook(@RequestBody BookLoanRequest request) {
bookService.loanBook(request);
}
package com.group.libraryapp.service.book;
import com.group.libraryapp.domain.book.Book;
import com.group.libraryapp.domain.book.BookRepository;
import com.group.libraryapp.domain.user.User;
import com.group.libraryapp.domain.user.UserRepository;
import com.group.libraryapp.domain.user.loanhistory.UserLoanHistory;
import com.group.libraryapp.domain.user.loanhistory.UserLoanHistoryRepository;
import com.group.libraryapp.dto.book.request.BookCreateRequest;
import com.group.libraryapp.dto.book.request.BookLoanRequest;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
@Service
public class BookService {
private final BookRepository bookRepository;
private final UserLoanHistoryRepository userLoanHistoryRepository;
private final UserRepository userRepository;
public BookService(
BookRepository bookRepository,
UserLoanHistoryRepository userLoanHistoryRepository,
UserRepository userRepository
) {
this.bookRepository = bookRepository;
this.userLoanHistoryRepository = userLoanHistoryRepository;
this.userRepository = userRepository;
}
@Transactional
public void createBook(BookCreateRequest request) {
bookRepository.save(new Book(request.getName()));
}
@Transactional
public void loanBook(BookLoanRequest request) {
// 1. 책 정보 가져오기 (bookName 기준이므로 내장 CRUD 사용 불가)
Book book = bookRepository.findByName(request.getBookName())
.orElseThrow(IllegalArgumentException::new);
// 2. 대출중인 책인지 점검하기
if (userLoanHistoryRepository.existsByBookNameAndIsReturn(book.getName(), false)) {
// 3. 대출중인 책이라면 예외 발생시키기
throw new IllegalArgumentException("Book already loaned");
} else {
// 4. 대출중이지 않다면 빌려주기
// 4-1. user 정보 가져오기
User user = userRepository.findByName(request.getUserName())
.orElseThrow(IllegalArgumentException::new);
// 4-2. user 정보와 책 정보를 기반으로 UserLoanHistory를 저장
userLoanHistoryRepository.save(new UserLoanHistory(user.getId(), book.getName()));
}
}
}

| HTTP Method | HTTP path | HTTP body | API return 값 |
|---|---|---|---|
| PUT | /book/return | { "userName": String, "bookName": String } | 200 OK |
BookReturnRequest 만들기package com.group.libraryapp.dto.book.request;
public class BookReturnRequest {
private String userName;
private String bookName;
public String getUserName() {
return userName;
}
public String getBookName() {
return bookName;
}
}
@PutMapping("book/return")
public void returnBook(@RequestBody BookReturnRequest request) {
bookService.returnBook(request);
}
@Transactional
public void returnBook(BookReturnRequest request) {
//1. user 정보 가져오기
User user = userRepository.findByName(request.getUserName())
.orElseThrow(IllegalArgumentException::new);
// 2. user id, book name을 기준으로 대출기록을 찾음
UserLoanHistory history = userLoanHistoryRepository.findByUserIdAndBookName(user.getId(), request.getBookName())
.orElseThrow(IllegalArgumentException::new);
// 3. 반납 처리: isReturn == true로 바꿔주기
history.doReturn();
}

User와 UserLoanHistory가 직접 협업하도록 할 수는 없을까? 현재 상황
| 대출 기능 | 반납 기능 |
|---|---|
![]() | ![]() |
개선하고자 하는 방향성
| 대출 기능 | 반납 기능 |
|---|---|
![]() | ![]() |
User가 UserLoanHistory에 연결되도록 수정하기아래와 같이, 기존의 userId를 User 객체로 대체하고, N:1로 매핑하도록 설정하기
UserLoanHistory:User= N:1
@Entity
public class UserLoanHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id = null;
// @Column
// private Long userId;
@ManyToOne // 내가 다수이고(N), 네가 하나임(1)
private User user;
UserLoanHistory가 User에 연결되도록 수정하기userLoanHistories를 User 객체에 추가하고, 1:N으로 매핑되도록 설정하기
@Entity
public class User {
@Id // 이 필드를 PK로 간주한다는 의미
@GeneratedValue(strategy = GenerationType.IDENTITY) // 자동생성되는 값임을 의미
private Long id = null;
@Column(nullable = false, length = 20)
private String name;
private Integer age;
@OneToMany
private List<UserLoanHistory> userLoanHistories = new ArrayList<>();
- 연관관계의 주인이란? FK를 가진 테이블
- 1:N 관계에서는 무조건 N이 연관관계의 주인이다.
위의 예시에서, UserLoanHistory와 User 테이블이 있다고 하자. 만약 테이블을 기반으로 ERD를 그린다고 가정하고, 둘의 관계를 설정할 때에는 User 테이블의 PK를 UserLoanHistory 테이블의 FK로 하여 연결할 것이라고 가정하자.
이 때, User 테이블 (PK를 가진 테이블)에 mappedBy = "user")라고 적어주면 된다. 이 때의 user 는 UserLoanHistory에서 불러온 @ManyToOne private User user; 부분의 user이다.
추가적으로, UserLoanHistory 테이블 (FK를 가진 테이블)을 연관관계의 주인이라고 부른다고 한다. (참고로, 1:N 관계에서는 무조건 N이 연관관계의 주인이다. )
즉, 상대 테이블을 참조하고 있는 테이블이 연관관계의 주인이다.
연관관계의 주인이 아닌 테이블에 mappedBy를 사용해야 한다.
트랜젝션이 끝나야 연결관계가 성립함. (연결관계를 실행하는 트랜젝션은 항상 다른 기능들과 분리해야 함.)
만약 하나의 트랜젝션 안에서 연결관계도 설정하고 설정된 연결관계를 통해서 A테이블의 속성을 활용해 B테이블을 조회한다거나 하는 식의 연결이 필요한 기능을 동작시키면 조회에 실패하여 null만 반환할 것이다.
⮕ 해결책: setter 한번에 두 테이블을 같이 이어주자.
@JoinColumn(name = <테이블에 생성할 외래키 컬럼명>): JPA에서 어떤 컬럼을 외래 키로 설정할지를 지정하는 어노테이션으로, 연관관계의 주인 엔티티 필드 위에 사용함
null 여부, 유일성 여부, 업데이트 여부 등을 저장함@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String name;
@OneToOne
@JoinColumn(
name = "passport_id", // 외래키 컬럼 이름
referencedColumnName = "id", // 참조하는 컬럼
nullable = false, // NOT NULL 제약
unique = true, // UNIQUE 제약 (1:1에서 자주 사용)
updatable = false, // 수정 불가능
insertable = true // INSERT는 허용
)
private Passport passport;
}
passport_id BIGINT NOT NULL UNIQUE
M : N 관계를 표현하는 @ManyToMany 어노테이션은 권장되지 않음 (필요하다면 1:N 관계로 풀어서 설정하기)
cascade : 한 객체가 저장되거나 삭제될 때 그 변경사항이 폭포처럼 흘러서 연결되어있는 객체도 함께 저장되거나 삭제되는 기능
User 가 삭제될 때 UserLoanHistory도 같이 삭제됨@Entity
public class User {
@Id // 이 필드를 PK로 간주한다는 의미
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id = null;
@Column(nullable = false, length = 20)
private String name;
private Integer age;
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
private List<UserLoanHistory> userLoanHistories = new ArrayList<>();
orphanRemoval : 객체간의 관계가 끊어진 데이터를 자동으로 제거하는 옵션이다. 즉, 관계가 끊어진 데이터를 orphan으로 이해하면 된다.