반응형

1. 개요

 @Column 옵션 중 nullable = false를 적용하는 순간 ConstraintViolationException: could not execute statement 예외가 발생하였다.


2. 배경

 nullable = false 옵션은 null은 허용하지 않는다는 의미이다. null을 허용할 경우 연관관계가 맺어진 테이블 조회 시 left outer join을 하게되어 성능상 이슈를 가져올 수 있으나, 허용하지 않을 경우 inner join을 하여 성능상 이점을 가져올 수 있기에 해당 옵션을 추가하였다. 그런데 옵션을 추가하자마자 아래와 같은 에러가 발생하였다. null을 허용하지 않는 컬럼에 null이 들어가 SQL을 실행할 수 없다는 에러였다.

 

ConstraintViolationException 예외 발생


3. 원인 분석

 CLASS_ID는 Student 엔티티와 다대일 관계에 있는 Classroom 엔티티의 Join Column이다. 메인 메서드에서 Student 엔티티를 생성한 후 Classroom 설정을 하지 않았는지 확인해보았다.

 

1) main 메서드

 - main 메서드에서는 Student의 수정자 메서드와 classroom의 addStudent 메서드를 통해 양방향으로 주입 가능하도록 메서드를 구현했었다. 어쨌든 student1과 student2는 이를 사용하여 classroom을 set하는 것을 확인하였다. 그후 메서드 구현부분을 차례로 확인해보았다.

	Student student1 = new Student();
        student1.setName("심심심");
        em.persist(student1);

        Student student2 = new Student();
        student2.setName("홍길동");
        em.persist(student2);

        Classroom classroom = new Classroom();
        classroom.setName("배드민턴반");
        em.persist(classroom);

        student1.setClassroom(classroom); // student 1에 classroom set
        classroom.addStudent(student2); // student 2에 classroom set

        List<Student> list = classroom.getStudentList();
        for(Student student : list){
            System.out.println("========================="+student.getName());
        }

 

2) Student.java

@Entity
@Getter
@Setter
public class Student {

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

    private String name;

    @ManyToOne
    @JoinColumn(name = "CLASS_ID", nullable = false)
    private Classroom classroom;

    public void setClassroom(Classroom classroom){
        if(this.classroom != null){
            this.classroom.getStudentList().remove(this);
        }
        this.classroom = classroom;
        classroom.getStudentList().add(this);
    }
}

 Student의 경우 setClassroom 시 Student.classroom에 set 함과 동시에 객체 상태의 classroom에도 Student를 반영해주기 위해 classroom.getStudentList().add(this)구문을 추가해주었다. 어쨌든 classroom이 'this.classroom = classroom' 코드에 의해 설정되므로 여기서 null을 발생시키진 않는 것으로 판단하였다.

 

3) Classroom.java

@Entity
@Getter
@Setter
public class Classroom {

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

    private String name;

    @OneToMany(mappedBy = "classroom")
    private List<Student> studentList = new ArrayList<>();

    public void addStudent(Student student){
        if(student.getClassroom() != null){
            student.getClassroom().getStudentList().remove(student);
        }
        student.setClassroom(this);
    }
}

 addStudent 메서드도 studentList에 대한 순수 객체 상태도 엔티티와 동일하게 가져가기 위해 요청으로 들어온 Student가 속한 classroom이 있을 경우, 해당 classroom의 StudentList에서 요청으로 들어온 student를 remove해주는 코드를 넣었다. 중요한 부분은 Student 엔티티의 classroom이 set 되냐 마냐인데 student.setClassroom(this)를 통해 요청으로 들어온 Student 엔티티에도 classroom이 설정되는 것을 확인하였다.

 

 결론은 문제의 원인이 될만한 곳을 발견하지 못했다. 디버깅을 해봐도 student1과 student2 엔티티 모두 classroom을 갖고 있는 것을 확인하였다. 그럼 뭐가 문제일까를 고민하다가 자연스레 JPA의 동작원리를 생각해보았다.

 

 엔티티 매니저의 persist 메서드를 사용하면 엔티티 매니저가 관리하는 영속성 컨텍스트로 엔티티가 관리된다. 동시에 1차 캐시에 엔티티 id와 엔티티가 key, value 형식으로 저장되고 쓰기지연 SQL에 엔티티에 대한 insert 쿼리가 생성되어 쌓인다. 후에 EntityTransaction혹은 EntityManager에 의해 flush() 메서드가 호출되면 쓰기지연 SQL에 있는 쿼리가 실제 DB에서 실행되어 데이터가 저장된다.

 여기서 쓰기지연 SQL에 들어간 insert쿼리가 어떤 순서로 실행됐는지를 확인해보기 위해 실행된 쿼리를 확인해보았다.

