다음은 글은 infrean의 "자바와 스프링 부트로 생애 최초 서버 만들기, 누구나 쉽게 개발부터 배포까지! [서버 개발 올인원 패키지]" 강의의 학습 목적으로 작성된 것입니다. 강의의 디테일한 내용이나, 코드 등은 빠져있을 수 있습니다.
1. 문자열 SQL을 직접 사용?
우리는 그동안 SQL 문을 Repository 코드안에서 직접 작성했다.
1-1. SQL을 직접 작성하면 생기는 단점
1. 사람이 문자열을 직접 작성하기 때문에 실수가 많이 발생할 수 있다.
ex)
SELECT * FROM user WHER id = ?
Intellij Ultimate ver. 에서는 일부 SQL 에러를 도와준다.(코드에는 빨간줄이 그어짐)
그러나Repository 파일 안에는 빨간색 에러가 뜨지 않는다.
컴파일 시점에 발견되지 않고, 런타임 시점에 발견된다.
2. 특정 데이터베이스에 종속적이게 된다. (MariaDB 등, 여러 가지 DB가 존재한다.)
3. 반복 작업이 많아진다. 테이블을 하나 만들 때마다 CRUD 쿼리가 항상 필요하다.
4. 데이터베이스의 테이블과 객체는 패러다임이 다르다. ex) 연관 관계, 상속
=> 그래서 등장한 것이 "JPA (Java Persistence API)"
= JAVA 진영의 ORM (Object-Relational Mapping)
객체와 관계형 DB의 테이블을 짝지어 데이터를 영구적으로 저장할 수 있도록 정해진 Java 진영의 규칙!
자바 객체를 데이터베이스 테이블과 매핑하고, 데이터베이스와의 상호작용을 객체 지향적으로 처리할 수 있도록 도와준다.
1-2. HIBERNATE
JPA는 결국 규칙일 뿐 (인터페이스의 느낌)
이를 실제로 JAVA 코드로 구현해줘야한다.
JPA의 구현체가 바로 HIBERNATE
HIBERNATE는 내부적으로 JDBC를 사용한다.
2. Table 에 대응되는 Entity Class 만들기
2-1. JPA 기본 Annotation
1) @Entity
: 스프링이 User 객체와 user 테이블을 같은 것으로 본다.
* Entity : 저장되고, 관리되어야 하는 데이터
2) @Id
: 이 필드를 primary key로 간주한다.
3) @GeneratedValue(strategy = GenerationType.~~)
: primary key는 DB에서 자동 생성해주기 때문에 이 어노테이션을 붙여주어야 한다.
DB의 종류마다 자동 생성 전략이 다른데, MySQL의 경우 auto_increment는 IDENTITY 전략이다.
* JPA를 사용하기 위해서는 기본 생성자가 꼭 필요하다.
public 또는 protected 를 사용해야 한다. (보통은 안정성 측면에서 protected를 많이 사용한다.)
... 이유는 해당 블로그 참고 : https://velog.io/@yyy96/JPA-EA%B8%B0%EB%B3%B8%EC%83%9D%EC%84%B1%EC%9E%90
protected User() {
}
4) @Column
: 객체의 필드와 Table의 필드를 매핑한다.
(괄호 안에) Nullable, 길이 제한, DB에서의 column 이름 등의 속성이 들어간다.
서로 동일할 경우 생략이 가능하다.
@Column(nullable = false, length = 20, name = "name") // name varchar(20)
private String name; // name = "name" 생략 가능
name은 nullable = false, 혹은 최대 글자수 등 알려줄 정보가 있지만,
age는 db table 에도 (age int) 정보만 있다.
고로 @Column 어노테이션을 아예 안붙여줘도 무방하다.
2-2. application.yml 에서 JPA 최초 설정
spring:
datasource:
url: "jdbc:mysql://localhost/library"
username: "root"
password: ""
driver-class-name: com.mysql.cj.jdbc.Driver
# add
jpa:
hibernate:
ddl-auto: none
properties:
hibernate:
show_sql: true
format_sql: true
dialect: org.hibernate.dialect.MySQL8Dialect
- ddl-auto : 스프링이 시작할 때 DB에 있는 테이블을 어떻게 처리할지
스키마 생성 / 수정을 자동으로 관리하지 않고, 데이터베이스의 테이블 구조를 애플리케이션에서 직접 관리하겠다.- create : 기존 테이블이 있다면 삭제 후 다시 생성
- create-drop : 스프링이 종료될 때 테이블을 모두 제거
- update : 객체와 테이블이 다른 부분만 변경
- validate : 객체와 테이블이 동일한지 확인
- none : 별다른 조치를 하지 않는다.
- show_sql : JPA를 사용해 DB에 SQL을 날릴 때 SQL 쿼리를 콘솔에 출력할지 여부
- format_sql : SQL을 보여줄 때 보기 쉽게 포맷팅 할 것인가
- dialect : SQL 방언(Dialect)을 설정. 이 옵션으로 DB를 특정하면, 조금씩 다른 SQL을 수정해준다.
3. Spring Data JPA를 이용해 자동으로 쿼리 날리기
3-1. UserRepository 변경
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
}
JpaRepository를 상속받는 interface만 설정해주면 끝!
JpaRepository는 Spring Data JPA에서 제공하는 인터페이스로,
기본적인 CRUD(Create, Read, Update, Delete) 기능을 지원한다.
이 인터페이스를 상속받는다면, 해당 인터페이스에 정의된 CRUD 메서드들을 자동으로 사용할 수 있게 된다!!!
여기서 JpaRepository<Entity, Primary Key Type> 를 적어준다.
(JpaRepository를 타고 들어가보면 해당 코드를 확인할 수 있다.)
public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {
// ...
}
User 엔티티를 관리하며, 주요 키가 Long 타입인 경우를 나타낸다!
3-1. POST API
// POST API
public void saveUser(UserCreateRequest request) {
userRepository.save(new User(request.getName(), request.getAge()));
}
- save 메소드에 객체를 넣어주면 INSERT SQL이 자동으로 날라간다!
3-2. GET API
// GET API
public List<UserResponse> getUsers() {
return userRepository.findAll().stream()
.map(UserResponse::new)
//.map(user -> new UserResponse(user))
.collect(Collectors.toList());
}
- findAll을 사용하면 모든 데이터를 가져온다! => select * from user;
[TIP] UserResponse 클래스에서 User 객체를 받는 생성자를 만들면,
.map(user -> new UserResponse(user.getId(), user.getName(), user.getAge()))
부분을
.map(user -> new UserResponse(user))로 바꿀 수 있고,
이때 .map(UserResponse::new)를 사용하면 User 객체를 받아 UserResponse 객체를 생성하는 코드로 바뀐다.
3-3. PUT API
public void updateUser(UserUpdateRequest request) {
User user = userRepository.findById(request.getId())
.orElseThrow(IllegalArgumentException::new);
user.updateName(request.getName());
userRepository.save(user);
}
3-5. 어떻게 SQL을 작성하지 않아도 작동할까?
Sping Data JPA :
복잡한 JPA 코드를 스프링과 함께 쉽게 사용할 수 있도록 도와주는 라이브러리 (SimpleJpaRepository에서 확인가능하다.)
즉,
[1] Spring Data JPA는 JPA(ORM)를 사용하고,
[2] JPA는 Hibernate로 구현되어 있고,
[3] Hibernate는 내부적으로 JDBC를 사용하고 있다.
4. Spring Data JPA를 이용해 다양한 쿼리 작성하기
저장, 업데이트, 전체 조회, ID를 기준으로 조회하는 기본적인 기능 외에 다양한 쿼리를 배워보자.
4-.1 DELETE API
JpaRepository를 상속받는 UserRepository는 userRepository.findByName() ??????? 이런 함수는 없다.
그렇다면 "SELECT * FROM user WHERE name = ?" 쿼리를 만들 수 있을까?
interface UserRepository 를 조금 손봐주자!
// UserRepository
public interface UserRepository extends JpaRepository<User, Long> {
User findByName(String name);
}
// UserService.deleteUser
public void deleteUser(String name) {
User user = userRepository.findByName(name);
if (user == null) {
throw new IllegalArgumentException();
}
userRepository.delete(user);
}
Spring Data JPA에서는 메서드 이름 규칙에 따라 다양한 형태의 쿼리 메서드를 선언할 수 있다!!!
더 알아보자.
4-2. 다양한 Spring Data JPA 쿼리
1. 메소드 이름 정의하기 (자동생성)
2. @NamedQuery 사용하기
3. Query로 직접 쿼리 작성하기
[1] By 앞에 들어갈 수 있는 기능
- find : 1건을 가져온다. 반환 타입은 객체가 될 수도 있고, Optional<타입>이 될 수도 있다.
- findAll : 쿼리의 결과물이 N개인 경우 사용. 반환 타입은 List<타입>
- exists : 쿼리 결과가 존재하는지 확인. 반환 타입은 boolean
- count : SQL의 결과 개수를 센다. 반환 타입은 long
[2] By 뒤에 들어갈 수 있는 기능
- And 나 Or 로 조합 가능
- GreaterThan : 초과
- GreaterThanEqual : 이상
- LessThan : 미만
- LessThanEqual : 이하
- Between : 사이에
- StartsWith : ~로 시작하는
- EndsWith : ~로 끝나는
ex)
boolean existsByUsername(String username);
long countByAge(int age);
해당 공식 문서를 보면 더 자세히 알아볼 수 있다.
https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html
5. 트랜젝션 이론편
Service에서는 비지니스 로직을 짜준다고 했다.
여기서 CRUD의 로직 말고 해야할 일이 하나 더 남았다.
그것은 바로 "트랜잭션 관리"!
GPT에게 물어보자...
트랜잭션(Transaction)은 데이터베이스에서 여러 개의 작업을 논리적으로 묶어서 하나의 실행 단위로 다루는 개념이다.
트랜잭션은 데이터베이스 상태를 일관성 있게 유지하기 위해 사용된다.
여러 개의 데이터 조작 연산이 수행될 때,
모든 연산이 성공적으로 완료되면 트랜잭션은 커밋되고, 하나라도 실패하면 트랜잭션은 롤백된다.
그렇다고 한다..
쉽게 말해,
트랜잭션은 여러 업무들의 집합이며, 논리적 최소 실행 단위이다.
예를 들어, "은행에서 돈을 이체한다고 해보자"
1) A 출금 2) A 이체 3) B 입금
A가 출금과 이체에 성공했지만, B의 입금과정에서 문제가 생겼다고 해보자.
이렇듯 "은행 이체" 트랜잭션 도중에 어떠한 문제 (B 입금의 문제) 가 발생해 이체가 성공하지 않는 경우,
A 출금과 A 입금도 롤백되어야 한다.
=> 이 모든 과정이 성공적으로 끝나면 트랜잭션은 커밋되어야 한다.
// 트랜잭션 시작
start transaction;
// 트랜잭션 정상 종료
commit; // SQL 반영
// 트랜잭션 실패 처리
rollback; // SQL 미반영
즉, start transaction; 이후 과정은 commit;이 완료 되기 전까지 "변경 사항이 적용되지 않는다."
- commit; => 일괄 적용
- rollback; => 일괄 실패 (start transaction; 이전 상태로 롤백)
6. 트랜잭션 적용과 영속성 컨텍스트
6-1. 트랜잭션 적용
각 서비스 API 메서드 위에
@Transactional 을 붙여 트랜잭션을 적용시킨다.
(org.springframework. )에 있는 어노테이션
* SELECT 쿼리만 사용한다면 (조회 API) readonly = true 옵션을 붙여주어, 안정성을 높일 수 있다.
// GET API
@Transactional(readOnly = true)
public List<UserResponse> getUsers() {
...
}
=> 함수를 한 몸으로 취급!!
🚨주의 사항 : IOException과 같은 Checked Exception은 롤백이 일어나지 않는다.
(IllegalArgumentException은 UnChecked Exception이다.)
*추가
1. Checked Exception (예외 처리가 강제되는 예외) :
컴파일러가 확인하고, 개발자가 이를 처리하도록 강제하는 예외이다.
Java에서는 Exception 클래스의 하위 클래스 중 RuntimeException을 제외한 모든 예외가 Checked Exception에 해당된다.
"컴파일 오류 발생!"
2. Unchecked Exception (런타임 예외) :
컴파일러가 확인하지 않는다.
실행 시에 발생한다.
로직 상의 오류나, 코드 상의 문제로 인한 오류로 발생한다.
(이는 RuntimeException의 하위 클래스이다.)
6-2. 영속성 컨텍스트
개념 : "테이블과 매핑된 엔티티(Entity) 객체의 상태를 관리하는 환경"
역할 : 엔티티의 생명주기를 추적하고, 데이터베이스와의 상호작용을 최적화하기 위해 사용된다.
트랜잭션과의 관계
- 트랜잭션을 시작하면 영속성 컨텍스트가 활성화된다.
- 트랜잭션이 커밋되면 영속성 컨텍스트는 변경된 엔티티를 데이터베이스에 동기화하고, 캐시된 엔티티를 데이터베이스에 반영한다.
- 트랜잭션이 롤백되면 영속성 컨텍스트는 변경된 엔티티를 이전 상태로 되돌리고, 캐시된 엔티티를 롤백 이전 상태로 복원한다.
쉽게 말하면,
∴ 트랜잭션 사용 시점 -> 영속성 컨텍스트 생겨나고, 트랜잭션 종료 시점 -> 영속성 컨텍스트 종료
영속성 컨텍스트의 특수한 역할 4가지
1. 엔티티의 상태 변경 감지 (Dirty Check) :
영속성 컨텍스트 안에서 불러와진 Entity는 명시적으로 save하지 않더라도, 변경을 감지해 자동으로 저장된다.
// PUT API
@Transactional
public void updateUser(UserUpdateRequest request) {
User user = userRepository.findById(request.getId())
.orElseThrow(IllegalArgumentException::new);
user.updateName(request.getName());
// userRepository.save(user); "해당 코드 생략 가능"
}
2. 쓰기 지연 (Write Behind) :
DB의 INSERT / UPDATE / DELETE SQL을 바로 날리는 것이 아니라, 트랜잭션이 commit 될 때 모아서 한 번만 날린다.
트랜잭션이 여러 개의 데이터를 변경하더라도 실제로 데이터베이스에 쓰기 작업을 미뤄두고,
트랜잭션이 커밋될 때 한꺼번에 데이터베이스에 반영하는 것!!
이를 통해 데이터베이스와의 I/O 작업을 최소화하고 성능을 향상시킬 수 있다.
3. 1차 캐싱 (First Level Cache) :
특정 트랜잭션 범위 내에서 엔티티를 저장하고 관리하여 동일한 엔티티가 중복으로 로딩되지 않도록 하는 메커니즘
식별자(Primary Key ; ID)를 기준으로 동일 Entity를 판단한다.
@Transactional(readOnly = true)
public void findUsers() {
User user1 = userRepository.findById(1L).get();
User user2 = userRepository.findById(1L).get();
User user3 = userRepository.findById(1L).get();
}
다음 코드에서 동일 쿼리문을 3번 날리지 않고
ID를 기준으로 캐싱해두고, 트랜잭션에 내에서 해당 ID인 엔티티를 한 번 더 조회한다면 캐시에서 값을 찾아서 준다.
이렇게 캐싱된 객체는 완전히 동일하다!
(equals()의 동등성이 아닌, == 연산자를 통한 동일성 )
4. 지연 로딩 (Lazy Loading) :
(다음 섹션에서 자세히 다룰 예정)