지난 번까지는 각 테이블 간의 연관관계를 하나도 지정해주지 않고 기본적인 작동 방식만 알아보았다. 이번에는 PK-FK로 연관된 테이블의 관계를 엔티티에서 역시 맺어보고자 한다.

이 프로젝트의 테이블들의 상관관계는 다음과 같다.

상품과 관련된 prod 테이블이 있고, 상품을 조회하면 글/이미지 쌍으로 디테일이 있는 prod_detail 테이블이 있다. 두 테이블은 1(prod) : N(prod_detail) 관계이다. prod 테이블의 no 컬럼이 PK이며, prod_detail의 prod_no 컬럼이 prod 테이블을 참조하는 컬럼이다. 상품이 삭제되면 디테일도 삭제되어야 하기 때문에 양방향 관계를 가진다.

이 방향이라는 것은, 양방향/ 단방향이 있다.

테이블 개념에서 볼 때 PK-FK로 연관관계를 맺고 있으면 방향이랄 것이 없다.

하지만 JPA 환경에서 엔티티들은 기본적으로 단방향이다. Prod 엔티티에 @OneToMany 어노테이션으로 ProdDetail을 명시해주면 Prod->ProdDetail의 방향이 된다. 테이블의 양방향 상태처럼 만들어주려면 ProdDetail 엔티티에서도 @ManyToOne 설정을 해 주어야 한다.

 

@Getter
@Setter
@ToString
@Table(name = "prod")
@Entity
public class Prod {

	@Id
	@GeneratedValue
	@Column(name = "no")
	private Long no;
	private String name;
	private String thumbnailUrl;
	private Long originPrice;
	private Long discPrice;
	private String description;
	private LocalDateTime createdAt;
	@Transient
	private boolean inBasket;

	@PrePersist
	public void createdAt() {
		this.createdAt = LocalDateTime.now();
	}

	@OneToMany(mappedBy = "prod", fetch = FetchType.EAGER)
	private List<ProdDetail> detailList = new ArrayList<ProdDetail>();
}
@Getter
@Setter
@ToString
@Table(name = "prod_detail")
@Entity
public class ProdDetail {

	@Id
	private Long id;
//	@Column(name = "prod_no")
//	private Long prodNo;
	private String content;
	private String imageUrl;
	private LocalDateTime createdAt;

	@PrePersist
	public void createdAt() {
		this.createdAt = LocalDateTime.now();
	}

	@ManyToOne
	@JoinColumn(name = "prod_no", nullable = false, updatable = false)
	@JsonIgnore
	private Prod prod;

	public void setProd(Prod prod) {
		if (this.prod != null) {
			this.prod.getDetailList().add(this);
		}
		this.prod = prod;
		prod.getDetailList().add(this);
	}

}

@OneToMany의 mappedBy 속성은 양방향 매핑할 때 사용하는데 반대쪽 매핑의 필드 이름을 값으로 준다. 이렇게 되면 서로 참조할 수 있는 구조를 가진다.

1:N에서 N입장인 ProdDetail에서 @JoinColumn을 명시해주어야 연관관계를 관리하는 조인 테이블 전략을 기본으로 사용해서 매핑한다. 반드시 name 속성에 참조하는 테이블(prod)_기본키(no) 형태로 적어주어야 한다.

이렇게 하면 양방향 관계 설정이 된다.

 

상품을 조회할 때 상품과 디테일의 글/이미지 모두를 조회하는 API를 만들어보고자 한다.

@RestController
@RequestMapping("/prod")
public class ProdController {

	private static final Logger logger = LoggerFactory.getLogger(ProdController.class);

	@Autowired
	ProdService prodService;

	@GetMapping("/{id}")
	public ResponseVO findProdWithDetailByUser(@CookieValue(value = "accesstoken", required = false) String accesstoken,
			@PathVariable("id") Long no) throws Exception {
		logger.info("call findProdWithDetailByUser()");
		
		Optional<Prod> prodResult = prodService.findProdWithDetailByUser(accesstoken, no);
		ResponseVO result = new ResponseVO();
		result.setCode(HttpStatus.OK);
		result.setMessage("SUCCESS");
		result.setData(prodResult);
		return result;
	}	
}
@Service
public class ProdService {

