1.  Querydsl을 이용한 동적 쿼리 처리

데이터베이스를 이용해야 할 때 JPA나 JPQL을 이용하면 SQL을 작성하거나 쿼리를 처리하는 소스 부분이 줄어들기 때문에 무척 편리하지
어노테이션을 이용해서 지정하기 때문에 고정된 형태라는 단점이 있음
  ✓  예를 들어 Board의 경우 '검색' 기능이 필요한데 '제목 / 내용 / 작성자'와 같은 단일 조건이 생성되는 경우도 있지만 '제목과 내용, 제목과 작성자'와 같이 복합적인 검색 조건이 생길 수 있음. 만일 여러 종류의 검색 조건이 생기면 모든 경우의 수를 별도의 메소드로 작성을 해야함.

이러한 문제의 근본 원인은 JPQL이 정적으로 고정되기 때문. JPQL은 코드를 통해서 생성해야 하는데 이 문제를 해결하기 위해 다양한 방법이 존재하지만 국내에서 가장 많이 사용되는 방식은 Querydsl


  💡  엄밀하게 말하면 'Querydsl'은 JPA의 구현체인 Hibernate 프레임워크가 사용하는 HQL Hibernate Query Language 을 동적으로 생성할 수 있는 프레임워크지만 JPA를 지원
    ➡️  Querydsl을 이용하면 자바 코드를 이용하기 때문에 타입의 안정성을 유지한 상태에서 원하는 쿼리를 작성할 수 있음
    ➡️  Q도메인이라는 존재가 필요한데 Q도메인은 Querydsl의 설정을 통해서 기존의 엔티티 클래스를 Querydsl에서 사용하기 위해서 별도의 코드로 생성하는 클래스

 


1) Querydsl을 사용하기 위한 프로젝트 설정 변경

Querydsl을 이용하기 위해서는 build.gradle 설정을 변경
dependencies에 Querydsl 관련 라이브러리들을 추가

// querydsl 추가

implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
// === QueryDsl 빌드 옵션 (선택) ===
// 마지막 부분에 추가
def querydslDir = "$buildDir/generated/querydsl"

sourceSets {
	main.java.srcDirs += [ querydslDir ]
}

tasks.withType(JavaCompile) {
	options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
}

clean.doLast {
	file(querydslDir).deleteDir()
}

 

Querydsl의 설정 확인


Querydsl의 설정이 올바르게 되었는지 확인하는 방법은 Q도메인 클래스가 정상적으로 만들어지는지 확인.
프로젝트 내에 Gradle 메뉴를 열어서 'other'부분을 살펴보면 compileJava task가 존재하는데 이를 실행.

complileJava가 실행되면 build 폴더에 QBorad 클래스가 생성되는 것을 볼 수 있음.

 

 


2)  기존의 Repository와 Querydsl 연동하기

📌  Querydsl을 기존 코드에 연동하기 위해서는 다음과 같은 과정으로 작성

  • Querydsl을 이용할 인터페이스 선언
  • '인터페이스 + Impl'이라는 이름으로 클래스를 선언 - 이때 QuerydslRepositorySupport라는 부모 클래스를 지정하고 인터페이스를 구현
  • 기존의 Repository에는 부모 인터페이스로 Querydsl을 위한 인터페이스 지정
repository 패키지에 search 하위 패키지를 추가하고 BoardSearch라는 인터페이스를 선언
public interface BoardSearch {
    Page<Board> search1(Pageable pageable);
}

 

실제 구현 클래스는 반드시 '인터페이스 이름 + Impl'로 작성
public class BoardSearchImpl extends QuerydslRepositorySupport implements BoardSearch {
    public BoardSearchImpl() {
        super(Board.class);
    }

    @Override
    public Page<Board> search1(Pageable pageable) {
        return null;
    }
}

 

마지막으로 기존의 BoardRepository의 선언부에 BoardSearch 인터페이스를 추가로 지정
public interface BoardRepository extends JpaRepository<Board, Long>, BoardSearch {
    @Query(value="select now()", nativeQuery = true)
    String getTime();
}

 

A.  Q도메인을 이용한 쿼리 작성 및 테스트


  🚀  Querydsl의 목적은 '타입'기반으로 '코드'를 이용해서 JPQL 쿼리를 생성하고 실행하는 것
         ➡️  이때 코드를 만드는 클래스가 Q도메인 클래스

BoardSearchImpl에서 Q도메인을 이용하는 코드를 작성
    @Override
    public Page<Board> search1(Pageable pageable) {

        QBoard board = QBoard.board; // Q도메인 객체
        JPQLQuery<Board> query = from(board); // select .. from board
        query.where(board.title.contains("1")); // where title like
        
        List<Board> list = query.fetch();
        long count = query.fetchCount();
        return null;
        
    }

 

  ✓  search1()은 아직 완성된 코드는 아니지만 Q도메인을 어떻게 사용하고 JPQLQuery라는 타입을 어떻게 사용하는지 보여줌
  ✓  JPQLQuery는 @Query로 작성했던 JPQL을 코드를 통해서 생성할 수 있게 함. 이를 통해서 where나 group by 혹은 조인 처리 등이 가능.
  ✓  JPQLQuery의 실행은 fetch()라는 기능을 이용하고, fetchCount()를 이용하면 count쿼리를 실행할 수 있음

 

아직은 null을 반환하지만 Querydsl을 이용해서 코드를 통한 JPQL의 쿼리를 확 인하기 위해 테스트 코드 작성
    @Test
    public void testSearch1() {
        Pageable pageable = PageRequest.of(1, 10, Sort.by("bno").descending());
        boardRepository.search1(pageable);
    }

 


B.  Querydsl로 Pageable 처리 하기


Querydsl의 실행 시에 Pageable을 처리하는 방법은 BoardSearchImpl이 상속한 QuerydslRepositorySupport라는 클래스의 기능을 이용

search1()의 코드 중간에 getQuerydsl()과 applyPagination()을 적용
@Override
    public Page<Board> search1(Pageable pageable) {

        QBoard board = QBoard.board; // Q도메인 객체
        JPQLQuery<Board> query = from(board); // select .. from board
        query.where(board.title.contains("1")); // where title like
        
        // paging
        this.getQuerydsl().applyPagination(pageable, query);
        
        List<Board> list = query.fetch();
        long count = query.fetchCount();
        return null;
        
    }

 

 

  ✓  applyPagination()이 적용된 후에 실행되는 쿼리의 마지막에는 페이징 처리에 사용하는 limit 가 적용되는 것 확인

 

 

 

 

 

 

 


3)  Querydsl로 검색 조건과 목록 처리

검색의 경우 '제목(t), 내용(c), 작성자(w)'의 조합을 통해서 이루어진다고 가정하고 이를 페이징 처리와 함께 동작하도록 구성

 

A. BooleanBuilder


예를 들어 '제목이나 내용'에 특정한 키워드 keyword가 존재하고 bno가 0보다 큰 테이터를 찾는다면 SQL을 다음과 같이 작성.

select * from board where (title like concat('%', '1', '%') or content like concat('%', '1', '%')) and bno > 0

 

  📍  where 조건에 and와 or이 섞여 있을 때는 연산자의 우선 순위가 다르기 때문에 or 조건은 ()로 묶어서 하나의 단위를 만들어 주는 것이 좋음
  📍  Querydsl을 이용할 때 '()'가 필요한 상황이라면 BooleanBuilder를 이용해서 작성할 수 있음

@Override
public Page<Board> search1(Pageable pageable) {
    QBoard board = QBoard.board; // Q도메인 객체
    JPQLQuery<Board> query = from(board); // select .. from board
    //query.where(board.title.contains("1")); // where title like

    BooleanBuilder booleanBuilder = new BooleanBuilder(); // (
    booleanBuilder.or(board.title.contains("11")); // title like
    booleanBuilder.or(board.content.contains("11")); // content like

    query.where(booleanBuilder);
    query.where(board.bno.gt(0L));

    // paging
    this.getQuerydsl().applyPagination(pageable, query);

    List<Board> boards = query.fetch();
    long count = query.fetchCount();
    return null;
}

 

  ✓  앞의 코드에는 '제목 혹은 or 내용'에 대한 처리를 BooleanBuilder에 or()을 이용해서 추가하고 있고, 'bno가 0보다 큰' 조건은 바로 JPQLQuery 객체에 적용되고 있음.
  ✓  코드를 실행하게 되면 다음과 같은 쿼리가 실행되는데 '()'가 생성되어서 or 조건들을 하나로 묶어서 처리하는 것을 볼 수 있음

 


B.  검색을 위한 메서드 선언과 테스트


  🚀  검색을 위해서는 적어도 검색 조건들과 키워드가 필요하므로 이를 types와 keyword로 칭하고 types은 여러 조건의 조합이 가능하도록 처리하는 메서드를 BoardSearch에 추가

public interface BoardSearch {
    Page<Board> search1(Pageable pageable);
    Page<Board> searchAll(String[] types, String keyword, Pageable pageable);
}

 

  🚀  BoardSearchImpl에서 searchAll의 반복문과 제어문을 이용한 처리가 가능. 검색 조건을 의미하는 types는 '제목(t), 내용(c), 작성자(w)'로 구성된다로 가정하고 이를 반영해서 코드 작성

@Override
public Page<Board> searchAll(String[] types, String keyword, Pageable pageable) {
    QBoard board = QBoard.board;
    JPQLQuery<Board> query = from(board);

    if ((types != null && types.length > 0) && keyword != null) { // 검색 조건과 키워드가 있다면
        BooleanBuilder booleanBuilder = new BooleanBuilder(); // (
        for (String type : types) {
            switch (type) {
                 case "t":
                     booleanBuilder.or(board.title.contains(keyword));
                     break;
                 case "c":
                     booleanBuilder.or(board.content.contains(keyword));
                     break;
                 case "w":
                     booleanBuilder.or(board.writer.contains(keyword));
                     break;
             }
         } // end for
         query.where(booleanBuilder);
    }// end if

    // bno > 0
    query.where(board.bno.gt(0L));

    // paging
    this.getQuerydsl().applyPagination(pageable, query);
    List<Board> list = query.fetch();
    long count = query.fetchCount();

    return null;
}

 

  🚀  아직 리턴 값은 null로 지정되어 있으므로 결과는 반환되지 않지만 테스트 코드를 작성해서 원하는 쿼리문이 실행되는지 확인

