스터디 5주차 : JPA 기초 및 프로젝트 구조

📝 UMC 8기 Spring Boot 스터디 5주차 JPA 기초 및 프로젝트 구조
SpringBoot
avatar
2025.04.29
·
26 min read

UMC 동아리 8기 스프링 부트 스터디 5주차 JPA 기초 및 프로젝트 구조를 진행하며, 공부했던 내용과 미션을 정리하는 포스트입니다.

📚 개념 정리

자바는 캡슐화, 상속, 다형성을 활용하는 게 목표인 객체 지향 언어인데, 데이터를 관리할 때 사용하는 데이터베이스는 데이터를 정교하게 구성하는 것이 목표인 관계형 데이터베이스(RDBMS)이다. 자바와 RDBMS는 서로 패러다임이 달라, 서로의 니즈를 완벽하게 충족시켜주지 못 한다.

일반 자바를 사용하면 preparedStatement 등을 사용해 쿼리문을 작성하고 실행하고 받아온 결과물을 매핑하는 과정이 필요한데, 이 과정이 매우 반복적이다. 모듈화를 하면 되지 않느냐 하기에는...

pStmt = conn.prepareStatement("SELECT * from member WHERE id = ?");
pStmt.setLong(1, user.getId());
res = pStmt.executeQuery();
			
if(res.next()) {
	return "이미 가입한 사용자입니다. 다른 id를 사용해주세요.";
}
JdbcUtil.close(res);
JdbcUtil.close(pStmt);
						
String sql = "INSERT INTO member(id, password, major, email, name) " + "VALUES (?, ?, ?, ?, ?, ?)";
pStmt = conn.prepareStatement(sql);
pStmt.setLong(1, user.getId());
pStmt.setString(2, user.getPassword());
pStmt.setString(3, user.getMajor());
pStmt.setString(4, user.getEmail());
pStmt.setString(5, user.getName());
			
int rowsAffected = pStmt.executeUpdate();
if(rowsAffected > 0) {
    conn.commit();
	return user.getName()+"님, 회원가입에 성공하였습니다.";
}

이런 코드인데, 모듈화를 하기가 쉽지 않다. 위처럼 자바만을 사용하여 프로그램을 작성해본 적이 있는데, 써보면서 굉장히 휴먼 에러가 나기 쉬운 작업이라는 생각이 들었다. 이러한 반복적인 작업을 줄이기 위해 나온 기술이 JPA이다.

JPA

Java Persistence API란, 자바의 ORM 기술의 표준으로 사용되는 인터페이스의 모음이다.

Object Relational Mapping, ORM은 객체와 데이터베이스 테이블을 매핑하는 작업을 말한다. 무슨 소리냐 하면, 애플리케이션 객체를 RDB 클래스에 연결시키기에 (복잡한 쿼리나 최적화가 꼭 필요한 쿼리가 아니면) 쿼리문을 짤 필요 없고 매핑도 자동으로 진행시켜 준다는 뜻이다. 아래의 코드를 보면 더 쉽게 이해할 수 있다.

Member 데이터베이스에 기본 키로 id, name, gender, inactiveDate, point 속성이 필요하다고 하자.

@Entity
@Getter
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Member extends BaseEntity {

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

    private String name;
    
    @Enumerated(EnumType.STRING)
    @Column(length = 10)
    private Gender gender;

    @Enumerated(EnumType.STRING)
    @Column(length = 10)
    private MemberStatus status;

    private LocalDate inactiveDate;

    private Integer point;
}

이렇게 자바 코드로만 작성하고 실행을 시키면 알아서 데이터베이스를 생성시켜준다!

