본문 바로가기

(10.23) Spring Boot - JUnit JPA 테스트, JUtil과 DAO 구현, 엔티티 매핑 심화

@starweb2025. 10. 23. 21:26

[ 10주차 - 1023 ]

    금일 커리큘럼
        ├ 09:00 ~ 12:00 backend 프로그래밍 (JUnit JPA Entity 테스트)
        └ 13:00 ~ 18:00 backend 프로그래밍 (JUtil과 DAO 구현, 엔티티 매핑 심화)

1. JUnit - JPA Entity 테스트 방식

엔티티 테스트 시 기본 설정

  1. EntityManagerFactory 는 프로그램 내 한번만 생성이므로 @BeforeAll, @AfterAll 에서 생성 및 종료 처리
  2. EntityManager, EntityTransaction 은 각 테스트 메서드마다 새로 생성해야 하므로 @BeforeEach, @AfterEach 에서 생성 및 종료 처리
import jakarta.persistence.*;
import org.junit.jupiter.api.*;


public class UserJpaTest {
    // beforeAll, afterAll 은 static 메서드여야 하므로,
    // static 필드로 선언
    private static EntityManagerFactory emf;
    private EntityManager em;
    private EntityTransaction tx;

    @BeforeAll // 전체실행 1번
    public static void init() {
        emf = Persistence.createEntityManagerFactory("lionPU");
    }

    @AfterAll // 전체종료 1번
    public static void close() {
        if(emf != null) emf.close();
    }

    @BeforeEach
    public void setUp() {
        if(emf != null) {
            em = emf.createEntityManager();
            tx = em.getTransaction();
            tx.begin(); // 트랜잭션 시작
        }
    }

    @AfterEach
    public void tearDown() {
        // 트랜잭션 커밋없이 마지막까지 돌아가고있으면 롤백
        if(tx != null && tx.isActive()) tx.rollback();
        if(em != null) em.close();
    }
}

Assertions 검증

  • Assertions : 테스트 결과가 기대값과 일치하는지 검증하는 기능

Assertions 매개변수 설명

  • exp (expected): 기대값
  • act (actual): 실제값
  • msg (message): 실패 시 출력할 메시지

Assertions 주요 검증 메서드

메서드 설명 비교 방식
assertEquals(exp, act, msg) 같은지 검증 값 비교 (.equals())
assertNotEquals(exp, act, msg) 다른지 검증 값 비교 (.equals())
assertSame(exp, act, msg) 같은 인스턴스인지 검증 참조 비교 (==)
assertNotSame(exp, act, msg) 다른 인스턴스인지 검증 참조 비교 (==)
import jakarta.persistence.*;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;

public class UserJpaTest {
    // ... 필드,메서드 생략 (위의 설정 코드 동일)

    @Test
    @DisplayName("persist 후 id 값 여부")
    void insertUser() {
        User user = new User("test01", "test01@test01.com");
        em.persist(user);
        // tx.commit(); //DB등록 안하고 종료시 롤백넘기기
        assertNotNull(user.getId(), "못받았음");
    }


    @Test
    @DisplayName("같은 인스턴스 여부")
    void findId() {
        User user = new User("test02", "test02@test02.com");
        em.persist(user);
        tx.commit(); // tx 1 end

        tx.begin(); // tx 2 start
        User find01 = em.find(User.class, user.getId());
        User find02 = em.find(User.class, user.getId());

        try {
            // 전부다 같은 인스턴스인지?
            assertSame(user, find01, "user != find01");
            assertSame(find01, find02, "find01 != find02");
            assertSame(find02, user, "find02 != user");
        } finally {
            // 테스트 실패와 관계없이 항상 데이터 정리
            em.remove(user);
            tx.commit(); // tx 2 end
        }
    }
}

2. JPA - JUtil과 DAO 구현

JPAUtil 싱글톤 패턴

EMF를 싱글톤 패턴으로 해야하는 이유 ?

  • EntityManagerFactory 생성은 비용이 많이 들어서 하나로 관리하는 것이 좋음
  • 여러개의 DAO에서 개별 생성시 리소스 낭비됨

싱글톤 패턴시 장점

  • 싱글톤 인스턴스로 EntityManagerFactory 관리
  • JVM 종료 시점에 EntityManagerFactory 자동 종료 처리
    • -> Runtime.getRuntime().addShutdownHook() 사용

