ORM JPA3

유요한·2023년 12월 1일
0

JPA

목록 보기
3/10
post-thumbnail

JPA에서 가장 중요한 2가지

  1. 객체와 관계형 데이터베이스 매핑하기
  2. 영속성 컨텍스트

엔티티 매핑 관련 어노테이션

어노테이션설명
@Entity클래스를 엔티티로 선언
@Table엔티티와 매핑할 테이블을 지정
@Id테이블의 기본키에 사용할 속성을 지정
@GeneratedValue키 값을 생성하는 전략 명시
@Column필드와 컬럼 매핑
@LobBLOB, CLOB 타입 매핑
@CreationTimestampinsert시 시간 자동 저장
@UpdateTimestampupdate시 시간 자동 저장
@Enumeratedenum 타입 매핑
@Transient해당 필드 데이터베이스 매핑 무시
@Tmporal날짜 타입 매핑
@CreateDate엔티티가 생성되어 저장될 때 시간 자동 저장
@LastModifiedDate조회한 엔티티의 값을 변경할 때 시간 자동 저장

CLOB과 BLOB의미

CLOB이란 사이즈가 큰 데이터를 외부 파일로 저장하기 위한 데이터입니다. 문자형 대용량 파일을 저장하는데 사용하는 데이터 타입이라고 생각하면 됩니다.

BLOB은 바이너리 데이터를 DB외부에 저장하기 위한 타입입니다. 이미지, 사운드, 비디오 같은 멀티미디어 데이터를 다룰 때 사용할 수 있습니다.

@Entity

  • @Entity가 붙은 클래스는 JPA가 관리, 엔티티라 한다.
  • JPA를 사용해서 테이블과 매핑할 클래스는 @Entity 필수

주의

  • 기본 생성자 필수(파라미터가 없는 public or protected 생성자)
  • final 클래스, enum, interface, inner 클래스 사용x
  • 저장할 필드에 final 사용x

@Entity 속성 정리

  • 속성 : name
    • JPA에서 사용할 엔티티 이름을 지정한다.
    • 기본값 : 클래스 이름을 그대로 사용(예: Member)
    • 같은 클래스 이름이 없으면 가급적 기본값을 사용한다.

@Table

  • @Table은 엔티티와 매핑할 테이블 지정

데이터베이스 스키마 자동생성

  • DDL을 애플리케이션 실행 시점에 자동 생성
  • 테이블 중심 → 객체 중심
  • 데이터베이스 방언을 활용해서 데이터베이스에 맞는 적절한 DDL 생성
  • 이렇게 생성된 DDL은 개발 장비에만 사용
  • 생성된 DDL은 운영서버에서는 사용하지 않거나, 적절히 다듬은 후 사용

주의

  • 운영 장비에는 절대 create, create-drop, update 사용하면 안된다.
    • 개발 초기 단계는 create 또는 update
    • 테스트 서버는 update or validate
    • 스테이징과 운영 서버는 validate or none

DDL 생성 기능

  • 제약조건 추가 : 회원 이름은 필수, 10자 초과x

    @Column(nullable = false, length =10)

  • 유니크 제약조건 추가

  • DDL 생성 기능은 DDL을 자동 생성할 때만 사용되고 JPA의 실행 로직에는 영향을 주지 않는다.

@Column 속성

테이블을 생성할 때 컬럼에는 다양한 조건들이 들어갑니다. 예를 들면 문자열을 저장하는 VARCHAR 타입은 길이를 설정할 수 있고, 테이블에 데이터를 넣을 때 데이터가 항상 존재해야 하는 Not Null 조건 등이 있습니다. @Column 어노테이션의 속성을 사용하면 테이블에 매핑되는 컬럼의 이름, 문자열의 최대 저장 길이 등 다양한 제약 조건들을 추가할 수 있습니다.

속성설명기본값
name필드와 매핑할 컬럼의 이름 설정객체의 필드이름
unique(DDL)@Table의 uniqueConstraints와 같지만 한 컬럼에 간단히 유니크 제약조건을 걸 때 사용한다.
insertableinsert 기능 여부true
updatableupdate 기능 여부true
length(DDL)String 타입의 문자 길이 제약조건 설정255
nullable(DDL)null 값의 허용 여부 설정, false 설정 시 DDL 생성 시에 not null 제약 조건 추가
columnDefinition(DDL)데이터베이스 컬럼 정보 직접 기술

