개요
졸작을 위해 만든 프로젝트를 정리
사용기술
- Spring boot
- JPA
- thymeleaf
- MySQL
- H2(테스트 용도)
도메인 설계
기본 시간 사용자 정보 작성
위 ERD를 보면 알 수 있듯이 저장되는 거의 모든 엔티티에 시간정보와 사용자 정보를 기본으로 입력을 하도록 설정을 하였다
모든 엔티티에 직접 작성하여 넣을 수도 있지만 하나의 클래스로 도출하여 값만 상속을 받을 수 있도록 설계를 하였다
하지만 어느 엔티티는 시간정보만 다른 엔티티느 시간, 사용자 정보둘다 필요로 하기 때문에 위 문제는 아래와 같이 두개의 클래스로 나누어서 사용을 하였다
@Getter
@MappedSuperclass
public class TimeInfo {
@Column(name = "CREATED_AT", updatable = false)
private LocalDateTime createdAt;
@Column(name = "MODIFIED_AT")
private LocalDateTime modifiedAt;
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
modifiedAt = now;
}
@PreUpdate
public void preUpdate() {
LocalDateTime now = LocalDateTime.now();
modifiedAt = now;
}
}
@Getter
@NoArgsConstructor
@MappedSuperclass
public class TimeAndPersonInfo extends TimeInfo{
@Column(name = "CREATED_BY")
private String createdBy;
@Column(name = "MODIFIED_BY")
private String modifiedBy;
public TimeAndPersonInfo(String createdBy) {
this.createdBy = createdBy;
this.modifiedBy = createdBy;
}
}
엔티티 작성
어려웠던 점
이전에 공부를 할 때는 항상 @GeneratedValue
만을 사용하여 DB에서 정해주는 임의의 Auto Increment값(인조키)을 사용하여 공부를 해왔지만 이번에는 몇몇 엔티티에서는 자연키, 혹은 복합키를 사용하도록 설정을 하여 해당 부분에서 약간의 어려움이 있었다
자연키 설정 시 주의사항
우선 Spring data JPA를 사용하여 저장하는 로직을 살펴보면 하나의 문제점이 있다
public String join(User user) throws DuplicatedUserException {
userRepository.save(user);
return user.getId();
}
위 코드는 추가적인 검증로직은 제거하고 간단히 저장하고 아이디를 반환하는 서비스 계층의 로직이다
단순히 코드만 보면 저장을 하는 것 같아 보이지만 실행을 하게 되면 select 후 insert 쿼리가 나가는 것을 확인 할 수 있다
왜 이렇게 두개의 쿼리가 같이 나가는지 내부 코드를 확인해 보면 엔티티가 새로운게 아니라고 판단되어 persist가 아닌 merge(병합)이 이루워졌기 때문이다
@Transactional
@Override
public <S extends T> S save(S entity) {
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
간단하게 debug를 통해서 isNew가 어디서 실행이 되는지 확인을 해보자
Persistable을 상속받은 JpaPersistableEntityInformation
에서 실행이 되는 것을 확인 할 수 있고 아래와 같은 코드가 실행이 되는 것을 확인 할 수 있다
@Transient
@Override
public boolean isNew() {
return null == getId();
}
EntityInformation을 어떤 객체로 주입할지 담당하는 메소드
Persistable을 구현하면 구현한 Entity객체로 판단
@SuppressWarnings({ "rawtypes", "unchecked" })
public static <T> JpaEntityInformation<T, ?> getEntityInformation(Class<T> domainClass, EntityManager em) {
Metamodel metamodel = em.getMetamodel();
if (Persistable.class.isAssignableFrom(domainClass)) {
return new JpaPersistableEntityInformation(domainClass, metamodel);
} else {
return new JpaMetamodelEntityInformation(domainClass, metamodel);
}
}
그럼 우리는 Persistable을 직접 상속을 받아서 isNew()
를 재정의 하여 사용을 하면된다
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Table(name = "USERS")
@ToString(of = {"id", "name"})
public class User extends TimeInfo implements Persistable<String> {
@Id
@Column(name = "USER_ID")
private String id;
@Column(name = "PASSWORD")
private String password;
@Column(name = "NAME")
private String name;
@Override
public boolean isNew() {
return getCreatedAt() == null;
}
}
결과적으로 위와 같은 코드가 된다
createAt값은 TimeInfo클래스에 정의되어 있는데 @PrePersist
를 통해서 엔티티메니저에 영속 상태가 되기전 즉 save
함수에서 em.persist()
가 실행이 되기 직전에 값을 할당하기 때문에 isNew()가 실행 될때는 null값을 가지게 되어 새로운 엔티티로 인식이 되게 된다
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
createdAt = now;
modifiedAt = now;
}
복합키 작성
@EmbeddedId
와 @IdClass
두가지 방식이 있다고 하는데 현재 프로젝트에서는 @EmbeddedId
를 사용하여 구현을 하였다
찾아보니 @EmbeddedId
는 객체지향에 가깝고 @IdClass
는 DB에 더 가깝다고 하는데 후자는 사용을 해보지 않아서 아직은 차이를 자세히는 모르겠다 추후 두개를 비교 정리를 하겠다
이 부분은 크게 어렵다고는 느껴지진 않았지만 처음써보는 것이고 약간의 해매이는 부분이 없지는 않았다
@Getter
@Embeddable
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class FriendId implements Serializable {
@Column(name = "USER_ID")
private String userId;
@Column(name = "FRIEND_ID")
private String friendId;
}
친구에 관련된 복합키이다 복합키를 사용하는데 있어서는 약간의 규약? 규칙이 있다
- Serializable 인터페이스 구현
- @Embeddeable 어노테이션 사용
- equals, hashCode 구현
- 기본 생성자, 클래스 public
복합키를 사용한 클래스는 다음과 같다
@Entity
@Getter @Setter
@NoArgsConstructor
@AllArgsConstructor
@ToString(of = {"userId", "friendId"})
@Table(name = "FRIEND")
public class Friend {
@EmbeddedId
private FriendId id;
@MapsId("userId")
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "USER_ID")
private User userId;
@MapsId("friendId")
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "FRIEND_ID")
private User friendId;
}
식별관계로 엔티티가 구성이 되어있기 때문에 @MapsId
를 통해서 연관관계를 지정을 해주면 된다
다음으로 어떻게 사용되는지에 대해서 알아 보자
- save
User userA = userRepository.findById("userA").get();
User userB = userRepository.findById("userB").get();
FriendId id = new FriendId(userA.getId(), userB.getId());
Friend friend = new Friend(id, userA, userB);
friendRepository.save(friend);
- find
User userA = userRepository.findById("userA").get();
User userB = userRepository.findById("userB").get();
Friend friend = friendRepository.findById(new FriendId(userA.getId(), userB.getId())).get();