Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
import konkuk.thip.common.util.CursorBasedList;
import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;

@Transactional(readOnly = true)
@Repository
@RequiredArgsConstructor
public class BookQueryPersistenceAdapter implements BookQueryPort {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
import konkuk.thip.comment.application.port.out.CommentLikeQueryPort;
import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Repository;

import java.util.Set;

@Transactional(readOnly = true)
@Repository
@RequiredArgsConstructor
public class CommentLikeQueryPersistenceAdapter implements CommentLikeQueryPort {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
import konkuk.thip.common.util.Cursor;
import konkuk.thip.common.util.CursorBasedList;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;

@Transactional(readOnly = true)
@Repository
@RequiredArgsConstructor
public class CommentQueryPersistenceAdapter implements CommentQueryPort {
Expand Down

This file was deleted.

26 changes: 26 additions & 0 deletions src/main/java/konkuk/thip/common/aop/FilterContextHolder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package konkuk.thip.common.aop;

public class FilterContextHolder {

public enum FilterMode {
ACTIVE_ONLY,
UNFILTERED
}

private static final ThreadLocal<FilterMode> context =
ThreadLocal.withInitial(() -> FilterMode.ACTIVE_ONLY);

public static FilterMode get() {
return context.get();
}

public static void set(FilterMode mode) {
context.set(mode);
}

static void clear() {
context.remove();
Comment on lines +10 to +22
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

중첩 @Unfiltered 호출에서 상태가 유실됩니다.

지금 API는 set()clear()만 가능해서, @Unfiltered 메서드 A 안에서 또 다른 @Unfiltered 메서드 B를 호출하면 B의 finally에서 clear()한 뒤 A의 남은 구간이 다시 ACTIVE_ONLY로 돌아갑니다. 이전 모드를 저장하고 finally에서 복원하는 방식으로 바꿔야 합니다.

🔧 예시 방향
-    public static void set(FilterMode mode) {
-        context.set(mode);
-    }
-
-    static void clear() {
-        context.remove();
-    }
+    static FilterMode push(FilterMode mode) {
+        FilterMode previous = context.get();
+        context.set(mode);
+        return previous;
+    }
+
+    static void restore(FilterMode previous) {
+        if (previous == null) {
+            context.remove();
+            return;
+        }
+        context.set(previous);
+    }

StatusFilterAspect 쪽에서는 around advice에서 이전 값을 받아 finally에서 restore(previous) 하도록 맞추면 됩니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/konkuk/thip/common/aop/FilterContextHolder.java` around lines
10 - 22, The ThreadLocal in FilterContextHolder currently only supports
set(FilterMode) and clear(), which loses the outer caller's mode when nested
`@Unfiltered` calls occur; modify FilterContextHolder to save and restore previous
values by adding a method like setAndGetPrevious(FilterMode) or a
restore(FilterMode) (or make set return the previous FilterMode) so callers can
capture the prior mode, and change StatusFilterAspect's around advice to call
get() before setting the new mode and then in finally restore(previous) (or call
clear() only when previous was null) so nested invocations correctly revert to
the outer FilterMode instead of defaulting back to ACTIVE_ONLY.

}

Comment on lines +21 to +24
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clear() method is defined with package-private visibility, making it inaccessible from outside the konkuk.thip.common.aop package. It is currently not called from anywhere in the codebase (neither production code nor tests). If it's intended for use as a cleanup mechanism (e.g., after each HTTP request via a filter), it needs to be made public. If it's intended only for unit tests of the StatusFilterAspect, it should be called from within the test. Otherwise, this is dead code and should be removed.

Suggested change
static void clear() {
context.remove();
}

Copilot uses AI. Check for mistakes.
private FilterContextHolder() {}
}
114 changes: 33 additions & 81 deletions src/main/java/konkuk/thip/common/aop/StatusFilterAspect.java
Original file line number Diff line number Diff line change
@@ -1,115 +1,73 @@
package konkuk.thip.common.aop;

import jakarta.persistence.EntityManager;
import konkuk.thip.common.entity.StatusType;
import konkuk.thip.common.exception.InternalServerException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.hibernate.Session;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronizationManager;

import java.util.List;

import static konkuk.thip.common.exception.code.ErrorCode.PERSISTENCE_TRANSACTION_REQUIRED;
import static konkuk.thip.common.aop.FilterContextHolder.FilterMode.ACTIVE_ONLY;
import static konkuk.thip.common.aop.FilterContextHolder.FilterMode.UNFILTERED;

@Slf4j
@Order(Ordered.LOWEST_PRECEDENCE) // aspect order 명시 (우선순위 최하위 -> transaction aspect 이후에 동작)
@Aspect
@Component
@RequiredArgsConstructor
public class StatusFilterAspect {

private final EntityManager em;

/**
* Hibernate Session은 thread-not-safe 하므로 반드시 트랜잭션 경계 내에서만 사용해야함
* 현재 스레드에 바인딩된 EntityManager를 통해 세션을 획득하도록 강제
*/
private Session currentTxSession() {
if (!TransactionSynchronizationManager.isActualTransactionActive()) {
throw new InternalServerException(PERSISTENCE_TRANSACTION_REQUIRED);
}
return session();
}

private Session session() {
return em.unwrap(Session.class); // 현재 스레드의 em에서 Hibernate 세션 얻기
}

private static final String FILTER_NAME = "statusFilter";
private static final String PARAM_STATUSES = "statuses";

private static final String ANN_TX = "org.springframework.transaction.annotation.Transactional";
private static final String ANN_INCLUDE_INACTIVE = "konkuk.thip.common.annotation.persistence.IncludeInactive";
private static final String ANN_UNFILTERED = "konkuk.thip.common.annotation.persistence.Unfiltered";

// 기본: ACTIVE만 (트랜잭션 경계 진입 시)
// 1) @Transactional 이고
// 2) @IncludeInactive, @Unfiltered 가 붙어있지 않은 경우에만 적용
/**
* @Unfiltered: @Transactional 없이도 동작.
* ThreadLocal에 UNFILTERED 의도를 기록하고 반환.
* 실제 Session 조작은 트랜잭션 경계(PCUT_TX_DEFAULT)에서 수행.
*/
private static final String PCUT_UNFILTERED = "@annotation(" + ANN_UNFILTERED + ")";

/**
* 기본: @Transactional 경계에서 ThreadLocal을 읽어 filter를 활성화.
* @Unfiltered가 붙은 메서드는 제외 (PCUT_UNFILTERED에서 처리).
*/
private static final String PCUT_TX_DEFAULT =
"(" + "@annotation(" + ANN_TX + ") || @within(" + ANN_TX + ")" + ")" +
" && !" + "@annotation(" + ANN_INCLUDE_INACTIVE + ")" +
" && !" + "@annotation(" + ANN_UNFILTERED + ")";
Comment on lines +36 to 44
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @Unfiltered annotation (declared in Unfiltered.java) uses @Target({ElementType.METHOD, ElementType.TYPE}), which allows class-level usage. However, neither PCUT_UNFILTERED nor the exclusion in PCUT_TX_DEFAULT handles class-level @Unfiltered:

  • PCUT_UNFILTERED = "@annotation(ANN_UNFILTERED)" — only matches method-level annotations.
  • PCUT_TX_DEFAULT exclusion && !@annotation(ANN_UNFILTERED) — also only checks method-level annotations.

If @Unfiltered is placed on a class, its methods would still be intercepted by PCUT_TX_DEFAULT (which uses @within for @Transactional), causing the filter to be incorrectly activated. The AOP implementation should either remove ElementType.TYPE from @Unfiltered's @Target to prevent unsupported class-level usage, or extend both pointcuts with @within handling (e.g., || @within(ANN_UNFILTERED)) to properly support class-level annotation.

Copilot uses AI. Check for mistakes.

// @IncludeInactive: 트랜잭션 컨텍스트가 보장된 경우에만 동작
private static final String PCUT_INCLUDE_INACTIVE =
"@annotation(" + ANN_INCLUDE_INACTIVE + ") && (" + "@annotation(" + ANN_TX + ") || @within(" + ANN_TX + ")" + ")";

// @Unfiltered: 트랜잭션 컨텍스트가 보장된 경우에만 동작
private static final String PCUT_UNFILTERED =
"@annotation(" + ANN_UNFILTERED + ") && (" + "@annotation(" + ANN_TX + ") || @within(" + ANN_TX + ")" + ")";

// 기본: ACTIVE만
@Around(PCUT_TX_DEFAULT)
public Object enableActiveByDefault(ProceedingJoinPoint pjp) throws Throwable {
var s = currentTxSession();
var wasEnabled = isFilterEnabled(s);
if (!wasEnabled) {
enableFilterWith(s, List.of(StatusType.ACTIVE.name()));
}
@Around(PCUT_UNFILTERED)
public Object unfiltered(ProceedingJoinPoint pjp) throws Throwable {
FilterContextHolder.FilterMode prev = FilterContextHolder.get();
FilterContextHolder.set(UNFILTERED);
try {
return pjp.proceed();
} finally {
if (!wasEnabled) {
disableFilter(s);
}
FilterContextHolder.set(prev);
}
}

// Include Inactive: ACTIVE, INACTIVE 모두 + 종료 시 active-only 로 복귀
@Around(PCUT_INCLUDE_INACTIVE)
public Object includeInactive(ProceedingJoinPoint pjp) throws Throwable {
var s = currentTxSession();
var prevEnabled = isFilterEnabled(s);

enableFilterWith(s, List.of(StatusType.ACTIVE.name(), StatusType.INACTIVE.name()));
@Around(PCUT_TX_DEFAULT)
public Object enableActiveByDefault(ProceedingJoinPoint pjp) throws Throwable {
Session s = em.unwrap(Session.class);
boolean wasEnabled = isFilterEnabled(s);

try {
return pjp.proceed();
} finally {
restoreToActive(s);
if (!prevEnabled) {
disableFilter(s);
}
if (FilterContextHolder.get() == ACTIVE_ONLY && !wasEnabled) {
enableFilter(s);
}
}

// Unfiltered: 필터 해제 + 종료 시 active-only 로 복귀
@Around(PCUT_UNFILTERED)
public Object unfiltered(ProceedingJoinPoint pjp) throws Throwable {
var s = currentTxSession();
var wasEnabled = isFilterEnabled(s);
if (wasEnabled) {
disableFilter(s);
}
try {
return pjp.proceed();
} finally {
if (wasEnabled) {
restoreToActive(s);
if (!wasEnabled) {
disableFilter(s);
}
}
}
Expand All @@ -118,19 +76,13 @@ private boolean isFilterEnabled(Session s) {
return s.getEnabledFilter(FILTER_NAME) != null;
}

private void enableFilterWith(Session s, List<String> statuses) {
s.enableFilter(FILTER_NAME).setParameterList(PARAM_STATUSES, statuses);
log.debug("statusFilter -> ENABLED [statuses={}]", statuses);
}

private void restoreToActive(Session s) {
var restored = List.of(StatusType.ACTIVE.name());
s.enableFilter(FILTER_NAME).setParameterList(PARAM_STATUSES, restored);
log.debug("statusFilter -> RESTORED [statuses={}]", restored);
private void enableFilter(Session s) {
s.enableFilter(FILTER_NAME);
log.debug("statusFilter -> ENABLED [ACTIVE only]");
}

private void disableFilter(Session s) {
s.disableFilter(FILTER_NAME);
log.debug("statusFilter -> DISABLED");
}
}
}
11 changes: 2 additions & 9 deletions src/main/java/konkuk/thip/common/entity/BaseJpaEntity.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import lombok.Getter;
import org.hibernate.annotations.Filter;
import org.hibernate.annotations.FilterDef;
import org.hibernate.annotations.ParamDef;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
Expand All @@ -19,14 +18,8 @@
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@FilterDef(
name = "statusFilter",
parameters = @ParamDef(name = "statuses", type = String.class)
)
@Filter(
name = "statusFilter",
condition = "status in (:statuses)"
)
@FilterDef(name = "statusFilter")
@Filter(name = "statusFilter", condition = "status = 'ACTIVE'")
public abstract class BaseJpaEntity {

@CreatedDate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ public enum ErrorCode implements ResponseCode {
AWS_BUCKET_BASE_URL_NOT_CONFIGURED(HttpStatus.INTERNAL_SERVER_ERROR, 50101, "aws s3 bucket base url 설정이 누락되었습니다."),
WEB_DOMAIN_ORIGIN_EMPTY(HttpStatus.INTERNAL_SERVER_ERROR, 50102, "허용된 웹 도메인 설정이 비어있습니다."),

PERSISTENCE_TRANSACTION_REQUIRED(HttpStatus.INTERNAL_SERVER_ERROR, 50110, "@Transactional 컨텍스트가 필요합니다. 트랜잭션 범위 내에서만 사용할 수 있습니다."),
RESOURCE_LOCKED(HttpStatus.LOCKED, 50200, "자원이 잠겨 있어 요청을 처리할 수 없습니다."),

/* 60000부터 비즈니스 예외 */
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/konkuk/thip/config/AppConfig.java
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
package konkuk.thip.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@Configuration
@EnableTransactionManagement(order = Ordered.LOWEST_PRECEDENCE - 1)
public class AppConfig {
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@
import konkuk.thip.feed.application.port.out.dto.FeedQueryDto;
import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;

@Transactional(readOnly = true)
@Repository
@RequiredArgsConstructor
public class FeedQueryPersistenceAdapter implements FeedQueryPort {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
import konkuk.thip.notification.application.port.out.NotificationQueryPort;
import konkuk.thip.notification.application.port.out.dto.NotificationQueryDto;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Repository;

import java.util.List;

@Transactional(readOnly = true)
@Repository
@RequiredArgsConstructor
public class NotificationQueryPersistenceAdapter implements NotificationQueryPort {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
import konkuk.thip.post.adapter.out.persistence.repository.PostLikeJpaRepository;
import konkuk.thip.post.application.port.out.PostLikeQueryPort;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Repository;

import java.util.Set;

@Transactional(readOnly = true)
@Repository
@RequiredArgsConstructor
public class PostLikeQueryPersistenceAdapter implements PostLikeQueryPort {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import konkuk.thip.post.application.port.out.PostQueryPort;
import konkuk.thip.post.application.port.out.dto.PostQueryDto;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Repository;

@Transactional(readOnly = true)
@Repository
@RequiredArgsConstructor
public class PostQueryPersistenceAdapter implements PostQueryPort {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@
import konkuk.thip.recentSearch.application.port.out.RecentSearchQueryPort;
import konkuk.thip.recentSearch.domain.RecentSearch;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Transactional(readOnly = true)
@Repository
@RequiredArgsConstructor
public class RecentSearchQueryPersistenceAdapter implements RecentSearchQueryPort {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository;
import konkuk.thip.room.application.port.out.RoomParticipantQueryPort;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Repository;

@Transactional(readOnly = true)
@Repository
@RequiredArgsConstructor
public class RoomParticipantQueryPersistenceAdapter implements RoomParticipantQueryPort {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@
import konkuk.thip.room.domain.value.Category;
import konkuk.thip.room.domain.Room;
import lombok.RequiredArgsConstructor;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;

@Transactional(readOnly = true)
@Repository
@RequiredArgsConstructor
public class RoomQueryPersistenceAdapter implements RoomQueryPort {
Expand Down
Loading