반응형

문제


서비스 테스트는 성공하는데 API 요청 테스트는 실패하는 현상이 발생했다.

상황 설명

  1. 댓글을 작성한다.
  2. 대댓글을 작성한다.
  3. 대대댓글을 작성하면 대댓글로 작성되어야 한다.

3번이 문제 상황이다. 3번을 서비스 레어어에서 테스트 코드로 테스트할 때는 정상 동작했다.
하지만, API 요청 테스트를 하면 실패했다. 실패했다는건 아예 작성이 되지 않는 상황이었던 것이다.

원인


트랜잭션과 하이버네이트 프록시가 원인이었다.

하이버네이트 프록시 원인

하이버네이트 프록시는 아래와 같은 구조를 기진다. 동작 과정을 살펴보자.

  1. getName() 요청 시 프록시 객체의 타겟은 null이다.
  2. 진짜 객체에서 가져오도록 영속성 컨텍스트에 요청한다.
  3. 영속성 컨텍스트에 없으면 DB 조회를 한다.
  4. 실제 엔티티를 생성한다.
  5. 프록시는 실제 객체(타겟)에서 데이터를 가져온다.

출처 : 자바 ORM 표준 JPA 프로그래밍 - 기본편

그림을 살펴보면, 프록시 객체는 get 메서드로 실제 객체에서 데이터를 가져온다.
이는 프록시는 실제 객체가 아니므로 실제 필드에 접근을 할 수 없는 것이다.

프록시란

프록시를 위한 글은 아니기에 간단히 설명하면, 프록시 패턴을 생각하면 된다.
진짜 객체(타겟)을 감싸는 가짜 객체가 프록시 객체이다. 이 가짜 객체는 AOP 같이
실행 전이나 후 등의 상황에 진짜 객체의 메서드를 실행하는 역할을 한다.
단순히 진짜 객체의 메서드를 실행하는 것이 아닌 실행 전에 로깅을 찍는다던가 하는 역할을 하는 것이다.

이로 인해 발생한 문제를 상세히 살펴보자.

먼저 댓글 엔티티 코드를 보자. 대댓글까지만 가능한 계층 구조이다.

@Entity
public class Comment extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Lob
    @Column(nullable = false)
    private String contents;

    @JoinColumn(name = "PARENT_ID")
    @ManyToOne(fetch = LAZY)
    private Comment parent;

    @OneToMany(mappedBy = "parent", cascade = {CascadeType.MERGE})
    private List<Comment> children = new ArrayList<>();

    @JoinColumn(name = "POST_ID", nullable = false)
    @ManyToOne(fetch = LAZY)
    private Post post;

    @JoinColumn(name = "MEMBER_ID", nullable = false)
    @ManyToOne(fetch = LAZY)
    private Member member;

    @Column(nullable = false)
    private boolean isDeleted = false;

    protected Comment() {
    }

    public Comment(String contents, Post post, Member member) {
        this(null, contents, post, member);
    }

    public Comment(Long id, String contents, Post post, Member member) {
        this.id = id;
        this.contents = contents;
        this.post = post;
        this.member = member;
    }

    public void update(String contents, Member member) {
        validateOwner(member);
        this.contents = contents;
    }

    public void delete(Member member) {
        validateOwner(member);
        this.isDeleted = true;
    }

    public Comment createNestedComment(Comment comment){
        if (this.parent == null) {
            comment.parent = this;
            children.add(comment);
            return this;
        }
                // 문제 지점인 대댓글과 대대댓글 저장
        comment.parent = this.parent;
        this.parent.children.add(comment);
        return this.parent;
    }
        ...

        public List<Comment> getChildren() {
        return Collections.unmodifiableList(children);
//        return children;
    }
        ...
}

CommentService.java

public Long createNestedComment(String email, Long postId, Long parentId, CommentRequest requestDto) {
    Member member = memberService.findMemberByEmail(email);
    Post post = postService.findPostById(postId);
    Comment parent = findCommentById(parentId)
            .createNestedComment(requestDto.toEntity(post, member));

    return commentRepository.save(parent).getId();
}
  • findCommnetById(parentId)를 호출할 경우 parentId에 대한 댓글 정보를 가져온다.
  • parentId상위 댓글을 의미한다. parentId는 정상적으로 댓글ID가 넘어올 수도 있고,
    비정상적인 대댓글ID가 넘어올 수도있다.
  • 댓글ID가 넘어오면, Comment 엔티티의 Parent는 null이고, 대댓글ID가 넘어오면 값이 존재하게 된다.
  • 이후 Commnet 엔티티에 대댓글 생성을 요청한다. 파라미터로 생성할 대댓글을 넘겨준다.

Commnet.java

public Comment createNestedComment(Comment comment){
        // 대댓글인 경우
    if (this.parent == null) {
        comment.parent = this;
        children.add(comment);
        return this;
    }
        // 대대댓글인 경우
    comment.parent = this.parent;
        this.parent.children.add(comment);
    return this.parent;
}
  • 서비스에서 조회한 댓글 정보가 넘어온다.
  • 대댓글 정보가 넘어온 경우
    • 정상적인 경우이다. 댓글에 대댓글을 다는 구조이기 때문이다.
    • this는 댓글이다. 대댓글 객체의 부모에 this를 넣어준다.
    • this의 자식(children)에 대댓글을 추가해준다.
    • 그리고 this를 반환한다.
    • 그러면 부모 댓글의 자식 계층에 대댓글을 등록하게 되는 것이다.
    • 정상적으로 저장이 된다.
  • 대대댓글 정보가 넘어온 경우(이 코드가 문제이다.)
    • this는 대댓글 객체이다.
    • 대대댓글의 부모에 this의 부모를 넣어준다. 대댓글로 남기기 위함이다.
    • this의 부모(댓글)의 자식(children)에 대대댓글을 추가한다. 대댓글로 남기기 위함이다.
      • 이 부분이 문제인데 children.add가 문제이다.
    • 대대댓글을 대댓글로 추가하여 대댓글의 부모인 댓글을 반환한다.