JPAUtil 구현

public class JPAUtil {
    // 싱글톤 인스턴스
    private static final EntityManagerFactory emf =
            Persistence.createEntityManagerFactory("lionPU");

    // JVM 종료 시 자동 close
    static {
        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
            if (emf != null) {
                System.out.println("---- EntityManagerFactory 종료 ---");
                emf.close();
            }
        }));
    }

    // 외부 인스턴스 생성 방지
    private JPAUtil() {}

    // EntityManagerFactory 반환
    public static EntityManagerFactory getEntityManagerFactory() {
        return emf;
    }
}

UserDAO 구현

@Slf4j
public class UserDAO {

    public UserDAO() {}

    // insert (return id)
    public Long addUser(User user) {
        EntityManager em = JPAUtil.getEntityManagerFactory().createEntityManager();
        EntityTransaction tx = em.getTransaction();
        try {
            tx.begin();
            em.persist(user);
            tx.commit();
            log.info("addUser 성공: id={}", user.getId());
            return user.getId();
        } catch (RuntimeException e) {
            if(tx.isActive()) tx.rollback();
            log.error("addUser 실패: {}", e.getMessage());
            throw e; // 호출자한테 넘김
        } finally {
            em.close();
        }
    }

    // select
    public User findUserById(Long id) {
        EntityManager em = JPAUtil.getEntityManagerFactory().createEntityManager();
        try {
            return em.find(User.class, id);
        } finally {
            em.close();
        }
    }

    // update
    public boolean updateUser(Long id, String name, String email) {
        EntityManager em = JPAUtil.getEntityManagerFactory().createEntityManager();
        EntityTransaction tx = em.getTransaction();
        boolean result = false;
        try {
            tx.begin();
            User user = em.find(User.class, id);
            if (user != null) {
                user.setName(name);
                user.setEmail(email);
                log.info("updateUser 성공: id={}", id);
                result = true;
            } else {
                log.warn("updateUser 대상없음: id={}", id);
            }
            tx.commit();
            return result;
        } catch (RuntimeException e) {
            if(tx.isActive()) tx.rollback();
            log.error("updateUser 실패: {}", e.getMessage());
            throw e;
        } finally {
            em.close();
        }
    }

    // delete
    public boolean deleteUser(Long id) {
        EntityManager em = JPAUtil.getEntityManagerFactory().createEntityManager();
        EntityTransaction tx = em.getTransaction();
        boolean result = false;
        try {
            tx.begin();
            User user = em.find(User.class, id);
            if (user != null) {
                em.remove(user);
                log.info("deleteUser 성공: id={}", id);
                result = true;
            } else {
                log.warn("deleteUser 대상없음: id={}", id);
            }
            tx.commit();
            return result;
        } catch (RuntimeException e) {
            if(tx.isActive()) tx.rollback();
            log.error("deleteUser 실패: {}", e.getMessage());
            throw e;
        } finally {
            em.close();
        }
    }
}

해당 DAO 사용예시

import org.example.jpaexam.User;

public class UserDAORun {
    private static UserDAO userDAO = new UserDAO();

    public static void main(String[] args) {
        // 사용자 생성
        User user = new User("홍길동", "hong@example.com");
        Long userId = userDAO.addUser(user);

        // 사용자 조회
        User foundUser = userDAO.findUserById(userId);
        System.out.println("조회확인: " + foundUser);

        // 사용자 수정
        userDAO.updateUser(userId, "김길동", "kim@example.com");

        // 사용자 삭제
        userDAO.deleteUser(userId);
    }
}
# 실행 결과
Hibernate: # ... // insert
2025-10-23 13:25:45 [main] INFO  org.example.jpaexam._1023.UserDAO - addUser 성공: id=13
Hibernate: # ... // select
조회확인: User(id=13, name=홍길동, email=hong@example.com)
Hibernate: # ... // select
2025-10-23 13:25:46 [main] INFO  org.example.jpaexam._1023.UserDAO - updateUser 성공: id=13
Hibernate: # ... // update
Hibernate: # ... // select
2025-10-23 13:25:46 [main] INFO  org.example.jpaexam._1023.UserDAO - deleteUser 성공: id=13
Hibernate: # ... // delete
---- EntityManagerFactory 종료 ---

