본문 바로가기
프로젝트/Recipository

[Dev] 22.12.02. 게시글 작성 항목 중 링크에 대하여(1) : 양방향 연관 관계

by 규글 2022. 12. 2.

내용을 작성하기 앞서...

 게시글 작성 항목 중에 링크에 대한 내용을 먼저 다뤄보려고 한다.

 

contentform.html

        <label for="link">참고 링크</label>
        <ul>
            <li id="linkList">
                <input type="text" class="refLink" id="link" name="link">
            </li>
        </ul>
        <a href="#" id="linkAddBtn">링크 추가</a>

 우선 간단히 언급만 하고 넘어갈 내용부터 작성해본다. Form을 구성하고 있는 input element 의 이름이 같은 경우에는 이들을 또다른 hidden input에 연결해서 넣어 받을 필요가 없다.(민망하지만 필자가 그랬다.) 그런데 이들은 input element의 이름이 같은 경우에는 controller에서 List 로 받는 것이 가능하다.

 

 본격적인 내용 전개에 앞서 이를 언급하는 것은 과거 프로젝트에서 필자가 택했던 방식에 이상함을 느꼈기 때문이다. (물론 당시에도 이상함을 느꼈으나, 해당 내용을 강사님께 질문하지 않았던 것을 후회한다.) 그 방식은 다음과 같다.

 

"aaa"
"bbb"
"ccc"

=> "aaa, bbb, ccc"

 위에 보이는 것처럼 client 측에서 저장하고 싶은 동일한 항목의 "aaa", "bbb", "ccc" 라는 값을 client로부터 하나로 연결된 String으로 받아 이를 그대로 DB에 저장하는 방식을 취했었다. 다시 client 쪽에 출력할 때는 이들을 그대로 client 측으로 보내서 jstl 을 이용해서 출력했으며, 혹시라도 특정 내용을 추가하거나 삭제하는 경우 server 단에서 이들을 String array로 받아서 다뤘다.[각주:1]

 지금도 얕지만 당시의 더 얕은 지식으로 해당 아이디어를 통해 구현하려고 했었으며, 이는 연결하여 저장하고자 했던 내용이 방대하지 않았기때문에 가능했다고 생각한다.

 

 그런데 문득 자려고 누워서 작업했던 내용들과 앞으로 작업할 내용을 떠올리다가, 사실 이런 내용들을 실제로는 각각을 DB의 row로 저장해서 사용하는 것이 옳은 방향이라는 생각이 들었다. 필자는 이미 로그인을 위한 UserDetails 와 GrantedAuthority 를 만들면서 경험했다는 것이 떠오른 것이다.[각주:2] (물론 지금에서는 수정하고 싶은 부분이 조금 생겼고, 이는 추후에 다른 게시글로 정리할 계획이다.) 휴식을 취하고 다시 이리저리 검색을 했을 때 생각했던 것들이 어느 정도 맞았다는 것을 인지했고, 그래서 이렇게 글을 작성하는 중이다. 서론이 길었다.

 

 

본론

 서론에서는 필자의 민망한 모습을 드러내었다. 앞으로는 해당 내용을 어떤 방식으로 타개해보려고 했는지에 대한 과정을 기록해보고자 한다. 크게 다음의 세 가지 annotation으로 과정을 설명할 수 있다.

  • @ElementCollection
  • @OneToMany
  • @ManyToOne

 사실 위 세 가지 모두 JPA 강의에서 살펴볼 수 있을 것이라고 생각한다. 현재 필자는 어느 정도 작업이 가능할 것이라고 생각하는 최소한의 내용까지만 듣고 작업을 강행하고 있다. 강의에서 얻을 수 있는 내용임에도 계속해서 강의를 듣는 것 보다 검색을 통해 일단 기능을 구성한 뒤, 남은 강의를 이어 들으면서 작업 내용을 update 하려는 것이 필자의 방향성이다.

 

