application-dev.yml 설정ddl-auto와 테이블 자동 생성@Entity, @Id, @GeneratedValue@ManyToOne, @OneToMany, @JoinColumnFetchType.LAZYJpaRepository와 쿼리 메서드 — Mapper와의 차이toEntity(), fromEntity()같은 기능을 MyBatis로 먼저 구현해봤으므로, 오늘은 같은 구조를 JPA로 바꾸면서 DB와 어떻게 연동되는지만 확인했다.
build.gradle에 JPA starter를 추가한다. MyBatis 의존성은 남겨둔 상태다.
// basic dependency lib
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// webmvc -> web
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.4'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
application-dev.yml에서 MyBatis의 mapper-locations 설정은 주석 처리하고 jpa 블록을 추가했다.
# jpa
jpa:
hibernate:
# dto X, entity(table) -> auto generator(table)
# create, create-drop, update, none
ddl-auto: create
# 콘솔출력
show-sql: true
properties:
hibernate:
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
# mariadb
dialect: org.hibernate.dialect.MariaDBDialect
format_sql: true
# mybatis:
# mapper-locations: classpath:/mappers/**/*Mapper.xml
ddl-auto는 엔티티를 기준으로 테이블을 만들어주는 설정이다. 선택지는 create, create-drop, update, none이고 지금은 create로 두었다. show-sql과 format_sql을 켜 두면 실행되는 SQL이 콘솔에 찍힌다.
datasource는 .env의 값을 그대로 참조한다.
# mariadb settingh
datasource:
driver-class-name: ${DB_DRIVER}
url: ${DB_URL}
username: ${DB_USER}
password: ${DB_PASSWORD}
DB_DRIVER=org.mariadb.jdbc.Driver
DB_URL=jdbc:mariadb://localhost:3307/lgcns
DB_USER=root
DB_PASSWORD=******
MyBatis에서는 DTO를 그대로 쿼리에 넘겼지만, JPA는 Entity를 기준으로 데이터의 영속성을 관리한다. 그래서 DTO와 별개로 Entity 클래스가 필요하다.
@Entity
@Table(name = "SPRING_JPA_USER_TBL")
@Builder
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class UserEntity {
@Id
@Column(name = "email", length = 50, nullable = false)
private String email;
private String password;
private String name ;
// option(role : 인가된 권한 : user, admin)
private String role ;
@OneToMany( mappedBy = "author",
orphanRemoval = false )
private List<BlogEntity> blogs = new ArrayList<>();
}
@Entity : 이 클래스를 테이블과 매핑되는 대상으로 등록한다@Table(name = ...) : 실제 테이블 이름을 지정한다@Id : primary key. UserEntity는 email이 키다@Column : 컬럼 속성(길이, null 허용 여부 등)을 지정한다BlogEntity는 키가 문자열이 아니라 자동 증가값이다.
@Entity
@Table(name = "SPRING_JPA_BLOG_TBL")
@Builder
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
public class BlogEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // auto increment
private Integer blogId ;
private String title ;
private String content ;
@Column(columnDefinition = "VARCHAR(20) DEFAULT '전체' CHECK( CATEGORY IN ('전체', '개발', '생활', '취미', '일상'))")
private String category ;
@GeneratedValue(strategy = GenerationType.IDENTITY)는 키 생성을 DB에 맡긴다는 뜻으로, MariaDB에서는 auto increment가 된다.
category처럼 어노테이션만으로 표현하기 어려운 제약은 columnDefinition에 DDL 조각을 직접 적는다. 기본값 '전체'와 CHECK 제약이 여기 들어갔다.
테이블 사이의 외래키를 자바 객체의 참조로 표현한다. 관계는 user(1) : blog(n), blog(1) : comment(n)이다.
BlogEntity가 UserEntity를 참조하는 쪽(N)이다.
// foreign key
// fetch : select * from userEntity where email = ?
// optional = false : null 허용하지 않음.
@ManyToOne(fetch = FetchType.LAZY , optional = false )
@JoinColumn(name = "email")
private UserEntity author ;
// user(1) : blog(n) - blog(1) : comment(n)
// 댓글 Comments
@OneToMany(mappedBy = "blog", orphanRemoval = false )
private List<CommentEntity> comments = new ArrayList<>();
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "blogId")
private BlogEntity blog ;
@ManyToOne : N쪽에 붙는다. 외래키를 실제로 들고 있는 쪽이다@JoinColumn(name = ...) : 그 외래키 컬럼 이름optional = false : null을 허용하지 않는다@OneToMany(mappedBy = ...) : 1쪽에 붙는다. mappedBy에는 상대편 필드 이름을 적어서, 이 관계의 주인이 N쪽임을 표시한다fetch = FetchType.LAZY는 연관된 객체를 바로 조회하지 않고 실제로 쓸 때 조회한다는 설정이다. 주석에 적힌 select * from userEntity where email = ?가 그때 나가는 쿼리다.
계층 사이를 오가는 것은 DTO이고 DB와 붙는 것은 Entity이므로, 둘 사이를 바꾸는 자리가 필요하다. BE-07에서 쓴 정적 메서드 패턴이 그대로 들어간다.
// JPA는 UserEntity 기반으로 데이터의 영속성을 관리 DTO -> Entity , Entity -> DTO
public static UserEntity toEntity(UserRequestDTO request) {
return UserEntity.builder()
.email(request.getEmail())
.password(request.getPassword())
.name(request.getName())
.role(request.getRole())
.build() ;
}
// JPA는 UserEntity 기반으로 데이터의 영속성을 관리 DTO -> Entity , Entity -> DTO
public static UserResponseDTO fromEntity(UserEntity entity) {
return UserResponseDTO.builder()
.email(entity.getEmail())
.password(entity.getPassword())
.name(entity.getName())
.role(entity.getRole())
.build() ;
}
들어올 때는 Request DTO → Entity, 나갈 때는 Entity → Response DTO다. 방향에 따라 toEntity()와 fromEntity()로 이름이 갈린다.
MyBatis에서는 Mapper 인터페이스와 XML에 쿼리를 직접 적었지만, JPA는 인터페이스만 선언한다.
@Repository
public interface UserRepository extends JpaRepository<UserEntity, String>{
// 사용자 정의 메서드 추가
// findByPropertyName, findByPropertyNameAndPropertyName
// findByEmailAndPassword()
public Optional<UserEntity> findByEmailAndPassword(String email, String password) ;
}
JpaRepository<UserEntity, String>의 두 번째 타입 파라미터는 PK 타입이다. UserEntity의 @Id가 String email이라 String이 들어간다.
MyBatis의 Mapper는 framework가 관리하는 대상이 아니었지만, Repository는 framework가 관리한다. 즉 Bean에 등록되어 주입받아 쓴다.
기본 CRUD(save(), findById(), existsById() 등)는 상속으로 이미 제공되고, 그 외에는 findByPropertyName 형태로 메서드 이름만 선언하면 된다. 조건이 두 개면 And로 잇는다. 반환 타입이 Optional<UserEntity>인 것은 조건에 맞는 값이 없을 수 있기 때문이다.
signUp()은 DTO를 Entity로 바꿔 저장하고, 저장 결과 Entity를 다시 Response DTO로 바꿔 돌려준다.
@Transactional
public UserResponseDTO signUp(UserRequestDTO request) {
System.out.println("debug >>>> user service signUp");
// case 01
UserEntity entity = UserRequestDTO.toEntity(request) ;
return UserResponseDTO.fromEntity(userRepository.save(entity));
}
같은 처리를 Optional 체이닝으로 쓰는 방법도 소개됐다. 중복 가입을 existsById()로 걸러내고 저장까지 한 흐름으로 잇는 형태다.
Optional.of(request)
.filter(req -> !userRepository.existsById(request.getEmail()))
.map(req -> userRepository.save(req.toEntity(request)))
.map(req -> UserResponseDTO.fromEntity(req))
.orElseThrow(() -> new LoginFailException("User SignUp Fail!!"));
현업을 고려하면 이런 방법도 있다는 설명이었고, 잘 안 들어오면 case 01만 이해해도 된다고 했다. 실습 파일에는 주석 상태로 남겨 두었다.
signIn()은 findByEmailAndPassword()로 조회하고, 없으면 orElseThrow()로 LoginFailException을 던진다.
@Transactional
public Map<String, Object> signIn(UserRequestDTO request) {
System.out.println("debug >>>> user service signIn");
// plain text version
UserEntity entity = userRepository
.findByEmailAndPassword(request.getEmail(), request.getPassword())
.orElseThrow(() -> new LoginFailException("SignIn Fail!!"));
토큰 발급 자리는 잡아만 두고 JwtProvider가 고정 문자열을 돌려준다. JWT를 아직 제대로 도입하지 않아 흉내만 내는 단계다.
@Component
public class JwtProvider {
public String createAT(String email) {
return "Bearer XXXXXXX" ;
}
public String createRT(String email) {
return "xxxxxxxxxxxxxxxx" ;
}
}
@SpringBootTest로 컨테이너를 띄우고 @Autowired로 repository를 주입받아 확인했다. @Transactional이 붙어 있어 테스트가 끝나면 롤백된다.
@SpringBootTest
@Transactional
public class UserApplicationTests {
@Autowired
private UserRepository userRepository ;
@Autowired
private Environment env ;
@Test
public void checkEnv() {
System.out.println("debug >>>> DB Driver : "+env.getProperty("DB_DRIVER"));
System.out.println("debug >>>> DB Driver : "+env.getProperty("DB_URL"));
System.out.println("debug >>>> DB Driver : "+env.getProperty("DB_USER"));
System.out.println("debug >>>> DB Driver : "+env.getProperty("DB_PASSWORD"));
}
checkEnv()는 .env 값이 제대로 올라왔는지 먼저 확인하는 용도다. 테스트 실행 시 .env 로딩 문제를 피하려고 application.yml에 import 설정이 들어가 있다.
# Junit 테스트시 .env 파일 로딩 문제 해결
config:
import: file:./.env[.properties]
저장과 조회는 repository 메서드를 직접 부른다.
@Test
public void signUp(){
System.out.println("debug >>>> signUp ");
System.out.println("debug >>>> signUp repository : "+userRepository);
UserRequestDTO request = UserRequestDTO.builder()
.email("admin@naver.com")
.password("123456789")
.name("임정섭")
.role("admin")
.build();
UserEntity saveEntity = userRepository.save(UserRequestDTO.toEntity(request));
System.out.println("debug >>>> entity : "+saveEntity);
System.out.println("debug >>>> response : "+UserResponseDTO.fromEntity(saveEntity));
}
@Test
public void signIn(){
System.out.println("debug >>>> signIn ");
System.out.println("debug >>>> signIN repository : "+userRepository);
// select * from table where email = ? and password = ? ;
UserRequestDTO request = UserRequestDTO.builder()
.email("admin@naver.com")
.password("123456789")
.build();
Optional<UserEntity> optional = userRepository.findByEmailAndPassword(request.getEmail(), request.getPassword());
System.out.println("debug >>>> entity : "+optional.get());
System.out.println("debug >>>> response : "+UserResponseDTO.fromEntity(optional.get()));
}
주석의 select * from table where email = ? and password = ?가 findByEmailAndPassword()로 만들어지는 쿼리다. 메서드 이름만 선언했는데 이 쿼리가 나가는 것이 MyBatis와 가장 크게 달라진 지점이다.
ddl-auto: create이므로 애플리케이션을 띄우면 엔티티 기준으로 테이블이 만들어진다. show-sql을 켜 두었기 때문에 Hibernate가 실행하는 DDL이 콘솔에 그대로 찍힌다.
Hibernate:
alter table if exists spring_jpa_blog_tbl
drop
foreign key if exists FKmdn3dvwd5hq1364xl6hw467rm
Hibernate:
alter table if exists spring_jpa_comment_tbl
drop
foreign key if exists FKt7bryppgy1bd0t7h2le7f3b6a
Hibernate:
drop table if exists spring_jpa_blog_tbl
Hibernate:
drop table if exists spring_jpa_comment_tbl
Hibernate:
drop table if exists spring_jpa_user_tbl
Hibernate:
create table spring_jpa_blog_tbl (
blog_id integer not null auto_increment,
email varchar(50) not null,
category VARCHAR(20) DEFAULT '전체' CHECK( CATEGORY IN ('전체', '개발', '생활', '취미', '일상')),
content varchar(255),
title varchar(255),
primary key (blog_id)
) engine=InnoDB
Hibernate:
create table spring_jpa_comment_tbl (
blog_id integer not null,
comment_id integer not null auto_increment,
comment varchar(255),
email varchar(255),
primary key (comment_id)
) engine=InnoDB
Hibernate:
create table spring_jpa_user_tbl (
email varchar(50) not null,
name varchar(255),
password varchar(255),
role varchar(255),
primary key (email)
) engine=InnoDB
Hibernate:
alter table if exists spring_jpa_blog_tbl
add constraint FKmdn3dvwd5hq1364xl6hw467rm
foreign key (email)
references spring_jpa_user_tbl (email)
Hibernate:
alter table if exists spring_jpa_comment_tbl
add constraint FKt7bryppgy1bd0t7h2le7f3b6a
foreign key (blog_id)
references spring_jpa_blog_tbl (blog_id)
로그를 순서대로 보면 외래키 제약을 먼저 끊고 → 테이블 세 개를 drop 하고 → 다시 create 한 뒤 → 외래키를 붙인다. create라서 실행할 때마다 기존 테이블이 날아간다.
엔티티에 적은 것이 DDL의 어디로 갔는지 대응이 보인다.
| 엔티티 | 생성된 DDL |
|---|---|
@Table(name = "SPRING_JPA_BLOG_TBL") | create table spring_jpa_blog_tbl |
@GeneratedValue(strategy = IDENTITY) | blog_id integer not null auto_increment |
@Id | primary key (blog_id) |
@Column(columnDefinition = ...) | category VARCHAR(20) DEFAULT '전체' CHECK(...) |
@JoinColumn(name = "email") + optional = false | email varchar(50) not null + add constraint ... foreign key (email) |
@Column(length = 50, nullable = false) | email varchar(50) not null |
@OneToMany 쪽은 mappedBy로 주인을 넘겼기 때문에 컬럼이 따로 생기지 않는다. 길이를 지정하지 않은 String 필드는 전부 varchar(255)가 됐다.
필드 이름이 컬럼에서는 blogId → blog_id, commentId → comment_id로 바뀌어 생성됐다.
제약 이름을 따로 주지 않은 외래키는 FKmdn3dvwd5hq1364xl6hw467rm처럼 자동으로 붙는다.
HeidiSQL에서는 이렇게 만들어진 테이블 세 개와 외래키가 실제로 들어갔는지 확인했다. DDL을 직접 쓰지 않고 엔티티에서 테이블이 나오는 방향이라, 스키마를 바꾸려면 SQL이 아니라 엔티티를 고치게 된다.
UserController에는 Swagger 어노테이션이 붙어 있어 브라우저에서 명세와 응답 코드를 확인할 수 있다.
@Tag(name = "User API", description = "사용자 생성과 로그인 관련 API 명세서")
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
@Operation(summary = "회원가입", description = "신규가입(email, password, name)")
@ApiResponses(
{
@ApiResponse(responseCode = "201", description = "가입성공"),
@ApiResponse(responseCode = "400", description = "유효성검사실패"),
@ApiResponse(responseCode = "500", description = "가입실패")
}
)
@PostMapping("/signUp")
@Tag는 API 묶음 이름, @Operation은 개별 엔드포인트 설명, @ApiResponses는 상태 코드별 응답을 문서에 표시한다. 컨트롤러와 서비스 코드는 MyBatis 때와 같고 repository 아래만 JPA로 바뀐 상태라, 화면에서 보이는 명세도 그대로다.
FetchType.LAZY의 import가 잘못 잡혔다CommentEntity에 @ManyToOne(fetch = FetchType.LAZY, ...)를 쓰는데 빌드가 실패했다. import를 보니 이렇게 들어가 있었다.
import net.bytebuddy.dynamic.TypeResolutionStrategy.Lazy;
Lazy라는 이름을 가진 클래스가 의존성 안에 여러 개 있어서, 자동 import가 jakarta.persistence.FetchType이 아닌 쪽을 골라 넣은 것이다. 잘못된 import를 지우고 다시 잡아서 해결했다.
import jakarta.persistence.FetchType;