쌩로그

실전! 스프링 부트와 JPA 활용 2편 본문

Spring/JPA

실전! 스프링 부트와 JPA 활용 2편

.쌩수. 2024. 1. 14. 16:33
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. Entity 노출 하지 않기
      2-2. 데이터 한 번 더 감싸기
      2-3. 양방향 연관관계에 있는 두 Entity 외부에 노출시 한 쪽에서 연관관계 끊기
      2-4. fetch 전략은 무적권 LAZY
      2-5. 엔티티 조회
      2-6. 프로퍼티는 Getter, Setter를 의미
      2-7. Entity를 DTO로 변환해서 반환할 때
      2-8. Hibernate 6 최적화
      2-9. 컬렉션 페이징 경고 원인
      2-10. 페이징 한계돌파
      2-11. DTO로 뽑을 때는 new Operation을 사용한다.
      2-12. OSIV
      2-13. 내가 고쳐야되는 것
      2-14. 내가 해봐야 하는 것
  3. 요약

1. 포스팅 개요

인프런에서 영한님의 실전! 스프링 부트와 JPA 활용 2편을 학습하고 정리한 포스팅이다.

이전까지 나는 JPA 강의에 대한 포스팅은 강의를 수강하고, 똑같은 챕터를 책을 보고, 정리하는 방식으로 했지만,
실전 활용 2편 강의 같은 경우는 책과 겹치는 부분이 딱 떨어지도록 구성되어있는 것이 아니라,
겹치는 부분도 있고, 연관된 부분도 있다.

현재 내가 보기엔 13, 14,15 챕터에 걸쳐서 뿔뿔이 흩어져 있는 것 같다.
(그리고 이전에 알려준 내용도 같이 중복되는 내용도 있다.)

따라서 먼저는 강의 중 정리한 내용을 포스팅 하고, 이후 책 내용을 포스팅하려고 한다.
이번 포스팅은 JPA 실전 활용 2편을 통해서 배운 것, 그리고 내가 이 강의를 통해서 해봐야 될 부분에 대해 말해보고, 다음 포스팅에서 책의 13, 14, 15 챕터에 대해 다뤄보고자 한다.

2. 본론

2-1. Entity 외부로 노출 하지 않기

화면단에서 동작하는 계층(Controller(Presentation) 계층)에서 Entity를 검증하게 하면 안 된다.

이렇게 되면, API의 스펙이 Entity에 따라 정해지는데, Entity가 바뀌면(예를 들어, field 이름) Entity 의 스펙이 바뀐다.
Entity가 수정된다고 해서 API의 스펙이 바뀌는 것은 큰 문제이다.

따라서 API 스펙을 위한 별도의 데이터 트랜스 오브젝트 (DTO)를 생성해야한다.
엔티티를 외부에서 오는 JSON과 같은 데이터로 바인딩 받아서 사용하면 안 된다.

따라서 API 요청 스펙에 맞춰서 별도의 DTO를 파라미터로 받아야 한다.

다음과 같은 코드가 있을 때,

@Entity
@Getter @Setter
public class Member {

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

    private String name;

}

위의 Entity를 매개변수로 받을 때

@PostMapping("/api/v1/members")
public CreateMemberResponse saveMemberV2(@RequestBody @Valid Member member) {...}

이와 같이 할 수 있을 것이다.
그런데 만약 Member의 name 필드를 username 필드로 변경했다면,

스펙자체가 변경되어버린다. 반면에 다음과 같이 바뀐다면,

// dto

class CreateMemberRequest {
  private String name;
}