예외 발생 쿼리

 그 결과 가장 먼저 실행된 쿼리는 student에 대한 INSERT 쿼리였다. 이 시점에서는 CLASS_ID가 할당되지 않는다. 왜냐하면 student에 classroom을 set 하기 전에 em.persist를 호출했고, 이때 쓰기지연 SQL에는 CLASS_ID가 할당되지 않은 Student의 INSERT 쿼리가 적재되기 때문이다.

 더 나아가면 아래 Student 엔티티에 대한 setClassroom을 하는 시점에 UPDATE 쿼리가 적재되게 된다.

	Student student1 = new Student();
        student1.setName("심심심");
        em.persist(student1);
        
        ..
        
        student1.setClassroom(classroom)

 


4. 해결방안

 student1, 2에 classroom을 set한 이후 em.persist를 호출하는 쪽으로 em.persist 메서드 위치를 변경하였다. 이제 Student에 대한 쓰기지연 SQL에는 classroom이 할당된 상태의 INSERT 쿼리가 나가게 되었다. 

	Student student1 = new Student();
        student1.setName("심심심");
        
        Student student2 = new Student();
        student2.setName("홍길동");
        
        Classroom classroom = new Classroom();
        classroom.setName("배드민턴반");
        em.persist(classroom);

        student1.setClassroom(classroom);
        classroom.addStudent(student2);

        em.persist(student1);
        em.persist(student2);

  

 결론은 em.persist의 순서였는데, 순서로 인한 이런 에러는 추후에도 발생할 여지가 충분히 있다고 생각되었다. 만약 널을 허용하지 않는 옵션이 일반 Column이었다면 실제로 set을 하지 않아 발생한 것이므로 바로 원인을 찾았을 것이다. 하지만 위 케이스의 경우 classroom이라는 부모 엔티티를 수정자 메서드를 통해 설정해줬음에도 불구하고 에러가 발생하기 때문에 원인 찾기가 힘들 수 있다고 생각되었다.

 어쨌든간에 null을 허용하지 않는 자식, 부모 관계에 있는 엔티티를 자식, 부모 순서로 persist 하여 발생하였는데, 부모 엔티티만 persist해도 연관된 엔티티들이 함께 영속상태로 들어가는 영속성 전이를 사용하면 더 깔끔하게 해결 가능하다.

 부모 엔티티의 연관관계 어노테이션에 cascade 옵션을 PERSIST로 설정하면 되며, 자식객체를 persist하는 코드 생략과 앞서 발생했던 문제들을 예방하는 효과를 얻을 수 있었다.

 참고로 영속성 전이는 em.persist()를 실행할 때가 아닌 flush()를 호출할 때 발생한다.

 

1) Classroom.java

@Entity
@Getter
@Setter
public class Classroom {

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

    private String name;

    @OneToMany(mappedBy = "classroom", cascade = CascadeType.PERSIST) // 영속성 전이 옵션
    private List<Student> studentList = new ArrayList<>();

    public void addStudent(Student student){
        if(student.getClassroom() != null){
            student.getClassroom().getStudentList().remove(student);
        }
        student.setClassroom(this);
    }
}

 

2) main 메서드

	Student student1 = new Student();
        student1.setName("심심심");

        Student student2 = new Student();
        student2.setName("홍길동");

        Classroom classroom = new Classroom();
        classroom.setName("배드민턴반");
        
        student1.setClassroom(classroom); // 연관관계 추가
        classroom.addStudent(student2); // 연관관계 추가
        
        em.persist(classroom); // classroom 영속화
        
        tx.commit();

