스터디/JPA 스터디

[JPQ] 객체 지향 쿼리 소개, JPQL

제이온 (Jayon) 2022. 1. 20.

jpa-study에서 스터디를 진행하고 있습니다.

객체지향 쿼리 소개

JPQL 소개

  • JPQL(Java Persistence Query Language)은 엔티티 객체를 조회하는 객체지향 쿼리다.
  • JPQL은 SQL을 추상화하여 특정 데이터베이스에 의존하지 않는다.
    • 데이터베이스 방언이 바뀌어도 JPQL을 수정하지 않아도 된다.
  • JPQL은 SQL보다 코드가 간결하다.
    • "select m from Member as m where m.username = 'kim'"
  • JPQL은 결국 SQL로 변환된다.

 

Criteria 쿼리 소개

  • Criteria는 JPQL을 생성하는 빌더 클래스이다.
  • Criteria는 문자가 아닌 프로그래밍 코드로 JPQL을 작성할 수 있다.
    • 가령 JPQL에서 select m from Membeeee m 처럼 오타가 나면 컴파일 시점에서 성공하고, 런타임 시점에서 실제로 사용이 되어야 예외가 발생한다.
    • 그래서 Criteria는 아래와 같은 장점이 있다.
      • 컴파일 시점에서 오류 발견 가능.
      • IDE를 사용하면 코드 자동 완성.
      • 동적 쿼리를 작성하기 편함.

 

// Criteria 사용 준비
CriteriaBuilder cb = eb.getCriteriaBuilder();
CriteriaQuery<Member> query = cb.createQuery(Member.class);

// 루트 클래스 (조회를 시작할 클래스)
Root<Member> m = query.from(Member.class);

// 쿼리 생성
CriteriaQuery<member> cq = query.select(m).where(cb.equal(m.get("username"), "kim"));
List<Member> resultList = em.createQuery(eq).getResultList();

 

  • 위와 같이 예제 코드를 작성할 수 있는데, 굉장히 복잡하고 장황하다. 따라서 최근에 Criteria는 거의 사용하지 않는다.

 

QueryDSL 소개

  • QueryDSL도 Criteria처럼 JPQL 빌더 역할을 한다.
  • QueryDSL은 오픈 소스 프로젝트로, JPA 외에 JDO, 몽고DB 등도 거의 같은 문법으로 지원하며, Criteria보다 이를 많이 사용하는 추세이다.

 

// 준비
JPAQuery query = new JPAQuery(em);
QMember member = QMember.member;

// 쿼리, 결과 조회
List<Member> members = 
    query.from(member)
    .where(member.username.eq("kim"))
    .list(member);

 

  • QMember는 Member 엔티티 클래스를 기반으로 생성한 QueryDSL 쿼리 전용 클래스이다.

 

네이티브 SQL 소개

  • JPQL을 사용해도 가끔 특정 데이터베이스에 의존하는 기능을 사용할 때가 있다.
    • 오라클 데이터베이스만 제공하는 CONNECT BY 기능이나 특정 데이터베이스에서만 동작하는 SQL 힌트 등이 있다.
  • 즉, JPQL에서 지원하지 않는 쿼리를 써야 할 경우 네이티브 SQL을 사용한다.
    • "SELECT ID, AGE, TEAM_ID, NAME FROM MEMBER WHERE NAME = 'kim'"

 

JDBC 직접 사용, Mybatis 같은 SQL 매퍼 프레임워크 사용

  • JPA의 EntityManager에서 하이버네이트 Session을 구하고, Session의 doWork() 를 호출하면 JDBC 커넥션을 획득할 수 있다.
  • JDBC 커넥션에 직접 접근하고 싶을 때 사용하나, 거의 그럴 일이 없다.

 

JPQL

기본 문법과 쿼리 API

SELECT문

SELECT m FROM Member AS m where m.username = 'Hello'

 

  • 대소문자 구분
    • 엔티티와 속성은 대소문자를 구분한다. 예를 들어, Member와 username은 대소문자를 구분한다.
    • SELECT, FROM, AS 같은 JPQL 키워드는 대소문자를 구분하지 않는다.
  • 엔티티 이름
    • JPQL에서 사용한 Member는 클래스 명이 아니라 엔티티 명이다. 기본 값인 클래스 명을 엔티티 명으로 사용하는 것을 추천한다.
    • @Entity(name="XXX") 로 엔티티 명을 수정하면 혼선을 빚을 수 있다.
  • 별칭은 필수
    • m 과 같은 별칭을 필수로 사용해야 하며, AS는 생략이 가능하다.

 

