섹션 6. 책 요구사항 구현하기

김민지·2025년 3월 27일

서버 기초 강의

목록 보기
6/12

책 생성 API 개발하기

요청 → Controller → Service → Repository → DB 저장

HTTP MethodHTTP pathHTTP bodyAPI return 값
POST/book { "name": String }200 OK

1. book 테이블 생성하기

create table book
(
    id   bigint auto_increment,
    name varchar(255),
    primary key (id)
);
  • 이 때, name 필드의 길이를 varchar(255)로 설정한 이유: JPA에서는 기본적으로 @Column 어노테이션을 사용하게 되는데, @Column 어노테이션의 기본 length가 255이므로 주로 이렇게 설정함
  • 테이블의 스키마를 바꾸는, 즉 DDL을 바꾸는 일은 생각보다 번거로울 수 있으므로 되도록 텍스트는 여유있게 설정하는 것이 권장됨 (물론 최적화를 해야 하는 경우는 예외)

2. book 객체 생성하기

  • 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가 내부적으로 사용하는 건 가능하지만 일반 사용자는 못 쓰도록 하기 위함)

3. book 레포지토리 생성하기

  • JPA 객체를 만들면 항상 interface 타입으로 레포지토리도 함께 생성해줘야 함
package com.group.libraryapp.domain.book;

import org.springframework.data.jpa.repository.JpaRepository;

public interface BookRepository extends JpaRepository<Book, Long> {
}
  • DB와의 통신을 담당하는 인터페이스
  • JpaRepository<Book, Long>: Book 엔티티를 Long 타입의 id로 관리하며, 해당 인터페이스를 생성함으로서 CRUD 메서드를 자동으로 제공받아 사용할 수 있게 됨!
    • CRUD 메서드 예시: save(), findById(), findAll(), delete()

4. 입력값 파싱을 위해 dto 만들기

package com.group.libraryapp.dto.book.request;

public class BookCreateRequest {
    private String name;

    public String getName() {
        return name;
    }
}
  • 클라이언트(프론트엔드 등)에서 전달하는 JSON 데이터를 자바 객체로 매핑

5. book 컨트롤러와 서비스 클래스 만들기

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);
    }
}
  • API 요청을 받는 역할 (HTTP 진입점)
  • @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 MethodHTTP pathHTTP bodyAPI return 값
POST/book/loan{ "userName": String, "bookName": String }200 OK
  • 요구사항: 사용자는 책을 빌릴 수 있다. (단, 다른 사람이 같은 책을 이미 빌렸을 경우 해당 책은 빌릴 수 없다.)

1. 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)
)

2. 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;
    }
}

3. user_loan_history 레포지토리 생성하기

package com.group.libraryapp.domain.user.loanhistory;

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserLoanHistoryRepository extends JpaRepository<UserLoanHistory, Long> {

}

4. 입력값 파싱을 위해 dto 만들기

  • 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;
    }
}

5. 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()));

        }
    }
}
  • 웹 UI로 테스트 후 데이터베이스 확인

반납 기능 개발하기

HTTP MethodHTTP pathHTTP bodyAPI return 값
PUT/book/return{ "userName": String, "bookName": String }200 OK
  • 따로 추가해야 하는 테이블이나 객체는 없음
  • 고민해봐야 할 부분: 앞서 개발한 대출 기능과 HTTP body가 완전히 동일한데, dto를 새로 만들어야 할까?
    강사님은 새로 만드는 걸 권장하신다고 함 ⭢ 두 기능 중 하나만 변화가 생겼을 때 유연하고 side-effect 없이 대응할 수 있기 때문!

1. 입력값 파싱을 위해 dto 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;
    }
}

2. user_loan_history 컨트롤러와 서비스 클래스 만들기

    @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();

    }

조금 더 객체지향적으로 개발할 수 없을까?

  • UserUserLoanHistory가 직접 협업하도록 할 수는 없을까?

현재 상황