예)
@Column(columnDefinition = "varchar(5) default '10' not null")
precision, scale(DDL)BigDecimal 타입에서 사용(BigInteger 가능) precision은 소수점을 포함한 전체 자리수이고, scale은 소수점 자리수, Double과 float 타입에는 적용되지 않음

DDL이란?

DDL(Data Definition Language)이란 테이블, 스키마, 인덱스, 뷰, 도메인을 정의, 변경, 제거할 때 사용하는 언어입니다. 가령, 테이블을 생성하거나 삭제하는 CREATE, DROP 등이 이에 해당됩니다.

@Entity 어노테이션은 클래스의 상단에 입력하면 JPA에 엔티티 클래스라는 것을 알려줍니다. Entity클래스는 반드시 기본키를 가져야 합니다. @Id 어노테이션을 이용하여 id 멤버 변수를 상품 테이블의 기본키로 설정합니다. @GeneratedValue 어노테이션을 통한 기본키를 생성하는 전략은 총 4가지 전략이 있습니다.

생성전략

  • GenerationType.AUTO(default)
    JPA 구현체가 자동으로 생성 전략 결정

  • GenerationType.IDENTITY
    기본키 생성을 데이터베이스에 위임

    예) MySQL 데이터베이스의 경우 AUTO_INCREMENT를 사용하여 기본키 생성

  • GenerationType.SEQUENCE
    데이터베이스 시퀀스 오브젝트를 이용한 기본키 생성
    @SequenceGenerator를 사용하여 시퀀스 등록 필요

  • GenerationType.TABLE
    키 생성용 테이블 사용.
    @TableGenerator 필요

    • 키 생성 전용 테이블을 하나 만들어서 데이터베이스 시퀀스를 흉내내는 전략
    • 장점 : 모든 데이터베이스에 적용 가능
    • 단점 : 성능

전략은 기본키를 생성하는 방법이라고 이해하면 됩니다. MySQL에서 AUTO_INCREMENT를 이용해 데이터베이스에 INSERT 쿼리문을 보내면 자동으로 기본키 값을 증가 시킬 수 있습니다.

기본키와 데이터베이스 시퀀스 오브젝트의 의미

기본키(primary key)는 데이터베이스에서 조건을 만족하는 튜플을 찾을 때 다른 튜플들과 유일하게 구별할 수 있도록 기준을 세워주는 속성입니다. 예를 들어서, 상품 데이터를 찾을 때 상품의 id를 통해서 다른 상품들과 구별을 할 수 있습니다. 여기서 기본키는 id입니다.

데이터베이스 시퀀스 오브젝트에서 시퀀스란 순차적으로 증가하는 값을 반환해주는 데이터베이스 객체입니다. 보통 기본키의 중복값을 방지하기 위해서 사용합니다.

4가지의 생성 전략 중에서 @GenerationType.AUTO를 사용해서 기본키를 생성하겠습니다. 데이터베이스에 의존하지 않고 기본키를 할당하는 방법으로, JPA 구현체가 IDENTITY, SEQUENCE, TABLE 생성 전략 중 하나를 자동으로 선택합니다. 따라서 데이터베이스가 변경되더라도 코드를 수정할 필요가 없습니다.

Item 클래스를 entity로 선언합니다. 또한 @Table 어노테이션을 통해 어던 테이블과 매핑될지를 지정합니다. item 테이블과 매핑되도록 name을 item으로 지정합니다.

entity로 선언한 클래스는 반드시 기본키를 가져야 합니다. 기본키가 되는 멤버변수에 @Id 어노테이션을 붙여줍니다. 그리고 테이블에 매핑될 컬럼의 이름을 @Column어노테이션을 통해 설정해줍니다. item 클래스의 id 변수와 item 테이블의 item_id 컬럼이 매핑되도록 합니다. 마지막으로 @GeneratedValue 어노테이션을 통해 기본키 생성 전략을 AUTO로 지정합니다.