3. 엔티티 매핑 심화

기존에 쓰던 엔티티 매핑 외에 다양한 매핑이 존재

기존 매핑 정리

  • @Entity : JPA 엔티티 지정
  • @Table : 엔티티와 테이블 매핑
  • @Id : 기본 키 매핑
  • @GeneratedValue : 기본 키 자동 생성 전략 매핑
  • @Column : 필드와 컬럼 매핑

연관관계 매핑

  • 연관관계 매핑이란 ? 두 개 이상의 엔티티 간의 관계를 정의하는 것을 의미한다.
  • 1:1 (One-to-One), 1:N (One-to-Many), N:1 (Many-to-One), N:N (Many-to-Many) 관계를 나타냄.
관계 유형 예시
1:1 (One-to-One) 사용자(User)프로필(Profile)
1:N (One-to-Many) 게시글(Post)댓글(Comments)
N:1 (Many-to-One) 댓글(Comments)게시글(Post)
N:N (Many-to-Many) 학생(Students)강의(Courses)

연관관계 매핑 어노테이션

어노테이션 설명
@OneToOne 1:1 관계 매핑
@OneToMany 1:N 관계 매핑
@ManyToOne N:1 관계 매핑
@ManyToMany N:N 관계 매핑
  • 그 외 연관 어노테이션
    • @JoinColumn : 외래 키 매핑
    • @JoinTable : 조인 테이블 매핑 (N:N 관계에서 주로 사용)

연관관계 매핑 어노테이션 옵션

@OneToMany(옵션 = ..)

  • cascade : 부모 엔티티 -> 자식 엔티티 전파 설정
    • CascadeType.PERSIST : 부모저장 시 자식도 함께 저장
    • CascadeType.MERGE : 부모 update 시 자식도 함께 update
    • CascadeType.REMOVE : 부모 remove 시 자식도 함께 remove
    • CascadeType.REFRESH : 부모 find 시 자식도 함께 find
    • CascadeType.DETACH : 부모 준영속 시 자식도 함께 준영속
    • CascadeType.ALL : 모든 옵션 적용
  • fetch : 연관된 엔티티를 조회할 때의 로딩 전략 설정
    • FetchType.EAGER : 즉시 로딩 (부모 조회시 자식도 함께 조회)
    • FetchType.LAZY : 지연 로딩 (자기만 조회, 필요시 자식 조회)
  • mappedBy : 양방향 연관관계에서 주인 엔티티 지정
    • 예: post <- comments (1:N) 관계에서 post 엔티티가 주인일 때,
    • mappedBy = "post" 로 설정
  • orphanRemoval : 고아 객체 제거 설정
    • = true : 부모 엔티티에서 참조가 제거된 자식 엔티티를 자동으로 삭제
    • = false : 비활성화

연관관계 엔티티 (학교 <-> 학생들 1:N 관계)

// school 엔티티 - 부모
@Entity
@Getter
@Setter
@ToString(of= {"id", "name"})
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "schools")
public class School {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    // 1:N  학교 ↔ 학생들 관계
    @OneToMany(
        mappedBy = "school",       // 연관관계의 주인이 아님을 지정
        cascade = CascadeType.ALL, // 관련 엔티티의 생명주기를 함께 관리
        orphanRemoval = true,      // 삭제시 참조한 자식 엔티티도 삭제
        fetch = FetchType.EAGER    
        // EAGER: 즉시로딩(연관 엔티티 조인 조회), LAZY: 지연로딩(자기만 조회)
    )
    private List<Student> students =  new ArrayList<>();

    public School(String name) {
        this.name = name;
    }
}
// student 엔티티 - 자식
@Entity
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "students")
public class Student {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    // N:1  학생들 ↔ 학교 관계
    @ManyToOne(fetch = FetchType.LAZY) // LAZY: 지연로딩 (자기만 조회)
    @JoinColumn(name = "school_id") // 외래키 매핑
    private School school;

    public Student(String name) {
        this.name = name;
    }

    public Student(String name, School school) {
        this.name = name;
        this.school = school;
    }
}

테스트 코드

@Slf4j
public class SchoolMain {