TypeQuery, Query

  • JPQL을 실행하려면 쿼리 객체를 만들어야 하는데, 반환할 타입을 지정할 수 있으면 TypeQuery 객체를 사용하고 그렇지 않으면 Query 객체를 사용한다.

 

TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m", Member.class);
List<Member> resultList = query.getResultList();

Query query = em.createQuery("SELECT m FROM Member m");
List resultList2 = query.getResultList();
for (Object o : resultList) {
    Object[] result = (Object[]) o; // 결과가 둘 이상이면 Object[] 반환
}

 

  • em.createQuery() 의 두 번째 파라미터에 반환할 타입을 지정하면 TypeQuery를 반환하고 지정하지 않으면 Query를 반환한다.
  • Query 객체는 SELECT 절의 조회 대상이 여러 개이면 Object[]를 반환한다.
    • SELECT m.username, m.age FROM Member m
  • 따라서 타입을 변환할 필요가 없는 TypeQuery를 사용하는 것을 추천한다.

 

결과 조회

  • query.getResultList() : 결과를 컬렉션으로 반환하며, 결과가 없으면 빈 컬렉션을 반환한다.
  • query.getSingleResult() : 결과가 정확히 하나일 때 사용할 수 있으며, 그렇지 않으면 예외가 발생한다. 컬렉션이 아닌, 객체 하나를 반환한다.

 

파라미터 바인딩

이름 기준 파라미터

  • 파라미터를 이름으로 구분하는 방법이다.
  • 앞에 : 를 사용한다.

 

String usernameParam = "User1";

List<member> resultList =
    em.createQuery("SELECT m FROM Member m WHERE m.username = :username", Member.class);
    .setParameter("username", usernameParam)
    .getResultList();

 

위치 기준 파라미터

  • ? 다음에 위치 값을 주면 되며, 위치 값은 1부터 시작한다.

 

List<member> resultList =
    em.createQuery("SELECT m FROM Member m WHERE m.username = ?1", Member.class);
    .setParameter(1, usernameParam)
    .getResultList();

 

참고로, 파라미터 바인딩을 사용하지 않고 + 연산을 사용하여 쿼리 문자열을 이어 붙이면 SQL 인젝션 공격을 받을 수 있고, 성능 상으로도 좋지 않으니 파라미터 바인딩을 필수로 사용하자.

 

프로젝션

  • SELECT 절에 조회 할 대상을 지정하는 것을 프로젝션이라 한다.
  • SELECT (프로젝션 대상) FROM
  • 프로젝션 대상은 엔티티, 임베디드 타입, 스칼라 타입이 있다.
    • 스칼라 타입은 기본 데이터 타입을 뜻함.

 

엔티티 프로젝션

SELECT m FROM Member m // 회원
SELECT m.team FROM Member m // 팀

 

  • 참고로 이렇게 조회한 엔티티는 영속성 컨텍스트에서 관리된다.
  •  

임베디드 타입 프로젝션

  • 임베디드 타입은 엔티티와 거의 비슷하게 사용되나, 조회의 시작점이 될 수 없다.
    • SELECT a FROM Address a
    • 위 코드에서 Address는 임베디드 타입이다.
  • 대신 엔티티를 통해서 임베디드 타입을 조회할 수 있가.
    • SELECT o.address FROM Order o
  • 임베디드 타입은 값 타입이므로 영속성 컨텍스트에서 관리되지 않는다.

 

스칼라 타입 프로젝션

  • 일반적으로 사용하는 쿼리문이다.
    • SELECT username FROM Member m

 

여러 값 조회

  • 프로젝션에서 여러 값을 선택하면 Query를 사용해야 한다. 그러나 여러 프로젝션을 처리하기 위해 Object[] 를 사용하여 적절한 객체로 변환하는 일은 바람직하지 않다.
  • 그래서 여러 값으로 구성된 객체를 따로 설계하고, 그 객체를 JPQL 단에서 생성하여 반환함으로써 TypedQuery를 사용한다. 자세한 예시를 살펴 보자.

 