@Test
public void searchAll() {
    String[] types = {"t", "c", "w"};
    String keyword = "1";
    Pageable pageable = PageRequest.of(1, 10, Sort.by("bno").descending());
    Page<Board> result = boardRepository.searchAll(types, keyword, pageable);
}


C.  PageImpl을 이용한 Page<T> 반환


페이징 처리의 최종 결과는 Page<T> 타입을 반환하는 것이므로 Querydsl에서는 이를 직접 처리해야 하는 불편함이 있음
Spring Data JPA에서는 이를 처리하기 위해서 PageImpl이라는 클래스를 제공해서 3개의 파라미터로 Page<T>을 생성할 수 있음

  • List<T> : 실제 목록 데이터
  • Pageable : 페이지 관련 정보를 가진 객체
  • long : 전체 개수
BoardSearchImpl 클래스 내 searchAll()의 마지막 return null 부분에 반영해서 수정
// paging
this.getQuerydsl().applyPagination(pageable, query);
List<Board> list = query.fetch();
long count = query.fetchCount();

return new PageImpl<>(list, pageable, count);

 

BoardRepositoryTests 테스트 코드에서는 페이지 관련된 정보를 추출
@Test
public void searchAll() {
    String[] types = {"t", "c", "w"};
    String keyword = "1";
    Pageable pageable = PageRequest.of(1, 10, Sort.by("bno").descending());
    Page<Board> result = boardRepository.searchAll(types, keyword, pageable);
    
    // total pages
    log.info(result.getTotalPages());

    // page size
    log.info(result.getSize());

    // pageNumber
    log.info(result.getNumber());

    // prev next
    log.info(result.hasPrevious() + ": " + result.hasNext());

    result.getContent().forEach(board -> log.info(board));
}

 

 

  ✓  테스트 결과를 보면 키워드 '1'이라는 글자가 있는 총 페이지 수는 20이고, 이전페이지는 없지만 다음 페이지는 존재하는 것을 확인

 

 

 

 

 

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  Spring Data JPA

JDBC에서 작업하다가 MyBatis를 배울 경우에는 SQL 작성법도 거의 동일하고, 코드의 간략화가 목표여서 러닝커브가 높지 않지만
JPA의 경우에는 JDBC, MyBatis와는 사용방법이 상당한 차이가 있고, 추상적인 개념이 많이 포함이 되어서 러닝커브가 상당히 높음.

그리고 SQL문을 직접 만드는 것보다 JPA가 만드는 SQL의 효율이 떨어진다거나, 동일한 작업인데 JPA가 더 많은 SQL문을 사용하는 비효율성이 존재. 하지만 MSA 방식으로 애플리케이션을 설계하는 경향이 높아짐에 따라, JPA를 사용하는 작업이 많아짐.


1)  ORM / JPA

우리가 사용하는 대부분의 프로그램은 사용자가 입력한 데이터나 비지니스 로직 수행 결과로 얻은 데이터를 재사용할 수 있도록 데이터베이스에 저장을 함. 하지만 자바의 객체와 데이터베이스의 테이블이 정확하게 일치하지는 않음. 따라서 둘 사이를 매핑하기 위해서 많은 SQL 구문과 자바 코드가 필요해짐.

💡  ORM은 정확하게 일치하지 않는 자바 객체와 테이블 사이를 매핑 

  ✓  자바 객체에 저장된 데이터를 테이블의 Row 정보로 저장하고. 반대로 테이블에 저장된 Row 정보를 자바 객체로 매핑

  ✓  이 과정에서 사용되는 SQL 구문과 자바 코드를 ORM 프레임워크가 자동으로 만들어줌.

어떤 DB 연동 기술이나 프레임워크를 사용하더라도 SQL 명령어를 자바 클래스나 외부의 XML 파일에 작성해야 함. 그리고 작성된 SQL은 유지보수 과정에서 지속적으로 수정되며 새로운 SQL이 추가되기도 함.

 

💡  ORM 프레임워크의 가장 큰 특징이자 장점은 DB 연동에 필요한 SQL을 자동으로 생성
  ✓  이렇게 생성된 SQL은 DBMS가 변경될때 자동으로 변경이 됨

        ➡️  다만 ORM 환경 설정 파일에서 DBMS가 변경된 것을 알려주어야 함

 

ORM ; Object Relational Mapping
객체 관계 매핑
  • 자바와 같은 객체지향 언어에서 의미하는 객체와 RDB Relational Database 의 테이블을 자동으로 매핑하는 방법
  • 클래스는 데이터베이스의 테이블과 매핑하기 위해 만들어진 것이 아니기 때문에 RDB 테이블과의 불일치가 존재
  • ORM이 이 둘의 불일치와 제약사항을 해결하는 역할. ORM을 이용하면 쿼리문 작성이 아닌 코드(메서드)로 데이터를 조작할 수 있음

 

JPA ; Java Persistence API
  • 자바 진영의 ORM 기술 표준으로 채택된 인터페이스 모음
  • ORM이 큰 개념이라면 JPA는 더 구체화된 스펙을 포함. 즉, JPA 또한 실제로 동작하는 것이 아니고 어떻게 동작해야 하는지 매커니즘을 정리한 표준 명세
  • JPA를 구현한 대표적인 구현체로 Hibernate, EclipseLink, DataNucleus, OpenJpa 등이 있음
  • JPA 인터페이스를 구현한 가장 대표적인 오픈소스가 Hibernate이고 실질적인 기능은 하이버네이트에 구현되어 있음

 

Spring Data JPA
  • JPA를 편리하게 사용할 수 있도록 지원하는 스프링 하위 프로젝트 중 하나
  • CRUD 처리에 필요한 인터페이스를 제공하며, 하이버네이트의 엔티티 매니저를 직접 다루지 않고 repository를 정의해 사용함으로써 스프링에 적합한 쿼리를 동적으로 생성하는 방식으로 데이터베이스를 조작
  • 이를 통해 하이버네이트에서 자주 사용되는 기능을 더 쉽게 사용할 수 있게 구현한 라이브러리

 

JPA 사용 시 장점


    A.  특정 데이터베이스에 종속되지 않음


        ✓  오라클을 MariaDB로 변경한다면 데이터베이스마다 쿼리문이 다르기 때문에 전체를 수정해야 함. 따라서 처음 선택한 데이터베이스를 변경하기 어려움. 하지만 JPA는 추상화한 데이터 접근 계층을 제공함. 설정 파일에 어떤 데이터베이스를 사용하는지 알려주면 얼마든지 데이터베이스를 변경할 수 있음

    B.  객체지향적 프로그램

      ✓  데이터베이스 설계 중심의 패러다임에서 객체지향적으로 설계가 가능. 이를 통해 좀 더 직관적이고 비지니스 로직에 집중


    C.  생산성 향상

 

      ✓  데이터베이스 테이블에 새로운 컬럼이 추가 되었을 경우, 해당 테이블의 컬럼을 사용하는 DTO 클래스의 필드도 모두 변경해야 함.
      ✓  JPA에서는 테이블과 매핑된 클래스에 필드만 추가한다면 쉽게 관리가 가능. 또한 SQL문을 직접 작성하지 않고 객체를 이용하여 동작하기 때문에 유지보수 측면에서 좋고 재사용성도 증가.

 

JPA 사용 시 단점


    A.  복잡한 쿼리 처리


      ✓  통계 처리 같은 복잡한 쿼리를 사용할 경우는 SQL문을 사용하는 것이 나을 수 있음
      ✓  JPA에서는 Native SQL을 통해 기존의 SQL문을 사용할 수 있지만 그러면 특정 데이터베이스에 종속된다는 단점이 생김. 이를 보완하기 위해서 SQL과 유사한 기술인 JPQL, Querydsl을 지원.

    B.  성능 저하 위험


      ✓  객체 간의 매핑 설계가 잘못했을 때 성능 저하가 발생할 수 있으며, 자동으로 생성되는 쿼리가 많기 때문에 개발자가 의도하지 않은 쿼리로 인해 성능이 저하되기도 함.

    C.  학습 시간


      ✓  JPA를 제대로 사용하려면 알아야 할 것이 많아서 학습하는데 시간이 오래 걸림.


 

2)  엔티티

엔티티 Entity 란 데이터베이스의 테이블에 대응하는 클래스. @Entity가 붙은 클래스는 JPA에서 관리하며 엔티티라고 함.
클래스 자체나 생성한 인스턴스도 엔티티라 부름.

 

엔티티 매니저 팩토리  Entity Manager Factory
  • 엔티티 매니저 인스턴스를 관리하는 주체
  • 애플리케이션 실행 시 한 개만 만들어지며 사용자로 부터 요청이 오면 엔티티 매니저 팩토리로 부터 엔티티 매니저를 생성.
엔티티 매니저  Entity Manager
  • 영속성 컨텍스트에 접근하여 엔티티에 대한 데이터베이스 작업을 제공
  • 내부적으로 데이터베이스 커넥션을 사용해서 데이터베이스에 접근
영속성 컨텍스트  Persistence Context
  • 엔티티를 영구 저장하는 환경으로 엔티티 매니저를 통해 영속성 컨텍스트에 접근

 


2.  Board 엔티티와 JpaRepository

JPA를 이용하는 개발의 핵심은 객체지향을 통해서 영속 계층을 처리하는데 있음. 따라서 JPA를 이용할 때는 테이블과 SQL을 다루는 것이 아니라 데이터에 해당하는 객체를 엔티티 객체로 다루고 JPA로 이를 데이터베이스와 연동해서 관리. 엔티티 객체는 쉽게 말해서 PK 기본키를 가지는 자바의 객체. 엔티티 객체는 고유의 식별을 위해 @Id를 이용해서 객체를 구분하고 관리