@ElementCollection

 사실 시작은 이 친구가 아니라 나머지 둘이었다. Client로부터 동일한 name의 input 들의 data를 List 로 받을 수 있지만, List(를 포함한 Colllection 객체)를 그대로 DB에 저장하는 것이 불가능하다는 점에서 출발했다. 지난 프로젝트에서도 List를 그대로 DB에 저장하지 못한다는 생각은 동일했으나, 그 이후의 아이디어가 다른 것이다. @ElementCollection을 알게 된 것은 client로부터 넘겨받은 List<String> 객체가 String 이었던 것이 그 이유이다.

 

JPA Data Type

 JPA에서 data type은 다음과 같이 크게 두 가지로 분류한다고 한다.[각주:3] 블로그에서는 따로 reference를 작성하지 않았지만, 거의 동일한 내용이 작성된 다른 블로그로부터 추론해보면 '자바 ORM 표준 JPA 프로그래밍 (저자 : 김영한)' 과 그 저자분의 강의가 reference일 것으로 생각한다. 

  • Entity Type
  • Value Type
    • Basic Value(기본 값) Type : int, double 등과 같은 primitive type + Integer, Long 등과 같은 wrapper class, 그리고 String 
    • Embedded Value Type : Basic value type을 wrapping 해서 새롭게 만든 class type
    • Collection Value Type : 위 두 가지 type을 List, Set 과 같은 Collection 객체를 이용한 type

 

String str1 = "test";

str1 = "test prime";

 이 중에서 Value Type값의 변경을 추적할 수 없다고 말한다. 이 말은 즉, 값을 변경할 수 없다는 의미와 동일하다고 언급한다. 예를 들어 "test" 라는 문자열이 String type variable 'str1' 에 대입된 상황을 생각해보면, 이것은 variable 자체가 heap 영역에 있는 문자열 그대로가 아니라 heap 영역 문자열이 가진 참조값(주소값)이 된다고 할 수 있다. 이때 variable에 다른 문자열을 넣는 것은 새로운 문자열을 heap 영역에 만들어서 그 새로운 참조값을 variable에 할당해주는 것이라고 할 수 있다. 따라서 겉으로 보기에는 문자열을 수정했다고 생각할 수 있으나, 사실 두 문자열은 서로 다른 존재이다. 따라서 값을 변경한 것도 아니고, 그 값의 변경을 추적할 수도 없는 것이라 할 수 있겠다.

 

 이런 흐름으로 Value type의 세 가지 중에서 Basic Value는 값의 변경을 추적할 수 없다고 말할 수 있다. Primitive type 은 heap 영역에 값이 있는 것이 아니라 동일한 참조값으로 값의 변경을 추적하는 개념이 아니기때문에 Basic Value에 포함된다.

 

public class Member {
	(...)
    
	Time time;
}

public class Time {
	Date start;
	Date end;
}

 이어서 이들의 모임인 Embedded Type도 값의 변경을 추적할 수 없다고 할 수 있다. 예를 들어 간단히 시작 시간 start와 끝 시간 end 값을 담는 Time이 어떤 Member class 안에 embedded 되어있다고 생각해보자. Member 라는 객체의 Data type이 변경된다고 하더라도 그것은 그 자체의 값에 대한 변경이 아니라 새로운 참조값이 들어가게 되는 것이다. 새로운 참조값이 들어간 Time 객체는 이전과는 다른 새로운 것이 되는 것 같다.

 

 필자는 납득하지 못하고 테스트를 해봤다. 하나의 Menu 객체이고 단순히 그 안에 있는 값만 바뀌었다고 생각해서 Menu 객체는 그대로일 것이라고 예상했는데, 값을 바꾸고 보니 hashcode가 서로 다른 것을 볼 수 있었다.

 

 작업에 사용할 Collection Value Type도 마찬가지이다. 이때 Collection Value Type을 사용하기 위해서는 다음과 같은 방식으로 annotation을 작성해야 한다.

 

    @ElementCollection
    @CollectionTable(
            name = "link",
            joinColumns = @JoinColumn(name = "content_id")
    )
    @Column(name = "link")
    private List<String> link;
  • @ElementCollection
    : Specifies a collection of instances of a basic type or embeddable class. Must be specified if the collection is to be mapped by means of a collection table.
  • @CollectionTable : Specifies the table that is used for the mapping of collections of basic or embeddable types. Applied to the collection-valued field or property.

 @ElementCollection annotation은 instance의 collection임을 명시한다. 그리고 @CollectionTable annotation은 mapping을 위해 사용할 table을 명시한다. Annotation의 name 속성은 table의 이름을 나타내고, joinColumns 속성은 collection table의 foreign key column을 어떤 것으로 할 지 나타낸다. @Column은 table의 column name을 지정한다.

 이렇게 Collection List에 annotation을 작성하고 project를 run 하게 되면, 작성한 내용에 맞게 collection table이 만들어진다.

 

 여기에서 생각한대로 DB의 table에 data가 저장되어서 다른 작업을 이어나가려고 했는데, 살펴본 몇 가지 블로그들에서 문제가 있음을 지적하고 있었다.

 