public class UserDTO {

    private String username;
    private int age;

    // 생성자

}

TypedQuery<UserDTO> query = 
    em.createQuery("SELECT new jpabook.jpql.UserDTO(m.username, m.age) FROM Member m", 
    UserDTO.class);

List<UserDTO> resultList = query.getResultList();

 

  • SELECT 다음에 NEW 명령어를 사용하면 반환 받을 클래스를 지정할 수 있는데 이 클래스의 생성자에 JPQL 조회 결과를 넘겨 줄 수 있다.
  • NEW 명령어를 사용할 때는 2가지를 주의해야 한다.
    • 패키지 명을 포함한 전체 클래스 명을 입력해야 한다.
    • 순서와 타입이 일치하는 생성자가 필요하다.

 

페이징 API

JPA는 페이징을 다음 두 API로 추상화하였다.

 

  • setFirstResult(int startPosition) : 조회 시작 위치(0부터 시작)
  • setMaxResults(int maxResult) : 조회할 데이터 수

 

TypedQuery<Member> query = "..."

query.setFirstResult(10); // 11번부터 시작
query.setMaxResults(20); // 11번 ~ 30번 데이터 조회
query.getResultList();

 

집합과 정렬

집합 함수

  • 집합 함수는 COUNT, MAX, MIN, AVG, SUM이 있다.
  • 주의 사항은 다음과 같다.
    • NULL 값은 무시한다.
    • 값이 없는데 집합 함수를 사용하면 NULL이 된다. 단, COUNT는 0.
    • DISTINCT를 집합 함수 안에 사용해서 중복된 값을 걸러내고 집합을 구할 수 있다.
    • DISTINCT를 COUNT에서 사용할 때 임베디드 타입은 지원하지 않는다.

 

GROUP BY, HAVING

  • GROUP BY는 통계 데이터를 구할 때 특정 그룹끼리 묶어 준다.

 

select t.name, COUNT(m.age), SUM(m.age), AVG(m.age), MAX(m.age), MIN(m.age)
from Member m LEFT JOIN m.team t
GROUP BY t.name

 

  • 위 코드는 팀 이름을 기준으로 그룹 별로 묶어서 통계 데이터를 구하고 있다.
  • 만약 그룹별 통계 데이터 중에서 평균 나이가 10살 이상인 그룹을 조회하고 싶으면 다음과 같이 코드를 작성할 수 있다.

 

select t.name, COUNT(m.age), SUM(m.age), AVG(m.age), MAX(m.age), MIN(m.age)
from Member m LEFT JOIN m.team t
GROUP BY t.name
HAVING AVG(m.age) .= 10

 

정렬

  • ORDER BY를 사용하며, ASC는 오름차순, DESC는 내림차순 정렬을 뜻한다.

 

select m from Member m order by m.age DESC, m.username ASC

 

JPQL 조인

내부 조인

  • INNER는 생략하고 JOIN만 써도 된다.

 

SELECT m
FROM Member m INNER JOIN m.team t
where t.name = :teamName

 

  • JPQL 조인의 가장 큰 특징은 연관 필드를 사용한다는 것이다.
    • 연관 필드: 다른 엔티티와 연관 관계를 가지기 위해 사용하는 필드
    • FROM Member m INNER JOIN Team t 과 같이 일반 SQL 문법을 사용하면 오류가 발생한다.

 

외부 조인

  • OUTER를 생략하고 LEFT JOIN만 써도 된다.

 

SELECT m
FROM Member m LEFT JOIN m.team t

 

컬렉션 조인

  • 일대다 관계나 다대다 관계처럼 컬렉션을 사용하는 곳에 조인하는 것을 컬렉션 조인이라 한다.
  • [회원 → 팀] 조인은 단일 값 연관 필드를 사용하고, [팀 → 회원] 조인은 컬렉션 값 연관 필드를 사용한다.

 

SELECT t, m
FROM Team t LEFT JOIN t.members m

 

세타 조인

  • WHERE 절을 사용해서 세타 조인을 할 수 있다. (세타 조인은 내부 조인만 가능)

 

select count(m) from Member m, Team t
where m.username = t.name

 

