본문 바로가기
기록, 회고/InFlearn Warming-up 0기 BE

[10일 차] - 내용 정리(JPA와 연관 관계, 지연 로딩(객체지향적 설계)), 개인 회고

by TwoJun 2024. 2. 28.
728x90
반응형

인프런에서 주최하는 Warming-up 클럽 0기 백엔드 스터디에 참여하고 있다.

 

스터디에 참여하면서 배우게 된 내용을 전체적으로 정리하고, 참여하면서 느낀 부분을 회고해 보고자 한다.

 

(1) 10일 차 : 2024-03-01(Fri)

 

 

 

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

(1) 연관 관계?

- 연관 관계는 객체 또는 테이블이 서로 논리적인 의미를 갖고 양쪽을 서로 참조하는 것을 의미한다.

 

(2) 연관 관계는 1:1(일대일), 1:N(일대다), N:1(다대일), N:M(다대다) 관계가 존재한다.

 

 

 

 

1-1. 일대일 연관 관계

(1) @OneToOne 어노테이션을 사용한다.

 

(2) 외래 키를 보유한 엔티티를 연관 관계의 주인으로 설정한다. 연관 관계 주인이 아닌 곳에 mappedBy를 적용한다.

 

(3) 연관 관계로 설정된 엔티티는 객체가 서로 연결되는 기준이 된다.

 

(4) 중요한 점은 상대 테이블을 참조하고 있게 되면 연관 관계의 주인이다.(테이블 상에서 상대 테이블의 외래 키를 보유하고 있는 경우)

 

(5) 연관 관계의 주인 엔티티에서 Setter가 사용되어야만 주인이 아닌 엔티티와 주인 엔티티가 서로 연결될 수 있다.

 

 

 

 

1-2. 다대일 연관 관계

(1) @ManyToOne, @OneToMany 어노테이션 사용

 

(2) 다대일 관계에서 연관 관계의 주인은 N쪽이다.

 

(3) 1쪽에서는 여러 개의 다른 엔티티를 가지고 있을 수 없기 때문에 연관 관계의 주인이 될 수 없다.

- 데이터베이스 관점 : 다쪽이 외래 키를 보유하고 있다 따라서 스키마상으로는 다쪽이 연관관계의 주인이다.

- 객체지향 관점 : 일쪽이 다쪽을 참조하게 되고 참조를 받는 쪽이 자연스럽게 연관관계의 주인이 된다.

- 데이터베이스와 객체지향 모델 간의 일관성을 맞추기 위해 다쪽이 연관 관계의 주인이 된다.

 

(4) 양쪽 모두 연관 관계를 갖고 있는 경우, 한 번 Setter를 호출할 때 양쪽이 연결되게끔 연관관계 편의 메서드를 만들어 두는 것이 좋다.

 

 

 

 

1-3. @ManyToOne은 단방향으로만 사용할 수 있다.

(1) 일쪽에서 @OneToMany로 참조하는 부분을 없애고 다쪽에 @ManyToOne만 단방향으로 남겨두는 방법

 

 

 

 

1-4. @JoinColumn

(1) 연관관계의 주인이 활용할 수 있는 어노테이션

 

(2) 필드의 이름이나 null 여부, 유일성 여부, 업데이트 여부 등을 지정할 수 있다.

 

 

 

 

1-5. 다대다 연관 관계

(1) 해당 연관 관계의 경우 테이블이 직관적으로 매핑되지 않는다. 따라서 많이 사용되지 않는다.

 

(2) 따라서 중간에 테이블을 따로 두어 별도로 처리하는 방법으로 매핑한다.

 

 

 

 

1-6. cascade 옵션 (영속성 전이), orphanRemoval

(1) 영속성 전이라고도 하며 해당 기능은 연관 관계와 아무런 관련은 없다.

 

(2) 부모 엔티티가 자식 엔티티를 한 번에 관리해야 할 때 사용한다.

(예를 들어 게시판, 첨부파일, 첨부파일의 데이터 경로 등 서로 연관이 있을 때 사용)

 

(3) orphanRemoval 옵션은 부모 엔티티와 자식 엔티티 간의 연관관계가 끊어진 경우 해당 자식 엔티티를 자동으로 삭제하는 것

 

 

 

 

 

2. 책 / 대출 반납 기능 Refactoring

2-1. 객체 간 협력이 가능하도록 엔티티를 수정한다.  - User.java, UserLoanHistory.java (도메인 주도 설계)