// controller
@PostMapping("/api/v2/members")
public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request) {

위의 V2 처럼 DTO로 받으면 Entity의 name을 username으로 바꿔도, 전혀 API에 형향을 받지 않는다.
엔티티를 수정하더라도 API 스펙이 바뀌지 않는다.

엔티티의 값이 어디서 채워지는지 알 수 없지만,
DTO로 받으면 API 스펙에서 무엇을 받는지 알 수 있다.
그리고 validation 또한 DTO로 명확히 확인할 수 있다.

API는 요청이나 응답에 절대 엔티티를 노출시키지 않는다.
항상 DTO를 사용한다.

2-2. 데이터 한 번 더 감싸기

@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {...}

현재 강의 중에 간략히 하기 위해 이렇게 List를 반환하도록 했다.

포스트맨의 응답방식은 다음과 같다.

[
    {
        "id": 1,
        "member": null,
        "orderItems": null,
        "delivery": null,
        "orderDate": "2024-01-14T15:08:37.295526",
        "status": "ORDER",
        "totalPrice": 50000
    },
    {
        "id": 2,
        "member": null,
        "orderItems": [
            {
                "id": 3,
                "item": null,
                "orderPrice": 20000,
                "count": 3,
                "totalPrice": 60000
            },
            {
                "id": 4,
                "item": null,
                "orderPrice": 40000,
                "count": 4,
                "totalPrice": 160000
            }
        ],
        "delivery": null,
        "orderDate": "2024-01-14T15:08:37.405448",
        "status": "ORDER",
        "totalPrice": 220000
    }
]

이처럼 List를 그대로 반환하면, Array가 반환된다.
만약에 다른 필드도 반환해야 하면, 이런 응답 방식은 스펙이 굳어버린다.
즉 확장이 어렵고, 유연성이 떨어진다.

따라서 배열은 한번 감싸주는 것이 좋다.

@Getter
public class singleResponseDto<T> {
    T data;

    public singleResponseDto(T data) {
        this.data = data;
    }
}

이와 같이 감싸줄 수 있는 클래스를 하나 만들고,
컨트롤러에서 다음과 같이 감쌀 수 있도록 한다.

@GetMapping("/api/v1/simple-orders")
public singleResponseDto ordersV1() {
    List<Order> all = orderRepository.findAllByString(new OrderSearch());
    return new singleResponseDto(all);
}

응답 방식이 다음과 같이 나온다.

{
    "data": [
        {
            "id": 1,
            "member": null,
            "orderItems": null,
            "delivery": null,
            "orderDate": "2024-01-14T15:14:02.021897",
            "status": "ORDER",
            "totalPrice": 50000
        },
        {
            "id": 2,
            "member": null,
            "orderItems": [
                {
                    "id": 3,
                    "item": null,
                    "orderPrice": 20000,
                    "count": 3,
                    "totalPrice": 60000
                },
                {
                    "id": 4,
                    "item": null,
                    "orderPrice": 40000,
                    "count": 4,
                    "totalPrice": 160000
                }
            ],
            "delivery": null,
            "orderDate": "2024-01-14T15:14:02.124734",
            "status": "ORDER",
            "totalPrice": 220000
        }
    ]
}

이와 같이 한 번 감쌀 때 필요한 데이터가 있으면 확장 가능하다.

2-3. 양방향 연관관계에 있는 두 Entity 외부에 노출시 한 쪽에서 연관관계 끊기

두 Entity가 양방향 연관관계에 놓여있고, 지연로딩으로 설정했을 때, 외부에서 Entity를 노출하려 할 때 연관관계를 통해서 가져온 객체는 실제 객체가 아니라, 프록시 객체다.
jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성해야 하는지 모르기 때문에, 예외가 발생한다.

따라서 다음 모듈을 등록하면 된다.

  • 스프링 부트 3.0 이상 : Hibernate5JakartaModule
  • 스프링 부트 3.0 미만 : Hibernate5Module

필자는 스프링 부트 3.0 이상을 사용하므로, 3.0 이상 버전을 기준으로 설명한다.

build.gradle에 라이브러리를 추가한다.

 implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-jakarta'

애플리케이션 실행 클래스에 모듈을 빈으로 등록한다.

@Bean
Hibernate5JakartaModule hibernate5Module() {
  return new Hibernate5JakartaModule();
}

이렇게 되면 기본적으로 초기화 된 프록시 객체만 노출하고, 초기화 되지 않은 프록시 객체는 노출 하지 않는다.
다음과 같이 설정하면 강제로 지연 로딩 가능하다.

@Bean
Hibernate5Module hibernate5Module() {
 Hibernate5Module hibernate5Module = new Hibernate5Module();
 //강제 지연 로딩 설정
 hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING,
true);
 return hibernate5Module;
}