JOIN ON 절

  • JPA 2.1부터 조인할 때 ON 절을 지원한다.
  • ON 절을 사용하면 조인 대상을 필터링하고 조인할 수 있다.
  • 참고로 내부 조인의 ON 절은 WHERE 절을 사용할 때와 결과가 같으므로 보통 ON 절은 외부 조인에서만 사용한다.

 

select m, t from Member m
left join m.team t on t.name = 'A'

 

페치 조인

  • 페치 조인은 SQL에서 말하는 조인의 종류는 아니고, JPQL에서 성능 최적화를 위해 제공하는 기능이다.
  • 연관된 엔티티나 컬렉션을 한 번에 같이 조회할 수 있다.
  • join fetch

 

엔티티 페치 조인

// JPQL
select m
from Member m join fetch m.team

// SQL
SELECT M.*, T.*
FROM MEMBER T
INNER JOIN TEAM T ON M.TEAM_ID = T.ID

 

  • 페치 조인을 이용하면 회원 엔티티를 조회하면서 연관된 팀 엔티티도 함께 조회할 수 있다.
  • 참고로 m.team 다음에 별칭이 없는데, 페치 조인은 별칭을 사용할 수 없다.
    • 하이버네이트는 페치 조인에도 별칭을 허용한다.

 

Untitled

  • 엔티티 페치 조인 SQL에서 회원과 연관된 팀을 모두 조회하는 것을 알 수 있다.
  • 회원 1과 회원 2는 팀 A를, 회원 3은 팀 B와 연관 관계를 맺게 되었다.
  • 회원과 팀을 지연 로딩으로 설정해도 페치 조인을 사용하면 즉시 로딩이 된다.

 

컬렉션 페치 조인

  • 이번에는 일대다 관계인 컬렉션을 페치 조인해 보자.

 

// JPQL
select t
from Team t join fetch t.members
where t.name = '팀A'

// SQL
SELECT T.*, M.*
FROM TEAM T
INNER JOIN MEMBER M ON T.ID = M.TEAM_ID
WHERE T.NAME = '팀A'

 

Untitled

  • 엔티티 페치 조인처럼 팀과 연관된 회원을 모두 조회한 것을 알 수 있다.
  • 문제는 TEAM 테이블에서 ‘팀A’는 하나지만 MEMBER 테이블과 조인하면서 결과가 증가하여 ‘팀A’가 2건 조회된 것을 알 수 있다.
  • 따라서 컬렉션 페치 조인 결과 객체인 Teams 는 동일한 주소를 가진 ‘팀A’ 2건을 가지게 된다.

 

Untitled

 

페치 조인과 DISTINCT

  • JPQL의 DISTINCT 명령어는 SQL에 DISTINCT를 추가하고, 애플리케이션에서 한 번 더 중복을 제거한다.
  • 위 예시에서 각 row의 데이터가 다르므로 SQL의 DISTINCT는 효과가 없으나 애플리케이션에서는 엔티티의 중복을 제거할 수 있다. 이것은 참조된 주소를 보고 판단한 것이다.

 

Untitled

 

페치 조인과 일반 조인의 차이

  • 페치 조인을 사용하지 않고 조인만 사용하면 결과를 반환할 때 연관관계를 고려하지 않고, 단순히 SELECT절에 지정한 엔티티만 조회한다.
  • 가령 다음과 같은 JPQL을 실행했을 때 팀과 회원 컬렉션을 조인했으니 회원 엔티티도 조회 되어야 하는데 실상은 그렇지 않다.

 

select t
from Team t join t.members m
where t.name = '팀A'

 

  • 만약 즉시 로딩으로 설정하면 회원 컬렉션을 즉시 로딩하기 위해 쿼리를 한 번더 실행한다.
  • 반면에 페치 조인을 사용하면 지연 로딩이든 즉시 로딩이든 상관 없이 연관된 엔티티를 함께 조회한다.

 