    private static void find(Long id) {
        EntityManager em = JPAUtil.getEntityManagerFactory().createEntityManager();
        try {
            School school = em.find(School.class, id);
            log.info("학교: {}", school.getName());
            System.out.println("-".repeat(40));

            for (Student student : school.getStudents()) {
                log.info("[find] 학교:{{}} 인 학생: {}", school.getName(), student.getName());
            }
            System.out.println("-".repeat(40));

            //id가 1L인 학생을 조회하고 싶다.
            log.info("[find] id가 1L인 학생");
            Student student = em.find(Student.class, 1L);
            log.info("[find] 학생명: {}", student.getName());
            log.info("[find] 학교명: {}", student.getSchool().getName());
            log.info("[find] 학교id: {}", student.getSchool().getId());
        } finally {
            em.close();
        }
    }

    private static Long create() {
        EntityManager em = JPAUtil.getEntityManagerFactory().createEntityManager();
        EntityTransaction tx = em.getTransaction();
        try {
            tx.begin();
            School school = new School("newSchool-A");
            Student stuA = new Student("stu-A", school);
            Student stuB = new Student("stu-B", school);
            school.getStudents().add(stuA);
            school.getStudents().add(stuB);
            em.persist(school);
            log.info("[create] 학교 : {}", school);
            for (Student student : school.getStudents()) {
                log.info("[create] 학교:{{}} 인 학생: {}", school.getName(), student.getName());
            }
            tx.commit();
            return school.getId();
        } catch (RuntimeException e) {
            tx.rollback();
            throw e;
        } finally {
            em.close();
        }
    }

    private static void update(Long id) {
        EntityManager em = JPAUtil.getEntityManagerFactory().createEntityManager();
        EntityTransaction tx = em.getTransaction();
        try {
            tx.begin();
            School school = em.find(School.class, id);
            school.setName("update-A");
            log.info("[update] id={} 인 학교 : {}", id, school);
            tx.commit();
        } catch (RuntimeException e) {
            tx.rollback();
            throw e;
        } finally {
            em.close();
        }
    }

    private static void delete(Long id) {
        EntityManager em = JPAUtil.getEntityManagerFactory().createEntityManager();
        EntityTransaction tx = em.getTransaction();
        try {
            tx.begin();
            School school = em.find(School.class, id);
            em.remove(school);
            log.info("[remove] id={} 인 학교 : {}", id, school);
            tx.commit();
        } catch (RuntimeException e) {
            tx.rollback();
            throw e;
        } finally {
            em.close();
        }
    }

    public static void main(String[] args) {
        find(1L);
        Long insertId = create();
        if(insertId != null) {
            update(insertId);
            delete(insertId);
        }
    }
}
# 실행 결과

# select 실행
Hibernate: # select - left join (id)
localTime [main] INFO  package - 학교: Greenwood High School
----------------------------------------
localTime [main] INFO  package - [find] 학교:{Greenwood High School} 인 학생: Alice
localTime [main] INFO  package - [find] 학교:{Greenwood High School} 인 학생: Bob
localTime [main] INFO  package - [find] 학교:{Greenwood High School} 인 학생: Charlie
----------------------------------------
localTime [main] INFO  package - [find] id가 1L인 학생
localTime [main] INFO  package - [find] 학생명: Alice
localTime [main] INFO  package - [find] 학교명: Greenwood High School
localTime [main] INFO  package - [find] 학교id: 1

# insert 실행
Hibernate: # insert - school (name)
Hibernate: # insert - student (name, school_id)
Hibernate: # insert - student (name, school_id)
localTime [main] INFO  package - [create] 학교 : School(id=6, name=newSchool-A)
localTime [main] INFO  package - [create] 학교:{newSchool-A} 인 학생: stu-A
localTime [main] INFO  package - [create] 학교:{newSchool-A} 인 학생: stu-B

# update 실행
Hibernate: # select - left join (id)
localTime [main] INFO  package - [update] id=6 인 학교 : School(id=6, name=update-A)
Hibernate: # update - school (name)

# delete 실행
Hibernate: # select - left join (id)
localTime [main] INFO  package - [remove] id=6 인 학교 : School(id=6, name=update-A)
Hibernate: # delete - student (id) ...

---- EntityManagerFactory 종료 ---
starweb
@starweb :: starweb 님의 블로그

starweb 님의 블로그 입니다.

공감하셨다면 구독도 환영합니다!

목차