반응형

1. 개요

 @Column 옵션 중 nullable = false를 적용하는 순간 ConstraintViolationException: could not execute statement 예외가 발생하였다.


2. 배경

 nullable = false 옵션은 null은 허용하지 않는다는 의미이다. null을 허용할 경우 연관관계가 맺어진 테이블 조회 시 left outer join을 하게되어 성능상 이슈를 가져올 수 있으나, 허용하지 않을 경우 inner join을 하여 성능상 이점을 가져올 수 있기에 해당 옵션을 추가하였다. 그런데 옵션을 추가하자마자 아래와 같은 에러가 발생하였다. null을 허용하지 않는 컬럼에 null이 들어가 SQL을 실행할 수 없다는 에러였다.

 

ConstraintViolationException 예외 발생


3. 원인 분석

 CLASS_ID는 Student 엔티티와 다대일 관계에 있는 Classroom 엔티티의 Join Column이다. 메인 메서드에서 Student 엔티티를 생성한 후 Classroom 설정을 하지 않았는지 확인해보았다.

 

1) main 메서드

 - main 메서드에서는 Student의 수정자 메서드와 classroom의 addStudent 메서드를 통해 양방향으로 주입 가능하도록 메서드를 구현했었다. 어쨌든 student1과 student2는 이를 사용하여 classroom을 set하는 것을 확인하였다. 그후 메서드 구현부분을 차례로 확인해보았다.

	Student student1 = new Student();
        student1.setName("심심심");
        em.persist(student1);

        Student student2 = new Student();
        student2.setName("홍길동");
        em.persist(student2);

        Classroom classroom = new Classroom();
        classroom.setName("배드민턴반");
        em.persist(classroom);

        student1.setClassroom(classroom); // student 1에 classroom set
        classroom.addStudent(student2); // student 2에 classroom set

        List<Student> list = classroom.getStudentList();
        for(Student student : list){
            System.out.println("========================="+student.getName());
        }

 

2) Student.java

@Entity
@Getter
@Setter
public class Student {

    @Id
    @GeneratedValue
    @Column(name = "STUDENT_ID")
    private Long id;

    private String name;

    @ManyToOne
    @JoinColumn(name = "CLASS_ID", nullable = false)
    private Classroom classroom;

    public void setClassroom(Classroom classroom){
        if(this.classroom != null){
            this.classroom.getStudentList().remove(this);
        }
        this.classroom = classroom;
        classroom.getStudentList().add(this);
    }
}

 Student의 경우 setClassroom 시 Student.classroom에 set 함과 동시에 객체 상태의 classroom에도 Student를 반영해주기 위해 classroom.getStudentList().add(this)구문을 추가해주었다. 어쨌든 classroom이 'this.classroom = classroom' 코드에 의해 설정되므로 여기서 null을 발생시키진 않는 것으로 판단하였다.

 

3) Classroom.java

@Entity
@Getter
@Setter
public class Classroom {

    @Id
    @GeneratedValue
    @Column(name = "CLASSROOM_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "classroom")
    private List<Student> studentList = new ArrayList<>();

    public void addStudent(Student student){
        if(student.getClassroom() != null){
            student.getClassroom().getStudentList().remove(student);
        }
        student.setClassroom(this);
    }
}

 addStudent 메서드도 studentList에 대한 순수 객체 상태도 엔티티와 동일하게 가져가기 위해 요청으로 들어온 Student가 속한 classroom이 있을 경우, 해당 classroom의 StudentList에서 요청으로 들어온 student를 remove해주는 코드를 넣었다. 중요한 부분은 Student 엔티티의 classroom이 set 되냐 마냐인데 student.setClassroom(this)를 통해 요청으로 들어온 Student 엔티티에도 classroom이 설정되는 것을 확인하였다.

 

 결론은 문제의 원인이 될만한 곳을 발견하지 못했다. 디버깅을 해봐도 student1과 student2 엔티티 모두 classroom을 갖고 있는 것을 확인하였다. 그럼 뭐가 문제일까를 고민하다가 자연스레 JPA의 동작원리를 생각해보았다.

 