5. 회고

 코드상 원인은 em.persist의 순서였지만, 근본 원인은 JPA에 대한 매커니즘을 생각하지 않고 코드를 작성한 나 자신이었다 :(  JPA는 프레임워크라는 사실을 잊지말자. 스프링도 마찬가지라고 생각하는데 프레임워크를 사용하기 위해서는 매커니즘과 그 성격을 이해하고 사용하는 것이 정말 중요하다고 다시한번 느끼게되었다.

반응형
반응형

1. 개요

 - JPQL의 내부, 외부, 세타 조인에 대해 알아보자.


2. 내부 조인

 - 연관 관계가 맺어진 엔티티들에 대한 Inner Join을 말한다.

 - JPQL 작성 시 INNER JOIN의 INNER 는 생략 가능하다.

SELECT m FROM Member m [INNER] JOIN m.team t

3. 외부 조인

 - 연관 관계가 맺어진 엔티티들에 대한 Left Outer Join을 말한다.

 - JPQL 작성 시 LEFT OUTER JOIN의 OUTER는 생략 가능하다.

SELECT m FROM Member m LEFT [OUTER] JOIN m.team t

4. 세타 조인

 - 엔티티들에 대한 조인을 말한다.

 - 연관 관계와 상관 없기에 엔티티 명을 정확히 기입해야 한다.

SELECT count(m) FROM Member m, Team t WHERE m.username = t.name

5. 조인 예제

 - 테스트를 위해 teamA와 teamB를 생성하였다. 멤버 0부터 9까지는 teamA, 멤버 10부터 19까지는 teamB에 속하도록 하였고, 멤버 20부터 29까지는 팀이 없도록 테스트 데이터를 세팅하였다. 데이터 셋 코드는 다음과 같다.

    Team teamA = new Team();
    teamA.setName("teamA");
    em.persist(teamA);

    Team teamB = new Team();
    teamB.setName("teamB");
    em.persist(teamB);

    for(int i =0 ; i< 10 ; i++){
        Member member = new Member();
        member.setUsername("멤버"+i);
        member.setAge(i);
        member.changeTeam(teamA);
        em.persist(member);
    }

    for(int i =10 ; i< 20 ; i++){
        Member member = new Member();
        member.setUsername("멤버"+i);
        member.setAge(i);
        member.changeTeam(teamB);
        em.persist(member);
    }

    for(int i =20 ; i< 30 ; i++){
        Member member = new Member();
        member.setUsername("멤버"+i);
        member.setAge(i);
        em.persist(member);
    }

 

5.1. 내부 조인 예제

 - 아래의 JPQL을 실행하여 내부 조인 시 team이 존재하는 Member 정보만을 리턴한다.

    String innerJoinQuery = "select m from Member m inner join m.team t";
    List<Member> list = em.createQuery(innerJoinQuery,Member.class)
    	.getResultList();

    for(Member member : list){
        System.out.println(member.toString());
        System.out.println(member.getTeam());
    }

	// 출력 결과
    Member{id=3, username='멤버0', age=0}
    Team{id=1, name='teamA'}
    Member{id=4, username='멤버1', age=1}
    Team{id=1, name='teamA'}
    ...
    Member{id=21, username='멤버18', age=18}
    Team{id=2, name='teamB'}
    Member{id=22, username='멤버19', age=19}
    Team{id=2, name='teamB'}

 

5.2. 외부 조인 예제

 - 아래의 JPQL을 실행하여 외부 조인 시 team이 존재하지 않는 Member 정보도 함께 리턴한다.

    String leftJoinQuery = "select m from Member m left join m.team t";
    List<Member> list2 = em.createQuery(leftJoinQuery,Member.class)
        .getResultList();

    for(Member member : list2){
        System.out.println(member.toString());
        System.out.println(member.getTeam());
    }

	// 출력 결과
    Member{id=3, username='멤버0', age=0}
    Team{id=1, name='teamA'}
    Member{id=4, username='멤버1', age=1}
    Team{id=1, name='teamA'}
    ...
    Member{id=31, username='멤버28', age=28}
    null
    Member{id=32, username='멤버29', age=29}
    null

 

5.3. 세타 조인 예제

 - 테스트를 위해 member 이름이 teamA인 멤버를 생성하였다. 실제 실행된 쿼리 확인 결과, 두 테이블을 크로스 조인 후 조건에 해당하는 값만을 조회한다.

    Member thetaMember = new Member();
    thetaMember.setUsername("teamA");
    thetaMember.changeTeam(teamA);
    em.persist(thetaMember);

    String thetaJoinQuery = "select m from Member m, Team t where m.username = t.name";
    List<Member> list3 = em.createQuery(thetaJoinQuery,Member.class)
        .getResultList();

    for(Member member : list3){
        System.out.println(member.toString());
        System.out.println(member.getTeam());
    }

    // 출력 결과
    Member{id=33, username='teamA', age=0}
    Team{id=1, name='teamA'}

6. 조인 대상 필터링

 - SQL에서 사용하던 on과 동일하게 사용한다. 내부 조인, 외부 조인도 동일한 방식으로 사용 가능하다.

    // 내부 조인에 대한 필터링 - 회원과 팀을 조인하면서, 팀 이름이 A인 팀만 조인
    JPQL : SELECT m, t FROM Member m LEFT JOIN m.team t ON t.name = 'A'
    SQL : SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.team_id = t.id and t.name = 'A'

    // 외부 조인에 대한 필터링 - 회원의 이름과 팀 이름이 같은 대상 외부조인
    JPQL : SELECT m, t FROM Member m LEFT JOIN Team t ON m.username = t.name
    SQL : SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.username = t.name

7. 회고

 - JPQL에서 사용하는 조인과 SQL에서 사용하는 조인은 조인의 개념만 잘 알고있다면 어렵지 않게 적용함을 알았다. 모든 조인에 세타 조인을 사용해도 되나, 크로스 조인으로 인한 성능 저하를 고려해봤을 때 테이블의 연관관계를 확실히 이해하고 그에 따른 조인 전략을 구상하는 게 중요함을 느꼈다.

 


8. 참고

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

반응형
반응형

1. 개요

 - 프로젝션과 JPA 페이징 API에 대한 개념을 정리한다.

 


2. 프로젝션이란?

 - SELECT 절에 조회할 대상을 지정하는 것을 말한다.

 - 조회된 대상은 모두 영속성 컨텍스트에서 관리된다.


3. 조회할 대상 (프로젝션 대상)

 - 조회할 대상은 엔티티, 임베디드 타입, 스칼라 타입(숫자, 문자, 등 기본 데이터 타입)이 있다. 각 타입의 의미는 예제를 통해 쉽게 이해 가능하다.

	SELECT m FROM Member m // Member 엔티티를 조회하는 엔티티 프로젝션

	SELEECT m.team FROM Member m // Member 엔티티와 관계를 맺고 있는 Team 엔티티를 조회하는 엔티티 프로젝션

	SELECT m.address FROM Member m // Member 엔티티에 임베디드 타입인 address를 조회하는 임베디드 타입 프로젝션

	SELECT m.username, m.age FROM Member m // 기본 데이터 타입들을 조회하는 스칼라 타입 프로젝션

4. 여러 값 조회하기

 - 한 로우에 여러 값을 조회한다는 것은 반환 값이 명확하지 않다는 뜻이다. 예를들어 위의 스칼라 타입 프로젝션 같은 경우 username이라는 String과, age라는 int형을 조회하는데 두 값을 한번에 받을 수 있는 타입이 존재하지 않기 때문이다.

 - 이처럼 반환 값이 명확하지 않을 경우 1차적으로 Query 타입으로 조회하게 된다.

 - getResultList 사용 시에는 결과를 ArrayList<Object>로, getSingleResult 사용 시에는 결과를 Object 형태로 리턴받는다.

 - 조회한 로우의 각 속성들을 조회하고 싶다면 Object를 Object[]로 캐스팅하여 result[0], result[1]과 같이 조회해야한다.

Query.getResultList() 결과

 - 이러한 매커니즘을 활용하여 여러 값을 조회하는 방법은 크게 3가지가 있다.

 1) 앞서 언급한 Query 타입으로 조회하는 방법

 2) 반환 값을 Object[]로 명확히 하여 TypeQuery 타입으로 조회하는 방법

 3) new 명령어로 조회하는 방법

 