이 옵션을 키면 양방향 연관관계에 있는 Entity에서 순환참조가 일어나므로, 한 쪽에서 @JsonIgnore 옵션을 주어서 순환참조를 끊어주어야 한다.

참고로 @JsonIgnore는 직렬화 역직렬화에 사용되는 논리적 프로퍼티(속성..) 값을 무시할 때 사용된다.

참고글

양방향 관계로 인해서 순환 참조로 StackOverFlow 오류가 발생할 수도 있으므로, 한쪽에서 @JsonIgnore를 사용할 수 도 있다.

2-4. fetch 전략은 무적권 LAZY

지연 로딩을 회피한다고 EAGER 로 설정하지 않는다.
즉시 로딩으로 설정하면, 다른 API에서도 문제가 발생한다.
항상 지연로딩을 기본으로 하고, fetch join을 사용하도록 한다.

##

Repository는 Entity를 조회해야 한다.

  // Repository가 API를 의존하는 경우임. 물리적으로는 계층이 나눠져있지만, 논리적으로는 계층이 깨짐
public List<OrderSimpleQueryDto> findOrderDtos() {
  return em.createQuery(
        "select new jpabook.jpashop.repository.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
                          " from Order o" +
                          " join o.member m" +
                          " join o.delivery d", OrderSimpleQueryDto.class)
        .getResultList();
}

위의 코드는 OrderRepository에 있는 코드인데, Repository가 API를 의존하는 경우이다.
물리적으로는 계층이 나눠져있지만, 논리적으로는 계층이 깨진 것임.

(영한님은 repository나 하위에 새로운 패키지를 만들고, 성능최적화를 위한 쿼리용을 별도로 사용한다고 하셨다.)
이 부분은 바로 밑의 2-5. 엔티티 조회 부분을 참고하면 된다.

2-5. 엔티티 조회

  • 엔티티 조회
    • 엔티티를 조회해서 그대로 반환
    • 엔티티 조회 후 DTO로 변환
    • 페치 조인으로 쿼리 수 최적화
    • 컬렉션 페이징과 한계 돌파
      • 컬렉션은 페치 조인시 페이징이 불가능
      • ToOne 관계는 페치 조인으로 쿼리 수 최적화
      • 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고, hibernate.default_batch_fetch_size , @BatchSize로 최적화
  • DTO 직접 조회
    • JPA에서 DTO를 직접 조회
    • 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화
    • 플랫 데이터 최적화 - JOIN 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 변환

엔티티 조회 권장 순서

  1. 엔티티 조회 방식으로 우선 접근
    1. 페치 조인으로 쿼리 수를 최적화한다.
    2. 컬렉션 최적화
      1. 페이징이 필요하면 배치사이즈로 최적화한다.
      2. 페이징 필요하지않다면, 페치조인을 사용한다.
  2. 엔티티 조회 방식으로 해결이 안 되면 DTO 조회 방식을 사용한다.
  3. DTO 조회 방식으로 해결이 안 되면 NativeSQL or 스프링 JdbcTemplate을 사용한다.

2-6. 프로퍼티는 Getter, Setter를 의미

제곧내
이번에 명확히 알게되서 메모함.

2-7. Entity를 DTO로 변환해서 반환할 때

Entity 정보를 반환할 때 껍데기만 바꾸는 것이 아니라, 속에 있는 데이터까지 Entity의 데이터를 DTO의 데이터로 변환해서 노출시켜야 한다.

2-8. Hibernate 6 최적화