사용한 어노테이션들은 다음과 같은 뜻을 가지고 있다.
@Entity : JPA의 엔티티임을 명시하여 DB 테이블과 매핑시켜준다.
@Getter : getter를 만들어준다.
마찬가지로 @Setter도 있지만 데이터가 변경될 수 있는 세터 함수를 어노테이션을 통해 생성해주는 것이 데이터 무결성에 위협이 되기에 설정하지 않았다.
@Builder : 따로 빌더를 반환하는 함수를 만들지 않아도 빌더 패턴을 구현할 수 있도록 도와준다.
@NoArgsConstructor : 파라미터가 없는 디폴트 생성자를 생성해주는 어노테이션이다. JPA에서는 엔티티를 생성할 때 반드시 파라미터 없는 생성자를 필요로 하기 때문에 필수로 작성해주어야 한다.
@AllArgsConstructor : 모든 필드 값을 파라미터로 받는 생성자를 생성해주는 어노테이션
@RequiredArgsConstructor : final, @NonNull로 선언된 필드만을 파라미터로 받는 생성자를 생성하는 어노테이션
위 세 Constructor 어노테이션에 지정해줄 수 있는 세부 옵션으로는 staticName, access, onConstructor, force가 있다.
staticName : 생성자를 지정한 이름의 정적 메소드로 생성한다.
access : 어떠한 클래스에서 생성자에 접근이 가능한지 정해준다. PUBLIC이 기본값이다.
onConstructor : 생성자의 접근 제어자를 설정한다.
force : @NoArgsConstructor에만 사용 가능한 어노테이션이다. 컴파일할 때 final 필드에 각각 primitive type에 맞는 기본 값을 설정해준다.(0, null, false 등)
@Enumerated : enum을 entity에 적용할 때 사용하는 어노테이션
EnumType.STRING으로 타입을 지정해주지 않으면 기본 값인 EnumType.ORDINAL으로 값이 저장이 된다. 이 때 Enum의 값이 아닌 Enum 값의 순서(인덱스)를 저장하게 되는데, 이렇게 되면 Enum의 값을 변경하면 의도치 않게 데이터베이스의 값도 함께 변경되기 때문에 꼭 Enum 타입을 지정해주어야 한다.
그리고 Enum을 사용하는데도 글자 수를 제한해주는 이유는 @Column 어노테이션 없이는 자동으로 enum을 VARCHAR(255)에 저장하기 때문에 메모리 절약을 위해 글자 수를 제한해주면 좋다.
@Column : 칼럼에 대한 세부적인 설정을 할 때 사용하는 어노테이션

연관 관계 매핑

단방향 매핑 : 연관 관계 주인(외래키를 지니는 엔티티)에만 연관 관계를 주입

양방향 매핑 : 연관 관계 주인이 아닌 엔티티에게도 연관 관계를 주입하는 매핑 방법
객체 그래프를 탐색할 수 있다.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "store_id")
private Store store;

@ManyToOne : N:1일 시 N에 해당하는 엔티티에 작성, 1에 해당하는 엔티티와 연관관계를 매핑할 때 사용하는 어노테이션
fetch 속성을 통해 fetchType을 결정할 수 있는데, fetch type 중에는 Eager(즉시)와 Lazy(지연) 로딩이 있다. 즉시 로딩은 연관관계에 있는 엔티티를 조회하는지 여부와 관계 없이 늘 쿼리를 발생시킨다. 지연 로딩은 연관관계에 있는 엔티티를 조회하려고 접근하면 쿼리를 발생시킨다. @ManyToOne, @OneToOne 어노테이션에서는 즉시 로딩 방식이 기본값에 해당하는데, 이 로딩 방식은 쿼리 1번을 의도했지만 추가적으로 N번을 더 발생시킨다는 의미의 N+1 문제를 일으키기에 보통 FetchType.LAZY 설정을 해두어야 한다.
@JoinColumn : 실제 데이터베이스의 해당 칼럼(외래키)의 이름을 설정하는 어노테이션으로, 이 어노테이션이 없으면 하이버네이트에서 중간 테이블을 자동으로 생성하여 연관관계를 관리한다. 자식 엔티티를 제거할 때 성능 문제가 발생할 수 있으므로 꼭 넣어주도록 하자.

@OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
private List<Review> reviewList = new ArrayList<>();

@OneToMany : N:1일 때 1에 해당하는 엔티티에 작성, N에 해당하는 엔티티와 관계가 있음을 명시
mappedBy 속성에 N에 해당하는 엔티티에 ManyToOne 설정된 멤버 변수를 적어주어야 한다.
원래는 연관관계의 주인인 테이블에 cascade를 설정하나, JPA에서는 참조의 대상이 되는 엔티티에 설정 해야 한다. CascadeType.ALL은 persist, merge, remove 등 모든 연속성 전이 동작에 엔티티가 영향을 받는다는 것을 의미한다.