4.1. Query 타입 조회

// Query 타입 조회 방법
	Object result2 = 
		em.createQuery("select m.username, m.id from Member m where m.id = 1L").getSingleResult();

	Object[] objects = (Object[])result2;
	System.out.println(objects[0]); // username
	System.out.println(objects[1]); // id

 - Query 타입으로 조회 시 Object로 받게 되며, 각 속성에 접근하기 위해 반드시 Object 배열로 캐스팅해야하고 배열 번호로 접근해야 하는 단점이 있다.

 

4.2. TypedQuery 타입 조회(= Object[] 조회)

	// TypedQuery 타입 조회 방법 == Object[] 타입 조회
	Object[] result3 = 
		em.createQuery("select m.username, m.id from Member m where m.id = 1L",Object[].class).getSingleResult();
        System.out.println(result3[0]); // username
 	System.out.println(result3[1]); // id

  - TypedQuery 타입으로 조회 시 createQuery 메서드에서 Object[] 로 캐스팅 작업을 먼저 하기때문에 추가적인 캐스팅 코드를 작성하지 않는다. 하지만 배열 번호를 통한 접근은 그리 좋지 않아보인다.

 

4.3. new 생성자를 통한 조회

	// new 생성자를 통한 조회
	MemberDto result3 = 
		em.createQuery("select new jqpl.MemberDto(m.id, m.username) from Member m where m.id = 1L",MemberDto.class).getSingleResult();
        System.out.println(result3.getId()); // username
        System.out.println(result3.getUsername()); // id

	
	...
	// MemberDto.java
	public class MemberDto {

    	private Long id;

    	private String username;

    	public MemberDto(Long id, String username) {
        	this.id = id;
        	this.username = username;
    	}
		...
		// getter, setter
	}

 - new 생성자를 사용할 경우 JPQL의 조회 형태에 맞는 생성자를 가진 DTO 클래스를 생성해야 한다.

 - 배열의 번호를 통한 접근이 아니고 캐스팅 코드가 없는 깔끔한 조회 방식이나 JPQL에 DTO에 대한 풀 패키지 경로를 입력해야하는 단점이 있다. 하지만 이는 쿼리 DSL에서 극복되었으므로 이 방식을 사용하는 것이 권장된다.

 