스프링부트 3.0 이상 버전은 Hibernate 6을 사용하는데, 페치 조인 사용시 자동적으로 distinct가 적용되어 중복이 제거된다.
ex) 단일 Entity와 컬렉션과 join의 경우 똑같은 Entity라도 각 컬렉션의 개수에 맞게 데이터가 조회되야 하는데 자동적으로 distinct 적용됨.

https://www.inflearn.com/questions/807577/spring-boot-3-x-distinct-%EA%B4%80%EB%A0%A8

2-9. 컬렉션 페이징 경고 원인

2023-12-26T12:52:42.024+09:00 WARN 11504 --- [nio-8080-exec-1] org.hibernate.orm.query : HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

이 경우는 컬렉션에 fetch를 사용했기 때문에 발생하는 메세지인데,
페이징 처리가 DB에서 하는 것이 아니라, DB에서 퍼올린 데이터를 애플리케이션 메모리에서 페이징 처리한다는 의미이다.
따라서 sql에서는 페이징 쿼리가 나가지 않는다.

컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터를 예측할 수 없이 증가한다.
페이징은 일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적이다.
그런데 데이터는 다(N)를 기준으로 row가 생성된다.

  • 예를 들어서, Order를 기준으로 페이징 하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다.

또한 컬렉션 페치 조인은 1개만 사용할 수 있다. 둘 이상의 컬렉션에 페치 조인을 사용하면 안된다.
데이터가 부정합하게 조회될 수 있다.
일대다도 복잡하지만, 일대다대다 같은 경우가 될 수 있기 때문에 더욱 복잡해진다.

2-10. 페이징 한계돌파

페이징 사용시

  • ToOne(OneToOne, ManyToOne) 관계는 모두 페치조인 한다. ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
  • 컬렉션은 지연 로딩으로 조회한다.
  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.
    • hibernate.default_batch_fetch_size을 yml에 설정하면, 애플리케이션에 글로벌(전역)로 설정된다.
    • @BatchSize: 지연 로딩 Entity를 개별로 최적화한다. - 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
    • ex) @BatchSize(size = 100)
      • 컬렉션일 때는 Entity 필드 위에 선언
      • 컬렉션이 아닌 ToOne Entity 같은 경우에는 클래스 위에 선언

※ 참고로 BatchSize 적용한만큼 '?'가 in 으로 적용된다.

하이버네이트 버전이 6으로 올라가면서 최적화된 부분이다.

원래는 컬렉션의 size에 따라 preparedstatement의 '?' 개수가 달라지는데,
? 개수가 다른 쿼리들은 RDB 입장에서 각각 개별적인 다른 쿼리들이다.
따라서 구문을 캐싱하는 DB입장에서 컬렉션의 개수가 달라지고, 어플리케이션 규모에따라 너무 많은 preparedstatement를 캐싱(저장)해야 하는데, 하이버네이트 6에서 이것이 최적화되었다.

https://www.inflearn.com/questions/34469/default-batch-fetch-size-%EA%B4%80%EB%A0%A8%EC%A7%88%EB%AC%B8

참고로 BatchSize는 1000개를 맥스로 두는 것이 좋다.

  • DB마다 in query가 1000개 이상 넘어가면 오류를 일으키는 DB가 있기 때문이다.
    (와스와 DB가 버틸 수 있으면 큰 숫자를 선택하면 된다.)

2-11. DTO로 뽑을 때는 new Operation을 사용한다.

DTO로 뽑을 때는 new Operation을 사용한다.
(패키지까지 다 적어줘야한다.)
컬렉션은 뽑아낸 DTO에서 연관된 DTO로 다 뽑아내야 한다.

2-12. OSIV

Open Session in View영속성 컨텍스트를 뷰까지 열어둔다는 뜻이다.
영속성 컨텍스트가 살아있으면 엔티티는 영속 상태로 유지된다.
따라서 뷰에서도 지연로딩을 사용할 수 있다.

  • Open Session In View : 하이버네이트
  • Open EntityManager In View : JPA(관례상 OSIV)