 엔티티 매니저의 persist 메서드를 사용하면 엔티티 매니저가 관리하는 영속성 컨텍스트로 엔티티가 관리된다. 동시에 1차 캐시에 엔티티 id와 엔티티가 key, value 형식으로 저장되고 쓰기지연 SQL에 엔티티에 대한 insert 쿼리가 생성되어 쌓인다. 후에 EntityTransaction혹은 EntityManager에 의해 flush() 메서드가 호출되면 쓰기지연 SQL에 있는 쿼리가 실제 DB에서 실행되어 데이터가 저장된다.

 여기서 쓰기지연 SQL에 들어간 insert쿼리가 어떤 순서로 실행됐는지를 확인해보기 위해 실행된 쿼리를 확인해보았다.

예외 발생 쿼리

 그 결과 가장 먼저 실행된 쿼리는 student에 대한 INSERT 쿼리였다. 이 시점에서는 CLASS_ID가 할당되지 않는다. 왜냐하면 student에 classroom을 set 하기 전에 em.persist를 호출했고, 이때 쓰기지연 SQL에는 CLASS_ID가 할당되지 않은 Student의 INSERT 쿼리가 적재되기 때문이다.

 더 나아가면 아래 Student 엔티티에 대한 setClassroom을 하는 시점에 UPDATE 쿼리가 적재되게 된다.

	Student student1 = new Student();
        student1.setName("심심심");
        em.persist(student1);
        
        ..
        
        student1.setClassroom(classroom)

 


4. 해결방안

 student1, 2에 classroom을 set한 이후 em.persist를 호출하는 쪽으로 em.persist 메서드 위치를 변경하였다. 이제 Student에 대한 쓰기지연 SQL에는 classroom이 할당된 상태의 INSERT 쿼리가 나가게 되었다. 

	Student student1 = new Student();
        student1.setName("심심심");
        
        Student student2 = new Student();
        student2.setName("홍길동");
        
        Classroom classroom = new Classroom();
        classroom.setName("배드민턴반");
        em.persist(classroom);

        student1.setClassroom(classroom);
        classroom.addStudent(student2);

        em.persist(student1);
        em.persist(student2);

  

 결론은 em.persist의 순서였는데, 순서로 인한 이런 에러는 추후에도 발생할 여지가 충분히 있다고 생각되었다. 만약 널을 허용하지 않는 옵션이 일반 Column이었다면 실제로 set을 하지 않아 발생한 것이므로 바로 원인을 찾았을 것이다. 하지만 위 케이스의 경우 classroom이라는 부모 엔티티를 수정자 메서드를 통해 설정해줬음에도 불구하고 에러가 발생하기 때문에 원인 찾기가 힘들 수 있다고 생각되었다.

 어쨌든간에 null을 허용하지 않는 자식, 부모 관계에 있는 엔티티를 자식, 부모 순서로 persist 하여 발생하였는데, 부모 엔티티만 persist해도 연관된 엔티티들이 함께 영속상태로 들어가는 영속성 전이를 사용하면 더 깔끔하게 해결 가능하다.

 부모 엔티티의 연관관계 어노테이션에 cascade 옵션을 PERSIST로 설정하면 되며, 자식객체를 persist하는 코드 생략과 앞서 발생했던 문제들을 예방하는 효과를 얻을 수 있었다.

 참고로 영속성 전이는 em.persist()를 실행할 때가 아닌 flush()를 호출할 때 발생한다.

 

1) Classroom.java

@Entity
@Getter
@Setter
public class Classroom {

    @Id
    @GeneratedValue
    @Column(name = "CLASSROOM_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "classroom", cascade = CascadeType.PERSIST) // 영속성 전이 옵션
    private List<Student> studentList = new ArrayList<>();

    public void addStudent(Student student){
        if(student.getClassroom() != null){
            student.getClassroom().getStudentList().remove(student);
        }
        student.setClassroom(this);
    }
}

 

2) main 메서드

	Student student1 = new Student();
        student1.setName("심심심");

        Student student2 = new Student();
        student2.setName("홍길동");

        Classroom classroom = new Classroom();
        classroom.setName("배드민턴반");
        
        student1.setClassroom(classroom); // 연관관계 추가
        classroom.addStudent(student2); // 연관관계 추가
        
        em.persist(classroom); // classroom 영속화
        
        tx.commit();

