backend

스프링부트 따라하기: 3. JPA 활용

cs77 2024. 11. 3. 16:26

gradle로 lombok 의존성 추가

dependencies {
	// ...
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	// ...
}

 

lombok은 어노테이션으로 런타임에는 사용하지 않고, 컴파일 타임에만 사용하므로 compileOnly로 추가하고, 어노테이션이기 때문에 annotationProcessor로도 추가해준다.

lombok은 어노테이션 기반 코드 자동완성 라이브러리이다. getter / setter 등을 어노테이션으로 편하게 추가할 수 있고, toString() 오버라이딩도 쉽게 할 수 있다.

 

application.properties 설정 추가

# jpa 관련 설정
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

 

ddl-auto: 애플리케이션 시작 시 데이터베이스 스키마의 자동 생성을 관리한다. 

  • update: 데이터베이스 스키마를 유지하면서 필요한 경우 테이블 구조를 업데이트한다.
  • create: 기존 테이블이 있어도 서버를 실행할 때 테이블을 삭제하고 다시 만든다.
  • validate: 엔티티 클래스와 테이블 구조가 일치하는지만 검사하고 스키마를 변경하거나 생성하지 않는다.
  • none

show-sql: JPA가 실행되는 동안 Hibernate가 생성하는 SQL 쿼리를 콘솔에 출력한다. gradlew를 --info 이상 옵션으로 실행해야 출력되더라...

format_sql: Hibernate가 생성하는 SQL 쿼리를 포맷팅해서 보기 좋게 출력한다.

 

User 테이블 생성

간단하게 id, username, password, type, createDate 정보를 가지는 사용자 테이블을 생성하고자 한다.

  • id: 식별자
  • username: 사용자가 직접 설정하는 고유의 문자열. 로그인 시 id로 사용
  • password: 비밀번호
  • type: 일반 사용자인지 관리자 계정인지 타입
  • createDate: 가입 일자

스크립트 파일로 작성해두면 테이블 생성을 쉽게 할 수 있다.

scripts/db/mysql/creaet_user_table.sh 파일

#!/bin/bash

MYSQL_CONTAINER_NAME="mysql"

MYSQL_CREATE_USER_SQL="create table user (
    id bigint not null auto_increment,
    username varchar(30) not null unique,
    password varchar(100) not null,
    type integer not null,
    createDate datetime(6) not null,
    primary key (id)
);"

CREATE_USER_CMD="sudo docker exec -i $MYSQL_CONTAINER_NAME mysql -u$DB_USER -p$DB_PASSWORD -D$DB_NAME -e \"$MYSQL_CREATE_USER_SQL\""

echo $CREATE_USER_CMD
result=$(eval $CREATE_USER_CMD)
if [ $? -ne 0 ]; then
	echo $result
	exit 1
fi

 

username는 30자, 비밀번호는 암호화할 것이기 때문에 100자로 뒀다.

 

User 객체 생성

@Entity

  • JPA가 데이터베이스의 테이블과 매핑할 수 있는 엔티티 클래스로 간주하도록 해주는 어노테이션. 즉 이 클래스의 인스턴스는 데이터베이스의 한 행을 나타낸다.
  • @Entity 어노테이션이 붙은 클래스의 필수 조건은 반드시 기본 생성자를 가져야 하며, @Id가 달린 필드가 꼭 있어야 한다.

@Table

  • 매핑할 테이블 설정을 하는 어노테이션.
  • 이 어노테이션이 없으면 기본적으로 JPA는 엔티티 클래스의 이름을 데이터베이스 테이블의 이름으로 사용한다.
  • 속성
    • name: 매핑할 데이터베이스 테이블의 이름을 지정
    • schema: 사용할 스키마 지정
    • catalog: 사용할 카탈로그 지정
    • uniqueConstraints: 테이블의 유니크 제약 조건 설정
    • indexes: 테이블에 인덱스 설정

src/main/java/com/example/demo/user/User.java 파일

package com.example.demo.user;

import lombok.*;
import org.hibernate.annotations.CreationTimestamp;

import jakarta.persistence.*;
import java.sql.Timestamp;

@Entity
@Table(name="user")
@Data
@Builder
@ToString
@AllArgsConstructor
@NoArgsConstructor
public class User implements Cloneable {
	@AllArgsConstructor
	public static enum UserType {
		ADMIN((byte)0),
		GENERAL((byte)1);

		private final byte value;
	}

	@Id
	@GeneratedValue(strategy=GenerationType.IDENTITY)
	@Column(name="id")
	private long id;

	@Column(name="username", nullable=false, length=30, unique=true)
	private String username;

	@Column(name="password", nullable=false, length=100, unique=false)
	private String password;

	@Enumerated(EnumType.ORDINAL)
	@Column(name="type", nullable=false)
	private UserType type;

	@CreationTimestamp
	@Column(name="createDate", nullable=false)
	private Timestamp createDate;

	@Override
	public User clone() {
		try {
			return (User)super.clone();
		} catch (CloneNotSupportedException e) {
			throw new AssertionError();
		}
	}
}

 

 