대출 기능반납 기능

개선하고자 하는 방향성

대출 기능반납 기능

1. UserUserLoanHistory에 연결되도록 수정하기

아래와 같이, 기존의 userIdUser 객체로 대체하고, 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;

2. UserLoanHistoryUser에 연결되도록 수정하기

userLoanHistoriesUser 객체에 추가하고, 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<>();

3. 연관관계의 주인 설정하기

  • 연관관계의 주인이란? FK를 가진 테이블
  • 1:N 관계에서는 무조건 N이 연관관계의 주인이다.
  • 위의 예시에서, UserLoanHistoryUser 테이블이 있다고 하자. 만약 테이블을 기반으로 ERD를 그린다고 가정하고, 둘의 관계를 설정할 때에는 User 테이블의 PK를 UserLoanHistory 테이블의 FK로 하여 연결할 것이라고 가정하자.

  • 이 때, User 테이블 (PK를 가진 테이블)에 mappedBy = "user")라고 적어주면 된다. 이 때의 userUserLoanHistory에서 불러온 @ManyToOne private User user; 부분의 user이다.

  • 추가적으로, UserLoanHistory 테이블 (FK를 가진 테이블)을 연관관계의 주인이라고 부른다고 한다. (참고로, 1:N 관계에서는 무조건 N이 연관관계의 주인이다. )
    즉, 상대 테이블을 참조하고 있는 테이블이 연관관계의 주인이다.

  • 연관관계의 주인이 아닌 테이블에 mappedBy를 사용해야 한다.

⊕ 단방향 매핑 vs 양방향 매핑

  • 단방향 매핑: 연관 관계 주인에게만 연관 관계를 주입하는 것
  • 양방향 매핑: 연관 관계 주인이 아닌 엔티티에게도 연관 관계를 주입하는 것

주의사항

  • 연관관계의 주인의 setter가 사용되어야만 테이블이 연결됨

문제점

  • 트랜젝션이 끝나야 연결관계가 성립함. (연결관계를 실행하는 트랜젝션은 항상 다른 기능들과 분리해야 함.)

  • 만약 하나의 트랜젝션 안에서 연결관계도 설정하고 설정된 연결관계를 통해서 A테이블의 속성을 활용해 B테이블을 조회한다거나 하는 식의 연결이 필요한 기능을 동작시키면 조회에 실패하여 null만 반환할 것이다.

⮕ 해결책: setter 한번에 두 테이블을 같이 이어주자.

JPA 연관관계에 대한 추가적인 기능들

  • @JoinColumn(name = <테이블에 생성할 외래키 컬럼명>): JPA에서 어떤 컬럼을 외래 키로 설정할지를 지정하는 어노테이션으로, 연관관계의 주인 엔티티 필드 위에 사용함

    • 외래키 필드명(컬럼명) 외에도 null 여부, 유일성 여부, 업데이트 여부 등을 저장함
    • 예를 들어, 여권과 사용자의 1:1 관계를 살펴보자.
    @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으로 이해하면 된다.

책 대출/반납 기능 리팩토링과 지연 로딩

  • 지연로딩 (Lazy Loading): 영속성 컨텍스트(트랜젝션 사용시 발생하는 특징)의 특징 중 하나로, 연결되어있는 객체의 데이터를 꼭 필요한 순간에 로딩하도록 하는 기능

연관관계를 사용했을 때의 장점

  1. 각자의 역할에 집중하게 됨 (응집성)
  2. 새로운 개발자가 코드를 읽을 때 이해하기 쉬워짐 (가독성)
  3. 테스트코드 작성이 쉬워짐

연관관계 사용 시 생각해볼 점

  • 연관관계를 과하게 사용할 경우, 성능상의 문제가 생길 수도 있고 도메인간의 복잡한 연결로 인해 시스템을 파악하기 어려워질 수도 있음
    SQL에서 정규화의 개념과 같은 맥락 같음.

0개의 댓글