@EnableJPAAuditing : JPA Auditing을 허용하고자 할 때 Application 파일에 작성해야 하는 어노테이션이다.
@MappedSuperclass : 다양한 엔티티에서 사용할 공통 매핑 정보가 필요할 때, 부모 클래스에 어노테이션과 함께 속성을 선언하고 extends를 통해 상속 받아 사용할 수 있다.

💥 미션 및 해결 과정

> 성능을 고려한 연관관계 매핑 & 최적화 적용

  1. @OneToMany 컬렉션을 조회할 때, List<Entity>를 Set<Entity>로 변경하는 것

    당연한 거지만 자료구조가 바뀌기 때문에 순서가 보장되지 않으며 중복 값이 저장되지 않는 차이가 존재한다.

    그리고 Hibernate에는 List로 구현된 PersistentBag라는 자료구조가 존재한다. @OneToMany의 컬렉션을 List로 선언하면 Hibernate는 내부적으로 PersistentBag로 감싸 처리를 진행한다.

    @Entity
    public class User {
        @OneToMany(mappedBy = "user")
        private List<Article> articles;  // Bag 1
    
        @OneToMany(mappedBy = "user")
        private List<Comment> comments; // Bag 2
    }

    위와 같은 클래스가 존재할 때, Fetch join을 하게 된다면 이러한 쿼리를 작성하게 된다. @Query("SELECT u FROM User u JOIN FETCH u.articles JOIN FETCH u.comments")

    카테시안 곱으로 인해 나오는 데이터의 양이 기하급수적으로 증가해 메모리와 성능 상으로 문제가 발생하기에 MultipleBagException 예외를 발생시킨다.

    이 때, List가 아닌 Set 자료구조로 변경해주면 PersistentBag가 아닌 PersistentSet으로 감싸준다. 이렇게 진행하면 에러가 뜨지 않기에 fetch join을 진행할 수 있다. 하지만... 카테시안 곱을 안 하는 것은 아니기에 성능 저하가 발생할 수 있다. 확실한 상황에만 에러를 피하기 위해 사용하도록 하자!

  2. 데이터 정합성을 고려한 orphanRemoval = true 적용

    orphanRemoval, 고아를 제거한다는 말에서 예상할 수 있겠지만 부모 엔티티와 관계가 없어진(컬렉션에서 제거된) 자식 엔티티를 제거한다는 뜻이다. 언뜻 보기엔 CascadeType.REMOVE와 비슷해보이지만, 다르다.

    orphanRemoval은 @OneToMany 어노테이션에 적용할 수 있는 속성이다. 자식 엔티티에 변경사항이 발생하면 먼저 변경된 자식 엔티티들을 insert한 뒤, 기존의 자식들을 null로 update한다. 이 때, orphanRemoval = true이면 PK 값이 null인 자식 엔티티들을 delete한다. 하지만 컬렉션에 있는 엔티티를 추적하려면 영속성이 꼭 필요해서 PERSIST cascade가 적용되어야 제대로 작동한다.

    public class Member extends BaseEntity {
        // ...
    
        @OneToMany(mappedBy = "member", cascade = CascadeType.ALL, orphanRemoval = true)
        private List<Review> reviewList = new ArrayList<>();
    
        @OneToMany(mappedBy = "member", cascade = CascadeType.ALL)
        private List<Mission> missionList = new ArrayList<>();
    }

    적용한 컬럼들 중 일부를 가져와봤다. reviewList에는 orphanRemoval을 적용한 반면, missionList에는 적용하지 않았다. 만약 유저가 작성한 리뷰가 삭제된다면 리뷰가 DB에서도 삭제되는 것이 옳다. 하지만 미션의 경우, 유저가 미션 수행 여부를 삭제하더라도(그런 기능이 있을까 싶지만?) DB에 히스토리로 남겨두어야 하는 데이터라고 생각해 적용하지 않았다.