	private static final Logger logger = LoggerFactory.getLogger(ProdService.class);

	@Autowired
	TokenRepository tokenRepo;

	@Autowired
	ProdRepository prodRepo;

	@Autowired
	BasketRepository basketRepo;

	public Optional<Prod> findProdWithDetailByUser(String accesstoken, Long no) {
		logger.info("call findProdWithDetailByUser()");

		Optional<Prod> prodResult = prodRepo.findById(no);

		Token token = tokenRepo.findByToken(accesstoken);
		String email = token.getUserEmail();
		Basket basket = basketRepo.findByUserEmailAndProdNo(email, no);
		if (basket != null) {
			boolean inBasket = true;
			prodResult.get().setInBasket(inBasket);
			return prodResult;
		} else {
			return prodResult;
		}
	}

}

(로그인했을 시 장바구니에 담겨 있는지 없는지 여부를 확인하는 로직도 있다.)

findById 메소드를 사용할 것이기 때문에 레포지토리에 따로 메소드를 생성할 필요는 없다.

 

포스트맨을 사용해서 1번 상품을 조회한다.

성공적으로 상품을 조회한다.

콘솔을 확인하면, findById 메소드가 실행되었을 때 left outer join으로 prod_detail의 데이터까지 조회한 것을 확인할 수 있다.

 

@OneToMany, @ManyToOne 등의 설정은 JPA로 테이블 간의 관계 설정을 보다 손쉽게 해 주는  또 하나의 특징적인 기능이라는 것을 이번 프로젝트 때 알게 되었다.

참조한 블로그

목차

1. DAO, Service, Controller 작성

2. REST API를 사용하여 회원가입 기능 구현

이번에는 간단히 CRUD 기능을 사용하기 위해 DAO, Service, Controller를 작성하는 것에 대해 알아보고자 한다.

 

1. DAO, Service, Controller 작성

MyBatis 환경에서 DAO와 동일한 개념이 레포지토리 인터페이스이다. 여기서 JPA의 특징적인 점은 별도의 구현 클래스를 만들지 않고 인터페이스만 정의함으로써 기능을 사용할 수 있다는 것이다.

여기서 보통 Spring Data 모듈에서 제공하는 CrudRepository를 상속한다. 추가적으로 페이징 처리를 할 경우에는 PagingAndSortingRepository를 사용한다.

package com.codepresso.persistence;

import org.springframework.data.repository.CrudRepository;

import com.codepresso.domain.User;

public interface UserRepository extends CrudRepository<User, Long> {

}

 

이렇게 간단한 인터페이스만으로도 데이터 입력이 잘 되는지 보기 위해 간단히 컨트롤러와 서비스를 작성한다.

@RestController
@RequestMapping("/user")
public class UserController {
	
	@Autowired
	private UserService userService;
	
	@PostMapping("/signup")
	public User signUp(@RequestBody User user) {
		User userResult = userService.signUp(user);
		return userResult;
	}

}
@Service
public class UserService {

	@Autowired
	UserRepository userRepo;

	public User signUp(User user) {
		User userResult = userRepo.save(user);
		return userResult;
	}

}

userRepo에 save라는 메소드를 생성하지 않았지만 CrudRepository를 상속하였기 때문에 기본적인 CRUD의 메소드를 바로 사용할 수 있다. ㄹㅇ 편리 ㅎㅎ

CrudRepository가 기본적으로 제공하는 메소드는 다음과 같다.

Modifier and Type Method and Description
long count()

Returns the number of entities available.

void delete(T entity)

Deletes a given entity.

void deleteAll()

Deletes all entities managed by the repository.

void deleteAll(Iterable<? extends T> entities)

Deletes the given entities.

void deleteById(ID id)

Deletes the entity with the given id.