Spring Data JPA는 엔티티 객체를 이용해서 JPA를 이용하는데 더욱 편리한 방법들을 제공하는 스프링 관련 라이브러리. 자동으로 객체를 생성하고 이를 통해서 예외 처리 등을 자동으로 처리하는데 이를 위해서 제공되는 인터페이스가 JpaRepository.

💡  개발의 첫 단계은 엔티티 객체를 생성하기 위한 엔티티 클래스를 정의하는 것

 

프로젝트에 domain 패키지를 구성하고 게시물을 위한 Board 엔티티를 작성
@Entity
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bno;

    private String title;

    private String content;

    private String writer;
}

 

  🥸  엔티티 객체를 위한 엔티티 클래스는 반드시 @Entity를 적용해야 하고 @Id가 필요
  🥸  게시물은 데이터베이스에 추가될 때 생성되는 번호 auto increment 를 이용할 것이므로 이런 경우에 '키 생성 전략 key generate strategy 중에 GenerationType.IDENTITY로 데이터베이스에서 알아서 결정하는 방식을 이용

  🚀  GenerationType.IDENTITY : 데이터베이스에 위임(MYSQL / MariaDB) - auto_increment


1) @MappedSuperclass를 이용한 공통 속성 처리

  👾  데이터베이스의 거의 모든 테이블에는 데이터가 추가된 시간이나 수정된 시간 등의 컬럼을 작성
  👾  자바에서는 이를 쉽게 처리하고자 @MappedSuperclass를 이용해서 공통으로 사용되는 칼럼들을 지정하고 해당 클래스를 상속해서 이를 손쉽게 처리.

 

프로젝트의 domain 패키지에 BaseEntity 클래스를 추가
@MappedSuperclass
@EntityListeners(value = {AuditingEntityListener.class})
@Getter
public class BaseEntity {
    @CreatedDate
    @Column(name = "regdate", updatable = false)
    private LocalDateTime regDate;

    @LastModifiedDate
    @Column(name = "moddate")
    private LocalDateTime modDate;

}

 

  🥸  BaseEntity에서 가장 중요한 부분은 자동으로 Spring Data JPA의 AuditingEntityListener를 지정하는 부분

  🥸  AuditingEntityListener는 리스너로 이를 적용하면 엔티티가 데이터베이스에 추가되거나 변경될 때 자동으로 시간 값을 지정

 

AuditingEntityListener를 활성화 시키기 위해서는 프로젝트 설정에 @EnableJpaAuditing을 추가
@SpringBootApplication
@EnableJpaAuditing
public class SpringbootApplication {
	public static void main(String[] args) {
		SpringApplication.run(SpringbootApplication.class, args);
	}
}

 

기존의 Board 클래스는 BaseEntity를 상속하도록 변경하고 추가적인 어노테이션들을 적용
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class Board extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bno;

    @Column(length = 500, nullable = false) // 컬럼의 길이와 null 허용 여부
    private String title;

    @Column(length = 2000, nullable = false)
    private String content;

    @Column(length = 50, nullable = false)
    private String writer;
    
}

 


2)  JpaRepository 인터페이스

  👾  Spring Data JPA 를 이용할 때는 JpaRepository라는 인터페이스 선언만으로 데이터베이스 관련 작업을 어느 정도 처리할 수 있음
  👾  개발 단계에서 JpaRepository 인터페이스를 상속하는 인터페이스를 선언하는 것 만으로 CRUD외 페이징 처리가 모두 완료

 

repository 패키지를 생성하고 BoardRepository 인터페이스를 선언
public interface BoardRepository extends JpaRepository<Board, Long> {}

 

  🥸  JpaRepositoy 인터페이스를 상속할 때에는 엔티티 타입과 @Id 타입을 지정해 주어야 하는 점을 제외하면 아무런 코드가 없이도 개발 가능

 


3)  테스트 코드를 통한 CRUD / 페이징 처리 확인

  👾  Spring Data JPA를 이용하면 SQL의 개발도 거의 없고, JPA의 많은 기능을 활용 할 수 있지만 항상 테스트 코드로 동작 여부를 확인하는 것이 좋음

 

프로젝트에 test에 repository 패키지를 추가하고 BoardRepositoryTests 클래스를 추가
@SpringBootTest
@Log4j2
public class BoardRepositoryTests {
    @Autowired
    private BoardRepository boardRepository;
}

 

insert 기능 테스트


  ✓  데이터베이스에 insert를 실행하는 기능은 JpaRepository의 save()를 통해서 이루어짐
  ✓  save()는 현재의 영속 컨텍스트 내에 데이터가 존재하는지 찾아보고 해당 엔티티객체가 없을 때는 insert를, 존재할 때는 update을 자동으로 실행

    @Test
    public void testInsert() {
        for (int i = 1; i <= 100; i++) {
            Board board = Board.builder()
                    .title("title...")
                    .content("content..." + i)
                    .writer("user" + (i % 10))
                    .build();
            Board result = boardRepository.save(board);
            log.info("BNO: " + result.getBno());
        }
    }

 

 

  📍  save()의 결과는 데이터베이스에 저장된 데이터와 동기화된 Board 객체가 반환
  📍  최종적으로 테스트 실행 후에 데이터베이스를 조회해 보면 100개의 데이터가 생성된 것을 확인
  📍  테스트 코드의 경우 @Id 값이 null 이므로 insert만 실행. (update와 비교)

 

 

 

 

 


 

select 기능 테스트


  ✓  특정한 번호의 게시물을 조회하는 기능은 findById()를 이용해서 처리. findById()의 리턴 타입은 Optional<T>

    @Test
    public void testSelect() {
        Long bno = 100L; // 조회하고자 하는 bno 번호
        Optional<Board> result = boardRepository.findById(bno);
        Board board = result.orElseThrow();
        log.info(board);
    }


 

update 기능 테스트


  ✓  update 기능은 insert와 동일하게 save()를 통해서 처리. 동일한 @Id 값을 가지는 개체를 생성해서 처리가능.
  ✓  update는 등록 시간이 필요하므로 가능하면 findById()로 가져온 객체를 이용하여 약간의 수정을 통해서 처리하도록 함
  ✓  일반적으로 엔티티 객체(Board 클래스)는 가능하면 최소한의 변경이나 변경이 없는 immutable로 설계하는 것이 좋지만, 강제적인 사항은 아니므로 Board 클래스에 수정이 가능한 부분을 미리 메서드로 설계

  📍  Board의 경우 '제목 / 내용'은 수정이 가능하므로 이에 맞도록 change() 라는 메서드를 추가

public void change(String title, String content) {
    this.title = title;
    this.content = content;
}

 

  📍  테스트 코드에서는 이를 활용하여 update를 실행하는 테스트 코드를 작성

@Test
public void testUpdate() {
    Long bno = 100L;
    Optional<Board> result = boardRepository.findById(bno);
    Board board = result.orElseThrow();

    board.change("update... title 100", "update content 100");
    boardRepository.save(board);
}

 

findById 실행 ▶️ save()실행 시 다시 검사 ▶️ update 실행

 

  ⚡️  update가 실행된 후에 moddate가 변경된 것 확인
  ⚡️  변경할 부분을 sql에서 set로 지정하는 것이 아니라, 객체를 영속성 콘텍스트에 올리면 @Id와 동일한 데이터를 찾아 매핑을 하는 개념이어서 수정할 부분만 엔티티 객체에 넣으면 안되고, 데이터베이스에 저장되어야 하는 데이터와 동일한 엔티티 객체가 필요함

 

  📍  JDBC나 MyBatis 방식으로 처리하면 에러

   @Test
    public void testUpdate2() {

        Long bno = 100L;
        Board board = Board.builder()
                .bno(bno)
                .title("title...")
                .content("content...update3")
                .build();
        boardRepository.save(board);
    }

 

  📍  없는 @Id 값을 지정하면 update가 아니라 insert가 실행

   @Test
    public void testUpdate3() {

        Long bno = 10000L;
        Board board = Board.builder()
                .bno(bno)
                .title("title...")
                .content("content...update")
                .build();
        boardRepository.save(board);
    }


 

delete 기능 테스트


  ✓ delete는 @Id에 해당하는 값으로 deleteById()를 통해서 실행

  ✓  deleteById() 역시 데이터베이스의 내부에 같은 @Id가 존재하는지 먼저 확인하고 delete문이 실행

@Test
public void testDelete() {
    Long bno = 1L;
    boardRepository.deleteById(bno);
}

 


 

수정이나 삭제 시에 굳이 select문이 먼저 실행되는 이유는 JPA의 동작 방식과 관련. JPA를 이용한다는 것은 엄밀하게 말하면 영속 컨텍스트와 데이터베이스를 동기화해서 관리한다는 의미. 그러므로 특정한 엔티티 객체가 추가되면 영속 컨텍스트에 추가하고, 데이터베이스와 동기화가 이루어짐. 마찬가지로 수정이나 삭제를 한다면 영속 컨텍스트에 해당 엔티티 객체가 존재해야만 하므로 먼저 select로 엔티티 객체를 영속 컨텍스트에 저장해서 이를 삭제한 후에 delete가 이루어짐.


4)  Pageable과 Page<E> 타입

  👾  Spring Data JPA를 이용해서 별도의 코드 없이도 CRUD가 실행되지만, 페이징 처리도 가능
  👾  페이징 처리는 Pageable이라는 타입의 객체를 구성해서 파라미터로 전달하면 됨.
  👾  Pageable은 인터페이스로 설계되어 있고, 일반적으로는 PageRequest.of()라는 기능을 이용해서 개발이 가능.

PageRequest.of(페이지 번호, 사이즈) : 페이지 번호은 0부터
PageRequest.of(페이지 번호, 사이즈, Sort) : 정렬 조건 추가
PageRequest.of(페이지 번호, 사이즈, Sort.Direction, 속성...) : 정렬 방향과 여러 속성 지정

 

  ⚡️  파라미터로 Pageable을 이용하면 리턴 타입은 Page<T> 타입을 이용할 수 있는데 이는 단순 목록뿐 아니라 페이징 처리에 데이터가 많은 경우에는 count 처리를 자동으로 실행
  ⚡️  대부분의 Pageable 파라미터는 메서드 마지막에 사용하고, 파라미터에 Pageable이 있는 경우에는 메서드의 리턴 타입을 Page<T> 타입으로 설계.

 