문제점

 기존 DB에는 link에 대한 정보가 2개 있었고, 두 개의 link 정보를 수정하기 위해 값을 바꿔서 save 해줬을 때의 console 에 나타난 query 문이다. 원래라면 update query가 수행되었어야 하는데, 기존에 있던 link data를 delete 시킨 후 다시 새로운 data를 insert 하는 것을 볼 수 있었다. 이렇게 기존 data를 모두 지우는 것은 새로운 data를 추가하거나, 일부 내용을 삭제할 때도 마찬가지 방식으로 동작한다.

 이는 Entity 객체와 lifecycle을 같이 하지만, collection table에는 식별자가 존재하지 않기 때문이라고 공통적으로 언급하고 있다. 단순히 insert나 update만 수행한다면 불필요한 delete query가 수행되지 않아도 되는 것인데, 이 부분은 @ElementCollection 을 사용하는 것을 꺼리게 했다.

 

 그래서 생각한 것은 Collection Value Type을 사용하지 않고, 새롭게 Entity를 구축한 다음 @OneToMany 와 @ManyToOne annotation 을 사용해서 일대다 관계를 구축하는 것이 좋겠다고 생각했다. 이 생각은 몇몇 블로그[각주:7] [각주:8] 에서도 마지막에 유사한 언급을 하고 있으며, 심지어 List 의 경우는 data가 많아질 경우 성능이 나빠질 수 있다고 언급한 것이 생각을 굳히는 것에 도움을 주었다고 할 수 있겠다.

 바로 작성한 footnote의 두 번째 블로그에서는 다음과 같이 언급하고 있다.

  • 식별자가 필요없고,
  • 중복이 없어 모든 필드를 PK로 묶을 수 있으며,
  • null이 없는 경우에만 값 타입 컬렉션을 사용하는 것이 바람직하다.

 

 

양방향 연관 관계

 사실 필자는 양방향 연관 관계로 구축하고자 하는 생각은 없었다. 결제해둔 강의 내용 중에 JPA를 몇 개 못들은 상태이기때문에 검색보다는 해당 강의를 듣고 작업하는 것이 좋겠다고 생각했기 때문이다. 하지만 두 테이블이 서로 연관되어있는 만큼 어쩔 수 없이 검색을 통해 1:N(일대다) 단방향 관계를 구축하고자 할 생각까지는 있었지만, 여러 검색 결과에서 1:N 단방향 관계는 좋지 못하다고 언급하는 것을 많이 볼 수 있었다.[각주:9] [각주:10] [각주:11] [각주:12]

 

1:N (일대다) 단방향 연관 관계

 1:N 연관 관계는 @OneToMany annotation을 통해 명시된다.

  • 1쪽에서는 N쪽을 참조하는 field가 있지만, N쪽에서는 1쪽을 참조하는 field가 없다.
  • 1이 연관 관계의 주인쪽이 된다. 이것은 foreign key를 주인인 1에서 관리하겠다는 의미라고 한다. 하지만 실제로 table에서는 foreign key를 N쪽에서 가지고 있다.
  • JPA 표준에서 지원하고는 있지만, 실무에서 추천하지는 않는다고 한다.
  • @JoinColumn : Join을 위한 column을 명시하는 annotation인데, 별다른 속성을 작성하지 않으면 join을 위한 table이 생성된다고 한다. 만약 name 속성에 무엇인가를 작성하게 된다면 Collection type 객체의 명시한 이름의 column을 foreign key로 사용하겠다는 의미가 된다.

 