@Column 어노테이션의 nullable 속성을 이용해서 항상 값이 있어야 하는 필드는 not null 설정을 합니다. String 필드는 default 값으로 255가 설정되어 있습니다. 각 String 필드마다 필요한 길이를 length 속성에 default 값을 세팅합니다.

package com.example.shopping_.entity;

import com.example.shopping_.constant.ItemSellStatus;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import javax.persistence.*;
import java.time.LocalDateTime;

@Getter
@Setter
@ToString
@Entity
@Table(name="item")
public class Item {

    @Id
    @Column(name="item_id")
    @GeneratedValue(strategy = GenerationType.AUTO)
    // 상품코드
    private long id;
    @Column(nullable = false, length = 50)
    // 상품명
    private String itemNum;
    @Column(name = "price", nullable = false)
    // 가격
    private int price;
    @Column(nullable = false)
    // 재고수량
    private int stockNumber;
    @Lob
    @Column(nullable = false)
    // 상품 상세 설명
    private String itemDetail;
    @Enumerated(EnumType.STRING)
    // 상품 판매 상태
    private ItemSellStatus itemSellStatus;
    // 등록 시간
    // LocalDateTime 타입은 현재 로컬 컴퓨터의 날짜와 시간을 반환
    private LocalDateTime regTime;
    // 수정 시간
    private LocalDateTime updateTime;
}

실행하면 다음과 같은 쿼리문을 확인할 수 있습니다.

여기까지는 엔티티 매니저를 이용해 item 엔티티를 저장하는 예제입니다. 하지만 Spring Data JPA에서는 엔티티 매니저를 직접 이용해 코드를 작성하지 않아도 된다. 그 대신에 Data Access Object의 역할을 하는 Repository 인터페이스를 설계한 후 사용하는 것만으로 충분합니다. 그럼 왜 앞서 엔티티 매니저를 직접 이용한 코드를 작성했을까? JPA 엔티티를 어떻게 관리하는지 보여주기 위함입니다.

package com.example.shopping_.repository;

import com.example.shopping_.entity.Item;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ItemRepository extends JpaRepository<Item, Long> {

}

JpaRepository를 상속받는 ItemRepository를 작성했습니다. JpaRepository는 2개의 제네릭 타입을 사용하는데 첫 번째는 엔티티 타입 클래스를 넣어주고 두 번째는 기본키 타입을 넣어줍니다. Item 클래스는 기본키 타입이 Long이므로 long을 넣어줍니다. JpaRepository는 기본적으로 CRUD 및 페이징 처리를 위한 메소드가 정의되어 있습니다. 메소드 및 몇 가지를 살펴보면 엔티티를 저장하거나, 삭제, 또는 엔티티의 개수 출력 등의 메소드를 볼 수 있습니다. 이번 예제에서 작성할 테스트 코드는 엔티티를 저장하는 save() 메소드입니다.

JpaRepository에서 지원하는 메소드 예시

메소드기능
<S extends T> save(S entity)엔티티 저장 및 수정
void delete(T entity)엔티티 삭제
count()엔티티 총 개수 반환
Iterable<T> findAll()모든 엔티티 조회

@Enumerated

자바 enum 타입을 매핑할 때 사용

@Temporal

날짜 타입(java.util.Date, java.util.Calendar)을 매핑할 때 사용

참고: LocalDate, LocalDateTime을 사용할 때는 생략 가능(최신 하이버네이트 지원)

@Lob

데이터베이스 BLOB, CLOB 타입과 매핑

  • @Lob에는 지정할 수 있는 속성이 없다.
  • 매핑하는 필드 타입이 문자면 CLOB 매핑, 나머지는 BLOB 매핑
    • CLOB : String, char[], java.sql.CLOB
    • BLOB : byte[], java.sql.BLOB

@Transient

  • 필드 매핑x
  • 데이터베이스에 저장x, 조회x
  • 주로 메모리상에서만 임시로 어떤 값을 보관하고 싶을 때 사용
package com.example.jpa.hellojpa;

import javax.persistence.*;
import java.util.Date;

@Entity(name = "member_ex")
@Table(name = "MBR")
public class Member {

    @Id
    private Long id;

    @Column(name = "name")
    private String userName;

    private Integer age;

    @Enumerated(EnumType.STRING)
    private RoleType roleType;