5. 페이징 API

 - JPA는 페이징 처리 시 setFirstResult, setMaxResults라는 메서드로 추상화한다.

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

 - 내부적으로 동작되는 쿼리는 JPA에 설정한 Database 방언에 맞게 실행된다.

 


6. 페이징 API 예제

 - 나이 오름차순으로 조회한 멤버 리스트들 중 0번째부터 시작해 10개의 데이터를 조회하는 예제이다. setFirstResult(0), set MaxResults(10)으로 하여 간단히 조회 가능하다.

    for(int i =0 ; i< 100 ; i++){
        Member member = new Member();
        member.setUsername("멤버"+i);
        member.setAge(i);
        em.persist(member);
    }

	// 0번째부터 시작해서 10개의 데이터를 조회한다.
    List<Member> list = 
		em.createQuery("select m from Member m order by m.age asc",Member.class)
            .setFirstResult(0)
            .setMaxResults(10)
            .getResultList();

    for(Member member : list){
        System.out.println(member.toString());
    }

	//실행결과
	Member{id=1, username='멤버0', age=0}
	Member{id=2, username='멤버1', age=1}
	Member{id=3, username='멤버2', age=2}
	Member{id=4, username='멤버3', age=3}
	Member{id=5, username='멤버4', age=4}
	Member{id=6, username='멤버5', age=5}
	Member{id=7, username='멤버6', age=6}
	Member{id=8, username='멤버7', age=7}
	Member{id=9, username='멤버8', age=8}
	Member{id=10, username='멤버9', age=9}

7. 참고

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

반응형
반응형

1. 개요

 - JPQL의 개념, 문법에 대해 알아보자.

 


2. 개념

 - JPQL은 Java Persistence Query Language의 약자로 자바에서 사용하는 객체 지향 쿼리 언어이다. 객체 지향이라는 말에 맞게 쿼리를 작성할 때 테이블을 대상으로 작성하는게 아닌 엔티티를 대상으로 작성한다.

 - JPQL은 결국에 SQL로 변환되며, 특정 데이터베이스에 의존하지 않는다.

 