TestMenu.java

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Table(name = "test_menu")
@Entity
public class TestMenu {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long contentId;
    private String writer;
    @OneToMany
    @JoinColumn(name = "menu_id")
    private List<TestLink> link = new ArrayList<>();

    public void addLink(TestLink testlink){
        link.add(testlink);
    }
}

 

TestLink.java

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Table(name = "test_link")
@Entity
public class TestLink {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String link;
    @Column(name = "menu_id")
    private Long menuId;
}

 

    @Test
    public void test6() {
        List<String> linkList = new ArrayList<String>();
        linkList.add("abc");
        linkList.add("de");

        TestMenu testMenu = new TestMenu();
        testMenu.setWriter("test1");

        linkList.forEach(tmp -> {
            TestLink testLink = new TestLink();
            testLink.setLink(tmp);
            testMenu.addLink(testLink);

            testLinkRepository.save(testLink);
        });

        testMenuRepository.save(testMenu);
    }

 테스트를 위한 두 Entity를 만들었다. 두 Entity는 Menu 쪽에서 @OneToMany로 Link쪽에 대해 1:N 연관 관계를 맺고 있다.

 

 검색을 통해 확인한대로 두 개의 Link를 만든 후에 save하고, 이어서 Menu를 save 했다. 동일하게 각각이 insert된 모습과 함께, 미리 save 했던 Link에 대한 update query가 수행된 것을 볼 수 있었다.

 처음 Link를 save할 때는 Menu로부터의 연관 관계인 menu_id 에 해당하는 정보가 없기때문에, 새롭게 Menu를 save할 때 연관 관계인 menu_id 정보가 들어가기 때문에 Link에 대해 update query가 수행되는 것은 당연하다고도 할 수 있다. Foreign key를 가지고 있는 table이 반대쪽인 Menu에 있으니 처음 Link에 data를 insert 할 때는 그 값이 없고, Menu가 만들어지고서야 비로소 data를 넣을 수 있어서 update가 수행되는 것이다.

 

 

 

 

org.hibernate.TransientObjectException
: object references an unsaved transient instance - save the transient instance before flushing
							: com.example.recipository.model.entity.TestLink;
                            
-----------------------------------------------------------------------------------------------

    @OneToMany(cascade = CascadeType.ALL)
    @JoinColumn(name = "menu_id")
    private List<TestLink> link = new ArrayList<>();

 그렇다고 Menu 만을 먼저 save할 수도 없다. 바로 위와 같은 에러 메시지를 보게 될 것이다. Transient instance인 Link의 부터 저장한 후에 Menu를 저장할 수 있다는 의미이다. 해당 객체가 존재하지 않아서 객체 입장에서는 관리할 foreign key를 찾을 수 없다는 것이다. 이는 객체와 table이 foreign key를 관리하는 면에서 서로 상반되기 때문이라고 생각할 수 있다.

 만약 @OneToMany의 cascade 속성으로 CascadeType.All 을 작성해주면 Menu로만 table에 insert하는 것이 가능하긴 하지만, 여전히 순서는 Menu, Link를 insert 한 후 Link에 대한 update query가 동일하게 수행된다. 이것은 다량의 data를 다룬다고 생각했을 때 효율 면에서 상당히 좋지 않기도 하다.

 