    // 날짜 타입(java.util.Date, java.util.Calendar)을 매핑할 때 사용
    // LocalDate, LocalDateTime을 사용할 때는 생략 가능
    // TemporalType.TIMESTAMP : 날짜와 시간, 데이터베이스 timestamp 타입과 매핑
    // 예) 2023-05-08 11:11:11
    @Temporal(TemporalType.TIMESTAMP)
    private Date createDate;
    @Temporal(TemporalType.TIMESTAMP)
    private Date updateDate;

    // 데이터베이스 BLOB, CLOB 타입과 매핑
    // DB에 큰 데이터를 넣고 싶으면 @Lob 사용
    @Lob
    private String description;

    public Member() {
    }
}


데이터 중심 설계의 문제점

  • 현재 방식은 객체 설계를 테이블 설계에 맞춘 방식
  • 테이블의 외래키를 객체에 그대로 가져옴
  • 객체 그래프 탐색이 불가능
  • 참조가 없으므로 UML도 잘못됨

mappedBy

객체와 테이블간에 연관관계를 맺는 차이를 이해해야 한다.

객체 연관관계 = 2개

  • 회원 → 팀 연관관계 1개(단방향)
  • 팀 → 회원 연관관계 1개(단방향)

객체의 양방향 관계는 사실 양방향 관계가 아니라 서로 다른 단방향 관계 2개다. 객체를 양방향으로 참조하려면 단방향 연관관계를 2개 만들어야 한다.

테이블 연관관계 = 1개

회원 ↔ 팀의 연관관계 1개(양방향)

테이블은 외래 키 하나로 두 테이블의 연관관계를 관리

연관관계의 주인(Owner)

연관관계의 주인을 정하는 기준

  • 비즈니스 로직을 기준으로 연관관계의 주인을 선택하면 안됨
  • 연관관계의 주인은 외래 키의 위치를 기준으로 정해야함

양방향 매핑 규칙

  • 객체의 두 관계 중 하나를 연관관계의 주인으로 지정
  • 연관관계의 주인만이 외래 키를 관리(등록, 수정)
  • 주인이 아닌쪽은 읽기만 가능
  • 주인은 mappedBy 속성 사용x
  • 주인이 아니면 mappedBy 속성으로 주인 지정

여기서 값을 넣고 하는 것은 Team team이다. members에 값을 넣어도 DB에 반영되지 않는다.

그러면 누구를 주인으로 해야하는 것인가?

  • 외래 키가 있는 곳을 주인으로 정해라
  • 여기서는 Member.team이 연관관계 주인

양방향 매핑시 가장 많이 하는 실수

연관관계의 주인에 값을 입력하지 않음

양방향 매핑시 연관관계의 주인에 값을 입력해야 합니다. 하지만 순수한 객체 관계를 고려하면 항상 양쪽에 다 값을 입력해야 합니다.

  • 연관관계 편의 메소드를 생성하자
  • 양방향 매핑시에 무한 루프를 조심하자

    예)
    toString(), lombok, JSON 생성 라이브러리

컨트롤러에는 엔티티를 반환하면 안된다. 엔티티를 컨트롤러로 반환하면 무한 루프에 빠질 수 있고 엔티티가 변경될 수 있는데 반환한 상태로 변경되면 api 스펙자체가 바뀌어 버린다. DTO로 변환해서 컨트롤러에 반환해야 한다.

정리

  • 단방향 매핑만으로도 이미 연관관계 매핑은 완료
  • 양방향 매핑은 반대 방향으로 조회(객체 그래프 탐색) 기능이 추가 된 것 뿐
  • JPQL에서 역방향으로 탐색할 일이 많음
  • 단방향 매핑을 잘하고 양방향은 필요할 때 추가해도 된다.
    (테이블에 영향을 주지 않음)

Member

@Entity
@Getter
@Setter
@Table(name = "Member")
public class MemberEntity {
    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;
    private String name;
    private String city;
    private String street;
    private String zipcode;
    
        // Order에서 단방향 처리한 것을 양방향으로 바꾸고 싶을 때
    // mappedBy = "member"는 OrderEntity에 있는 member가 주인이라는 뜻
    @OneToMany(mappedBy = "member")
    private List<OrderEntity> orders = new ArrayList<>();
}