boolean existsById(ID id)

Returns whether an entity with the given id exists.

Iterable<T> findAll()

Returns all instances of the type.

Iterable<T> findAllById(Iterable<ID> ids)

Returns all instances of the type T with the given IDs.

Optional<T> findById(ID id)

Retrieves an entity by its id.

<S extends T>
S
save(S entity)

Saves a given entity.

<S extends T>
Iterable<S>
saveAll(Iterable<S> entities)

Saves all given entities.

그리고 createdAt 변수를 지난번처럼 정의하였더니 DB에 올라가질 않아서 엔티티가 되는 User 클래스는 다음과 같이 수정하였다. 참조

@Getter
@Setter
@ToString
@Entity
public class User {

	@Id
	private String email;
	private String name;
	private String birth;
	private String password;
	@Transient
	private String passwordCheck;
	private String gender;
	private LocalDateTime createdAt;

	@PrePersist
	public void createdAt() {
		this.createdAt = LocalDateTime.now();
	}

}

프로젝트를 실행하고 다음과 같이 포스트맨에서 RequestBody에 필요한 부분들을 입력해주었다.

정상 작동됨을 확인하였다.

 

2. REST API를 사용하여 회원가입 기능 구현

이메일 중복 체크와 비밀번호 확인을 할 수 있는 회원가입 기능을 구현하고자 한다.

@RestController
@RequestMapping("/user")
public class UserController {

	@Autowired
	private UserService userService;
	
	@PostMapping("/signup")
	public ResponseVO signUp(@RequestBody User user) throws Exception {
		
		int emailResult = userService.checkEmail(user);
		boolean pwResult = userService.checkPw(user);
		
		if (emailResult == 0 && pwResult == true) {
			User userResult = userService.signUp(user);
			ResponseVO result = new ResponseVO();
			result.setCode(HttpStatus.OK);
			result.setMessage("SUCCESS");
			result.setData(userResult);
			return result;
		} else {
			ResponseVO result = new ResponseVO();
			result.setCode(HttpStatus.INTERNAL_SERVER_ERROR);
			result.setMessage("FAIL");
			result.setData(null);
			return result;
		}
	}
    
}
@Service
public class UserService {

	@Autowired
	UserRepository userRepo;
	
	@Autowired
	TokenRepository tokenRepo;

	public int checkEmail(User user) {		
		String email = user.getEmail();
		int emailResult = userRepo.countByEmail(email);
		return emailResult;
	}
	
	public boolean checkPw(User user) {		
		logger.info("user: " + user);
		String password = user.getPassword();
		String passwordCheck = user.getPasswordCheck();
		
		if (password.equals(passwordCheck)) {
			return true;
		} else
			return false;
	}
	
	public User signUp(User user) {
		User userResult = userRepo.save(user);
		return userResult;
	}
    
}
@Repository
public interface UserRepository extends CrudRepository<User, String> {
	
	public int countByEmail(String email);
    
}

눈여겨 볼 메소드는

public int countByEmail(String email);

이다.

MyBatis를 사용했으면 매퍼 파일에

<select id="checkEmail" resultType="int">
	<![CDATA[
		SELECT COUNT(*) FROM user
		WHERE email = #{email};
	]]>
</select>

다음과 같이 적었어야 하지만 UserRepository에 count메소드만 선언해주면 위와 같은 쿼리를 실행한다.

보다 복잡한 쿼리는 어떻게 다루어야 하는지는 더 공부가 필요할 듯 하다.

목차

1. perspective 설정

2. 새 프로젝트 생성

3. application.properties 작성

4. 엔티티 클래스 매핑

 

* Git Repository에 업로드

기존에 MyBatis로 DB를 사용했던 것을 JPA로 전환하려고 한다.

작업한 코드는 여기에 업로드하였다.

 

1. perspective 설정

Spring 선택

 

2. 새 프로젝트 생성

Spring Starter Project 선택

정보 입력

5가지 의존성 추가

 

3. application.properties 작성

