섹션 5. 생애 최초 JPA 사용하기

김민지·2025년 3월 26일

서버 기초 강의

목록 보기
5/12

문자열 SQL을 직접 사용하는 것이 너무 어렵다!!

문자열 SQL의 문제점

  1. 오타 등의 오류가 있어도 컴파일 시점에 발견되지 않고 런타임 도중에 발견됨
  2. 특정 데이터베이스 시스템에 종속적이게 됨 (예: Mysql로 작성했다가 다른 데이터베이스 시스템으로 바꾸게 된다면 하나하나 코드를 다 바꿔야 함)
  3. 반복작업이 많아짐 (테이블을 하나 만들 때마다 테이블 생성, CRUD 쿼리가 반복적으로 매번 필요함)
  4. 객체지향적인 프로그래밍 방식과 데이터베이스의 테이블은 페러다임이 다름 (예: 상속 등의 개념을 표현하기는 어려움)

JPA의 등장 (Java Persistence API)

  • 자바 진영의 ORM (Object-Relational Mapping)

  • JPA : 객체와 관계형 데이터베이스의 테이블을 짝지어서 데이터를 영구히 보관하기 위해 Java 진영에서 정해진 규칙
    규칙일 뿐! (REST API 등과 같이..)

  • JPA 를 구현하고 있는 건 Hibernate(구현체)이다. Hibernate는 내부적으로 Jdbc를 사용한다.

유저 테이블에 대응되는 Entity Class 만들기

@Entity : 스프링이 user 객체와 user 테이블을 같은 것으로 바라보도록 함
엔티티: 저장되고 관리되어야 하는 데이터를 의미함

com.group.libraryapp.domain.user 위치에 있던 User 클래스를 다시 가져와서, 클래스 위에 @Entity 어노테이션을 붙이자.

PK를 생성하고 매핑하는 방법을 알아보자. 아래와 같이 id값을 PK로 선언하고, 자동생성(auto increment) 되도록 설정할 수 있다.

@Id // 이 필드를 PK로 간주한다는 의미
@GeneratedValue(strategy = GenerationType.IDENTITY) // 자동생성되는 값임을 의미
private Long id = null;

이 때, @Entity 객체에는 반드시 기본 생성자가 있어야 하므로 protected 값으로 하나 생성해주도록 하자.

protected User() {}

또한,PK가 아닌 다른 필드들에서 객체의 필드와 테이블의 필드를 매핑하기 위해서는 @Column 라는 어노테이션을 사용해야 한다.

@Column 어노테이션에서는 주로 null 여부, 길이 제한, 데이터베이스에서의 컬럼 이름 등등의 정보들이 들어간다.
이 때, 데이터베이스에서의 컬럼 이름과 객체에서의 컬럼 이름이 name= "name”과 같이 동일한 경우는 컬럼이름을 따로 기재해주지 않아도 괜찮다.

@Column(nullable = false, length = 20)
private String name;

private Integer age;

추가로, application.yml 파일에 설정을 해줘야 함

spring:
  datasource:
    url: "jdbc:mysql://localhost/library"
    username: "root"
    password: "rlaalswl"
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: none // 스프링이 시작할 때 테이블과 객체의 정보가 다를 경우 어떻게 처리할지를 기재하는 옵션
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        dialect: org.hibernate.dialect.MySQL8Dialect
  • ddl-auto : 스프링이 시작할 때 테이블과 객체의 정보가 다를 경우 어떻게 처리할지를 기재하는 옵션
    • create : 기존 테이블이 있다면 삭제 후 다시 생성 -> 서버를 시작할 때마다 테이블이 모두 삭제되고 자연히 테이블 안의 정보값들도 사라짐
    • create-drop : 스프링이 종료될 때 테이블을 모두 제거 -> 서버를 시작할 때마다 테이블이 모두 삭제되고 자연히 테이블 안의 정보값들도 사라짐
    • update: 객체와 테이블이 다른 부분만 변경
    • validate : 객체와 테이블이 동일한지 확인
    • none : 별다른 조치를 하지 않음
  • show_sql : jpa를 사용해 데이터베이스에 sql을 날릴 때 sql문을 보여줄 것인가
  • format_sql : sql문을 보여줄 때 formatting해서 깔끔하게 보여줄 것인가
  • dialect : 이 옵션으로 데이터베이스를 특정해주면 자동으로 조금씩 sql을 다르게 수정해줌. (데이터베이스 서비스, 버전 등을 명시하면 됨)