JpaRepository에는 findAll()이라는 기능을 제공하여 기본적인 페이징 처리를 지원
@Test
public void testPaging() {
    // 1 page order by bno desc
    Pageable pageable = PageRequest.of(0, 10, Sort.by("bno").descending());
    Page<Board> result = boardRepository.findAll(pageable);
}

 

 

  ✓  findAll()의 리턴 타입으로 나오는 Page<T> 타입은 내부적으로 페이징 처리에 필요한 여러 정보를 처리
  ✓  예를 들어 다음 페이지가 존재하는지, 이전 페이지가 존재하는지, 전체 데이터의 개수는 몇 개인지 등의 기능들을 모두 알아낼수 있음

    @Test
    public void testPaging() {
        // 1 page order by bno desc
        Pageable pageable = PageRequest.of(0, 10, Sort.by("bno").descending());
        Page<Board> result = boardRepository.findAll(pageable);

        log.info("total count : " + result.getTotalElements());
        log.info("total page : " + result.getTotalPages()); 
        log.info("page number : " + result.getNumber());
        log.info("page size : " + result.getSize());

        // prev next
        log.info(result.hasPrevious() + ": " + result.hasNext());

        List<Board> boardList = result.getContent();

        boardList.forEach(board -> log.info(board));
    }

 


5)  쿼리 메서드와 @Query

  👾  간단한 CRUD는 JpaRepository를 이용하면 되지만 다양한 검색 조건이 들어가게 되면 추가 기능이 필요하게 됨
  👾  예를 들어 특정한 범위의 Memo 객체를 검색하거나, like 처리가 필요한 경우, 여러 검색 조건이 필요한 경우 등

💡  Spring Data JPA의 경우에는 이러한 처리를 위해 다음과 같은 방법을 제공
    ·  쿼리 메서드 : 메서드의 이름 자체가 쿼리의 구문으로 처리되는 기능.
    ·  @Query : SQL과 유사하게 엔티티 클래스의 정보를 이용해서 쿼리를 작성하는 기능. JPQL.
    ·  Querydsl 등의 동적 쿼리 기능


  ⚡️  쿼리 메서드는 보통 SQL에서 사용하는 키워드와 컬럼들을 같이 결합해서 구성하면 그 자체가 JPA에서 사용하는 쿼리가 되는 기능
        -  일반적으로는 메서드 이름을 'findBy...' 혹은 'get...'으로 시작하고 칼럼명과 키워드를 결합하는 방식으로 구성
            (각 키워드 사용법은 https://spring.io/projects/spring-data-jpa 참고)
        -  인텔리제이 얼티메이트에서는 자동완성 기능으로 쿼리 메서드를 작성할 수 있는 기능을 지원
        -  쿼리 메서드는 실제로 사용하려면 상당히 길고 복잡한 메서드를 작성하게 되는 경우가 많음
             ➡️  예를 들어 '제목'에 특정한 '키워드'가 존재하는 게시글들을 bno의 역순으로 정렬해서 가져오고 싶다면 아래의 메소드 이름이 생성

          ✓  쿼리 메서드는 주로 단순한 쿼리를 작성할 때 사용하고 실제 개발에서는 많이 사용되지 않음

Page<Board> findByTitleContainingOrderByBnoDesc(String keyword, Pageable pageable);


    
  ⚡️  @Query로 JPQL을 이용
      -  @Query 어노테이션의 value로 작성하는 문자열을 JPQL이라고 하는데 JPQL은 SQL과 유사하게 JPA에서 사용하는 쿼리 언어 query language 라고 생각하면 됨
      -  JPA는 데이터베이스에 독립적으로 개발이 가능하므로 특정한 데이터베이스에서만 동작하는 SQL 대신에 JPA에 맞게 사용하는 JPQL을 이용. JPQL은 테이블 대신에 엔티티 타입을 이용하고 컬럼 대신에 엔티티의 속성으로 이용해서 작성. JPQL은 SQL을 대신하는 용도로 사용하기 때문에 SQL에 존재하는 여러 키워드나 기능들이 거의 유사하게 제공.

 

  📍 앞선 쿼리 메서드에 @Query를 이용한다면 다음과 같이 작성

@Query("select b from Board b where b.title like concat('%', :keyword, '%')")
Page<Board> findKeyword(String keyword, Pageable pageable);

 

  ✓  작성된 JPQL을 보면 SQL과 상당히 유사하다는 것을 알 수 있음
  ✓  @Query를 이용하면 크게 쿼리 메서드가 할 수 없는 몇 가지 기능을 할 수 있음
        * 조인과 같이 복잡한 쿼리를 실행할 수 있는 기능
        * 원하는 속성들만 추출해서 Object[]로 처리하거나 DTO로 처리하는 기능
        * nativeQuery 속성값을 true로 지정해서 특정 데이터베이스에서 동작하는 SQL을 사용하는 기능


  📍  native 속성을 지정하는 예제는 다음과 같이 작성 가능

@Query(value="select now()", nativeQuery=true)
String getTime();

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  Room: ORM 라이브러리

ORM Object-Relational Mapping은 객체 Class와 관계형 데이터베이스의 데이터 Table을 매핑하고 변환하는 기술로 복잡한 쿼리를 잘 몰라도 코드만으로 데이터베이스의 모든 것을 컨트롤할 수 있도록 도와줌

  💡  안드로이드는 SQLite를 코드 관점에서 접근할 수 있도록 ORM 라이브러리인 Room을 제공


1) Room 추가하기


  ✏️  Room 프로젝트를 하나 새로 생성. SQLite 프로젝트에서 몇 개의 화면과 액티비티는 복사해서 사용

build.gradle 파일을 열고 android 블록에 viewBinding 설정 추가
viewBinding {
    enabled = true
}

 

dependencies 블록 앞부분에 다음과 같이 Room을 추가

 

  Room은 빠른 처리 속도를 위해서 어노테이션 프로세서 annotation processor를 사용하는데, 코틀린에서는 이것을 대신해서 kapt를 사용. kapt를 사용하기 위해서는 파일 상단에 kapt 플러그인을 추가 (큰 따옴표를 사용)

plugins {
    id 'com.android.application'
    id 'org.jetbrains.kotlin.android'
    id "org.jetbrains.kotlin.kapt" version "1.6.21"
}
dependencies {

    def room_version = "2.4.0"

    implementation("androidx.room:room-runtime:$room_version")
    annotationProcessor("androidx.room:room-compiler:$room_version")
    kapt("androidx.room:room-compiler:$room_version")
    implementation("androidx.room:room-ktx:$room_version")

 

💡  kapt란?
     "자바 6부터 도입된 Pluggable Annotation Processing API (JSR 269)를 Kotlin에서도 사용 가능하게 하는 것입니다."[안드로이드 공식문서]


  ✓  어노테이션 프로세싱이란 우리가 간단하게 '@명령어'처럼 사용하는 주석형태의 문자열을 실제 코드로 생성해주는 것
  ✓  @로 시작하는 명령어를 어노테이션이라고 하는데, 어노테이션이 컴파일 시에 코드로 생성되기 때문에 실행 시에 발생할 수 있는 성능 문제가 많이 개선됨
  ✓  Room을 사용하면 클래스명이나 변수명 위에 @어노테이션을 사용해서 코드로 변환할 수 있음

 


2) RoomMemo 클래스 정의하기

 

A. 먼저 SQLite 프로젝트에서 사용한 파일 중에서 java 패키지 아래에 있는 MainActivity, RecyclerAdapter를 복사해서 이 프로젝트에 붙여넣기 함

  • 동일한 파일인 MainActivity가 이미 있기 때문에 붙여넣기 여부를 묻는 팝업창에 [Overwrite]를 클릭
  • 붙여넣기를 한 ActivityMain을 열어보면 패키지 명과 네 번째 import인 ActivityMainBinding의 경로가 다름 두 군데만 수정
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.recyclerview.widget.LinearLayoutManager
import kr.somin.room_0521.databinding.ActivityMainBinding

B.  [res] - [layout] 밑에 있는 activity_main.xml과 item_recycler.xml도 복사해서 붙여넣기


C.  패키지 이름에서 [New] - [Kotlin File/Class]를 선택해서 RoomMemo로 클래스를 생성

@Entity(tableName = "room_memo")
class RoomMemo {

    constructor(content: String, datetime: Long) {
        this.content = content
        this.datetime = datetime
    }

    @PrimaryKey(autoGenerate = true)
    @ColumnInfo
    var num: Long? = null

    @ColumnInfo
    var content: String = ""

    @ColumnInfo(name = "date")
    var datetime: Long = 0

}

 

@Entity(tableName = "room_memo")
class RoomMemo  { ... }

 

  ✓  @Entity 어노테이션을 class RoomMemo 위에 작성. Room 라이브러리는 @Entity 어노테이션이 적용된 클래스를 찾아 테이블로 변환
  ✓  데이터베이스에서 테이블명을 클래스명과 다르게 하고 싶을 때는 @Entity(tableName = "테이블명")과 같이 작성하면 됨

 

@ColumnInfo
var num: Long? = null

@ColumnInfo
var content: String = ""

@ColumnInfo(name = "date")
var datetime: Long = 0

 

  ✓  멤버 변수 num, content, date 3개를 선언하고 변수명 위에 @ColumnInfo 어노테이션을 작성해서 테이블의 컬럼으로 사용된다는 것을 명시. 컬럼명도 테이블명처럼 변수명과 다르게 하고 싶을 때는 @ColumnInfo(name = "컬럼명")과 같이 작성하면 됨.

 

@PrimaryKey(autoGenerate = true)
@ColumnInfo
var num: Long? = null

 

  ✓  num 변수에 @PrimaryKey 어노테이션을 사용해서 키(Key)라는 점을 명시하고 자동증가 옵션을 추가

 

constructor(content: String, datetime: Long) {
    this.content = content
    this.datetime = datetime
}

 

  ✓  content외 datetime을 받는 생성자를 작성

 