페치 조인의 특징과 한계

  • 엔티티에 직접 적용하는 로딩 전략은 애플리케이션 전체에 영향을 미치므로 글로벌 로딩 전략이라 부른다.
  • 페치 조인은 글로벌 로딩 전략보다 우선적으로 실행한다. 그래서 지연 로딩으로 설정하더라도 JPQL에서 페치 조인을 사용하면 페치 조인을 적용하게 된다.
  • 글로벌 로딩 전략은 지연 로딩을 사용하고, 최적화가 필요하면 페치 조인을 적용하는 것이 좋다.
  • 페치 조인의 한계
    • 페치 조인 대상에는 별칭을 줄 수 없다.
    • 둘 이상의 컬렉션을 페치할 수 없다.
    • 컬렉션을 페치 조인하면 페이징 API를 사용할 수 없다.
      • 단일 값 연관 필드는 페치 조인을 사용해도 페이징 API를 사용할 수 있다.

 

경로 표현식

  • 경로 표현식은 . 을 찍어 객체 그래프를 탐색하는 것이다.

 

경로 표현식의 용어 정리

  • 상태 필드: 단순히 값을 저장하기 위한 필드(필드 or 프로퍼티)
  • 연관 필드: 연관 관계를 위한 필드 (임베디드 타입 포함)
    • 단일 값 연관 필드: @ManyToOne, @OneToOne 대상 엔티티
    • 컬렉션 값 연관 필드: @OneToMany, @ManyToMany 대상 컬렉션

 

경로 표현식과 특징

  • 상태 필드 경로: 경로 탐색의 끝이므로 더 탐색할 수 없다.
  • 단일 값 연관 경로: 묵시적으로 내부 조인이 일어나며, 계속 탐색할 수 있다.
    • select o.member from Order o
    • SQL에서 내부 조인이 일어난다. 이를 묵시적 조인이라고 하며, 묵시적 조인은 모두 내부 조인이므로 외부 조인을 사용하고 싶으면 명시적 조인을 사용해야 한다.
  • 컬렉션 값 연관 경로: 묵시적으로 내부 조인이 일어나며, 더 탐색할 수 없다. 단 FROM 절에서 조인을 통해 별칭을 얻으면 별칭으로 탐색할 수 있다.
    • select t.members from Team t
    • SQL에서 내부 조인이 일어난다. 다만, t.members.username 처럼 경로 탐색을 이어서 할 수 없다.
    • select t.members from Team t join t.members m 과 같이 명시적 조인을 통해 새로운 별칭을 획득했다면 별칭을 통해 이어서 탐색이 가능하다.

 

경로 탐색을 사용한 묵시적 조인 시

  • 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵기 때문에 명시적 조인을 사용하는 것이 바람직하다.

 

서브 쿼리

  • JPQL에서 서브 쿼리는 WHERE, HAVING 절에만 사용할 수 있다.
  • EXISTS, ALL, ANY, SOME, IN 함수를 사용하면 된다.

 

조건식

타입 표현

  • 문자, 숫자, 날짜, Boolean, Enum, 엔티티 타입이 있다.

 

연산자 우선 순위

  1. 경로 탐색 연산
  2. 수학 연산: +, -(단항 연산자), *, /, +, -
  3. 비교 연산: =, >, ≥, <, ≤, <>, BETWEEN, LIKE, IN, IS NULL, IS EMPTY, MEMBER, EXISTS
  4. 논리 연산: NOT, AND, OR

 

컬렉션 식

  • 빈 컬렉션 비교식
    • {컬렉션 값 연관 경로} IS EMPTY
    • 컬렉션에 값이 비었으면 참

 

// 주문이 하나라도 있는 회원 조회
select m from Member m
where m.orders is not empty

 

  • 컬렉션의 멤버 식
    • {엔티티나 값} MEMBER {컬렉션 값 연관 경로}
    • 엔티티나 값이 컬렉션에 포함되어 있으면 참

 

select t from Team t
where :memberParam member of t.members

 

스칼라 식

  • 스칼라는 숫자, 문자, 날짜, case, 엔티티 타입같은 가장 기본적인 타입이다.
  • 스칼라 타입에 사용하는 식은 수학 식, 문자 함수, 날짜 함수, CASE가 있다.

 

CASE 식

  • 위에서 언급한 스칼라 식 중 하나로, 특정 조건에 따라 분기할 때 사용한다.

 

[기본 CASE]

CASE
    {WHEN <조건식> THEN <스칼라 식>}+
    ELASE <스칼라식>
END

 

[심플 CASE]

CASE <조건 대상>
    {WHEN <스칼라식1> THEN <스칼라식2>}+
    ELSE <스칼라식>