Order

@Entity
@Getter
@Setter
@Table(name = "Orders")
public class OrderEntity {
    @Id @GeneratedValue
    @Column(name = "order_id")
    private Long id;

    // 주문에 입장에서는 주문을 여러개 넣을 수 있으니
    // @ManyToOne
    @ManyToOne
    @JoinColumn(name = "member_id")
    private MemberEntity member;

    @OneToMany(mappedBy = "order")
    private List<OrderItemEntity> orderItems = new ArrayList<>();

    private LocalDateTime orderDate;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    public void addOrderItem(OrderItemEntity orderItemEntity) {
        orderItems.add(orderItemEntity);
        orderItemEntity.setOrder(this);
    }
}

OrederItem

@Entity
@Table(name = "OrderItem")
@Getter
@Setter
public class OrderItemEntity {
    @Id @GeneratedValue
    @Column(name = "order_item_id")
    private Long id;

//    @Column(name = "order_id")
//    private Long orderId;
//
//    @Column(name = "item_id")
//    private Long itemId;

    @ManyToOne
    @JoinColumn(name = "order_id")
    private OrderEntity order;

    @ManyToOne
    @JoinColumn(name = "item_id")
    private ItemEntity item;
}

Item

@Entity
@Table(name = "Item")
@Getter
@Setter
public class ItemEntity {
    @Id
    @GeneratedValue
    @Column(name = "item_id")
    private Long id;

    private String name;

    private int price;

    private int stockQuantity;
}
@Slf4j
public class JpaMain {
    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager entityManager = emf.createEntityManager();

        EntityTransaction tx = entityManager.getTransaction();
        tx.begin();

        try {
            OrderEntity order = new OrderEntity();
            order.addOrderItem(new OrderItemEntity());

            tx.commit();
        } catch (Exception e) {
            tx.rollback();
        } finally {
            entityManager.close();
        }

        emf.close();
    }

연관관계 매핑시 고려사항 3가지

  • 다중성
    • 다대일 : @ManyToOne
    • 일대다 : @OneToMany
    • 일대일 : @OneToOne
    • 다대다 : @ManyToMany
  • 단방향, 양방향

    테이블

    • 외래 키 하나로 양쪽 조인 가능
    • 사실 방향이라는 개념이 없음

    객체

    • 참조용 필드가 있는 쪽으로만 참조 가능
    • 한쪽만 참조하면 단방향
    • 양쪽이 서로 참조하면 양방향
  • 연관관계 주인
    • 테이블은 외래 키 하나로 두 테이블이 연관관계를 맺음
    • 객체 양방향 관계는 A → B, B → A처럼 참조가 2군데
    • 객체 양방향 관계는 참조가 2군데 있음. 둘중 테이블의 외래 키를 관리할 곳을 지정해야함
    • 연관관계 주인 : 외래키를 관리하는 참조
    • 주인의 반대편 : 외래 키에 영향을 주지 않음, 단순 조회만 가능

다대일[N:1]

  • 가장 많이 사용하는 연관관계
  • 다대일의 반대는 일대다

  • 외래 키가 있는 쪽이 연관관계의 주인
  • 양쪽을 서로 참조하도록 개발

일대다 [1:N]

  • 일대다 단방향은 일대다(1:N)에서 일(1)이 연관관계의 주인
  • 테이블 일대다 관계는 항상 다(N) 쪽에 외래 키가 있음
  • 객체와 테이블의 차이 때문에 반대편 테이블의 외래 키를 관리하는 특이한 구조
  • @JoinColumn을 꼭 사용해야 함. 그렇지 않으면 조인 테이블 방식을 사용함(중간에 테이블을 하나 추가함)

단점

  • 엔티티가 관리하는 외래 키가 다른 테이블에 있음
  • 연관관계 관리를 위해 추가로 UPDATE SQL 실행

일대다 단방향 매핑보다는 다대일 양방향 매핑을 사용하자!

  • 이런 매핑은 공식적으로 존재X
  • @JoinColumn(insertable=false, updatable=false)
  • 읽기 전용 필드를 사용해서 양방향 처럼 사용하는 방법
  • 다대일 양방향을 사용하자