3. JPQL 기본 문법

 - JPQL은 SQL 문법과 거의 동일하지만 다른 점이 몇가지 있다.

 1) 엔티티와 속성은 대소문자를 구분하므로 엔티티 클래스에 정의한 대로 사용해야 한다.

 2) SELECT, FROM, WHERE 과 같은 JPQL 키워드는 대소문자를 구분하지 않는다.

 3) SQL에서는 테이블 명을 입력했다면 JPQL에서는 테이블 명이 아닌 엔티티 명을 사용해야 한다.

 4) 별칭이 필수이다. (ex. select m from Member m)

 


4. TypeQuery와 Query

 - TypeQuery는 반환 값이 명확할 때, Query는 불명확할 때 사용한다.

	// 반환 타입이 Member 클래스로 명확
	TypedQuery<Member> typedQuery = 
		em.createQuery("select m from Member m", Member.class);
    
	// 반환 타입이 username, id로 불명확
	Query query = 
		em.createQuery("select m.username, m.id from Member m");

 


5. 결과 조회

 - 결과 조회 시 Query 및 TypeQuery의 메서드인 getResultList(), getSingleResult() 중 하나를 사용한다.

 

5.1. getResultList

 - 결과가 하나 이상일 때 리스트를 반환하고, 결과가 없을 때 빈 리스트를 반환한다.

 - null 체크 할 필요가 없다.

 

5.2. getSingleResult

 - 결과가 정확히 하나일 때 단일 객체를 반환하고, 없을 때 NoResultException, 둘 이상일 때 NonUniqueResultException 예외를 발생시킨다.

 - 이처럼 예외가 발생할 수 있기에 try, catch를 통한 핸들링이 필요하다. Spring Data JPA에서는 값이 없을 경우 예외를 발생시키는 부분을 개선하였으며 null 혹은 Optional 객체를 리턴하도록 구현되어 있다.

	TypedQuery<Member> list = 
		em.createQuery("select m from Member m", Member.class);

	// 결과가 하나 이상일 것을 예상하여 getResultList를 통해 반환받음
	List<Member> memberList = list.getResultList();
    

	TypedQuery<Member> single = 
		em.createQuery("select m from Member m where m.id = 1L", Member.class);

	// 결과가 하나일 것을 예상하여 getSingleResult를 통해 반환받음.
	// 만약 결과가 2개 이상이거나 없을 경우 예외가 발생함.
    Member singleMember = single.getSingleResult();

 


6. 파라미터 바인딩

 - 이름 기준과 위치 기준으로 바인딩 하는 방법이 있다.

 - 이름 기준으로 사용 시 쿼리에 ":" 구문을 사용하며, 위치 기준으로 사용 시 "?번호" 구문을 사용한다.

 - 위치 기반 바인딩은 쿼리 중간에 조건이 하나 더 추가될 경우 쿼리에 사용한 번호가 밀려 에러를 유발할 수 있다. 이에따라 위치 기반보다는 이름 기준 바인딩이 권장된다.

 

 * TypedQuery와 Query는 메서드 체이닝을 지원하기에 한번에 처리 가능하다.

	// 이름 기준 파라미터 바인딩
	TypedQuery<Member> query 
		= em.createQuery("select m from Member m where m.id = :id and m.username = :username", Member.class);
    query.setParameter("username", "심승경");
    query.setParameter("id", 1L);

    Member singleMember = query.getSingleResult();

	// 메서드 체이닝 방식
	Member singleResult 
		= em.createQuery("select m from Member m where m.id = :id and m.username = :username", Member.class)
            .setParameter("username", "심승경")
            .setParameter("id", 1L)
            .getSingleResult();
	
	// 위치 기준 파라미터 바인딩
	singleResult = 
		em.createQuery("select m from Member m where m.id = ?1 and m.username = ?2", Member.class)
            .setParameter(1, 1L)
            .setParameter(2, "심승경")
            .getSingleResult();

7. 참고

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

반응형

+ Recent posts