중앙 해커톤 때 서로 다른 객체 3개를 DB에서 전부 긁어와 정말 복잡한 방법으로 거기서 데이터를 추출해 반환했던 기억이 난다. 그때는 CRUD와 @Query를 이용한 쿼리 메소드 정도밖에 할 줄 몰랐다. 중앙 해커톤 때 같은 백엔드 팀원과 같이 ERD와 API 명세서 등 같이 짰었는데, 그때는 상속을 이용해 엔티티를 짜는 방법도 몰랐다. 여름방학 이후 김영한 선생님의 JPA 교재를 읽고, QueryDSL을 공부하며 내가 여름방학에 정말 많은 삽질을 GPT와 했구나 깨달았다. 그래서 그때 짰던 모든 엔티티들부터 싹 다 리팩토링을 해보려고 한다. 개인적으로 데모데이 때보다, 이때 훨씬 더 개발 기간과 개발할 로직도 많았던 것 같다. 내가 구현했던 목록 중, Google Cloud API를 호출해 문장들을 원하는 언어로 번역하는 기능은, 따로 번역 API 호출 실습에 정리해두었다. 그래서 이 기능을 빼고 리팩토링을 진행하였다.
이 중, 전체 카테고리별 메인 피드 조회와 단일 카테고리 상세 피드 조회가 지옥이었다. 엔티티 3개를 다 긁어와 정렬하고 반환하는 건데 조건이 정말 복잡해서 N+1 문제도 엄청 터졌다. 그때는, 구현에 만족을 했어서... 이번에 QueryDSL을 이용해 리팩토링을 진행하였다.
@MappedSuperclass
@Getter
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseTimeEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
BaseTiemEntity를 추상 클래스로(DB에 안 만들어짐) 만들어 모든 클래스에서 이를 상속시켰다. @EnableJpaAuditing를 애플리케이션에 꼭 붙여줘야 createdAt, updatedAt이 잘 생성이 된다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "users")
@SQLDelete(sql = "UPDATE users SET status = 'DELETED', deleted_at = CURRENT_TIMESTAMP WHERE user_id = ?")
@SQLRestriction("status = 'ACTIVE'")
public class User extends BaseTimeEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@ElementCollection(fetch = FetchType.EAGER)
@Enumerated(EnumType.STRING)
private List<Category> categories = new ArrayList<>();
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Status status;
private String refreshToken;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@OneToOne(mappedBy = "owner", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private Store store;
@OneToMany(mappedBy = "owner", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Popup> popups = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<FavoriteStore> favoriteStores = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<FavoritePopup> favoritePopups = new ArrayList<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<FavoriteEvent> favoriteEvents = new ArrayList<>();
private User(String email, String password, Role role) {
this.email = email;
this.password = password;
this.role = role;
this.status = Status.ACTIVE;
this.categories = new ArrayList<>();
}
public static User create(String email, String password, Role role, List<Category> categories) {
User user = new User(email, password, role);
user.setupInitialCategories(categories);
return user;
}
/******************** 비즈니스 메서드 ********************/
public void toggleCategory(Category category) {
if (this.categories.contains(category)) {
if (this.categories.size() <= 1) {
throw new InvalidCategorySizeException("카테고리는 최소 1개 이상 선택해야 합니다.");
}
this.categories.remove(category);
} else {
if (this.categories.size() >= 3) {
throw new InvalidCategorySizeException("카테고리는 최대 3개까지 선택 가능합니다.");
}
this.categories.add(category);
}
}
private void setupInitialCategories(List<Category> initialCategories) {
List<Category> targetCategories = (initialCategories != null) ?
new ArrayList<>(initialCategories) : new ArrayList<>();
validateInitialCategories(targetCategories);
this.categories = targetCategories;
}
private void validateInitialCategories(List<Category> target) {
if (this.role == Role.MERCHANT) {
if (target.size() != 1) {
throw new InvalidCategorySizeException("가게 카테고리는 반드시 1개를 설정해야 합니다.");
}
} else {
if (target.isEmpty()) {
throw new InvalidCategorySizeException("카테고리는 최소 1개 이상 선택해야 합니다.");
}
if (target.size() > 3) {
throw new InvalidCategorySizeException("카테고리는 최대 3개까지 선택 가능합니다.");
}
}
}
public void updateRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
public void invalidateRefreshToken() {
this.refreshToken = null;
}
public boolean isMerchant() { return this.role == Role.MERCHANT; }
public boolean isUser() { return this.role == Role.USER; }
/******************** UserDetails 구현 ********************/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + role.name()));
}
@Override public String getPassword() { return this.password; }
@Override public String getUsername() { return this.email; }
@Override public boolean isAccountNonExpired() { return true; }
@Override public boolean isAccountNonLocked() { return true; }
@Override public boolean isCredentialsNonExpired() { return true; }
@Override public boolean isEnabled() { return this.status == Status.ACTIVE; }
}
회원탈퇴까지 고려해서 설계했는데, 굳이 회원 탈퇴는 만들지 않았다. 카테고리는 최대 3개이기 때문에, 그냥 Eager로 한번에 로딩시켰다. 회원 가입 시 카테고리 설정까지 같이 하기 때문에, 카테고리 관련된 비즈니스 로직도 포함시켰다. UserDetails는 서비스의 유저 엔티티와 Spring Security 를 이어주는 역할을 하는 인터페이스이다. 우리가 Spring Security를 사용하므로, 우리는 UserDetails를 implement해야 한다. Role은 User와 Merchant 2개이다.
public enum Role {
USER,
MERCHANT
}
public enum Status {
ACTIVE,
DELETED
}
유저와 관련된 enum이다.
@Getter
@MappedSuperclass
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class BasePlace extends BaseTimeEntity {
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String address;
private String intro;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Category category;
@Column(nullable = false)
private String thumbnail;
@ElementCollection
private List<String> images = new ArrayList<>();
@Column(nullable = false)
private LocalTime startTime;
@Column(nullable = false)
private LocalTime endTime;
@Column(name = "like_count", nullable = false)
private int likeCount;
protected BasePlace(String name, String address, String intro, Category category,
String thumbnail, List<String> images, LocalTime startTime, LocalTime endTime) {
this.name = name;
this.address = address;
this.intro = intro;
this.category = category;
this.thumbnail = thumbnail;
this.images = (images != null) ? images : new ArrayList<>();
this.startTime = startTime;
this.endTime = endTime;
this.likeCount = 0;
}
/********** 비즈니스 메서드 **********/
public void setLikeCountForTesting(int likeCount) {
this.likeCount = likeCount;
}
public void increaseLikeCount() {
this.likeCount++;
}
public void decreaseLikeCount() {
if (this.likeCount > 0) {
this.likeCount--;
}
}
}
김영한 선생님의 JPA 책을 보면 상속하는데 3가지 방법이 있다. 나는 그중 자식 엔티티들에게 필드 매핑 정보를 물려주고 테이블은 생성하지 않는 @MappedSuperClass를 사용하여 이 추상 클래스를 만들었다. 작년 여름 방학에는 이걸 만들지 않고 Store, Event, Popup을 만들었었는데 각각의 엔티티의 필드가 너무 길어지고 셋이 겹치는 필드도 많아서 아쉬웠었다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "stores")
public class Store extends BasePlace {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "store_id")
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false, unique = true)
private User owner;
@Column(nullable = false)
private String number;
private Store(User owner, String name, String address, String number, String intro,
Category category, String thumbnail, List<String> images,
LocalTime startTime, LocalTime endTime) {
super(name, address, intro, category, thumbnail, images, startTime, endTime);
this.owner = owner;
this.number = number;
}
public static Store create(User owner, String name, String address, String number, String intro,
Category category, String thumbnail, List<String> images,
LocalTime startTime, LocalTime endTime) {
return new Store(owner, name, address, number, intro, category, thumbnail, images, startTime, endTime);
}
}
``` java
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "events")
public class Event extends BasePlace {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "event_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "store_id", nullable = false)
private Store store;
@Column(nullable = false, columnDefinition = "TEXT")
private String description;
@Column(nullable = false)
private LocalDate startDate;
@Column(nullable = false)
private LocalDate endDate;
@OneToMany(mappedBy = "event", cascade = CascadeType.ALL, orphanRemoval = true)
private List<FavoriteEvent> favorites = new ArrayList<>();
private Event(Store store, String name, String description, String intro,
Category category, String thumbnail, List<String> images,
LocalDate startDate, LocalDate endDate, LocalTime startTime, LocalTime endTime) {
super(name, store.getAddress(), intro, category, thumbnail, images, startTime, endTime);
this.store = store;
this.description = description;
this.startDate = startDate;
this.endDate = endDate;
}
public static Event create(Store store, String name, String description, String intro,
Category category, String thumbnail, List<String> images,
LocalDate startDate, LocalDate endDate, LocalTime startTime, LocalTime endTime) {
return new Event(store, name, description, intro, category, thumbnail, images, startDate, endDate, startTime, endTime);
}
/******************** 비즈니스 메서드 ********************/
public boolean isExpired(LocalDate now) {
return now.isAfter(this.endDate);
}
public boolean isOngoing(LocalDate now) {
return (now.isEqual(this.startDate) || now.isAfter(this.startDate))
&& (now.isEqual(this.endDate) || now.isBefore(this.endDate));
}
}
이벤트는 무조건 Store 내에서 생성하도록 하였다. 이벤트와 팝업은 진행 기간이 있기 때문에 두 엔티티만 그것과 관련된 비즈니스 메서드를 추가하였다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "popups")
public class Popup extends BasePlace {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "popup_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@Column(nullable = false)
private String description;
@Column(nullable = false)
private LocalDate startDate;
@Column(nullable = false)
private LocalDate endDate;
@OneToMany(mappedBy = "popup", cascade = CascadeType.ALL, orphanRemoval = true)
private List<FavoritePopup> favorites = new ArrayList<>();
private Popup(User owner, String name, String address, String description, String intro,
Category category, String thumbnail, List<String> images,
LocalDate startDate, LocalDate endDate, LocalTime startTime, LocalTime endTime) {
super(name, address, intro, category, thumbnail, images, startTime, endTime);
this.owner = owner;
this.description = description;
this.startDate = startDate;
this.endDate = endDate;
}
public static Popup create(User owner, String name, String address, String description, String intro,
Category category, String thumbnail, List<String> images,
LocalDate startDate, LocalDate endDate, LocalTime startTime, LocalTime endTime) {
return new Popup(owner, name, address, description, intro, category, thumbnail, images, startDate, endDate, startTime, endTime);
}
/******************** 비즈니스 메서드 ********************/
public boolean isExpired(LocalDate now) {
return now.isAfter(this.endDate);
}
public boolean isOngoing(LocalDate now) {
return (now.isEqual(this.startDate) || now.isAfter(this.startDate))
&& (now.isEqual(this.endDate) || now.isBefore(this.endDate));
}
}
public enum Category {
CAFE,
FOOD,
SHOPPING,
ENTERTAINMENT,
K_POP,
CLUB,
ETC
}
public enum Filter {
POPULAR, // 현재 진행 중인 이벤트 중 좋아요 순
ONGOING, // 현재 진행 중인 이벤트 중 최신 등록 순
CLOSING_TODAY, // 오늘 종료됨
UPCOMING // 앞으로 시작할 예정
}
BasePlace와 관련된 enum이다.
@Getter
@MappedSuperclass
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class BaseFavorite extends BaseTimeEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
protected BaseFavorite(User user) {
this.user = user;
}
}
FavoritePopup,FavoriteStore, FavoriteEvent에 공통으로 들어가는 필드를 모아놨다. 하나밖에 없지만, 일부러 이렇게 만들었다.(학습용)
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(
name = "favorite_stores",
uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "store_id"})}
)
public class FavoriteStore extends BaseFavorite {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "store_id", nullable = false)
private Store store;
private FavoriteStore(User user, Store store) {
super(user);
this.store = store;
}
public static FavoriteStore create(User user, Store store) {
return new FavoriteStore(user, store);
}
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(
name = "favorite_events",
uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "event_id"})}
)
public class FavoriteEvent extends BaseFavorite {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id", nullable = false)
private Event event;
private FavoriteEvent(User user, Event event) {
super(user);
this.event = event;
}
public static FavoriteEvent create(User user, Event event) {
return new FavoriteEvent(user, event);
}
}
ntity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(
name = "favorite_popups",
uniqueConstraints = {@UniqueConstraint(columnNames = {"user_id", "popup_id"})}
)
public class FavoritePopup extends BaseFavorite {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "popup_id", nullable = false)
private Popup popup;
private FavoritePopup(User user, Popup popup) {
super(user);
this.popup = popup;
}
public static FavoritePopup create(User user, Popup popup) {
return new FavoritePopup(user, popup);
}
}
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class ExpiredCleanupService {
private final PopupRepository popupRepository;
private final EventRepository eventRepository;
private final FavoritePopupRepository favoritePopupRepository;
private final FavoriteEventRepository favoriteEventRepository;
public void cleanupExpiredPlaces() {
LocalDate today = LocalDate.now();
log.info("[Scheduler] 만료 데이터 벌크 삭제 시작: {}", today);
try {
favoritePopupRepository.deleteByExpiredPopups(today);
int deletedPopups = popupRepository.deleteByEndDateBefore(today);
favoriteEventRepository.deleteByExpiredEvents(today);
int deletedEvents = eventRepository.deleteByEndDateBefore(today);
log.info("[Scheduler] 삭제 완료 - 팝업: {}건, 이벤트: {}건", deletedPopups, deletedEvents);
} catch (Exception e) {
log.error("[Scheduler] 만료 데이터 삭제 중 에러 발생: {}", e.getMessage());
}
}
}
기간이 경과된 팝업과 이벤트를 DB에서 삭제하는 클래스이다.
@Component
@RequiredArgsConstructor
public class PlaceScheduler {
private final ExpiredCleanupService expiredCleanupService;
@Scheduled(cron = "0 0 0 * * *")
public void runCleanup() {
expiredCleanupService.cleanupExpiredPlaces();
}
}
매일 00시 00분이 되면 cleanupExpiredPlaces() 메서드를 수행하는 주체이다. 애플리케이션에 @EnableScheduling를 꼭 붙여줘야 실행이 된다.
boolean existsByEmail(String email);
Optional<User> findByEmail(String email);
FavoritePopup,FavoriteEventRepository에서 벌크 삭제 연산을 구현하였다.
@Modifying(clearAutomatically = true)
@Query("DELETE FROM FavoritePopup fp WHERE fp.popup.id IN " +
"(SELECT p.id FROM Popup p WHERE p.endDate < :today)")
void deleteByExpiredPopups(@Param("today") LocalDate today);
@Modifying(clearAutomatically = true)
@Query("DELETE FROM FavoriteEvent fe WHERE fe.event.id IN " +
"(SELECT e.id FROM Event e WHERE e.endDate < :today)")
void deleteByExpiredEvents(@Param("today") LocalDate today);
@EnableJpaAuditing
@EnableScheduling
@SpringBootApplication
public class RefactorApplication {
public static void main(String[] args) {
SpringApplication.run(RefactorApplication.class, args);
}
}
BaseEntity, BasePlace, BaseFavorite의 부모 추상 클래스를 만들어서, 다른 엔티티를 더 깔끔히 만들 수 있었던 것 같다. 다음 시간에는 JWT 인증 구현을 정리해볼 예정이다.