*** 22.12.15. ***

        linkList.forEach(tmp -> {
            TestLink testLink = new TestLink();
            testLink.setLink(tmp);
            testMenu.addLink(testLink);

//            testLinkRepository.save(testLink);
        });

        testMenuRepository.save(testMenu);

 정확히는 중간 Link에 대한 save를 주석처리했을 때 위 오류가 발생한다. 물론 Link 를 setting 하기 전에 Menu를 save하는 것은 가능하지만, 역시 link table에 Menu와 연결된 column에 대한 data는 들어가지 않는다. 그렇다고 Link의 save 없이 Menu만을 cascade 속성에 대한 설정 없이 save하려고 한다면, table은 link에서 foreign key를 관리하는데 object는 Menu에서 foreign key를 관리해서 Link의 save를 필요로 해서 발생하는 오류라고 할 수 있겠다.

 

 

N:1(다대일) 단방향 연관 관계

 N:1 연관 관계는 @ManyToOne annotation을 통해 명시된다.

  • N쪽에서는 1쪽을 참조하는 field가 있지만, 1쪽에서는 N쪽을 참조하는 field가 없다.
  • N이 연관 관계의 주인쪽이 된다. 이것은 foreign key를 주인인 N에서 관리하겠다는 의미라고 한다. 1:N 과는 다르게 table에서도 N쪽에서 foreign key를 가지고 있다. 객체와 table 모두 N쪽에서 foreign key를 관리하는 것이다.

 

TestMenu.java

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Table(name = "test_menu")
@Entity
public class TestMenu {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "content_id")
    private Long contentId;
    private String writer;
}

 

TestLink.java

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Table(name = "test_link")
@Entity
public class TestLink {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String link;
    @ManyToOne
    @JoinColumn(name = "content_id")
    private TestMenu testMenu;
}

 

    @Test
    public void test7() {
        List<String> linkList = new ArrayList<String>();
        linkList.add("abc");
        linkList.add("de");

        TestMenu testMenu = new TestMenu();
        testMenu.setWriter("test1");

        testMenuRepository.save(testMenu);

        linkList.forEach(tmp -> {
            TestLink testLink = new TestLink();
            testLink.setLink(tmp);
            testLink.setTestMenu(testMenu);

            testLinkRepository.save(testLink);
        });
    }

 1:N의 경우와 다르게 Cascade 속성을 작성해주지 않았음에도 Menu를 save하고 Link를 save하는 것이 insert query로만 수행되었다. Foreign key를 Link 객체와 table에서 다루고 있기때문에 Menu를 먼저 만들고 save 할 수 있으며, 이후에 foreign key를 가진 곳에서 이를 가지고 save가 가능해서 이전과는 다르게 update query가 필요없다.

 1:N의 Menu에서와 마찬가지로 Link는 Menu의 content_id를 필요로 하기때문에 먼저 save 할 수 없다. 하지만 Link에 Menu를 setting 하지 않고 save 하는 것은 가능하다. 이것은 어떻게 가능한 것일까?

 이것은 해당 객체가 아예 존재하지 않아서 null 인 경우와, 객체는 존재하지만 content_id 값이 null 인 경우로 구분할 수 있겠다. 전자의 경우 애초에 참조할 객체가 없어서 null 이라면 null을 넣어주면 되는 것이지만, 참조할 객체가 있는 경우에는 반드시 그 객체에 content_id의 값이 있어야하는 것으로 추정된다. @JoinColumn의 nullable 속성은 기본적으로 true인데, 이는 foreign key column에 null을 허용하므로 객체가 없는 것은 괜찮으나, 객체가 있는 순간 이미 null이 아니게 되어서 반드시 content_id를 필요로 하는 것 같다. (그런데 content_id 도 Long type이라 null이 가능한데, 이 부분을 이해하지 못하겠다.)

 

 

N:1(다대일) 양방향 연관 관계

  • 말이 양방향이지, 사실은 단방향 연관 관계 두 개이다. 
  • 둘 다 실선으로 표기했지만, 관계의 주인은 N쪽인 Link이다.
  • N:1 양방향 연관 관계는 단방향에서 반대쪽에도 N쪽을 참조할 수 있는 Collection field를 만들고 @OneToMany를 작성해준다. 이때 mappedBy 속성에는 관계를 소유할 field를 작성한다. 이 관계의 주인은 N쪽인 Link이고, 그림을 기준으로 한다면 Link의 'menu' 를 작성해주면 되겠다.

 

    @OneToMany(mappedBy = "testMenu")
    private List<TestLink> link = new ArrayList<>();