# DataSource Setting
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/discountak?characterEncoding=UTF-8&serverTimezone=UTC
spring.datasource.username=사용자이름
spring.datasource.password=비밀번호

# JPA Setting
spring.jpa.hibernate.ddl-auto=update
spring.jpa.generate-ddl=false
spring.jpa.show-sql=true
spring.jpa.database-platform=org.hibernate.dialect.MySQL5InnoDBDialect
spring.jpa.properties.hibernate.format_sql=true

# Logging Setting
logging.level.org.hibernate=info

참고로 본 프로젝트는 이 책을 참조하였다.

책은 H2를 사용했지만 나는 MySQL을 사용하니 DB 드라이버만 다르게 하고 거의 비슷하게 설정하였다.

여기서, 

spring.jpa.hibernate.ddl-auto=update

update로 설정해야 하는 이유는, 이미 저장해놓은 데이터들이 있는 스키마를 사용하기 때문에 create로 작성하면 테이블이 중복되는 오류가 발생한다.

스프링부트에서 JPA를 사용하기 위한 보다 자세한 설명은 여기에서 볼 수 있다.

@RunWith(SpringRunner.class)
@SpringBootTest
class DiscounbootApplicationTests {

	@Test
	void contextLoads() {
	}

}

src/test/java의 기본 테스트 클래스에 다음과 같이 작성한 후 실행하면 MySQL과 정상적으로 연결됨을 확인할 수 있다.

4. 엔티티 클래스 매핑

JPA는 DB의 테이블이 아닌 엔티티를 통해 데이터를 관리하기 때문에 테이블과 매핑할 엔티티 클래스를 작성해야 한다.

기존 MyBatis를 사용할 때 domain 패키지의 VO 클래스와 비슷한 개념이다.

@Getter
@Setter
@ToString
@Entity
public class User {

	@Id @GeneratedValue
	private String email;
	private String name;
	private String birth;
	private String password;
	@Transient
	private String passwordCheck;
	private String gender;
	@Temporal(value = TemporalType.TIMESTAMP)
	private Date createdAt;

}

@Entity는 User 클래스를 엔티티 처리한다.

@Id, @GenerateValue는 변수 email을 식별자로 처리한다.

@Transient는 특정 변수를 영속 필드에서 제외할 때 사용한다. 테이블에 존재하지 않는 변수이기 때문에 @Transient 처리하였다.

 

자바 애플리케이션으로 프로젝트를 실행하면 정상 작동한다.

 

더보기

에러

user 외의 다른 엔티티 클래스도 작성하고 JUnit 테스트를 실행했더니 갑자기 뜨는 

'no test found with test runner 'junit 5' 에러...

이럴 때에는 침착하게 이클립스를 종료하고 재실행하면 정상 작동된다.

이런 에러 나만 뜨나 ㅋ


지금까지의 작업을 MyBatis와 비교해보고자 한다. MyBatis로 작업을 했다면, 해야 하는 설정이 한두 가지가 아니다.

VO클래스는 공통적으로 작성하였지만, MyBatis 환경에서는 추가적인 작업이 더 필요하다.

먼저 VO의 알리아스를 설정해야 하며,

매퍼파일에 일일이 쿼리를 작성해주어야 하는 번거로움이 있다. 

반면, 프로젝트를 생성할 때 추가해주었던 JPA 의존성으로 인해 생성된 JPA 스타터는 JPA 연동에 필요한 라이브러리들과 복잡한 xml 설정을 자동으로 처리하는 효율성을 가지고 있다. 

 

다음 글에서는 DAO 단을 JPA환경에서 어떻게 작성하는지 알아보고자 한다.

 

* 번외: Git Repository 네이밍

작업한 것들을 Git에 하나씩 올리려다가 문득 레포지토리 네이밍에 대해 생각해보게 되었다.

1. 소문자 사용

2. _(밑줄)이 아닌 -(줄표) 사용

3. 구체적으로 명시하기

4. 일관되게 작성하기

참조한 사이트

+ Recent posts