> 트랜잭션 & 동시성 이슈 처리

  1. 하나의 트랜잭션에서 여러 엔티티를 처리하는 비즈니스 로직에 대해 생각해보자.

    리뷰를 작성하는 비즈니스 로직은 단순히 리뷰만 작성하는 것이 아니라, 이후 그 상점의 총 별점과 리뷰 수를 변화시켜야 한다. 이 로직에는 @Transactional을 사용하여 트랜잭션을 관리하면 될 것 같다.


    @Transactional은 메서드 실행 전후로 트랜잭션 시작, 커밋, 롤백을 처리하는 어노테이션이다. RuntimeException이 발생했을 때 롤백 처리를 해주기도 하고, readOnly = true와 같은 속성을 통해 조회만 하는 서비스를 최적화할 수도 있다.

    @Modifying@Query로 작성한 DML/DDL 쿼리를 사용할 때 붙이는 어노테이션이다. @Query는 원래 조회 쿼리만 지원하기 때문에 데이터를 변경하는 쿼리에는 @Modifying 어노테이션을 붙여야 JPA가 제대로 인식할 수 있다.

    clearAutomatically 속성을 통해 쿼리를 실행한 뒤 영속성 컨텍스트를 clear하게 한다. findById와 같은 메소드는 영속성 컨텍스트에 저장된 1차 캐시(@Id를 키 값으로 관리함)로 엔티티의 내용을 확인하는 방식으로 DB 접근 횟수를 줄여 성능을 개선한 메소드이다. 하지만 벌크 연산을 통해 데이터 변경 쿼리를 실행하면 1차 캐시를 포함한 영속성 컨텍스트를 무시하고 쿼리를 실행하기 때문에 조회한 값이 잘못될 수 있다. 영속성 컨텍스트에게 DB 값을 새로 받아오라고 요청하기 위해 활성화가 필요한 속성이다.

    flushAutomatically 옵션은 쿼리를 실행하기 전 영속성 컨텍스트를 flush하게 한다.

  2. 동시성 문제가 발생할 수 있는 시나리오를 생각하고 어떻게 처리할지 생각해보자.

    미션을 수행하는 손님 수에 제한을 두는 기능이 추가 되었다고 가정할 때, 한 미션에 남은 사람 제한이 1명인데 A와 B가 동시에 미션 시작 버튼을 클릭한 시나리오를 가정해보았다.

    두 사용자가 모두 성공 처리되면 안 되는 시나리오이다. 비관적 락 방식을 제일 먼저 생각했으나, 여러 미션이 동시 접근되면 교착 상태가 발생할 수 있을 것 같아서 원자적 락이 깔끔할 것 같다고 생각했다.

    단순히 남은 자리 > 0일 때만 쿼리가 실행되도록 보장해 둘 중 한 명만 성공하게 보장할 수 있다.


    락(Lock)에는 공유 락과 배타 락이 있다. 공유 락은 조회를 위해 잠그는 것이고 배타 락은 변경(Insert, Update, Delete)를 위해 잠그는 것이다.

    이러한 락 간에 서로 경합이 발생해 트랜잭션이 작업 하지 못 하고 대기하는 상태를 블로킹 상태라고 부른다. 이런 상태가 되면 처리 속도가 느려지고... 혹은 최악의 경우 아예 진행이 안 되는 상황이 발생할 수 있으므로 최대한 피해주어야 한다. 트랜잭션 작업 단위를 작게 구성하거나 동시성 제어를 위해 다양한 기법을 사용할 수 있다.

    1. Synchronized

      자바의 Synchronized 키워드를 사용해서 데이터에 하나의 스레드만 접근할 수 있도록 하는 방법이다. 근데? @Transactional 어노테이션과 함께 사용하면 기대한 대로 동작하지 않을 수 있다.

      @Transactional은 프록시 객체를 생성해서 트랜잭션을 처리하는데, synchronized는 메서드 시그니처가 아니라 상속이 되지 않는다. 원래대로라면 실행한 메소드가 종료되어야 다른 메소드에서 해당 메소드를 실행할 수 있는데, 프록시 객체에 synchronized가 따라가 붙어주지 않기 때문에 트랜잭션 종료 전에 메소드가 실행되게 된다.

      그렇다면 @Transactional을 빼고 synchronized를 붙여 실행하면 된다. 하지만... 여전히 문제가 있다. 하나의 프로세스에서만 동시성 제어가 보장되기 때문에, 서버가 여러 대일 때는 동시성이 보장되지 않는다.

      1. 비관적 락

        비관적으로 생각해서 미리 다 잠궈두는 락이다. DB 단에 X-Lock을 설정하여 동시성을 제어해주는 방법이다. 확실하게 데이터 정합성은 보장되지만 다른 작업들이 모두 대기해야 하기 때문에 성능이 감소할 수 있다.

        @Lock(LockModeType.PESSIMISTIC_WRITE)
        @Lock(LockModeType.PESSIMISTIC_READ)

        PESSIMISTIC_WRITE은 X-Lock 쿼리를 수행한다. (Select for update)

        PESSIMISTIC_READ는 S-Lock 쿼리를 수행한다. (Select for share)

        충돌 가능성이 높을 때나 정합성이 너무 중요한 트랜잭션에 사용하면 좋다.

      2. 낙관적 락

        낙관적으로 생각해서 DB 단에 락을 걸지 않는 기법이다. 버전을 관리하는 칼럼을 테이블에 추가해서 버전을 비교하며 쿼리를 수행한다.

        @Lock(LockModelType.OPTIMISTIC)

        OPTIMISTIC으로 설정해두면 되는데, 비교할 버전 칼럼이 필요하므로 엔티티 내부에 @Version 어노테이션으로 버전 칼럼을 지정해두어야 한다.

        만약 기대하던 버전과 다르게 버전이 바뀌어있으면 OptimisticLockException이 발생한다.

        충돌이 드물고 성능이 중요한 경우에 사용하면 좋다.

      3. 네임드 락

        MySQL에서 사용할 수 있는 기능으로 DB 단에서 락을 설정하는 기법이다.

        INSERT 작업은 기준으로 잡을 레코드(버전)가 존재하지 않기 때문에 비관적 락을 사용할 수 없다. 이 때 네임드 락을 사용하면 된다.

        별도의 MySQL 공간에 락을 설정하는 거라 같은 네임드 락을 사용하는 작업 외의 작업은 영향을 받지 않는다는 특징이 있다. 그리고 트랜잭션이 종료되어도 락이 자동으로 해제되지 않기 떄문에 락 해제를 구현하거나 타임아웃을 설정해주어야 한다.

        @Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
        void getLock(String key);
        
        @Query(value = "select release_lock(:key)", nativeQuery = true)
        void releaseLock(String key);

        get_lock으로 네임드 락의 이름과 타임 아웃 시간을 설정한다.

        release_lock으로 네임드 락을 해제할 수 있다.

        이 두 함수를 사용하여 동시성을 제어하면 된다. 보통 분산 잠금이나 크로스 프로세스를 동기화할 때 사용하는 기법이다.

      4. 원자적 락?

        단일 연산을 사용하는 기법이다. CPU나 DB 단에서의 단일 연산을 보장한다.

        UPDATE products SET stock = stock - 1 WHERE stock > 0;

        이렇게 DB 레벨에서 단일 쿼리로 처리해 동시성 문제를 방지하는 방법이다. 락 오버헤드가 없고 DB 종속적이지 않다. 하지만 스핀락 발생 가능성이 존재한다.

        단순한 값 증감(위에 작성한 시나리오 같은), 락 오버헤드를 회피할 때 사용하면 좋은 기법이다.

💭 회고

평소에 백엔드 코드를 작성하다보면 자동 완성에 다양한 속성이 떠서 늘 궁금했는데, 이번 기회에 많이 알 수 있게 되었다. 잘 알고 있다고 생각했던 어노테이션도 모르는 부분이 많아 놀랐다. 특히 @NoArgsConstructor@AllArgsConstructor는 엔티티를 생성할 때 통상적으로 붙이는 어노테이션이기에 무심코 넘겼는데 다양한 속성이 있고 비슷한 Constructor~ 계열의 어노테이션이 하나 더 있음을 알게 되었다. @RequiredArgsConstructor로 필요한 파라미터만 작성해 생성자를 만들 수 있다는 걸 알게 되어 필요할 때 사용할 수 있을 것 같다!! 그리고 이러한 어노테이션들이 개발을 편리하게 만들어준다는 게 더욱 실감됐다...🥹

그리고 저번에 진행했던 프로젝트 때 어노테이션에 대해 좀 더 잘 알았다면 최적화를 더 잘 진행할 수 있었을 것 같아 아쉬움이 남기도 한다. 공부한 내용을 적용시켜 어떻게 리팩토링하면 좋을지 생각해봐야겠다.







- 컬렉션 아티클