(Menu)
-----------------------------------------------------
(Link)

    @ManyToOne
    @JoinColumn(name = "content_id")
    private TestMenu testMenu;

 Menu와 Link의 field에 대해 위와 같이 살짝 바꿔주었다.

 

    @Test
    public void test8() {
        List<String> linkList = new ArrayList<String>();
        linkList.add("zxc");
        linkList.add("vv");

        TestMenu testMenu = new TestMenu();
        testMenu.setWriter("test2");

        linkList.forEach(tmp -> {
            TestLink testLink = new TestLink();
            testLink.setLink(tmp);
            testMenu.addLink(testLink);

            testLinkRepository.save(testLink);
        });

        testMenuRepository.save(testMenu);
    }

 작성된 test에 대한 결과이다. Link table의 content_id 에 내용이 들어있지 않다. 사실 이 경우는 서로에 대한 연관 관계라기보다는 Link 따로 Menu 따로 저장이 된 셈이라고 할 수 있다.

 

        linkList.forEach(tmp -> {
            TestLink testLink = new TestLink();
            testLink.setLink(tmp);
            testLink.setTestMenu(testMenu);
            testMenu.addLink(testLink);

            testLinkRepository.save(testLink);
        });

---------------------------------------------------

org.hibernate.TransientPropertyValueException
: object references an unsaved transient instance - save the transient instance before flushing
						: com.example.recipository.model.entity.TestLink.testMenu

그래서 Link를 저장하기 전에 Menu에 대한 정보를 넣어주고 test를 수행하면 content_id에 대한 data가 없기때문에 이번에는 TransientPropertyValuetException이 발생한다. 이전의 TransientObjectException과 다르게 Link의 menu property가 없다는 메시지를 보이는데, Link에 Menu는 있지만 그 안에 있는 content_id 정보를 찾을 수 없는 상황이기 때문이다.

 

        TestMenu newMenu = testMenuRepository.save(testMenu);

        linkList.forEach(tmp -> {
            TestLink testLink = new TestLink();
            testLink.setLink(tmp);
            testLink.setTestMenu(testMenu);
            testMenu.addLink(testLink);

            testLinkRepository.save(testLink);
        });

 따라서 반대로 Menu부터 save 해주고 저장한 Menu를 Menu field에 전달해주면 정상적으로 Link를 save 할 수 있다. 연관 관계의 주인인 Link에 Menu 를 넣어주어야만 DB에 반영된다고 할 수 있겠다. 마지막 부분에서도 언급되겠지만, 만약 @Transactional 과 @RollBack(false) 를 작성하고 이전과 같이 Link를 먼저 save 해준다면 Link가 저장되고 Menu가 저장된 후 Link에 대한 update query가 수행된다.

 

 

순환 참조

 양방향 연관 관계는 1쪽에서 N쪽을, N쪽에서 1쪽을 서로가 참조하면서 반복된다. 다음 이미지를 한 번 보자.

 

 Menu와 Link가 서로 양방향 연관 관계인 상황에서 Link에 Menu를 setting 한 후에 그것을 다시 Menu의 List<Link> field에 넣어주었다. 만약 이 상태에서 toString이나 Hashcode를 console에 띄우려고 하면 어떻게 될까?

 

 이렇게 StackOverflowError가 발생한다. 에러 메시지 이미지는 toString( ) 만을 첨부했지만 hashCode도 마찬가지이다.

 