일대일[1:1]

  • 주 테이블이나 대상 테이블 중에 외래 키 선택 가능

    • 주 테이블에 외래 키
    • 대상 테이블에 외래 키
  • 외래 키에 데이터베이스 유니크 제약조건 추가

  • 다대일(@ManyToOne) 단방향 매핑과 유사

  • 다대일 양방향 매핑 처럼 외래 키가 있는 곳이 연관관계의 주인
  • 반대편은 mappedBy 적용

  • 단방향 관계는 JPA 지원X
  • 양방향 관계는 지원

사실 일대일 주 테이블에 외래 키 양방향과 매핑 방법은 같음

다대다[N:M]

관계형 데이터베이스는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없음. 연결 테이블을 추가해서 일대다, 다대일 관계로 풀어내야함

  • @ManyToMany 사용
  • @JoinTable로 연결 테이블 지정
  • 다대다 매핑: 단방향, 양방향 가능

다대다 매핑의 한계

  • 편리해 보이지만 실무에서 사용X
  • 연결 테이블이 단순히 연결만 하고 끝나지 않음
  • 주문시간, 수량 같은 데이터가 들어올 수 있음

다대다 한계 극복

  • 연결 테이블용 엔티티 추가(연결 테이블을 엔티티로 승격)
  • @ManyToMany -> @OneToMany, @ManyToOne


상속관계 매핑

  • 관계형 데이터베이스는 상속관계 x

  • 슈퍼타입 서브타입 관계라는 모델링 기법이 객체 상속과 유사

  • 상속관계 매핑

    객체의 상속과 구조와 DB의 슈퍼타입 서브타입 관계를 매핑

  • 슈퍼타입 서브타입 논리 모델을 실제 물리 모델로 구현하는 방법

    • 각각 테이블로 변환 → 조인 전략
    • 통합 테이블로 변환 → 단일 테이블 전략
    • 서브타입 테이블로 변환 → 구현 클래스마다 테이블 전략

조인 전략

장점

  • 테이블 정규화
  • 외래 키 참조 무결성 제약조건 활용가능
  • 저장공간 효율화

단점

  • 조회시 조인을 많이 사용, 성능 저하
  • 조회 쿼리가 복잡함
  • 데이터 저장시 INSERT SQL 2번 호출

Item

package com.example.jpa.hellojpa;

import javax.persistence.*;

@Entity
// 조인 전략
@Inheritance(strategy = InheritanceType.JOINED)
// 부모 클래스에 선언
// 하위 클래스를 구분하는 용도의 컬럼
// DTYPE
@DiscriminatorColumn
public class Item {
    @Id @GeneratedValue
    private  Long id;

    private String name;
    private int price;
}

Album

@Entity
// 자식 클래스에서 DTYPE이 어떻게 넣어야할지 가이드
@DiscriminatorValue("A")
public class Album  extends Item{
    private String artist;
}

Book

@Entity
@DiscriminatorValue("B")
public class Book extends Item{
    private String author;
    private String isbn;
}

Movie

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
    private String director;
    private String actor;
}
  • 객체는 상속을 지원하므로 모델링과 구현이 똑같지만, DB는 상속을 지원하지 않으므로 논리 모델을 물리 모델로 구현할 방법이 필요하다.

  • DB의 슈퍼타입 서브타입 논리 모델을 실제 물리 모델로 구현하는 방법은 세가지 있다.

  • 중요한건, DB입장에서 세가지로 구현하지만 JPA에서는 어떤 방식을 선택하던 매핑이 가능하다.

  • JPA가 이 세가지 방식과 매핑하려면

단일 테이블 전략

장점

  • 조인이 필요 없으므로 일반적으로 조회성능이 빠름
  • 조회 쿼리가 단순함

단점

  • 자식 엔티티가 매핑한 컬럼은 모두 null 허용
  • 단일 테이블에 모든 것을 저장하므로 테이블이 커질 수 있다. 상황에 따라서 조회 성능이 오히려 느려질 수 있다.

단일 테이블에서는 DTYPE이 필수로 들어가야 한다. 아니면 구분하기 어렵기 때문이다. 하지만 모든 방식이든 DTYPE이 운영상 있는 것이 좋다.

profile
발전하기 위한 공부

0개의 댓글