3)  RoomMemoDAO 인터페이스 정의하기


Room은 데이터베이스에 읽고 쓰는 메서드를 인터페이스 형태로 설계하고 사용. 코드 없이 이름만 명시하는 형태로 인터페이스를 만들면 Room이 나머지 코드를 자동 생성함

💡  DAO란?
      Data Access Object의 약어로 데이터베이스에 접근해서 DML 쿼리 (SELECT, INSERT, UPDATE, DELETE)를 실행하는 메서드의 모음

 

[app] - [java] 밑의 패키지 아래에 RoomMemoDao 인터페이스를 생성

 

인터페이스 상단에 @Dao 어노테이션을 작성하고 Dao라는 것을 명시
@Dao
interface RoomMemoDao { ... }

 

조회, 삽입, 수정, 삭제에 해당하는 3개의 메서드를 만들고 각각의 어노테이션 을 붙여줌
@Dao
interface RoomMemoDao {
    // 다른 ORM 툴과는 다르게 조회를 하는 select 쿼리는 직접 작성하도록 설계.
    // 대부분의 ORM은 select도 메서드로 제공

    @Query("SELECT * FROM room_memo")
    fun getAll() : List<RoomMemo>

    @Insert(onConflict = REPLACE)
    fun insert(memo: RoomMemo)
}

 

  ✓  두 번째 @Insert 어노테이션의 경우 옵션으로 onConflict = REPLACE를 적용하면 동일한 키를 가진 값이 입력되었을 때 UPDATE 쿼리로 실행이 됨

 

어노테이션의 종류
어노테이션 위치 옵션 설명
@Database 클래스 entities, version 데이터베이스
@Entity 클래스 (tableName = "테이블명") 테이블
@ColumnInfo 멤버변수 (name = "컬럼명") 컬럼
@PrimaryKey 멤버변수 (autoGenerate = true) 컬럼 옵션
@Dao 인터페이스   실행 메서드 인터페이스
@Query 멤버 메서드 ("쿼리") 쿼리를 직접 작성하고 실행
@Insert 멤버 메서드 (onConflict = REPLACE) 중복 시 수정
@Delete 멤버 메서드   삭제

 


4)  RoomHelper 클래스 정의하기


SQLiteOpenHelper를 상속받아서 구현했던 것처럼 Room도 유사한 구조로 사용할 수 있음. Room은 RoomDatabase를 제공하는데 RoomDatabase를 상속받아 클래스를 생성하면 됨
  📍  주의할 점은 추상 클래스로 생성해야 함. 기존 클래스와 동일하게 생성하고 class 앞에 abstract 키워드를 붙이면 추상 클래스가 됨.

 

[app] - [java] 밑의 패키지 아래에 RoomHelper 클래스를 생성하고 앞에 abstract 키워드를 붙여서 추상 클래스를 만듦
이 클래스는 RoomDatabase를 상속받음.
abstract class RoomHelper: RoomdDatabase() {}

 

클래스명 위에 @Database 어노테이션을 작성
@Database(entities = arrayOf(RoomMemo::class), version = 1, exportSchema = false)

 

  📌  @Database 어노테이션 속성

옵션 설명
entities Room 라이브러리가 사용할 엔티티(테이블) 클래스 목록
version 데이터베이스의 버전
exportSchema true면 스키마 정보를 파일로 출력

 

RoomHelper 클래스 안에 앞에서 정의한 RoomMemoDao 인터페이스의 구현체를 사용할 수 있는 메서드명을 정의
@Database(entities = arrayOf(RoomMemo::class), version = 1, exportSchema = false)
abstract class RoomHelper: RoomDatabase() {
    abstract fun roomMemoDao(): RoomMemoDao
}

 

  ✓  빈 껍데기 코드만 작성해 두는 것만으로 Room 라이브러리를 통해서 미리 만들어져 있는 코드를 사용할 수 있게 됨

 

 


5)  어댑터에서 사용하는 Memo 클래스를 RoomMemo 클래스로 변경

 

  ·  Memo 문자열을 모두 RoomMemo로 수정

  ·  helper 변수가 선언된 부분을 RoomHelper를 사용할 수 있도록 수정 

        ➡️  var helper: SQLiteHelper? = null ▶️ var helper: RoomHelper? = null

  ·  buttonDelete 클릭리스너에 있는 deleteMemo() 메서드를 RoomHelper의 메서드로 수정. RoomHelper를 사용할 때는 여러 개의 Dao가 있을 수 있기 때문에 '헬퍼.Dao().메서드()'형태로 어떤 Dao를 쓸 것인지 명시해야 함.

       ➡️  helper?.deleteMemo(mMemo!!) ▶️ helper?.roomMemoDao()?.delete(mRoomMemo!!)


6)  MainActivity에서 RoomHelper 사용

 

MainActivity.kt 파일을 열고 앞에서 작성한 SQLiteHelper를 RoomHelper로 교체

 

MainActivity 맨 윗줄에 정의된 helper 변수를 RoomHelper를 사용할 수 있도록 코드를 수정
private var helper: RoomHelper? = null

 

onCreate()의 setContentView 바로 아래줄에 helper를 생성하는 부분을 추가
setContentView(binding.root)
helper = Room.databaseBuilder(this, RoomHelper::class.java, "room_memo")
    .allowMainThreadQueries()
    .build()


  ✓  databaseBuilder() 메서드의 세 번째 파라미터가 실제 생성되는 DB 파일의 이름.

  ✓  Room은 기본적으로 서브 스레드에서 동작하도록 설계되어 있기 때문에 allowMainThreadQueries() 옵션이 적용되지않으면 앱이 동작을 멈춤.

 

어댑터의 데이터 목록에 셋팅하는 코드를 RoomHelper를 사용하는 것으로 수정
adapter.listData.addAll(helper?.roomMemoDao()?.getAll()?: listOf())

 

  ✓  helper에 null이 허용되므로 helper 안의 코드를 사용하기 위해서는 helper?.의 형태로 사용. 이어지는 roomMemoDao()?.도 같은 맥락이고, adapter의 listData에 null이 허용되지 않기 때문에 마지막에 ?:(Elvis Operator)를 사용해서 앞의 2개가 null일 경우 사용하기 위한 디폴트 값을 설정

 

저장 버튼을 클릭 시 사용하는 코드도 RoomHelper로 바꿔줌
binding.buttonSave.setOnClickListener {
    if (binding.editMemo.text.toString().isNotEmpty()) {
        val memo = RoomMemo(binding.editMemo.text.toString(), System.currentTimeMillis())
        helper?.roomMemoDao()?.insert(memo)
        adapter.listData.clear()

        adapter.listData.addAll(helper?.roomMemoDao()?.getAll()?: listOf())
        adapter.notifyDataSetChanged()
        binding.editMemo.setText("")
     }
}

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  화면 만들기


1) activity_main.xml 편집하기

 

  📍  파일의 [Design] 모드에서 기존의 텍스트뷰는 지우고 팔레트의 컨테이너 카테고리에서 리사이클뷰를 선택해 화면 상단에 배치

  •  id 속성에 'recyclerMemo'를 입력

  📍  팔레트의 텍스트 카테고리에서 플레인텍스트를 드래그해서 화면 하단에 배치

  •  id 속성은 'editMemo', text 속성은 지우고, hint 속성을 '메모를 입력하세요'라고 수정
  • 여러 줄을 입력할 수 있어야 하므로 inputType 속성의 깃발를 눌러 textMultiLine을 'true'로 변경
  • textPersonName도 그대로 체크 상태로 두고 [Apply] 클릭

  📍  팔레트의 버튼 카테고리에서 버튼을 하나 드래그해서 우측 하단에 가져다 둠

  •  id 속성에는 'buttonSave', text 속성에는 '저장'이라고 입력

 


 

2)  item_recycler.xml 추가하기

 

리사이클러뷰의 아이템 용도로 사용할 item_recycler.xml 레이아웃 파일을 생성해서 편집

 

  📍  [app] - [res] - [layout] 디렉토리에서 새 리소스 파일을 생성

 

  📍  레이아웃 파일이 생성되면 [Design]모드에서 컴포넌트 트리의 최상위 컨스트레이트 레이아웃을 클릭

  • layout_height 속성을 '100dp'로 수정해서 아이템의 높이를 미리 정함

  📍  번호와 메모의 내용을 표시할 텍스트뷰 2개와 날짜를 표시할 텍스트뷰을 하나 배치

  • 번호를 표시할 텍스트뷰 : id - textNo, text = 01
  • 내용을 표시할 텍스트뷰 : id - textContent, maxLines - 2, ellipsize - end, gravity - center_vertical, text - 메모 내용 표시
  • 날짜를 표시할 텍스트뷰 : id - textDatetime, text - 2024/01/01 12:12
  • maxLines속성이 2인데, 두 줄을 넘어가면 말줄임표(...)가 나오도록 하는 속성

 


2.  소스 코드 연결하기

레이아웃과 소스코드를 연결. 코드에서 binding을 사용하므로 build.gradle 파일에 viewBinding 설정을 추가.

viewBinding {
    enabled = true
}

 


1)  RecyclerAdapter 클래스 만들기


Memo 클래스를 데이터로 사용하는 RecyclerAdapter 클래스를 정의

[app] - [java] 밑에 있는 패키지에 RecyclerAdapter라는 이름의 클래스를 생성

class RecyclerAdapter : RecyclerView.Adapter<Holder>() {

    var listData = mutableListOf<Memo>()

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder {
        // 뷰 홀더가 만들어질 때 호출
        val binding = ItemRecyclerBinding.inflate(LayoutInflater.from(parent.context), parent, false)
        return Holder(binding)
    }

    override fun onBindViewHolder(holder: Holder, position: Int) {
        // 뷰 홀더가 재사용될 때 호출
        val memo = listData[position]
        holder.setMemo(memo)
    }

    override fun getItemCount(): Int { // 어댑터에서 관리하는 아이템의 개수
       return listData.size
    }
}