yml에서 spring.jpa.open-in-view 옵션을 설정할 수 있는데, true가 기본값이다.

스프링 시작시 나오는 문구가 있다.

2023-12-27T11:57:45.808+09:00 WARN 20972 --- [ restartedMain] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning

기본 값을 뿌리면서 애플리케이션 시작 시점에 로그를 남기는 이유는 다음과 같다.

  • OSIV 전략은 트랜잭션 시작처럼 최초 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때 까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지한다.
    그래서 지금까지 View Template이나 API 컨트롤러에서 지연 로딩이 가능했던 것이다.
    지연 로딩은 영속성 컨텍스트가 살아있어야 가능하고, 영속성 컨텍스트는 기본적으로 데이터베이스 커넥션을 유지한다. 이것 자체가 큰 장점이다.

트랜잭션이 끝나는 시점은 다음과 같다.

  • api 일 경우 : response가 반환될 때까지
  • view 일 경우 : view 템플릿으로 렌더링될 때까지

서비스 계층에서 트랜잭션 밖에 나왔다고 생각했지만, 컨트롤러 계층에서 지연로딩이 발생한 이유가 이런 이유 때문이다.

그런데 이 전략은 너무 오랜시간동안 데이터베이스 커넥션 리소스를 사용하기 때문에, 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자랄 수 있다.
이것은 장애로 이어질 수 있다.

예를 들어서 컨트롤러에서 외부 API를 호출하면 외부 API 대기 시간 만큼 커넥션 리소스를 반환하지 못하고, 유지해야한다.
예시로 api 호출이 3초걸리면 커넥션도 3초동안 가지고 있어야 한다.

spring.jpa.open-in-view : false 설정시

OSIV를 끄면 트랜잭션을 종료할 때 영속성 컨텍스트를 닫고, 데이터베이스 커넥션도 반환한다. 따라서 커넥션 리소스를 낭비하지 않는다.

OSIV를 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야 한다

따라서 지금까지 작성한 많은 지연 로딩 코드를 트랜잭션 안으로 넣어야 하는 단점이 있다.
그리고 view template에서 지연로딩이 동작하지 않는다.

결론적으로 트랜잭션이 끝나기 전에 지연 로딩을 강제로 호출해 두어야 한다.

커멘드와 쿼리 분리

영한님이 강의 중 커멘드와 쿼리 분리라는 말을 했었는데, 커맨드와 쿼리가 어떻게 다른지 몰랐다만, 강의 커뮤니티에서 발견했다.
다음과 같다.

커맨드 : 데이터의 변경이 일어나는 명령을 의미
쿼리 : 조회하는 쿼리를 뜻함.

실무에서 OSIV를 끈 상태로 복잡성을 관리하는 좋은 방법이 있다. 바로 Command와 Query를 분리하는 것이다.

| 참고 : https://en.wikipedia.org/wiki/Command–query_separation

보통 비즈니스 로직은 특정 엔티티 몇 개를 등록하거나 수정하는 것이므로 성능이 크게 문제가 되지 않는다.
그런데 복잡한 화면을 출력하기 위한 쿼리는 화면에 맞추어 성능을 최적화 하는 것이 중요하다.
하지만 그 복잡성에 비해 핵심 비즈니스에 큰 영향을 주는 것은 아니다.
그래서 크고 복잡한 애플리케이션을 개발한다면, 이 둘의 관심사를 명확하게 분리하는 선택은 유지보수 관점에서 충분히 의미 있다.

단순하게 설명해서 다음처럼 분리하는 것이다.

  • OrderService
    • OrderService: 핵심 비즈니스 로직
    • OrderQueryService: 화면이나 API에 맞춘 서비스 (주로 읽기 전용 트랜잭션 사용)

보통 서비스 계층에서 트랜잭션을 유지한다. 두 서비스 모두 트랜잭션을 유지하면서 지연 로딩을 사용할 수 있다.