Spring Data JPA를 이용해 자동으로 쿼리 날리기

  • 유저 생성, 조회 업데이트 기능을 mysql 사용하지 않고 동작하도록 리펙토링하자.
  1. 기존의 UserRepositoryUserJdbcRepository로 수정하기
  2. User 옆에 UserRepository 인터페이스 생성해주기
  3. UserRepository 인터페이스로 JPA 레포지토리 상속 받기
    JpaRepository<엔티티 객체, PK>
  4. UserService 의 이름을 UserServiceV1으로 바꾸기: JDBC를 사용하는 코드를 보존하기 위해 여러 버전으로 나눠서 생성하기 위한 작업
  5. UserServiceV2 추가로 생성하기

JPA Repository의 기능 정리

  • save : 주어지는 객체를 저장하거나 업데이트 시킴
  • findAll : 주어지는 객체가 매핑된 테이블의 모든 데이터를 가져옴
  • findById : id를 기준으로 특정한 1개의 데이터 가져오기

JPA 기능 처리 로직

  • Spring Data JPA : 복잡한 JPA 코드를 스프링과 함께 쉽게 사용할 수 있도록 도와주는 라이브러리이다.
    우리가 앞서 사용했던 기능들은 SimpleJpaRepository 를 검색하면 복잡한 코드들을 확인할 수 있다.

Spring Data JPA를 이용해 다양한 쿼리 작성하기

  • 위의 기능으로 해결할 수 없는 기능을 구현해야 할 경우에는 어떻게 해야 할까?
    예를 들어, JPA Repository는 findById만 지원하는데, 나는 이름을 기준으로 검색해야 한다면?
    이럴 때에는 기존의 방식과는 조금 다르게, UserRepository 인터페이스에 가서 아래와 같이 설정해줘야 한다.
package com.group.libraryapp.domain.user;

import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
    // @Repository 어노테이션 없이도 JpaRepository 를 상속한 것 만으로도 스프링빈으로 관리됨

    // 함수 만들기: 이 때 함수의 이름이 아주 중요함 (함수명을 따라서 쿼리가 작성됨)
    User findByName(String name);
}
  • 이 때, findByName는 아무렇게나 지은 이름이 아니다. find 를 사용해서 하나의 결과만이 반환되었고, by 뒤에 오는게 Name 이므로 이름을 기준으로 찾아서 반환하게 된다.
package com.group.libraryapp.service.user;