@Entity
@Getter
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @Column(nullable = false, length = 20)
    private String name;

    private Integer age;

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<UserLoanHistory> userLoanHistories = new ArrayList<>();

    public User(String name, Integer age) {
        /** 회원의 이름은 공백일 수 없다 */
        if (name == null  || name.isBlank()) {
            throw new IllegalArgumentException(String.format("잘못된 회원 이름 name(%s)이 입력되었습니다.", name));
        }
        this.name = name;
        this.age = age;
    }

    public User(long id, String name, Integer age) {
        this.id = id;
        this.name = name;
        this.age = age;
    }

    protected User() {}

    public void updateName(String name) {
        this.name = name;
    }

    // 대출 : 도메인 내부에 핵심 비즈니스 추가(객체 간 협력이 가능하도록)
    public void loanBook(String bookName) {
        this.userLoanHistories.add(new UserLoanHistory(this, bookName));
    }

    // 반납
    public void returnBook(String bookName) {
        UserLoanHistory targetHistory = this.userLoanHistories.stream()
                .filter(history -> history.getBookName().equals(bookName))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("해당 책을 대상으로 대출 기록이 존재하지 않습니다."));

        targetHistory.doReturn();
    }
}


@Entity
@Getter
public class UserLoanHistory {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;

    private String bookName;

    private boolean isReturn;

    protected UserLoanHistory() {}

    public UserLoanHistory(User user, String bookName) {
        this.user = user;
        this.bookName = bookName;
        this.isReturn = false;
    }

    public void doReturn() {
        this.isReturn = true;
    }

    public String getBookName() {
        return this.bookName;
    }
}

(1) 도서 반납, 대출 등 핵심 비즈니스를 엔티티 계층으로 위임했다.

 

(2) 이를 통해 서비스 계층에서는 해당 엔티티들의 핵심 기능을 호출해서 사용하면 된다.

 

(3) 도서 대출의 경우 연관관계를 직접 이용해서 도서 이름을 통해 새로운 도서 대출 기록을 생성하도록 코드가 작성되어 있다.

 

(4) 반납 로직의 경우, 도서 대출 기록을 스트림 연산을 통해 각각 가져오고 파라미터로 넘어온 도서 정보가 현재 존재하는지 확인한다.

존재한다면 userLoanHistories 객체의 doReturn() 메서드로 대출에서 반납으로 상태를 변환함으로써 대출 반납이 완료된다.

 

 

 

 

2-2. BookService.java

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class BookService {

    private final BookRepository bookRepository;
    private final UserLoanHistoryRepository userLoanHistoryRepository;
    private final UserRepository userRepository;

    @Transactional
    public void saveBook(BookCreateRequest request) {
        bookRepository.save(new Book(request.getName()));
    }

    @Transactional
    public void loanBook(BookLoanRequest request) {
        // 책 대출 여부 확인
        Book findBook = bookRepository.findByName(request.getBookName())
                .orElseThrow(() -> new IllegalStateException("존재하지 않는 책입니다."));

        // 대출 중인지 확인
        if (userLoanHistoryRepository.existsByBookNameAndIsReturn(findBook.getName(), false)) {
            throw new IllegalArgumentException("이미 대출 중인 책은 대여할 수 없습니다.");
        }

        // 대출 중인 책이 아니라면 유저 정보를 가져와 새로운 대출정보를 생성
        User findUser = userRepository.findByName(request.getUserName())
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원 정보입니다."));

        findUser.loanBook(findBook.getName());
    }

    @Transactional
    public void returnBook(BookReturnRequest request) {
        // 존재하는 책, 회원인지 확인 필요
        User findUser = userRepository.findByName(request.getUserName())
                .orElseThrow(() -> new IllegalStateException("존재하지 않는 회원 정보입니다."));

        // findBook, findUser가 모두 히스토리에 존재해야 반납 가능
        findUser.returnBook(request.getBookName());
    }
}

(1) 도메인 간 협업이 가능하도록 코드를 설계함으로써 서비스 계층의 로직이 단순해지고 기능이 필요한 경우 엔티티의 기능을 직접 불러오도록 코드가 작성된 것을 확인할 수 있다.

 

 

 

3. 영속성 컨텍스트의 4번 째 기능 : 지연 로딩(Lazy Loading)

(1) 연관관계에 있는 두 엔티티를 대상으로 특정 엔티티를 조회함에 있어서 연관된 엔티티는 바로 가져오지 않고 필요한 순간일 때 로딩하는  방법을 의미한다.

 

(2) 실제 로직은 이렇다. 연관된 엔티티는 프록시 형태로 우선 가져오고 실제로 연관된 엔티티를 필요로 할 때 프록시 객체의 초기화가 일어나서 연관된 엔티티를 조회하기 위한 쿼리가 나가게 된다.

 

 

 

 

 

4. 연관관계를 사용하면 얻게 되는 이점?