TestMenu newMenu = testMenuRepository.findTestMenuByContentId(testMenu.getContentId());

 코드가 정상적으로 수행되더라도 bebugging 과정에서 DB에서 data를 받아올 때에도 'Collectiing data...'  라는 메시지를 계속 보이다가 결국 StackOverflowError를 발생시킨다. 이는 모두 양방향 연관 관계에서의 순환 참조에 의해 발생하는 문제이다.

 

 사실 처음에는 순환 참조에 대한 문제를 인지하지 못하고 있었다. 에러 메시지의 StackOverflow 라는 내용을 보고서 순환 참조의 문제라는 것을 인지했지만, 대체 그 이유를 깨닫지 못했다. 그런데 @Data annotation이 아닌 기존의 toString method를 override 하여 return 하는 것을 보면 Menu 에서는 Link를, Link에서는 Menu를 String으로 출력하려고 한다. 이는 서로가 서로를 계속해서 무한히 출력하려고 하는 것이기때문에 StackOverflowException이 발생하는 것이라 할 수 있으며, hashCode의 경우도 동일하다.

 

 이와 관련하여 올바르게 lombok annotation을 사용하기 위한 방법을 고찰한 글도 발견할 수 있었다.[각주:13] @Data annotation으로 인한 toString 이외에도 setter method나 constructor에 대한 이야기도 작성되어 있으니 혹시 궁금하면 참고하면 좋을 것 같다.

 또 다른 블로그에서는 annotation을 통해서 toString( ) method를 사용하지 말고 번거롭더라도 연관 관계를 맺고 있는 field에 대한 내용을 지우고 출력하도록 하거나, @ToString annotation을 사용하되 그 exclude 속성으로 연관 관계를 맺고 있는 field를 제외시키는 방법을 제시한다.[각주:14]

 

 이외에도 순환 참조를 막기 위한 다양한 방법을 소개하는 블로그들도 있었다.[각주:15] 이 블로그에서는 다음과 같은 다섯 가지 방법들을 소개하고 있다. 당장 참고할 수 있는 부분은 아니지만, 후에 관련 오류를 다시 접할 것 같아서 본 김에 작성해두려고 한다.

  • @JsonIgnore
    이 annotation을 붙인 field의 값은 null로 들어가게 된다. Return 시에 null로 넣어 순환 참조를 막는 것 같다.
  • @JsonManagedReference / @JsonBackReference
    N:1 중에 1쪽에 @JsonManagedReference를, N쪽에 @JsonBackReference를 작성해준다. Annotation @interface에는 전자를 parent link에 후자를 child link에 작성하라고 적혀있다. 전자는 nomal 하게 serialization 되고, 후자는 serialization되지 않으며 deserialization 시 forward link 가 가진 instance의 값으로 setting 되도록 한다.
  • @JsonIgnoreProperties({"xxx"})
    부모 class의 연관 관계 field에 작성해준다. Property의 seriaization을 막고(during serialization), JSON properties의 processing을 무시한다.(during deserialization)
  • DTO
    Entity 를 return 하지 않고 필요한 data만을 field로 만들어 담아서 DTO를 가지고 handling 하도록 한다.
  • 연관 관계 재설정
    양방향 연관 관계가 정말 필요한 것인지 재고해본다.

 

 우선은 @Data annotation을 사용하지 않는 선에서 오류를 막았다. 하지만 위의 내용이 필요할 시점이 생길 것이라고 생각한다. DTO와 관계 재고에 대한 언급이 있는 또 다른 블로그도 있었다.[각주:16]또 보자(?)

 

 

마주한 문제

@SpringBootTest
public class MenuRepositoryTest {
    @Autowired
    private MenuRepository menuRepository;
    @Autowired
    private LinkRepository linkRepository;

    @PersistenceUnit
    EntityManagerFactory emf;

    @PersistenceContext
    EntityManager em;