| 참고: 영한님은 고객 서비스의 실시간 API는 OSIV를 끄고, ADMIN 처럼 커넥션을 많이 사용하지 않는 곳에서는 OSIV를 켠다고 한다.

2-13. 내가 고쳐야되는 것

현재 사고방식이 좀 뻣뻣하다.(?)

문제가 되는 부분이기도 하면서, 놓치고 있던 부분이다.

(그럴 일은 없겠지만,) Entity 를 컨트롤러 매개변수로 받는다고 가정할 때, Entity에 validation을 적용했었다.

내가 순간 Entity에 validation을 적용할 때와 DTO에 validation을 적용하면 뭔가 나도 모르게 다르게 동작할 것이라고 생각 했었다.

그러나, Entity와 DTO가 서로 다른 용도로 쓰이는 클래스에 validation을 적용한다고 하여 validation이 다르게 동작하는 것이 아니라, 둘 다 근본은 자바코드다.
(...왤케 사고방식이 뻣뻣하지;;)

여하튼 나는 좀 더 큰 그림을 볼 줄 알아야 한다...

2-14. 내가 해봐야 하는 것

이전에 했던 팀프로젝트 두 개(원더허브나, 북빌리지)에서

  1. Repository는 Entity만 조회하도록 고민해보고, DTO 같은 경우 repository 계층의 하위로 놔둬서 조회해보도록 해보기.

(Entity를 외부로 노출하지 않는 건 숙지함.)

  1. Repository가 Controller 혹은 반대로 Controller가 Repository를 의존하는 경우가 있는지 확인해보고 고쳐봐야겠다.

3. 요약

2-1. Entity 노출 하지 않기
Entity가 아니라, DTO로 변환해서 반환하기

2-2. 데이터 한 번 더 감싸기
배열 같은 경우 바로 반환하는 것이 아니라, 한번 감싼 이후 반환한다.

2-3. 양방향 연관관계에 있는 두 Entity 외부에 노출시 한 쪽에서 연관관계 끊기
사실 잘 사용하진 않지만, 양방향 연관관계 순환 참조를 주의해서 한 쪽에서 끊어줘야 한다.

2-4. fetch 전략은 무적권 LAZY
EAGER이 아니라, LAZY로 두고, 필요할 때 fetch join을 사용하기

2-5. 엔티티 조회
엔티티 조회순서의 권장 방법을 참고하자.

2-6. 프로퍼티는 Getter, Setter를 의미
그 말대로다.

2-7. Entity를 DTO로 변환해서 반환할 때
Entity 정보를 반환할 때 껍데기만 바꾸는 것이 아니라, 속에 있는 데이터까지 Entity의 데이터를 DTO의 데이터로 변환해서 노출시켜야 한다.

2-8. Hibernate 6 최적화
distinct가 자동으로 적용된다.

2-9. 컬렉션 페이징 경고 원인
DB가 아니라, DB에서 퍼올린 데이터를 애플리케이션 차원에서 페이징 시킨다는 것이다.

2-10. 페이징 한계돌파
페이징 사용시 batchSize를 적극 활용한다.

2-11. DTO로 뽑을 때는 new Operation을 사용한다.
말 그대로다.

2-12. OSIV
영속성 컨텍스트 범위를 어디까지 적용시킬 것인가에 대한 내용이었다.

2-13. 내가 고쳐야되는 것
사고 좀 유연하게 하기..

2-14. 내가 해봐야 하는 것

  1. Repository는 Entity만 조회하도록 고민해보고, DTO 같은 경우 repository 계층의 하위로 놔둬서 조회해보도록 해보기.
  2. Repository가 Controller 혹은 반대로 Controller가 Repository를 의존하는 경우가 있는지 확인해보고 고쳐봐야겠다.

다음 포스팅은 책 내용 정리다.
책 챕터별로 하나 하나 포스팅해보겠다.
(이번 주는 포스팅 주일듯..)

728x90
Comments