class Holder(val binding: ItemRecyclerBinding): RecyclerView.ViewHolder(binding.root) {
    fun setMemo(memo: Memo) {
        binding.textNo.text = "${memo.num}"
        binding.textContent.text = memo.content
        val sdf = SimpleDateFormat("yyyy/MM/dd hh:mm")
        binding.textDatetime.text = sdf.format(memo.datetime)
    }
}

 


2)  MainActivity에서 코드 조합하기

onCreate()
class MainActivity : AppCompatActivity() {
    private val binding by lazy { ActivityMainBinding.inflate(layoutInflater)}
    private val helper = SQLiteHelper(this, "memo", 1)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(binding.root)
        
        val adapter = RecyclerAdapter()
        adapter.listData.addAll(helper.selectMemo())

        binding.recyclerMemo.adapter = adapter
        binding.recyclerMemo.layoutManager = LinearLayoutManager(this)

    }
}

 

private val binding by lazy {ActivityMainBinding.inflate(layoutInflater) }
private val helper = SQLiteHelper(this, "memo", 1)

 

  👩🏻‍💻  클래스 코드 블럭 맨 윗줄에서 바인딩을 생성하고 binding 변수에 저장. 그리고 아랫줄에 SQLiteHelper를 생성하고 변수에 저장

 

setContentView(binding.root)
val adapter = RecyclerAdapter()

 

  👩🏻‍💻  onCreate()의 setContentView에 binding.root를 전달하고, 다음 줄에서 RecyclerAdapter를 생성

 

adapter.listData.addAll(helper.selectMemo())

 

  👩🏻‍💻  adapter의 listData에 데이터베이스에서 가져온 데이터를 세팅

 

binding.recyclerMemo.adapter = adapter
binding.recyclerMemo.layoutManager = LinearLayoutManager(this)

 

  👩🏻‍💻  화면의 리사이클러뷰 위젯에 adapter를 연결하고 레이아웃 매니저를 설정

 


저장 버튼에 클릭리스너
binding.buttonSave.setOnClickListener {
    if (binding.editMemo.text.toString().isNotEmpty()) {
        val memo = Memo(null, binding.editMemo.text.toString(), System.currentTimeMillis())
        helper.insertMemo(memo)
        adapter.listData.clear()

        adapter.listData.addAll(helper.selectMemo())
        adapter.notifyDataSetChanged()
        binding.editMemo.setText("")
    }
}

binding.buttonSave.setOnClickListener {
    if (binding.editMemo.text.toString().isNotEmpty()) {
        val memo = Memo(null, binding.editMemo.text.toString(), System.currentTimeMillis())
    }
}

 

  👩🏻‍💻  메모를 입력하는 플레인텍스트를 검사해서 값이 있으면 해당 내용으로 Memo 클래스를 생성

 

helper.insertMemo(memo)

 

  👩🏻‍💻  helper 클래스의 insertMemo() 메서드에 앞에서 생성한 Memo를 전달해 데이터베이스에 저장

 

adapter.listData.clear()

 

  👩🏻‍💻  어댑터의 데이터를 모두 초기화

 

adapter.listData.addAll(helper.selectMemo())
adapter.notifyDataSetChanged()

 

  👩🏻‍💻  데이터베이스에서 새로운 목록을 읽어와 어댑터에 세팅하고 갱신. 새로 생성되는 메모에는 번호가 자동으로 입력되므로 번호를 갱신하기 위해서 새로운 데이터를 세팅하는 것

 

binding.editMemo.setText("")

 

  👩🏻‍💻  메모 내용을 입력하는 위젯의 내용을 지워서 초기화

 


3.  삭제 버튼 추가하기

메모 목록에 삭제 버튼을 추가하여 메모를 삭제할 수 있도록 만듦


1) item_recycler.xml 파일을 열고 목록 아이템의 우측에 삭제 버튼을 배치

  • id : buttonDelete
  • text : 삭제

 


 

2) 메모를 삭제하려면 SQLite의 데이터와 어댑터에 있는 Memo 컬렉션의 데이터를 삭제


  ✓  SQLite의 데이터를 삭제하기 위해서 MainActivity.kt를 열고 클래스의 두 번째 줄에 생성해 둔 helper를 어댑터에 전달
  ✓  어댑터 생성 코드 바로 아랫줄에 작성하는데 어댑터에는 아직 helper 프로퍼티가없기 때문에 빨간색으로 나타남

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(binding.root)

    val adapter = RecyclerAdapter()
    adapter.helper = helper // 추가 코드

3) RecyclerAdapter.kt를 열고 클래스 블록 가장 윗줄에 helper 프로퍼티를 만듦

class RecyclerAdapter : RecyclerView.Adapter<Holder>() {
    var helper: SQLiteHelper? = null // 추가 코드
    val listData = mutableListOf<Memo>()

 


4) RecyclerAdapter.kt의 Holder 클래스에 init 블록을 만듦

 

  ✓  추가한 buttonDetele에 클릭리스너를 달아줌

class Holder(val binding: ItemRecyclerBinding) :RecyclerView.ViewHolder(binding.root) {
    init {
        binding.buttonDelete.setOnClickListener { }
    }

 


 

삭제 버튼을 클릭하면 어댑터의 helper와 listData에 접근해야 되는데, 지금은 어댑터 밖에 Holder 클래스가 있기 때문에 접근할 수 없음. Holder 클래스 전체를 어댑터 클래스 안으로 옮기고 class 앞에 inner 키워드를 붙여줌

