평가 로직을 구현하는데 발생한 문제를 정리해보고자 한다.
평가 로직
웹툰에 대한 사용자의 평가를 Evaluation Entity에 저장하는 로직이다.
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
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Evaluation extends BaseEntity {
@Id @GeneratedValue
private Long id;
@ManyToOne
@JoinColumn(name = "account_id")
private Account account;
@ManyToOne
@JoinColumn(name = "webtoon_id")
private Webtoon webtoon;
private Double rating;
@Builder
public Evaluation(Account account, Webtoon webtoon, Double rating) {
this.account = account;
this.webtoon = webtoon;
this.rating = rating;
}
public void updateRating(Double rating) {
this.rating = rating;
}
}
문제
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@PostMapping
public ResponseEntity<Evaluation> evaluate(@RequestBody EvaluationRequestDto evaluationRequest) {
Account account = accountService.findByUsername(evaluationRequest.getUsername());
Webtoon webtoon = webtoonService.findById(evaluationRequest.getWebtoonId());
Evaluation evaluation = Evaluation.builder()
.account(account)
.webtoon(webtoon)
.rating(evaluationRequest.getRating())
.build();
Evaluation savedEvaluation = evaluationService.saveEvaluation(evaluation);
return ResponseEntity.ok(savedEvaluation);
}
Evaluation 엔티티에는 Account (평가 주체) 와 Webtoon (평가 대상)이 필요하다.
따라서 각각의 Service 계층을 이용해 해당 객체들을 가져왔다.
여기서 Account 엔티티를 보자.
1
2
3
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "mbti_id")
private Mbti mbti;
Mbti 엔티티와의 관계가 ManyToOne이고, 지연로딩 설정이 되어 있다.
이 와중 아래와 같은 에러를 만나게 된다.
1
2
3
4
InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain:
com.recommtoon.recommtoonapi.evaluation.entity.Evaluation["account"]-
>com.recommtoon.recommtoonapi.account.entity.Account["mbti"]-
>com.recommtoon.recommtoonapi.mbti.entity.Mbti$HibernateProxy$dYXn3N0k["hibernateLazyInitializer"])
요약하자면 엔티티를 json으로 직렬화하는데, 이 중 mbti가 지연로딩으로 설정되어 프록시 객체를 직렬화하게 되어 발생하는 예외이다.
이 문제의 해결 방법은 3가지이다.
- 지연로딩을 즉시로딩으로 바꾸기.
- DB 쿼리를 추가로 실행하므로 성능에 좋지 않아 사용하지 않음.
- DTO를 사용해 엔티티 대신 DTO를 직렬화 하는 방법.
- Evaluation 엔티티에 데이터를 저장하는 로직이 필요했으므로 X.
- 페치조인
해결
이 중 위와 같은 이유들로 페치조인으로 해결하는 것을 선택했다.
복습할 겸 정리하자면 페치조인은 연관된 엔티티나 컬렉션을 SQL 쿼리 한 번으로 모두 함께 조회하도록 해준다.
사실 이 부분만 봐서는 한 명의 Account, 하나의 Mbti만을 필요로 하기 때문에 N+1 문제나, 즉시로딩을 사용함에 의한 성능 문제는 발생하지 않을 것이다.
하지만 추후 지연로딩 설정이 필요하게 될 것이고, 따라서 지연 로딩을 유지할 수 있는 페치조인을 선택하게 된 것이다.
1
2
@Query("select a from Account a join fetch a.mbti where a.username = :username")
Account findByUsernameWithMbti(@Param("username") String username);
변경 감지에 관한 문제
내 웹툰 평가는 웹툰 카드의 별을 누르면 post 요청을 보내 별에 해당하는 수를 rating으로 DB에 저장하게 된다.
이 때 별을 바꾸면서 누를 수 있다.
한 번 별을 누르면 웹툰 카드가 사라지거나, 제약을 걸어 다시 평가할 수 없도록 한 것이 아니다. 따라서 이 부분을 고려한다면 DB에 계속 컬럼이 추가되는 것이 아닌 기존 값이 update 되어야 한다.
이 때 문제가 있다.
1
evaluationRepository.save(evaluation);
스프링 데이터 JPA 리포지토리를 사용하고 있는데, 위의 save() 메서드는 값이 없을 때 persist()를, 값이 존재한다면 merge()를 실행한다.
- merge()의 문제
- merge는 일부 값의 업데이트가 아닌 해당 데이터를 통째로 교체한다.
- 따라서 경우에 따라 null 값이 들어갈 수 있다.
- 거기에 더해 DB에 쿼리를 실행하므로 추후 성능에 문제가 있을 수 있다.
따라서 나는 단순하게 save()를 호출하는 대신 merge()를 사용하지 않을 로직이 필요했다.
변경 감지
트랜잭션이 적용된 상태에서 영속상태의 엔티티를 조회해 그 엔티티의 값을 바꾸면 변경 감지를 통한 값 변경이 가능하다.
- 트랜잭션 커밋 시점에 변경 감지가 실행된다.
- 원하는 속성만 변경이 가능하다.
이러한 변경 감지를 통한 값 업데이트를 구현하려 했는데 고민이 생겼다.
setter에 대한 문제
클린코드에서 setter의 사용을 주의해야 한다.
- 객체의 일관성을 유지하기 어렵다.
- setter로 어디서든 접근이 가능해 의도치 않게 값이 변경될 수 있다.
- 사용의도를 파악하기 어렵다.
- 값의 생성인지, update인 지 setter 네이밍으로는 파악하기 어렵다.
- 캡슐화가 깨진다.
- 불변 객체를 만들 수 없다.
- 객체의 상태가 변하지 않음을 보장해야 예측 가능하고 안전하다.
위와 같은 이유로 setter를 사용하지 않으려 했다.
하지만 변경감지를 하려면 결국 값을 변경해야 하는데 setter 없이 할 수 있을까에 대한 고민이 시작되었다.
해결
여러가지 해결방법을 찾아보았다.
- 단순한 setter 대신 의미 있는 메서드를 만든다.
- 생성자를 통한 값 변경
- 빌더패턴을 이용한 값 변경
이 중 나는 setter 대신 의미있는 메서드를 만들기로 했다.
생성자나 빌더패턴을 이용하는 것은 결국 객체를 새로 생성하는 것이고 이는 엔티티의 상태를 변경하는 것이 아니기 때문에 변경 감지가 일어나지 않는다.
즉 변경감지가 아닌 새로운 객체를 생성해 저장하는 것일 뿐이다. 따라서 해결책이 되지 않는다.
1
2
3
public void updateRating(Double rating) {
this.rating = rating;
}
Evaluation 엔티티의 값이 변동되는 것은 rating 뿐이다. 따라서 로직 자체는 setter와 같다.
하지만 의미있는 네이밍을 통해 의도를 명확히 할 수 있다.
그리고 이렇게 단순하게 하나의 속성이 아닌 여러 속성을 바꾸게 된다면 setter와는 다르다고 볼 수 있을 것이다.
결론적으로는 setter를 사용한 것은 맞다. 하지만 변경감지를 위해서는 결국 setter 로직은 반드시 필요하다.
내가 모르는 것일 수 있기 때문에 다른 방법이 있는지는 더 찾아볼 예정이다.