중앙해커톤 리팩토링-1

EauLune01·2026년 2월 13일

ggolist-refactor

목록 보기
1/3

중앙 해커톤 때 서로 다른 객체 3개를 DB에서 전부 긁어와 정말 복잡한 방법으로 거기서 데이터를 추출해 반환했던 기억이 난다. 그때는 CRUD와 @Query를 이용한 쿼리 메소드 정도밖에 할 줄 몰랐다. 중앙 해커톤 때 같은 백엔드 팀원과 같이 ERD와 API 명세서 등 같이 짰었는데, 그때는 상속을 이용해 엔티티를 짜는 방법도 몰랐다. 여름방학 이후 김영한 선생님의 JPA 교재를 읽고, QueryDSL을 공부하며 내가 여름방학에 정말 많은 삽질을 GPT와 했구나 깨달았다. 그래서 그때 짰던 모든 엔티티들부터 싹 다 리팩토링을 해보려고 한다. 개인적으로 데모데이 때보다, 이때 훨씬 더 개발 기간과 개발할 로직도 많았던 것 같다. 내가 구현했던 목록 중, Google Cloud API를 호출해 문장들을 원하는 언어로 번역하는 기능은, 따로 번역 API 호출 실습에 정리해두었다. 그래서 이 기능을 빼고 리팩토링을 진행하였다.

리팩토링 목록

  • 엔티티
  • 세션 인증->JWT 인증 (회원가입, 로그인, 토큰 재발급, 로그아웃)
  • 관심 카테고리 리스트 토글, 내 관심 카테고리 조회
  • 필터 조건별 이벤트 목록 조회
  • 이번주 팝업 스테이션 조회
  • 전체 카테고리별 메인 피드 조회
  • 단일 카테고리 상세 피드 조회

이 중, 전체 카테고리별 메인 피드 조회와 단일 카테고리 상세 피드 조회가 지옥이었다. 엔티티 3개를 다 긁어와 정렬하고 반환하는 건데 조건이 정말 복잡해서 N+1 문제도 엄청 터졌다. 그때는, 구현에 만족을 했어서... 이번에 QueryDSL을 이용해 리팩토링을 진행하였다.

엔티티 설계

BaseEntity

@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이 잘 생성이 된다.

User

@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이다.

BaseEntity

@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을 만들었었는데 각각의 엔티티의 필드가 너무 길어지고 셋이 겹치는 필드도 많아서 아쉬웠었다.

Store

@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이다.

BaseFavorite

@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에 공통으로 들어가는 필드를 모아놨다. 하나밖에 없지만, 일부러 이렇게 만들었다.(학습용)

FavoriteStore

@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);
    }
}

FavoriteEvent

@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);
    }
}

FavoritePopup

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);
    }
}

ExpiredCleanupService

@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에서 삭제하는 클래스이다.

PlaceScheduler

@Component
@RequiredArgsConstructor
public class PlaceScheduler {

    private final ExpiredCleanupService expiredCleanupService;

    @Scheduled(cron = "0 0 0 * * *")
    public void runCleanup() {
        expiredCleanupService.cleanupExpiredPlaces();
    }
}

매일 00시 00분이 되면 cleanupExpiredPlaces() 메서드를 수행하는 주체이다. 애플리케이션에 @EnableScheduling를 꼭 붙여줘야 실행이 된다.

UserRepository

boolean existsByEmail(String email);
    Optional<User> findByEmail(String email);

FavoriteRepository

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);
  • @Modifying(clearAUtomaticcaly=true)를 설정해줘야 함.(연산 후 영속성 컨텍스트 초기화)
  • 벌크 연산: 영속성 컨텍스트를 완전히 무시하고 DB에 바로 SQL을 날리기 때문

애플리케이션 예시

@EnableJpaAuditing
@EnableScheduling
@SpringBootApplication
public class RefactorApplication {

	public static void main(String[] args) {
		SpringApplication.run(RefactorApplication.class, args);
	}

}

BaseEntity, BasePlace, BaseFavorite의 부모 추상 클래스를 만들어서, 다른 엔티티를 더 깔끔히 만들 수 있었던 것 같다. 다음 시간에는 JWT 인증 구현을 정리해볼 예정이다.

profile
Séoul

0개의 댓글