  ➡️  Holder 클래스의 위치가 바뀌었기 때문에 RecyclerAdapter.Holder의 제네릭을 다시 import

inner class Holder(val binding: ItemRecyclerBinding: RecyclerView.ViewHolder(binding.root) {
    init {
        binding.buttonDelete.setOnClickListener {
    }
}

fun setMemo(memo: Memo) {
    binding.textNo.text = "${memo.num}"
    binding.textContent.text = memo.content
    val sdf = SimpleDateFormat("yyyy/MM/dd hh:mm")
    binding.textDatetime.text = sdf.format(memo.datetime)
}

 

class RecyclerAdapter : RecyclerView.Adapter<RecyclerAdapter.Holder> () { ... }

 


홀더는 한 화면에 그려지는 개수만큼 만든 후 재사용을 함으로 1번 메모가 있는 홀더를 스크롤해서 위로 올리면 아래에서 새로운 메모가 1번 홀더를 재사용하는 구조, 따라서 클릭하는 시에 어떤 데이터가 있는지 알야야 하므로 Holder 클래스의 init 위에 변수를 하나 선언하고 setMemo() 메서드로 넘어온 Memo를 임시로 저장

inner class Holder(val binding: ItemRecyclerBinding) :RecyclerView.ViewHolder(binding.root) {
    private var mMemo: Memo? = null
    init {
        binding.buttonDelete.setOnClickListener {}
    }

    fun setMemo(memo: Memo) {
        binding.textNo.text = "${memo.num}"
        binding.textContent.text = memo.content
        val sdf = SimpleDateFormat("yyyy/MM/dd hh:mm")
        binding.textDatetime.text = sdf.format(memo.datetime)
        this.mMemo = memo
    }
}

 


 

init 블록 안에 있는 buttonDelete의 클릭리스너 블록 안에서 SQLite의 데이터를 먼저 삭제하고, listData의 데이터도 삭제 후 어댑터를 갱신. deleteMemo()는 null을 허용하지 않는데, mMemo는 null을 허용하도록 설정되었기 때문에 !!를 사용해서 강제해야 함.

init {
    binding.buttonDelete.setOnClickListener {
        helper?.deleteMemo(mMemo!!)
        listData.remove(mMemo)
        notifyDataSetChanged()
    }
}

 


 

에뮬레이터에서 실행하고 테스트 : 메모 데이터 하나를 삭제한 후 앱을 껐다 켰을 때도 삭제되어 있으면 정상적으로 동작하는 것

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  Switch 문

🔅 단 하나의 조건식으로 많은 경우의 수를 처리할 수 있고 표현도 간결

수행 절차

 

  • 조건식을 계산한다.
  • 조건식의 결과와 일치하는 case문으로 이동
  • 이후의 문장들을 수행
  • break 문이나 switch문의 끝을 만나면 switch문 전체를 빠져나감
💡  POINT

     -  break문을 생락하면 case문 사이의 구분이 없어지므로 다른 break문을 만나거나 switch문 블럭 {} 끝을 만날 때까지 나오는 모든 문장들을 수행

    -  제약조건
   
   a.
조건식 결과는 정수 또는 문자열

       b. case문의 값은 정수 상수만 가능, 중복 X

    -  if문 처럼 중첩 가능

switch (조건식) {
    case 값1 :
        // 조건식의 결과가 값1과 같을 경우 수행될 문장들
        //...
        break;
    case 값2 :
        // 조건식의 결과가 값2과 같을 경우 수행될 문장들
        //...
        break;
    default :
        // 조건식의 결과와 일치하는 case 문이 없을 때 수행될 문장들
        //...
        break; // 생략가능.
}

 

조건식이 정수인 경우
public static void main(String[] args) {
    // 일년 동안 읽은 책 수에 따라 멘트를 출력합니다.
		
    int book = 5;
    switch (book/10) {
	    case 0 : // 10권 미만
		    System.out.println("조금 더 노력 하세요!");
		    break;
		case 1 : // 10이상 20권 미만
		    System.out.println("책 읽는 것을 즐기는 분이시네요!");
		    break;
		case 2 : // 20이상 30권 미만
		    System.out.println("책을 사랑하는 분이시네요!");
		    break;
		default : // 30권 이상
		    System.out.println("당신은 다독왕입니다!");
		    break; // 생략가능
    }
}

 

조건식이 문자열인 경우
public static void main(String[] args) {
		
    String medal = "Silver";
		
	switch(medal) {
	    case "Gold":
		    System.out.println("금메달 입니다.");
		    break;
		case "Silver":
		    System.out.println("은메달 입니다.");
		    break;
		case "Bronze":
		    System.out.println("동메달 입니다.");
		    break;
		default:
		    System.out.println("메달이 없습니다.");
		    break;
    }
}

 
​응용 예제
public static void main(String[] args) {
		
    Scanner scanner = new Scanner(System.in);
	System.out.print("월을 입력해 주세요 >>> ");
	int month = scanner.nextInt();
	int day;
		
	switch(month) {
//		case 1: case 3: case 5: case 7: case 8: case 10: case 12:
		case 1, 3, 5, 7, 8, 10, 12: // case가 여러가지인 경우 생략 가능.
			day = 31;
		    break;
		case 4: case 6: case 9: case 11:
			day = 30;
			break;
		case 2:
			day = 28;
			break;
		default:
			day = 0;
			System.out.println("존재하지 않는 달 입니다.");
	}
		
	System.out.println(month + " 월은 " + day + " 일까지 있습니다.");
	scanner.close();
}
 
public static void main(String[] args) {
    /* Java 12부터 개선된 switch 문을 지원 */
    Scanner scanner = new Scanner(System.in);
	    
	System.out.print("월을 입력해 주세요 >>> ");
	int month = scanner.nextInt();
	int day;
	    
	switch (month) {
	    
	    case 1, 3, 5, 7, 8, 10, 12 -> day = 31;
	    case 4, 6, 9, 11 -> day = 30;
	    case 2 -> day = 28;
	    default -> {
	    	day = 0;
	    	System.out.println("존재하지 않는 달입니다.");
	        }
	}
	    
	System.out.println(month + "월은 " + day + "일까지 있습니다.");
	scanner.close();
}

 

public static void main(String[] args) {
		/* 사용자에게 성적을 입력받아
		 * switch문을 사용해서 학점을 출력하는 코드를 완성하세요.
		 * 입력은 0 ~ 100까지 입력이 됩니다.
		 * 기준은 아래와 같습니다.
		 * A : 90 ~ 100
		 * B : 80 ~ 89
		 * C : 70 ~ 79
		 * D : 60 ~ 69
		 * F : 0 ~ 59 */
		
		Scanner scanner = new Scanner(System.in);
		int grade;
		String credit;
		
		System.out.print("성적을 입력하세요 >> ");
	    grade = scanner.nextInt();
	    
	    // 'int/int' 결과는 int 이기 때문에 '88/10'은 8.8이 아닌 8이 됨.
	    switch(grade/10*10) {
	    case 90,100: // 90 ~ 100
	        credit = "A";
	        break;
	    case 80:
	    	credit = "B";
	    	break;
	    case 70:
	    	credit = "C";
	    	break;
	    case 60:
	    	credit = "D";
	    	break;
	    default :
	    	credit = "F";
	    	break;	    	    	
	    }
	    
	    System.out.println("입력하신 성적은 " + grade + "점이고, 학점은 " + credit + "입니다.");
	    scanner.close();
		

	}
 


2. 반복문

💡 어떤 작업이 반복적으로 수행되도록 할 때 사용

💡 종류 ; for문, while문, do-while문

💡 조건식의 결과가 true이면 참, false면 거짓으로 간주

 

1) for문

 

✓ 반복 횟수를 알고 있을 때 적합

 

  👾  실수 사용 x 정수만 사용하기 ▶️ 계산이 부정확하기 때문

  👾  초기화, 조건식, 증감식 모두 생략 가능

            ▶️  조건식이 생략된 경우, 참으로 간주되어 무한 반복문이 된다.

            ▶️  대신 블럭{} 안에 if 문을 넣어서 특정 조건을 만족하면 for 문을 빠져 나오게 해야 한다.

 

for문의 구조
for (초기화; 조건식; 증감식) {
    // 조건식이 참일 때 수행될 문장들을 적는다.
}

 

for문 수행 순서

 

  📍  초기화 ▶️ 조건식 ▶️ 수행될 문장 ▶️ 증감식 ▶️ 조건식 ▶️ 수행될 문장 ▶️ 증감식 ...

public class MyFor_02 {
	public static void main(String[] args) {
		/* for문의 순서 */
		int sum = 0; // 총 합을 담을 변수. 초기화가 꼭 필요.
		// for (초기값; 조건식; 증감식)
		for (int i = 1; i > 0; i++) { //1부터 10까지의 합
			System.out.println("i = " + i + " sum = " + (sum += i));
			
			// 1) 초기값 실행
			// 2) 조건식 확인
			// 3) 조건식이 참이면 명령문 실행
			
			// 4) 증감식 실행
			// 5) 조건식 확인
			// 6) 조건식이 참이면 명령문 실행
			// 4) 5) 6) 반복
		}	
			// System.out.println(i); // i는 for문에서만 유효
		{ 
			int tmp = 12;
		    System.out.println(tmp);
		}    	
	}
}
 

    변수 i에 1을 저장한 다음, 매 반복마다 i의 값을 1씩 증가시킨다.

    그러다가 i의 값이 5를 넘으면 조건식 'i <= 5'가 거짓이 되어 반복을 마치게 됨

    i의 값이 1부터 5까지 1씩 증가 하니까 모두 5번 반복한다.

A. 초기화

· 반복문에 사용될 변수를 초기화하는 부분이며 처음에 한번만 수행.

· 둘 이상의 변수가 필요할 때 콤마 ',' 를 구분자로 변수를 초기화 (단, 두 변수의 타입은 같아야)

     ex. for (int i=1, j=0; i <= 10; i++) {...

 

B. 조건식

· 조건식의 값이 참이면 반복을 계속하고, 거짓이면 반복을 중단하고 for문을 벗어난다.

 

C. 증감식

· 반복문을 제어하는 변수의 값을 증가 또는 감소시키는 식.

· 콤마 ',' 를 이용해서 두 문장 이상을 하나로 연결해서 쓸 수 있다.

    ex. for (int i =1, j=10; i <= 10; i++, j--) {...

public class MyFor_03 {

	public static void main(String[] args) {
		/* for문 외부에 변수 선언 할 경우엔 i대신 다른 변수명 사용 */
		int sum = 0;
		int num;
		
		for(num = 0; sum < 100 ; num++) {
			sum += num; // sum = sum + num -> sum을 초기화 하지 않으면 오류가 남.
			System.out.println("num : " + num + " / sum : " + sum);
		}
		
		System.out.println("num : " + num);
		System.out.println("sum : " + sum);

	}
}

 

응용 예제 
public class Ex_02_04 {
	public static void main(String[] args) {
		/* 1부터 100사이의 정수 중에서 3또는 4의 배수의 합을 구하는 코드를 작성 */
		
		int sum = 0;
		for (int i = 1; i <= 100; i++) {
	        if ((i%3==0) || (i%4==0)) {
			sum += i;
	        }
		}
		System.out.println("3 또는 4의 배수의 합 : " + sum );
	}
}

 


 

2)  중첩 for문

 

💡 중첩의 횟수 제한 x.

💡 안쪽 for문의 모든 반복이 끝나고서야 바깥쪽 for문의 다음 반복으로 넘어간다.

 

별 출력
public class Ex_03_03 {
	public static void main(String[] args) {
		/* for문을 이용해서 다음과 같이 *를 출력하는 코드를 작성해보세요. 
		 * 
		 **
		 ***
		 ****
		 */
		
		for (int i=1; i<=4; i++) {
			
			for (int j=1; j<=i; j++) {
				System.out.print("*");
				
			}
			System.out.println();
		}
	}
}

 

구구단 출력
public class MyFor_11 {
	public static void main(String[] args) {
		/* 중첩 for문으로 구구단 출력
		 * for문은 또 다른 for문을 내포할 수 있는데, 이것을 중첩 for문이라고 함.
		 * 이 경우 바깥쪽 for문이 한 번 실핼할 때마다 중첩된 for문은 지정된 횟수만큼
		 * 반복해서 돌다가 다시 바깥쪽 for문이 돌아감.  */
		
		for(int dan = 2; dan <= 9; dan++) { // 바깥 쪽 for문. 8번 반복
			System.out.println("*** " + dan + "단 ***");
			
			for(int times = 1; times <= 9; times++) { // 중첩 for문. 9번 반복
			    System.out.println(dan + " X " + times + " = " + dan * times);
			}
			System.out.println(); // -> 각 단이 끝날 때 넣어줌.
		}
	}
}

 

주사위 게임
public class Ex_03_01 {
	public static void main(String[] args) {
		/* 2개의 주사위를 던지는 게임이 있다고 가정하자.
		 * 중첩 for문을 이용하여 2개의 주사위의 합이 6이 되는 경우를 출력하는 코드 작성.
		   (1,5) (2,4) (3,3) (4,2) (5,1) */
		
		for (int i = 1; i <= 6; i++) {
			
			for ( int j = 1; j <= 6; j++) {
				if (i+j == 6)
				System.out.println("(" + i + ", " + j + ")");
				// 변수 통상적으로 i 다음에 j 씀.
			}
		}
	}
}

 


 

3)  float 타입 카운터 변수

 

  ✓  for문을 작성할 때 주의할 점은 초기화 식에서 루프 카운터 변수를 선언할 때 부동 소수점을 쓰는 float 타입을 사용 x
         ➡️  0.1은 float 타입으로 정확하게 표현할 수 없기 때문에 x에 더해지는 값이 0.1보다 약간 커서, 루프는 9번 실행
         ➡️  float과 double은 계산이 부정확
  ✓  대안으로 정수로 변환 후 계산 결과 값을 실수로 변환 or 자바에서는 정확한 실수 계산을 위해 Decimal 클래스를 제공

public class MyFor_05 {
	public static void main(String[] args) {
		
		for (float x = 0.1f; x <= 1.0f; x += 0.1f) {
			System.out.println(x);
			/* 0.1
			 * 0.2
			 * 0.3
			 * 0.4
			 * 0.5
			 * 0.6
			 * 0.70000005
			 * 0.8000001
			 * 0.9000001 */
		}
	}
}

 


 

4)  for문 동작 시간 구하기