    @Test
    @Transactional
    public void test(){

 JUnit test를 잘 사용하고 있지 않아서 이번에는 조금 사용해보고 싶었다. 그래서 Test Application을 만들고 test를 진행했다. @SpringBootTest 는 해당 class가 Test class라는 것을 명시하고, 각 method를 @Test 를 통해 unit 화 하여 각각에 대한 test를 진행할 수 있도록 한다.

 

 문제는 @Transactional annotation에서 발생했다. 이 친구는 어떤 Service나 Service method에 명시하여, 해당 Transaction(트랜잭션) 단위의 동작에서 오류가 발생했을 때 동작을 roll back 하는 역할을 한다.

 

 그런데 이것이 @Test와 함께 쓰일 때는 조금 달라진다. @Test와 함께 쓰일 때는 오류가 발생하지 않아도 무조건 roll back을 한다. 이는 단순히 test임에도 불구하고 test의 결과가 DB에 반영되는 것이 번거롭기때문에 택해진 방식인 것 같다. Test는 올바로 수행된다고 나오는데 DB는 바뀌지 않아서 당황했고, 그것이 기타 오류와 함께 등장하면서 필자가 작성한 코드가 원인인 것으로 판단하여 사흘의 시간을 소모했다.

 

 한 블로그[각주:17] 에서도 언급하고 있다. 해당 블로그에서는 직접 눈으로 DB가 변경되는 것을 보고 싶다면 @Rollback annotation을 붙이고, 그 속성을 false로 하는 방법까지 언급해두었다.

 

 또한 @Transactional annotation은 Test 과정에서 Entity의 영속을 유지할 수 있게 해주는데, 기존에는 오류로 test가 시작할 수도 없었지만 foreign key의 값이 null 인 상태로 Link Entity가 save가 되고 이후에 Menu Entity를 save하여 해당 foreign key 값이 들어간다면 영속을 유지하여 변화를 감지해서 update query가 수행되는 것을 목격했다.

 

 이제라도 알게 되어서 다행이고 그 과정에서 몇 가지 배운 것이 있기는 하지만, 역시 무지에서 오는 시간 소모가 부끄러울 뿐이다.

 

p.s. Entity Manager

 상당히 많은 블로그에서 Entity Manager를 사용하고 있었다. Test 에서 사용해보고 싶었는데, 해당 내용에 대한 지식이 아직 없어서 고민하던 중에 발견한 블로그가 있다.[각주:18] 순환 참조를 비롯한 여러 오류에 막혀 버둥거리다가 사람들이 사용하고 있는 친구를 필자도 사용해보고자 했었다. 결과는 성공이긴 했는데, 일단은 이친구보다는 JPA만을 사용해보려고 했다.

 

Reference

  1. [Refactoring] 22.08.15. 매장 정보 관리 페이지 (이어서) (tistory.com) [본문으로]
  2. [Dev] 22.11.17. Signin with Validation (tistory.com) [본문으로]
  3. [Spring Data JPA] 값 타입(기본 값 타입, 임베디드 타입) (tistory.com) [본문으로]
  4. java:jpa:elementcollection [권남] (kwonnam.pe.kr) [본문으로]
  5. [인프런 김영한] JPA - 값 타입 컬렉션 (tistory.com) [본문으로]
  6. [JPA] 값 타입 컬렉션 : @ElementCollection, @CollectionTable (tistory.com) [본문으로]
  7. [JPA] 값 타입 컬렉션 @ElementCollection과 @CollectionTable 활용 예시 (tistory.com) [본문으로]
  8. 값 타입 컬렉션 (tistory.com) [본문으로]
  9. JPA OneToMany 단방향 맵핑의 단점 이해하기 삽질기 (tistory.com) [본문으로]
  10. [JPA] @ManyToOne, @OneToMany 이해하기 (tistory.com) [본문으로]
  11. [JPA] @OneToMany, 일대다[1:N] 관계 (tistory.com) [본문으로]
  12. [JPA] 일대다[1:N] 연관관계 매핑 | 공부하고 정리하는 공간 (ym1085.github.io) [본문으로]
  13. 만렙 개발자 키우기 (nowwatersblog.com) [본문으로]
  14. [Spring] 양방향 매핑시 주의점 : toString (tistory.com) [본문으로]
  15. [JPA] 양방향 순환참조 문제 및 해결방법 — 슬기로운 개발생활 (tistory.com) [본문으로]
  16. Entity, DTO 그리고 @Service :: 당근케잌 (tistory.com) [본문으로]
  17. YG's Programming Blog: Spring에서 JUnit 테스트 시 Transaction 처리 (credemol.blogspot.com) [본문으로]
  18. 스프링 부트에서의 EntityManagerFactory, EntityManager 생성 - JUN BLOG (drynod.github.io) [본문으로]

댓글