5. 회고

 코드상 원인은 em.persist의 순서였지만, 근본 원인은 JPA에 대한 매커니즘을 생각하지 않고 코드를 작성한 나 자신이었다 :(  JPA는 프레임워크라는 사실을 잊지말자. 스프링도 마찬가지라고 생각하는데 프레임워크를 사용하기 위해서는 매커니즘과 그 성격을 이해하고 사용하는 것이 정말 중요하다고 다시한번 느끼게되었다.

반응형
반응형

1. 개요

 - JPQL의 내부, 외부, 세타 조인에 대해 알아보자.


2. 내부 조인

 - 연관 관계가 맺어진 엔티티들에 대한 Inner Join을 말한다.

 - JPQL 작성 시 INNER JOIN의 INNER 는 생략 가능하다.

SELECT m FROM Member m [INNER] JOIN m.team t

3. 외부 조인

 - 연관 관계가 맺어진 엔티티들에 대한 Left Outer Join을 말한다.

 - JPQL 작성 시 LEFT OUTER JOIN의 OUTER는 생략 가능하다.

SELECT m FROM Member m LEFT [OUTER] JOIN m.team t

4. 세타 조인

 - 엔티티들에 대한 조인을 말한다.

 - 연관 관계와 상관 없기에 엔티티 명을 정확히 기입해야 한다.

SELECT count(m) FROM Member m, Team t WHERE m.username = t.name

5. 조인 예제

 - 테스트를 위해 teamA와 teamB를 생성하였다. 멤버 0부터 9까지는 teamA, 멤버 10부터 19까지는 teamB에 속하도록 하였고, 멤버 20부터 29까지는 팀이 없도록 테스트 데이터를 세팅하였다. 데이터 셋 코드는 다음과 같다.

    Team teamA = new Team();
    teamA.setName("teamA");
    em.persist(teamA);

    Team teamB = new Team();
    teamB.setName("teamB");
    em.persist(teamB);

    for(int i =0 ; i< 10 ; i++){
        Member member = new Member();
        member.setUsername("멤버"+i);
        member.setAge(i);
        member.changeTeam(teamA);
        em.persist(member);
    }

    for(int i =10 ; i< 20 ; i++){
        Member member = new Member();
        member.setUsername("멤버"+i);
        member.setAge(i);
        member.changeTeam(teamB);
        em.persist(member);
    }

    for(int i =20 ; i< 30 ; i++){
        Member member = new Member();
        member.setUsername("멤버"+i);
        member.setAge(i);
        em.persist(member);
    }

 

5.1. 내부 조인 예제

 - 아래의 JPQL을 실행하여 내부 조인 시 team이 존재하는 Member 정보만을 리턴한다.

    String innerJoinQuery = "select m from Member m inner join m.team t";
    List<Member> list = em.createQuery(innerJoinQuery,Member.class)
    	.getResultList();

    for(Member member : list){
        System.out.println(member.toString());
        System.out.println(member.getTeam());
    }

	// 출력 결과
    Member{id=3, username='멤버0', age=0}
    Team{id=1, name='teamA'}
    Member{id=4, username='멤버1', age=1}
    Team{id=1, name='teamA'}
    ...
    Member{id=21, username='멤버18', age=18}
    Team{id=2, name='teamB'}
    Member{id=22, username='멤버19', age=19}
    Team{id=2, name='teamB'}

 

5.2. 외부 조인 예제

 - 아래의 JPQL을 실행하여 외부 조인 시 team이 존재하지 않는 Member 정보도 함께 리턴한다.

    String leftJoinQuery = "select m from Member m left join m.team t";
    List<Member> list2 = em.createQuery(leftJoinQuery,Member.class)
        .getResultList();

    for(Member member : list2){
        System.out.println(member.toString());
        System.out.println(member.getTeam());
    }

	// 출력 결과
    Member{id=3, username='멤버0', age=0}
    Team{id=1, name='teamA'}
    Member{id=4, username='멤버1', age=1}
    Team{id=1, name='teamA'}
    ...
    Member{id=31, username='멤버28', age=28}
    null
    Member{id=32, username='멤버29', age=29}
    null

 

5.3. 세타 조인 예제

 - 테스트를 위해 member 이름이 teamA인 멤버를 생성하였다. 실제 실행된 쿼리 확인 결과, 두 테이블을 크로스 조인 후 조건에 해당하는 값만을 조회한다.

    Member thetaMember = new Member();
    thetaMember.setUsername("teamA");
    thetaMember.changeTeam(teamA);
    em.persist(thetaMember);

    String thetaJoinQuery = "select m from Member m, Team t where m.username = t.name";
    List<Member> list3 = em.createQuery(thetaJoinQuery,Member.class)
        .getResultList();

    for(Member member : list3){
        System.out.println(member.toString());
        System.out.println(member.getTeam());
    }

    // 출력 결과
    Member{id=33, username='teamA', age=0}
    Team{id=1, name='teamA'}

6. 조인 대상 필터링

 - SQL에서 사용하던 on과 동일하게 사용한다. 내부 조인, 외부 조인도 동일한 방식으로 사용 가능하다.

    // 내부 조인에 대한 필터링 - 회원과 팀을 조인하면서, 팀 이름이 A인 팀만 조인
    JPQL : SELECT m, t FROM Member m LEFT JOIN m.team t ON t.name = 'A'
    SQL : SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.team_id = t.id and t.name = 'A'

    // 외부 조인에 대한 필터링 - 회원의 이름과 팀 이름이 같은 대상 외부조인
    JPQL : SELECT m, t FROM Member m LEFT JOIN Team t ON m.username = t.name
    SQL : SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.username = t.name

7. 회고

 - JPQL에서 사용하는 조인과 SQL에서 사용하는 조인은 조인의 개념만 잘 알고있다면 어렵지 않게 적용함을 알았다. 모든 조인에 세타 조인을 사용해도 되나, 크로스 조인으로 인한 성능 저하를 고려해봤을 때 테이블의 연관관계를 확실히 이해하고 그에 따른 조인 전략을 구상하는 게 중요함을 느꼈다.

 


8. 참고

 - JAVA ORM 표준 JPA 프로그래밍 - 김영한

반응형
반응형

* 이동욱 저자의 스프링 부트와 AWS로 혼자 구현하는 웹서비스 교재 참고

1. 개요

 - JPA란?

 - H2란?

 - JPA 및 H2 설정 및 연동

 - Junit을 사용한 JPA CRUD 테스트

 

2. JPA란?

 - DB 처리를 쿼리 매핑(ex: ibatis, mybatis)이 아닌 객체 매핑으로 처리할 수 있도록 하는 기술

 - ORM (Object Relational Mapping) 기반

 - 기본적인 CRUD(Create, Read, Update, Delete) 메서드 자동 생성

 

3. H2란?

 - 인메모리 관계형 데이터베이스

 - 별도의 설치가 필요 없이 프로젝트 의존성만으로 관리가 가능

 - 메모리에서 실행되기 때문에 애플리케이션을 재시작할 때마다 초기화됨 (테스트용으로 많이 사용)

 

4. JPA 및 H2 의존성 설정 (build.gradle)

1
2
compile('org.springframework.boot:spring-boot-starter-data-jpa'//스프링 부트용 Spring Data Jpa 라이브러리
compile('com.h2database:h2'//h2 라이브러리
cs

 

5. JPA 설정 및 예제

 5.1. 도메인 설정 (Posts.java)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Getter
@NoArgsConstructor
@Entity
public class Posts{
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(length = 500, nullable = false)
    private String title;
    
    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;
    
    private String author;
    
    @Builder
    public Posts(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }
}
cs

- @NoArgsConstructor : 클래스에 대한 기본 생성자 메서드 생성

- @Entity : 테이블과 링크될 클래스를 지정하는 어노테이션이며, 언더스코어 네이밍으로 테이블 이름을 매칭

  (ex : PostsManager > posts_manager table)

 

- @Id : PK 필드

 

- @GenerateValue(strategy = GenerationType.IDENTITY) : 자동 인덱스 생성 설정

 

- @Column : 테이블의 컬럼을 나타내며 필요 옵션 추가 시 length, nullable, columnDefinition 등을 사용

  > null을 허용하지 않고 길이가 500인 title 컬럼을 생성 

  > null을 허용하지 않고 TEXT 타입인 content 컬럼을 생성

  > 필드에 Column 어노테이션을 붙이지 않아도 기본적으로 컬럼이 생성됨

 

- @Builder : 해당 클래스의 빌더 패턴 클래스 생성

  > build 패턴 사용시 보다 명시적인 객체 생성이 가능 (PostTestRepository.java 코드의 21번째 줄 참고)

 

 5.2. JpaRepository 설정 (PostsRepository.java)

1
2
3
public interface PostsRepository extends JpaRepository<Posts, Long>//Entity 클래스, PK 타입
 
}
cs

- extends JpaRepository<Entity.class, PK Type> : 기본적인 CRUD 메서드를 지원하는 Repository 인터페이스 생성

 > JpaRepository : DB Layer 접근자를 의미

 > Entity 클래스와 해당 Entity Repository는 같은 패키지에 위치해야함.

 

6. H2 설정 (src/main/resources/application.properties)

1
2
3
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5InnoDBDialect
spring.h2.console.enabled=true
cs

- spring.jpa.show-sql=true : 콘솔 내에서 쿼리 로그를 확인할 수 있도록 하는 설정

- spring.jpa.properties.hibernate.dialect : H2 형태의 쿼리 로그를 MySQL 버전으로 변경

- spring.h2.console.enabled=true : h2 웹 콘솔 사용 허용 (localhost:port/h2-console 으로 접속 가능)

 

7. Junit 테스트 (PostsRepositoryTest.java)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@RunWith(SpringRunner.class)
@SpringBootTest //별다른 설정없이 SpringBootTest를 사용할 경우 H2 데이터 베이스를 자동으로 실행
public class PostsRepositoryTest {
 
    @Autowired
    PostsRepository postsRepository;
    
    //After : 테스트 단위가 끝날때마다 수행되는 메서드를 지정하는 어노테이션
    @After
    public void cleanup() {
        postsRepository.deleteAll();
    }
    
    @Test
    public void 게시글저장_불러오기() {
        
        String title = "테스트 게시글";
        String content = "테스트 본문";
        
        //builder 클래스를 통해 생성자 생성 후 save (insert/update)
        postsRepository.save(Posts.builder()
                                .title(title)
                                .content(content)
                                .author("sksim@gmail.com")
                                .build());
        
        List<Posts> postsList = postsRepository.findAll();
        Posts posts = postsList.get(0);
        assertThat(posts.getTitle()).isEqualTo(title);
        assertThat(posts.getContent()).isEqualTo(content);
    }
}
cs

- @SpringBootTest : H2 데이터 베이스 자동 실행 

 

- postsRepository.save() : posts 테이블에 insert/update 쿼리 실행

  > id 값이 있다면 update, 없다면 insert

 

- postsRepository.findAll() : posts 테이블에 있는 모든 데이터를 조회

 

- assertThat, isEqualTo : assertJ에서 지원하는 테스트 메서드

 

8. H2 콘솔 접속 방법

 8.1. localhost:port/h2-console 을 입력하여 H2 웹 콘솔 접속 및 JDBC URL 입력 후 Connect

H2 웹 콘솔 접속 정보 입력

 

 8.2. DB 접속 및 Entity 객체와 매핑된 테이블 확인

H2 웹 콘솔 접속 성공

  * Junit 테스트 시 11번째 줄의 deleteAll()을 주석처리하면 posts 테이블에 데이터가 들어가는 것을 확인할 수 있음

반응형

+ Recent posts