저장소 객체 생성

Repository<T,ID> 인터페이스

  • Spring Data가 제공하는 저장소에 대한 추상 인터페이스
  • 도메인 클래스 타입(T)과, 식별자 타입(ID)을 타입 인자로 받는다.
  • 아무 메소드도 없지만 주로 타입을 식별하고, 이를 확장한 인터페이스들을 찾는 마커 인터페이스 역할을 해서 스프링이 이 인터페이스를 상속받은 객체들을 인식하고 자동으로 구현체를 생성해준다.
  • 자세한 사용은 Spring Data 문서 참고

 

src/main/java/com/example/demo/user/UserRepository.java 파일

package com.example.demo.user;

import org.springframework.data.repository.CrudRepository;

public interface UserRepository extends CrudRepository<User, Long> {
}

 

 

테스트 코드 작성

꼭 TDD로 개발하지 않더라도 테스트코드는 꼭 작성해두는 것이 좋다. 특히 많은 곳에서 사용하는 객체일수록 기능 추가나 수정 등으로 인한 버그를 추적하기 쉽지 않기 때문에 더욱 꼼꼼히 작성해둬야 한다. 버그가 생길 때마다 해당 버그에 맞는 테스트 코드를 계속 추가하며 관리하자!!

처음은 객체 메소드마다 하나씩만 테스트를 생성하여 시작한다. (User 객체의 clone(), UserRepository 객체의 CRUD 기능)

 

src/test/java/com/example/demo/user/UserTest.java 파일

package com.example.demo.user;

import org.junit.jupiter.api.Test;

import java.sql.Timestamp;

public class UserTest {
	@Test
	public void testClone() {
		User user = User.builder()
			.type(User.UserType.ADMIN)
			.username("user-id")
			.password("pw haha")
			.id(1001)
			.createDate(new Timestamp(100001))
			.build();

		// 객체 복사
		User clonedUser = user.clone();

		// 복사된 객체의 주소는 달라야 하고, 담고 있는 정보는 같아야 한다.
		assert clonedUser != user;
		assert clonedUser.equals(user);
	}
}

 

 

src/test/java/com/example/demo/user/UserRepositoryTest.java 파일

package com.example.demo.user;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import jakarta.persistence.EntityManager;
import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.sql.Timestamp;

@DataJpaTest
@AutoConfigureTestDatabase(replace=AutoConfigureTestDatabase.Replace.NONE)
public class UserRepositoryTest {
	@Autowired
	UserRepository userRepository;

	@Autowired
	private EntityManager entityManager;

	@AfterEach
	public void end() {
		userRepository.deleteAll();
	}

	@Test
	public void testSaveUser() {
		User user = User.builder()
			.username("adminUser")
			.password("securePassword")
			.type(User.UserType.ADMIN)
			.build();

		User savedUser = userRepository.save(user);
		assertThat(savedUser).isNotNull();
		assertThat(savedUser.getUsername()).isEqualTo(user.getUsername());
		assertThat(savedUser.getPassword()).isEqualTo(user.getPassword());
		assertThat(savedUser.getType()).isEqualTo(user.getType());
	}

	@Test
	public void testDeleteUser() {
		User user = User.builder()
			.username("User")
			.password("securePassword")
			.type(User.UserType.GENERAL)
			.build();

		User savedUser = userRepository.save(user);
		Long userId = savedUser.getId();

		userRepository.deleteById(userId);
		assertThat(userRepository.existsById(userId)).isFalse();
	}

	@Test
	public void testUniqueUsername() {
		User user1 = User.builder()
			.username("duplicateUser")
			.password("password123")
			.type(User.UserType.GENERAL)
			.build();

		User user2 = User.builder()
			.username("duplicateUser") // same username as user1
			.password("anotherPassword")
			.type(User.UserType.ADMIN)
			.build();

		userRepository.save(user1);
		assertThrows(Exception.class, () -> {
			try {
				userRepository.save(user2);
			} catch (Exception e) {
				entityManager.clear(); // 예외 후 세션 초기화
				throw e; // 예외를 다시 던져 테스트 실패를 유지
			}
		});
	}

	@Test
	public void testIdGenerator() {
		User user1 = User.builder()
			.username("user1")
			.password("password123")
			.type(User.UserType.GENERAL)
			.build();

		User user2 = User.builder()
			.username("user2")
			.password("anotherPassword")
			.type(User.UserType.ADMIN)
			.build();

		User savedUser1 = userRepository.save(user1);
		User savedUser2 = userRepository.save(user2);

		assertThat(savedUser1.getId()).isEqualTo(savedUser2.getId()-1);
	}

	@Test
	public void testCreationTimestamp() {
		User user = User.builder()
			.username("user")
			.password("password123")
			.type(User.UserType.GENERAL)
			.build();

		Timestamp startTime = new Timestamp(System.currentTimeMillis());
		User savedUser = userRepository.save(user);
		Timestamp endTime = new Timestamp(System.currentTimeMillis());

		assertThat(savedUser.getCreateDate()).isAfter(startTime).isBefore(endTime);
	}
}