  📍  프로그램의 동작 시간을 구하는 방법은 프로그램 시작 위의 부분에서 시작시간을 구하고, 프로그램이 끝나는 부분에서 종료시간을 구한후 종료시간에서 시작시간을 빼면 프로그램이 동작한 시간을 구할 수 있음

public class MyFor_04 {
	public static void main(String[] args) {
		
	     long startTime = System.currentTimeMillis(); // 시작시간
	     for(int i = 0; i < 1000000000; i++) { // 10억번 반복
	    	 ; // 빈문장 실행
	     }
	     long endTime = System.currentTimeMillis(); // 종료시간
	     
	     System.out.println("시작시간 : " + startTime);
	     System.out.println("종료시간 : " + endTime);
	     System.out.println("소요시간 : " + (endTime - startTime));

	}
}
 

 

 

 

(출처 : 학원강의 및 java의 정석 3rd)


💡  간단한 메모를 저장하고 사용하는 프로젝트 

 

  이 프로젝트에서 메모 데이터를 저장하고 사용할 테이블을 다음처럼 설계

CREATE TABLE memo (
    num INTEGER PRIMARY KEY,
    content TEXT,
    datetime INTEGER
)

 

 -  SQLite 에서는 INTEGER 타입이 primary key 이면 숫자가 자동 증가됨


1.  SQLiteOpenHelper 사용하기

  👾  SQLiteOpenHelper 클래스는 데이터베이스를 파일로 생성하고 코틀린 코드에서 사용할 수 있도록 데이터베이스와 연결하는 역할

 

1) 프로젝트 생성 후 [app] - [java] 디렉토리 밑에 있는 패키지에 SQLiteHelper 클래스를 생성


2) 클래스 정의 및 생성자

 

-  SQLite 데이터베이스를 사용하려면 SQLiteOpenHelper 클래스를 상속받아야 함

SQLiteOpenHelper는 생성 시에 Context, 데이터베이스명, 팩토리, 버전 정보가 필요. 팩토리는 사용하지 않아도 되므로 나머지 세 가지 정보를 내가 만든 클래스의 생성자에 파라미터로 정의한 후에 상속받은 SQLiteOpenHelper에 전달.

class SQLiteHelper(context: Context, name: String, version: Int):
    SQLiteOpenHelper(context, name, null, version)  {

 

3) SQLiteOpenHelper 메서드를 구현

class SQLiteHelper(context: Context, name: String, version: Int):
    SQLiteOpenHelper(context, name, null, version)  {
    override fun onCreate(p0: SQLiteDatabase?) {
        // 데이터베이스가 처음 생성될 때 호출
    }

    override fun onUpgrade(p0: SQLiteDatabase?, p1: Int, p2: Int) {
        // 데이터베이스 업그레이드 시 호출
    }

 

4) 아직 데이터베이스가 생성되지 않았기 때문에 onCreate() 메서드에서 테이블을 생성

  -  이 메서드 안에 테이블 생성 쿼리를 작성하고 실행. 데이터베이스가 생성되어 있으면 더 이상 실행되지 않음. onCreate() 메서드 안에 앞에서 만든 테이블 생성 쿼리를 문자열로 입력한 후, db의 execSQL() 메서드에 전달해서 실행

override fun onCreate(p0: SQLiteDatabase?) {
    val sql = "CREATE TABLE `memo` (`num` INTEGER PRIMARY KEY," +
        "`content` TEXT, `datetime` INTEGER)"
        p0?.execSQL(sql)
}

 

5) Memo 데이터 클래스

 

  -  SQLiteHelper 클래스의 바깥에 Memo 클래스를 하나 생성하고 정의

        ➡️  이 클래스는 SQLite 데이터베이스와 상호작용하여 메모 데이터를 관리하는 기본적인 CRUD(생성, 읽기, 업데이트, 삭제) 작업을 수행할 수 있도록 함

data class Memo(var num: Long?, var content: String, var datetime: Long)

 

num과 datetime의 타입을 데이터베이스에서는 INTEGER로 정의했는데, 여기서는 Long. 숫자의 범위가 서로 다르기 때문.
특별한 이유가 없다면 SQLite에서 INTEGER로 선언한 것은 소스 코드에서는 Long으로 사용.
그리고 num만 null을 허용한 것은 PRIMARY KEY 옵션으로 값이 자동으로 증가 되기 때문에 데이터 삽입 시에는 필요하지 않음.

 


2. 삽입 메서드

1) SQLiteOpenHelper를 이용해서 값을 입력할 때는 코틀린의 Map 클래스처럼 키, 값 형태로 사용되는 ContentValues 클래스를 사용

 

  👾  ContentValues에 put("컬럼명", 값)으로 저장

fun insertMemo(memo: Memo) {
    val value = ContentValues()
    value.put("content", memo.content)
    value.put("datetime", memo.datetime)
}

 

2) 상속받은 SQLiteOpenHelper에 이미 구현된 writableDatabase에 테이블명과 함께 앞에서 작성한 값을 전달해서 insert()하고, 사용한 후에는 close()를 호출하여 꼭 닫아줘야 함

fun insertMemo(memo: Memo) {
    val value = ContentValues()
    value.put("content", memo.content)
    value.put("datetime", memo.datetime)

    writableDatabase.insert("memo", null, value)
    writableDatabase.close()
}

 

  -  첫 번째 파라미터 : 테이블 이름 / 두 번째 파라미터 : 값이 null 일 때 입력 방지 / 세 번째 파라미터 : 삽입할 데이터를 포함하는 ContentValues 객체


3.  조회 메서드

1)  조회 메서드는 반환값이 있으므로 메서드의 가장 윗줄에 반환할 값을 변수로 선언하고, 가장 아랫줄에 반환하는 코드를 작성한 후 그 사이에 구현 코드를 작성하는 것이 좋음

fun selectMemo(): MutableList<Memo> {
    val list = mutableListOf<Memo>()
    
    return list
}

2) 메모의 전체 데이터를 조회하는 쿼리를 작성

val sql = "SELECT * FROM memo"

3) 읽기 전용 데이터베이스를 변수에 담음

val rd = readableDatabase

 

4) 데이터베이스의 rawQuery() 메서드에 앞에서 작성해둔 쿼리를 담아서 실행하면 커서 cursor 형태로 반환

 val cursor = rd.rawQuery(sql, null)

 

커서 Cursor


데이터셋을 처리할 때 현재 위치를 포함하는 데이터 요소. 커서를 사용하면 쿼리를 통해 반환된 데이터셋을 반복문으로 반복하며 하나씩 처
리할 수 있음. 반복할 때마다 커서가 현재 위치를 가르키고 있어 [데이터 읽기 -> 다음 줄 이동]의 단순 로직으로 데이터를 쉽게 처리할 수 있음.


 

5) 커서의 moveToNext() 메서드가 실행되면 다음 줄에 사용할 수있는 레코드가 있는지 여부를 반환하고, 해당 커서를 다음 위치로 이동. 레코드가 없으면 반복문을 빠져나감. 모든 레코드를 읽을 때까지 반복.

while(cursor.moveToNext()) { }

 

6) 반복문을 돌면서 테이블에 정의된 3개의 컬럼에서 값을 꺼낸 후 각각 변수에 담음

val num = cursor.getLong(0)
val content = cursor.getString(1)
val datetime = cursor.getLong(2)

 


 

7) 앞에서 변수에 저장해두었던 값들로 Memo 클래스를 생성하고 반환할 목록에 더함

list.add(Memo(num, content, datetime))

 

8) while 문의 블록 밖에서 커서와 읽기 전용 데이터베이스를 모두 닫아 줌

cursor.close()
rd.close()

    fun selectMemo(): MutableList<Memo> {
        val list = mutableListOf<Memo>()
        val sql = "SELECT * FROM memo"
        val rd = readableDatabase

        val cursor = rd.rawQuery(sql, null)
        while(cursor.moveToNext()) {
            val num = cursor.getLong(0)
            val content = cursor.getString(1)
            val datetime = cursor.getLong(2)
            list.add(Memo(num, content, datetime))
        }
        cursor.close()
        rd.close()
        return list
    }

 


4. 수정 메서드

1) INSERT와 동일하게 ContentValues를 사용해서 수정할 값을 저장

 fun updateMemo(memo: Memo) {
        val values = ContentValues()
        values.put("content", memo.content)
        values.put("datetime", memo.datetime)
 }

 

2) writableDatabase의 update() 메서드를 사용하여 수정한 다음 close()를 호출


update() 메서드의 파라미터는 총 4개인데 (테이블명, 수정할 값, 수정할 조건)
수정할 조건은 PRIMARY KEY로 지정된 컬럼을 사용하며 여기에서는 PRIMARY KEY인 컬럼이 num이기 때문에 'num = 숫자'가 됨.
세 번째 값을 'num = ?'의 형태로 입력하고, 네 번째 ?에 매핑할 값을 arrayOf("${memo.num}")의 형태로 전달할 수도 있음. 여기서는 세 번째에 조건과 값을 모두 할당했기 때문에 네 번째에 null을 사용

fun updateMemo(memo: Memo) {
    val values = ContentValues()
    values.put("content", memo.content)
    values.put("datetime", memo.datetime)

    val wd = writableDatabase
    wd.update("memo", values, "num = ${memo.num}", null)
    wd.close()
}

 


5. 삭제 메서드

SQLiteOpenHelper 클래스를 사용하면 insert(), update() 메서드의 사용법만 알면 쿼리를 몰라도 데이터베이스를 사용할 수 있음. 하지만 복잡한 데이터베이스를 다룰 때는 쿼리를 직접 작성하면 데이터를 더 정밀하게 다룰 수 있으므로 쿼리를 공부하는 것이 중요. 삭제 메서드 (DELETE)는 쿼리를 직접 입력해서 데이터를 삭제하는 코드로 작성.


1) 데이터 삭제 메서드를 작성

  -  조건식은 '컬럼명 = 값'의 형태가 됨. 삭제 쿼리를 작성하고 변수에 저장

 fun deleteMemo(memo: Memo) {
        val sql = "DELETE FROM memo WHERE num=${memo.num}"
 }

 

2) writableDatabase의 execSQL() 메서드로 쿼리를 실행한 후 close()를 호출. execSQL() 메서드로 쿼리를 직접 실행할 수 있음

fun deleteMemo(memo: Memo) {
        val sql = "DELETE FROM memo WHERE num=${memo.num}"
        val wd = writableDatabase
        wd.execSQL(sql)
        wd.close()
}

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]

+ Recent posts