END

 

[COALESCE]

  • 문법: COALESCE(<스칼라식> {, <스칼라식>}+)
  • 스칼라 식을 차례대로 조회하여 null이 아니면 반환한다.

 

// m.username이 null이면 '이름 없는 회원'을 반환하라.
select coalesce(m.username, '이름 없는 회원') from Member m

 

[NULLIF]

  • 문법: NULLIF(<스칼라식>, <스칼라식>)
  • 두 값이 같으면 null을 반환하고 다르면 첫 번째 값을 반환한다. 집합 함수는 null을 포함하지 않으므로 보통 집합 함수와 함께 사용한다.

 

// 사용자 이름이 '관리자'면 null을 반환하고, 나머지는 본인의 이름을 반환하라.
select NULLIF(m.username, '관리자') from Member m

 

다형성 쿼리

  • JPQL로 부모 엔티티를 조회하면 그 자식 엔티티도 함께 조회한다.

 

TYPE

  • 엔티티의 상속 구조에서 조회 대상을 특정 자식 타입으로 한정한다.

 

select i from Item i
where type(i) IN (Book, Movie)

 

TREAT

  • 자바의 타입 캐스팅과 유사하여 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용한다.
select i from Item i where treat(i as Book).author = 'kim'

 

사용자 정의 함수 호출

  • 사용할 데이터베이스 함수를 미리 persistence.xml에 등록하고, 아래 문법으로 사용하면 된다.
  • function_invocation::=FUNCTION(function_name {, function_arg}*)

 

엔티티 직접 사용

기본 키 값

  • 객체 인스턴스는 참조 값으로 식별하고, 테이블 row는 기본 키 값으로 식별한다.
  • 따라서 JPQL에서 엔티티 객체를 직접 사용하면 SQL에서는 해당 엔티티의 기본 값을 사용한다.

 

// JPQL
select count(m) from Member m

// SQL
select count(m.id) as cnt from Member m

 

외래 키 값

Team team = em.find(Team.class, 1L);

String query = "select m from Member m where m.team = :team";
List resultList = em.createQuery(query)
    .setParameter("team", team)
    .getResultList();

 

기본 키 값이 1L인 팀 엔티티를 파라미터로 사용하고 있다. m.team 은 현재 team_id 라는 외래 키와 매핑되어 있다. 따라서 다음과 같은 SQL이 실행된다.

 

select m.*
from Member m
where m.team_id=? (팀 파라미터의 ID 값)

 

그래서 엔티티 대신 식별자 값을 파라미터로 줄 수도 있다.

 

String query = "select m from Member m where m.team.id = :teamId";
List resultList = em.createQuery(query)
    .setParameter("teamId", 1L)
    .getResultList();

 

참고로, m.team.id 를 보면 Member와 Team 간에 묵시적 조인이 일어날 것 같지만 Member 테이블이 team_id 외래 키를 가지고 있으므로 묵시적 조인은 일어나지 않는다. 물론 m.team.name 을 호출하면 묵시적 조인이 일어난다.

 

Named 쿼리: 정적 쿼리

  • 동적 쿼리: em.createQuery() 처럼 JPQL을 문자로 완성해서 직접 넘기는 쿼리이다. 런타임에 따라 특정 조건에 따라 JPQL을 동적으로 구성할 수 있다.
  • 정적 쿼리: 미리 정의한 쿼리에 이름을 부여해서 필요할 때 재사용할 수 있고, 이를 Named 쿼리라 부른다. Named 쿼리는 한 번 정의하면 변경할 수 없다.

 

Named 쿼리를 어노테이션에 정의

@Entity
@NamedQuery(
    name = "Member.findByUsername",
    query = "select m from Member m where m.username = :username")
public class Member {
}

 

위와 같이 세팅을 하고, 다음과 같이 사용해 보자.

 

List<Member> result List = 
em.createNamedQuery("Member.findByUsername", Member.class)
    .setParameter("username", "회원1")
    .getResultList();

 

Named 쿼리를 XML에 정의

자바 언어로 멀티 라인 문자를 다루는 것은 상당히 귀찮은 일이므로 그나마 나은 XML에 정의를 할 수 있다.

 

출처

김영한 - 자바 ORM 표준 JPA 프로그래밍

댓글

추천 글