두 경우 모두 반환은 할 필요는 없다. 하지만 여기서는 save하여 저장된 ID 값을 사용하기 위해 반환했다.

children.add의 문제를 살펴보자.

하이버네이트 프록시의 구조를 위에서 설명했다. 그 구조로 봤을 때 children이 아닌 getChildren()으로 접근해야만 한다.
(프록시는 필드 접근이 아닌 부모의 메서드를 호출하는 것일 뿐 부모의 필드에 접근하는 것은 아니기 때문이다.)
이미 this.parent는 지연 로딩이기 때문에 프록시가 사용되고 있어 문제가 없었다. 하지만, children은 parent라는 프록시의 필드이다.

그런데 바로 필드 접근인 children을 하고 있던 것이다. 조금 더 상세히 설명하자면, parent는 프록시이기 때문에 상속받은 필드는 null이다.
이는 사용하지 않는 필드이다. 단지 상속만 받았을 뿐이다. 근데 그 필드인 children에 접근을 했기 때문에 add()를 해도 껍데기에 add()를 한 것이다.
프록시는 타겟에 접근할 때 메서드로 접근해야 한다. 프록시는 껍데기일 뿐이라는 것을 명심해야 한다.

대댓글은 되고, 대대댓글은 왜 안됐을까?

이런 의문이 들었다. 대댓글도 결국 children.add()로 필드에 접근하는 것인데 왜 될까?
라고 생각했다. 계층 구조이기 때문에 헷갈렸던 부분이었다.

대댓글의 경우 코드가 아래왜 같은데 위에서 설명했지만 this는 댓글이고, 나머지는 프록시이다.
즉, children도 프록시이다. 즉, 프록시 객체의 필드에 접근한 것이 아니라 프록시 객체 자체에 접근한 것이기 때문에
가능한 것이다. 반면, 대대댓글은 프록시인 parent의 필드인 children에 접근했기 때문에 이런 차이가 있던 것이다.

if (this.parent == null) {
    comment.parent = this;
    children.add(comment);
    return this;
}

해결 방법

세 가지의 해결 방법을 찾았다.

  1. getChildren()을 사용하고, getChildren()의 unmodifiableList()를 없앤다.

결국 실제 객체에 children을 수정해야 하기 때문에 unmodifiableList() 사용할 수 없어
캡슐화가 깨진다.

  1. children을 add() 하는 메서드를 만든다.
this.parent.addChildren(comment);
...
protected void addChildren(Comment comment) {
    this.children.add(comment);
}

프록시 객체인 parent는 getChildren()을 사용하는 것과 같이 addChildren()이라는 메서드에
요청한다. 그러면, 진짜 객체에 요청이 되고, 진짜 객체이기 때문에 필드 접근이 가능하다.
1번 보다 좋은 점은 unmodifiableList()을 유지하여 캡슐화를 유지할 수 있다.

protected를 사용해야 하는 이유는 부모의 메서드를 상속 받아서 오버라이딩 하는 개념이기 때문에
protected 이하만 가능하다. 굳이 예를 들자면, 아래와 같은 코드의 모양이 될 것이다.

public class Parent {
    private String name;

    protected void call() {
        System.out.println(name);
    }
}

class Child extends Parent {

    @Override
    protected void call() {
        System.out.println("호출 전");
        super.call();
        System.out.println("호출 후");
    }
}

트랜잭션 원인


서비스 테스트가 성공한 이유는 서비스 테스트는 트랜잭션이 걸려있기 때문이었다.
API 테스트 같은 경우는 사전 데이터인 포스트, 댓글, 대댓글이 각각의 트랜잭션이지만,
서비스 테스트는 모두 하나의 트랜잭션으로 같은 영속성 컨텍스트를 사용하기 때문에
프록시를 사용하지 않았기 때문에 가능한 것이었다.

정리


  • 하이버네이트 프록시의 필드에 접근할 수 없다.
  • 접근은 메서드로 접근해야 한다. 프록시 패턴 그 자체이다.

추가 개선 내용


저장되는 주체를 바꾼다.

기존에는 생성할 대댓글 및 대대댓글을 save()한 것이 아니고, Commnet 객체에서 반환된 parent를 반환 받아서
save()를 했다. 그렇기 때문에 이와 같은 문제가 발생한 것이다. 왜냐면, parent에 add()한 대댓글 및 대대댓글을
저장하는 것이기 때문이다. 하지만 저장되는 주체를 생성할 대댓글 및 대대댓글로 변경하면, 이와 같은 문제는 발생하지 않았다.
이렇게 추가 개선한 이유는 저장될 객체가 아닌 다른 객체가 컨트롤을 하게 되면, 아무래도 헷갈리는 상황이 발생하게 되기 때문이다.
즉, 현재 상황에서는 add() 자체를 하지 않아도 문제가 없는 코드이지만, 양방향 관계이기 때문에 해주는 것이 좋다.

반응형
복사했습니다!