import com.group.libraryapp.domain.user.User;
import com.group.libraryapp.domain.user.UserRepository;
import com.group.libraryapp.dto.user.request.UserCreateRequest;
import com.group.libraryapp.dto.user.request.UserUpdateRequest;
import com.group.libraryapp.dto.user.response.UserResponse;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class UserServiceV2 {
    private final UserRepository userRepository;
    public UserServiceV2(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    // 유저 저장 기능: save 메소드에 객체를 넣어주면 INSERT sql이 자동으로 요청됨
    public void createUser(UserCreateRequest request) {
        User u = userRepository.save(new User(request.getName(), request.getAge()));
        // u.getId(); // 자동 생성 된 아이디 값을 가져올 수 있음!
    }

    // 유저 조회 기능
    public List<UserResponse> getUsers() {
        return userRepository.findAll().stream()
                .map(user -> new UserResponse(user.getId(), user.getName(), user.getAge())) // .map(UserResponse::new) 로 대체 가능
                        .collect(Collectors.toList());
    }

    // 유저 업데이트 기능
    public void updateUser(UserUpdateRequest request) {
        // 1. id를 이용해 user를 가져와서 해당 user가 데이터베이스에 존재하는지 하지 않는지 확인
        User user = userRepository.findById(request.getId())
                        .orElseThrow(); // IllegalAccessException::new 왜 오류나지..?

        // 2. user가 있다면 update 쿼리를 날려서 데이터를 수정
        user.updateName(request.getName());
        userRepository.save(user);
    }

    // 유저 삭제 기능
    public void deleteUser(String name) {
        // 사용자 이름으로 유저 가져오기
        User user = userRepository.findByName(name);
        if (user != null) {
            userRepository.delete(user);
        } else {
            throw new IllegalArgumentException("User not found");
        }
    }
}
  • 위와 같이 sql문을 직접 작성하는 기존의 서비스 코드에서 나아가, JPA를 활용해서 sql문을 직접 다루지 않는 새로운 서비스 코드를 작성 완료했다면, 이제는 컨트롤러 코드에서 기존의 서비스 코드가 아닌, 새로 작성한 JPA 활용 서비스 코드로 바꿔줘야 한다.
@RestController
public class UserController {
    private final UserServiceV2 userService;

    public UserController(UserServiceV2 userService) { // 생성자 추가
        this.userService = userService;
    }

다양한 Spring Data JPA 쿼리

  • By 앞에 들어갈 수 있는 기능

    • find : 1건의 데이터를 반환함. 반환타입은 객체 or Optional<타입>
    • findAll : N건의 데이터를 반환함. 반환타입은 List<타입>
    • exists : 쿼리 결과가 존재하는지 확인. 반환타입은 boolean
  • 각 구절은 AndOr 등을 활용하여 자유롭게 조합할 수 있음

List<User> FindAllByNameAndAge(String name, int age)
SELECT * FROM user WHERE name = ? AND age = ?;
  • By 뒤에 들어갈 수 있는 기능
    • GreaterThan (초과), GreaterThanEqual (이상), LessThan (미만), LessThanEqual (이하), Between (사이에), StartsWith (~로 시작하는), EndsWith (~로 끝나는)
List<User> FindAllByAgeBetween(int startAge, int endAge)
SELECT * FROM user WHERE age BETWEEN ? AND ?;

트랜잭션 이론편

  • 트랜잭션이란?: 쪼갤 수 없는 업무의 최소단위

    • 모든 SQL을 성공시키거나, 하나라도 실패한다면 모두 실패시켜야 하는 상황이 존재함
    • 예: 쇼핑몰에 주문 기록 저장, 포인트 저장, 결제 기록 저장 등의 로직들이 있다고 가정하자. 이 중 포인트 저장 로직만 실패한다면? 사용자는 결제는 성공 했으나 포인트를 받지 못하게 된다. 또한, 이 중 결제 기록 저장 로직만 실패한다면? 결제하지 않은 사용자에게도 포인트가 무한대로 지급되게 된다.
  • 트랜젝션 명령어

    • start transaction;: 트랜젝션 시작
    • commit : 트랜젝션 시작 후 한 몸인 SQL들을 한번에 반영하는 정상 종료
    • rollback : 실패처리 (SQL에 반영되지 않음)
  • 트랙젝션은 시작한 후, commit (성공했으며 이제 SQL들을 한번에 반영해도 된다는 의미)이나 rollback (실패했으며 직전 commit으로 돌아가야 한다는 의미)을 입력하기 전까지는 작성한 쿼리의 결과가 다른 창에서 보이지 않는다. 아래의 사진에서도 볼 수 있듯이, 왼쪽 창에서 start transaction 이후 입력한 insert into user (name, age) values ('minji', 23);는 오른쪽 창에 반영되어있지 않다. 아직 commit 을 하지 않았기 때문이다.

  • 이후 한 줄의 데이터를 더 추가한 후 commit 을 해주면 아래와 같이 오른쪽 창에서도 변경사항이 반영되어서 보인다.

  • rollback으로 실패처리를 하면 아래와 같이 오른쪽 창에는 transaction 내에서 변경된 내용들이 보이지 않는다.

트랜잭션 적용과 영속성 컨텍스트

이상적으로 Service 클래스에 트랜젝션 적용하기

  1. Service 메소드가 시작될 때 트랜젝션이 시작됨
    2-1. Service 메소드 로직이 모두 정상적으로 실행되면 commit
    2-2. Service 메소드 로직 실행 도중 하나라도 문제가 생기면 rollback

스프링에서 트랜젝션 적용하는 방법

  • @Transactional 어노테이션을 메소드 위에 붙여주면, 해당 메소드가 시작될 때 start transaction;으로 트랜젝션이 시작되고, 메소드가 종료될 때 commit이나 rollback으로 종료시켜줌.

  • 이 때, 만약 SELECT 쿼리만 사용하는 경우에는 @Transactional(readOnly = true)와 같이 설정해줄 수 있다.

  • 트랜젝션을 테스트해보기 위해 아래와 같이 오류를 throw 해준 후 유저 저장이 되는지 안되는지 살펴보자. 정석대로라면 오류가 발생했으므로 롤백되어서 저장되지 않아야 한다.

    // 유저 저장 기능: save 메소드에 객체를 넣어주면 INSERT sql이 자동으로 요청됨
    @Transactional
    public void createUser(UserCreateRequest request) {
        User u = userRepository.save(new User(request.getName(), request.getAge()));
        throw new IllegalArgumentException(); // 저장은 됐는데 오류가 발생했으니 저장로직까지 전부 롤백되어서 저장이 안 될 것임. 
    }
  • 아래와 같이 오류가 발생한 후, 저장이 되지 않는 것을 확인할 수 있다.

Checked Exception과 Unchecked Exception간의 차이

  • 자바의 예외처리 개념임
  1. Checked Exception (체크 예외)
  • 컴파일 시점에 체크되는 예외
  • 예외 처리를 강제함 (예외 처리하지 않으면 컴파일 에러 발생)
  • 트랜젝션 안에서 발생하더라도 롤백이 되지 않음!
  • 종류: IOException, SQLException, FileNotFoundException
public void readFile(String fileName) throws IOException {
    FileReader reader = new FileReader(fileName);
}

IOException은 Checked Exception이기 때문에, 반드시 try-catch로 처리하거나 throws로 넘겨야 함.

  1. Unchecked Exception
  • 런타임 시점에 발생하는 예외
  • 예외 처리를 강제하지 않음 (컴파일러가 체크하지 않음)
  • 종류: NullPointerException, ArrayIndexOutOfBoundsException, IllegalArgumentException
  • 트랜젝션 안에서 발생 시 롤백이 이루어짐!
public void divide(int a, int b) {
    int result = a / b;  // b가 0일 경우 ArithmeticException 발생
}

ArithmeticException은 Unchecked Exception이라 try-catch 없이도 컴파일 가능.

영속성 컨텍스트

  • 테이블과 매핑된 Entity 객체(예제에서는 User 객체)를 관리/보관하는 역할
  • 스프링에서는 트랜젝션을 사용하면 영속성 컨텍스트가 생겨나고, 트랜젝션이 종료되면 영속성 컨텍스트가 종료됨
  1. 변경 감지 (Dirty Check): 영속성 컨텍스트 안에서 불러와진 Entity는 명시적으로 save하지 않더라도, 변경을 감지해 자동으로 저장됨
    // 유저 업데이트 기능
    @Transactional
    public void updateUser(UserUpdateRequest request) {
        // 1. id를 이용해 user를 가져와서 해당 user가 데이터베이스에 존재하는지 하지 않는지 확인
        User user = userRepository.findById(request.getId())
                        .orElseThrow(); // IllegalAccessException::new 왜 오류나지..?

        // 2. user가 있다면 update 쿼리를 날려서 데이터를 수정
        user.updateName(request.getName());
        // userRepository.save(user); // 이런 저장이 불필요함!
        // 자동으로 유저의 변경을 감지함. 
    }
  1. 쓰기 지연: DB에 INSERT, UPDATE, DELETE sql을 바로 날리는 것이 아니라, 트랜젝션이 commit될 때 모아서 한번만 날린다.
    ⮕ 스프링과 데이터베이스간의 통신횟수를 줄여줌

  2. 1차 캐싱: ID를 기준으로 Entity를 기억함
    ⮕ 영속성 컨텍스트가 ID를 기준으로 Entity를 기억해뒀다가, 알고 있는 Entity 중 필요한 ID에 부합하는 정보가 있다면 바로바로 넘겨줌
    ⮕ 이렇게 캐싱된 객체는 객체의 주소까지 해서 완전히 동일함!

0개의 댓글