(1) 현재 코드를 살펴보면 조금 더 객체지향적인 설계를 위해서 도메인끼리 협업하도록 했고, 이 과정에서 JPA의 연관 관계를 사용했다.

이를 통해 얻게 되는 장점이 무엇일까?

 

 

4-1. 장점 : 각자의 역할에 집중하게 된다.

(1) 즉, 계층별로 응집성이 강해지는데, 서비스 계층의 역할은 꼭 필요한 경우에만 서로 다른 도메인끼리 협업을 하도록 도와주고, 트랜잭션을 관리하고, 외부 의존성을 관리하는 등의 역할을 맡게 하고 도메인은 도메인 객체가 포함하고 있는 관심사(비즈니스)에 대해서 로직을 직접 처리하게 한다. 이를 통해 각자의 역할에 조금 더 집중할 수 있게 한다.

 

 

4-2. 장점 : 협업할 때 코드를 읽기 쉬워진다.

(1) 어떤 코드는 서비스 계층에 모든 로직에 대한 코드가 존재하는 것이다. 그렇다면 코드가 객체지향적이 아닌 절차지향적 코드가 되는데, 요구사항이 복잡한 경우라면 이 코드들을 모두 읽고 해석해야 하므로 코드를 해석하는 부분이 번거롭고 까다롭게 된다.

 

(2) 하지만 도메인이 서로 협력할 수 있게 도메인 주도적 설계를 가져간다면, 계층이 어느 정도 분리되어 있고, 이 도메인 계층이 어떤 일을 하는지 각각 파악할 수 있다 보니 새로운 개발자 등과 협업할 때 코드 리딩이 쉬워지는 경향이 있다.

 

 

 

4-3. 장점 : 테스트 코드 작성이 쉬워진다.

(1) 도메인 객체에 대한 각 기능을 호출하는 메서드를 테스트함으로써 테스트 코드의 작성이 조금은 쉬워진다.

 

 

 

 

 

5.  그렇다면 연관 관계를 항상 사용하는 것은 올바른 선택일까?

(1) 결론 : 항상 그렇지는 않다.

 

(2) 연관 관계를 지나치게 사용하면 성능상 문제가 생길 수도 있고 도메인 간의 복잡한 연결로 인해 시스템을 파악하기 어려워질 수 있다.

 

(3) 또한 너무 얽혀 있으면 한 부분을 수정하는 상황에서 다른 코드까지 건드려야 하는 Side-effect가 생길 수 있다.

 

(4) 원론적인 이야기이지만, 비즈니스 요구사항, 기술적 요구사항, 도메인 아키텍처 등 여러 부분을 고민해서 연관 관계 사용을 선택해야 한다. 항상 도메인을 설계할 때는 어떤 선택을 함에 따라 각각의 트레이드 오프가 존재하기 마련이다.

 

 

 

 

6. 개인 회고

(1) 수업 중 이번 파트가 가장 중요했던 것 같다.

 

(2) JPA를 사용하면서 가장 중요했던 부분인 영속성 컨텍스트의 매커니즘, 객체와 테이블을 매핑시킬 때 이해해야 하는 객체 간 연관관계에 대한 내용이기 때문이다.

 

(3) 처음 학습하거나 학습한지 오래됐다면 잊어버리기 쉬운 내용이다. 코드로 계속 타이핑해보면서 체화시키는 것이 중요한 것 같다.

 

 

 

 

7. Reference

(1) 관련 레퍼런스는 인프런에서 활동하고 계시는 최태현 강사님의 온라인 강의를 듣고 내용을 정리했습니다.

https://www.inflearn.com/course/%EC%9E%90%EB%B0%94-%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%EC%84%9C%EB%B2%84%EA%B0%9C%EB%B0%9C-%EC%98%AC%EC%9D%B8%EC%9B%90/dashboard

 

자바와 스프링 부트로 생애 최초 서버 만들기, 누구나 쉽게 개발부터 배포까지! [서버 개발 올인

Java와 Spring Boot, JPA, MySQL, AWS를 이용해 서버를 개발하고 배포합니다. 웹 애플리케이션을 개발하며 서버 개발에 필요한 배경지식과 이론, 다양한 기술들을 모두 학습할 뿐 아니라, 다양한 옵션들

www.inflearn.com

 

※ 해당 포스팅에 대해 내용 추가가 필요하다고 생각되면 기존 포스팅 내용에 다른 내용이 추가될 수 있습니다.

개인적으로 공부하며 정리한 내용이기에 오타나 틀린 부분이 있을 수 있으며, 이에 대해 댓글로 알려주시면 감사하겠습니다

728x90
반응형

댓글