diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/dto/DailySpendingNotification.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DailySpendingNotification.java similarity index 94% rename from pennyway-batch/src/main/java/kr/co/pennyway/batch/dto/DailySpendingNotification.java rename to pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DailySpendingNotification.java index 0c69f224e..aebce3313 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/dto/DailySpendingNotification.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DailySpendingNotification.java @@ -1,6 +1,5 @@ -package kr.co.pennyway.batch.dto; +package kr.co.pennyway.batch.common.dto; -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; import kr.co.pennyway.domain.domains.notification.type.Announcement; import lombok.Builder; diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/dto/DeviceTokenOwner.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DeviceTokenOwner.java similarity index 77% rename from pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/dto/DeviceTokenOwner.java rename to pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DeviceTokenOwner.java index 15c4a8c9f..dc49d649c 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/dto/DeviceTokenOwner.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/dto/DeviceTokenOwner.java @@ -1,4 +1,4 @@ -package kr.co.pennyway.domain.domains.device.dto; +package kr.co.pennyway.batch.common.dto; /** * 디바이스 토큰과 유저 아이디를 담은 DTO diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReader.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReader.java new file mode 100644 index 000000000..d6fb308bb --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslNoOffsetPagingItemReader.java @@ -0,0 +1,69 @@ +package kr.co.pennyway.batch.common.reader; + +import com.querydsl.jpa.JPQLQuery; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.EntityTransaction; +import kr.co.pennyway.batch.common.reader.options.QuerydslNoOffsetOptions; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; + +import java.util.function.Function; + +public class QuerydslNoOffsetPagingItemReader extends QuerydslPagingItemReader { + private QuerydslNoOffsetOptions options; + + private QuerydslNoOffsetPagingItemReader() { + super(); + setName(ClassUtils.getShortName(QuerydslNoOffsetPagingItemReader.class)); + } + + public QuerydslNoOffsetPagingItemReader(EntityManagerFactory entityManagerFactory, + int pageSize, + QuerydslNoOffsetOptions options, + Function> queryFunction) { + super(entityManagerFactory, pageSize, queryFunction); + setName(ClassUtils.getShortName(QuerydslNoOffsetPagingItemReader.class)); + this.options = options; + } + + @Override + @SuppressWarnings("unchecked") + protected void doReadPage() { + + EntityTransaction tx = getTxOrNull(); + + JPQLQuery query = createQuery().limit(getPageSize()); + + initResults(); + + fetchQuery(query, tx); + + resetCurrentIdIfNotLastPage(); + } + + @Override + protected JPAQuery createQuery() { + JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager); + JPAQuery query = queryFunction.apply(queryFactory); + options.initKeys(query, getPage()); // 제일 첫번째 페이징시 시작해야할 ID 찾기 + + return options.createQuery(query, getPage()); + } + + private void resetCurrentIdIfNotLastPage() { + if (isNotEmptyResults()) { + options.resetCurrentId(getLastItem()); + } + } + + // 조회결과가 Empty이면 results에 null이 담긴다 + private boolean isNotEmptyResults() { + return !CollectionUtils.isEmpty(results) && results.get(0) != null; + } + + private T getLastItem() { + return results.get(results.size() - 1); + } +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslPagingItemReader.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslPagingItemReader.java new file mode 100644 index 000000000..0fb2163b4 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/QuerydslPagingItemReader.java @@ -0,0 +1,141 @@ +package kr.co.pennyway.batch.common.reader; + +import com.querydsl.jpa.JPQLQuery; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.EntityTransaction; +import org.springframework.batch.item.database.AbstractPagingItemReader; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Function; + +/** + * Querydsl을 이용한 커스텀 PagingItemReader + *

+ * 이동욱님 깃헙 참고 + */ +public class QuerydslPagingItemReader extends AbstractPagingItemReader { + + protected final Map jpaPropertyMap = new HashMap<>(); + protected EntityManagerFactory entityManagerFactory; + protected EntityManager entityManager; + protected Function> queryFunction; + protected boolean transacted = true; // default value + + protected QuerydslPagingItemReader() { + setName(ClassUtils.getShortName(QuerydslPagingItemReader.class)); + } + + public QuerydslPagingItemReader(EntityManagerFactory entityManagerFactory, + int pageSize, + Function> queryFunction) { + this(entityManagerFactory, pageSize, true, queryFunction); + } + + public QuerydslPagingItemReader(EntityManagerFactory entityManagerFactory, + int pageSize, + boolean transacted, + Function> queryFunction) { + this(); + this.entityManagerFactory = entityManagerFactory; + this.queryFunction = queryFunction; + setPageSize(pageSize); + setTransacted(transacted); + } + + /** + * Reader의 트랜잭션격리 옵션
+ * - false: 격리 시키지 않고, Chunk 트랜잭션에 의존한다
+ * (hibernate.default_batch_fetch_size 옵션 사용가능)
+ * - true: 격리 시킨다
+ * (Reader 조회 결과를 삭제하고 다시 조회했을때 삭제된게 반영되고 조회되길 원할때 사용한다.) + */ + public void setTransacted(boolean transacted) { + this.transacted = transacted; + } + + @Override + protected void doOpen() throws Exception { + super.doOpen(); + + entityManager = entityManagerFactory.createEntityManager(jpaPropertyMap); + if (entityManager == null) { + throw new DataAccessResourceFailureException("Unable to obtain an EntityManager"); + } + } + + @Override + @SuppressWarnings("unchecked") + protected void doReadPage() { + EntityTransaction tx = getTxOrNull(); + + JPQLQuery query = createQuery() + .offset(getPage() * getPageSize()) + .limit(getPageSize()); + + initResults(); + + fetchQuery(query, tx); + } + + protected EntityTransaction getTxOrNull() { + if (transacted) { + EntityTransaction tx = entityManager.getTransaction(); + tx.begin(); + + entityManager.flush(); + entityManager.clear(); + return tx; + } + + return null; + } + + protected JPAQuery createQuery() { + JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager); + return queryFunction.apply(queryFactory); + } + + protected void initResults() { + if (CollectionUtils.isEmpty(results)) { + results = new CopyOnWriteArrayList<>(); + } else { + results.clear(); + } + } + + /** + * where 의 조건은 id max/min 을 이용한 제한된 범위를 가지게 한다 + * + * @param query + * @param tx + */ + protected void fetchQuery(JPQLQuery query, EntityTransaction tx) { + if (transacted) { + results.addAll(query.fetch()); + if (tx != null) { + tx.commit(); + } + } else { + List queryResult = query.fetch(); + for (T entity : queryResult) { + entityManager.detach(entity); + results.add(entity); + } + } + } + + @Override + protected void doClose() throws Exception { + entityManager.close(); + super.doClose(); + } +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/Expression.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/Expression.java new file mode 100644 index 000000000..49111586c --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/Expression.java @@ -0,0 +1,39 @@ +package kr.co.pennyway.batch.common.reader.expression; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberPath; +import com.querydsl.core.types.dsl.StringPath; + +public enum Expression { + ASC(WhereExpression.GT, OrderExpression.ASC), + DESC(WhereExpression.LT, OrderExpression.DESC); + + private final WhereExpression where; + private final OrderExpression order; + + Expression(WhereExpression where, OrderExpression order) { + this.where = where; + this.order = order; + } + + public boolean isAsc() { + return this == ASC; + } + + public BooleanExpression where(StringPath id, int page, String currentId) { + return where.expression(id, page, currentId); + } + + public > BooleanExpression where(NumberPath id, int page, N currentId) { + return where.expression(id, page, currentId); + } + + public OrderSpecifier order(StringPath id) { + return isAsc() ? id.asc() : id.desc(); + } + + public > OrderSpecifier order(NumberPath id) { + return isAsc() ? id.asc() : id.desc(); + } +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/OrderExpression.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/OrderExpression.java new file mode 100644 index 000000000..b243ed100 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/OrderExpression.java @@ -0,0 +1,5 @@ +package kr.co.pennyway.batch.common.reader.expression; + +public enum OrderExpression { + ASC, DESC +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereExpression.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereExpression.java new file mode 100644 index 000000000..4e128d4e3 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereExpression.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.batch.common.reader.expression; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberPath; +import com.querydsl.core.types.dsl.StringPath; + +/** + * 첫페이지 조회시에는 >=, <= + * 두번째 페이지부터는 >, < + */ +public enum WhereExpression { + GT( + (id, page, currentId) -> page == 0 ? id.goe(currentId) : id.gt(currentId), + (id, page, currentId) -> page == 0 ? id.goe(currentId) : id.gt(currentId)), + LT( + (id, page, currentId) -> page == 0 ? id.loe(currentId) : id.lt(currentId), + (id, page, currentId) -> page == 0 ? id.loe(currentId) : id.lt(currentId) + ); + + private final WhereStringFunction string; + private final WhereNumberFunction number; + + WhereExpression(WhereStringFunction string, WhereNumberFunction number) { + this.string = string; + this.number = number; + } + + public BooleanExpression expression(StringPath id, int page, String currentId) { + return this.string.apply(id, page, currentId); + } + + public > BooleanExpression expression(NumberPath id, int page, N currentId) { + return this.number.apply(id, page, currentId); + } +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereNumberFunction.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereNumberFunction.java new file mode 100644 index 000000000..2bde88c94 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereNumberFunction.java @@ -0,0 +1,10 @@ +package kr.co.pennyway.batch.common.reader.expression; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberPath; + +@FunctionalInterface +public interface WhereNumberFunction> { + BooleanExpression apply(NumberPath id, int page, N currentId); + +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereStringFunction.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereStringFunction.java new file mode 100644 index 000000000..5e3655bf9 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/expression/WhereStringFunction.java @@ -0,0 +1,9 @@ +package kr.co.pennyway.batch.common.reader.expression; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.StringPath; + +@FunctionalInterface +public interface WhereStringFunction { + BooleanExpression apply(StringPath id, int page, String currentId); +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetNumberOptions.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetNumberOptions.java new file mode 100644 index 000000000..263e04562 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetNumberOptions.java @@ -0,0 +1,138 @@ +package kr.co.pennyway.batch.common.reader.options; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberPath; +import com.querydsl.jpa.impl.JPAQuery; +import jakarta.annotation.Nonnull; +import kr.co.pennyway.batch.common.reader.expression.Expression; + +public class QuerydslNoOffsetNumberOptions> extends QuerydslNoOffsetOptions { + + private final NumberPath field; + private N currentId; + private N lastId; + + private QuerydslNoOffsetNumberOptions(@Nonnull NumberPath field, + @Nonnull Expression expression) { + super(field, expression); + this.field = field; + } + + private QuerydslNoOffsetNumberOptions(@Nonnull NumberPath field, + @Nonnull Expression expression, + String idName) { + super(idName, expression); + this.field = field; + } + + /** + * QEntity의 NumberPath 필드를 사용하여 offset을 설정하는 옵션을 생성합니다. + * + * @param field {@link NumberPath} : offset으로 사용할 필드 + * @param expression {@link Expression} : 정렬 방향 + */ + public static > QuerydslNoOffsetNumberOptions of(@Nonnull NumberPath field, @Nonnull Expression expression) { + return new QuerydslNoOffsetNumberOptions<>(field, expression); + } + + /** + * QEintity의 NumberPath 필드를 사용하여 offset을 설정하는 옵션을 생성합니다. + *

+ * 만약, 쿼리의 응답을 QEntity가 아닌 Dto를 사용한 경우 마지막으로 조회한 offset의 값이 저장된 필드를 idName으로 지정해야 하며, 참조될 dto의 필드는 Number 타입이어야 합니다. + *

+ * 해당 클래스는 idName의 유효성을 검사하지 않습니다. 따라서, idName에 해당하는 필드가 존재하지 않거나 타입이 다른 경우 예기치 못한 에러가 발생할 수 있습니다. + * + * @param field {@link NumberPath} : offset으로 사용할 필드 + * @param expression {@link Expression} : 정렬 방향 + * @param idName {@link String} : 마지막으로 조회한 offset이 저장된 필드 이름 + */ + public static > QuerydslNoOffsetNumberOptions of(@Nonnull NumberPath field, @Nonnull Expression expression, String idName) { + return new QuerydslNoOffsetNumberOptions<>(field, expression, idName); + } + + public N getCurrentId() { + return currentId; + } + + public N getLastId() { + return lastId; + } + + @Override + public void initKeys(JPAQuery query, int page) { + if (page == 0) { + initFirstId(query); + initLastId(query); + + if (logger.isDebugEnabled()) { + logger.debug("First Key= " + currentId + ", Last Key= " + lastId); + } + } + } + + @Override + protected void initFirstId(JPAQuery query) { + JPAQuery clone = query.clone(); + boolean isGroupByQuery = isGroupByQuery(clone); + + if (isGroupByQuery) { + currentId = clone + .select(field) + .orderBy(expression.isAsc() ? field.asc() : field.desc()) + .fetchFirst(); + } else { + currentId = clone + .select(expression.isAsc() ? field.min() : field.max()) + .fetchFirst(); + } + + } + + @Override + protected void initLastId(JPAQuery query) { + JPAQuery clone = query.clone(); + boolean isGroupByQuery = isGroupByQuery(clone); + + if (isGroupByQuery) { + lastId = clone + .select(field) + .orderBy(expression.isAsc() ? field.desc() : field.asc()) + .fetchFirst(); + } else { + lastId = clone + .select(expression.isAsc() ? field.max() : field.min()) + .fetchFirst(); + } + } + + @Override + public JPAQuery createQuery(JPAQuery query, int page) { + if (currentId == null) { + return query; + } + + return query + .where(whereExpression(page)) + .orderBy(orderExpression()); + } + + private BooleanExpression whereExpression(int page) { + return expression.where(field, page, currentId) + .and(expression.isAsc() ? field.loe(lastId) : field.goe(lastId)); + } + + private OrderSpecifier orderExpression() { + return expression.order(field); + } + + @Override + public void resetCurrentId(T item) { + //noinspection unchecked + currentId = (N) getFiledValue(item); + + if (logger.isDebugEnabled()) { + logger.debug("Current Select Key= " + currentId); + } + } +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetOptions.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetOptions.java new file mode 100644 index 000000000..5255f0349 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetOptions.java @@ -0,0 +1,72 @@ +package kr.co.pennyway.batch.common.reader.options; + +import com.querydsl.core.types.Path; +import com.querydsl.jpa.impl.JPAQuery; +import jakarta.annotation.Nonnull; +import kr.co.pennyway.batch.common.reader.expression.Expression; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import java.lang.reflect.Field; + +/** + * Querydsl No Offset의 기준을 설정하는 클래스 + */ +public abstract class QuerydslNoOffsetOptions { + protected final String fieldName; + protected final Expression expression; + protected Log logger = LogFactory.getLog(getClass()); + + protected QuerydslNoOffsetOptions(@Nonnull Path field, @Nonnull Expression expression) { + String[] qField = field.toString().split("\\."); + this.fieldName = qField[qField.length - 1]; + this.expression = expression; + + if (logger.isDebugEnabled()) { + logger.debug("fieldName= " + fieldName); + } + } + + protected QuerydslNoOffsetOptions(@Nonnull String dtoField, @Nonnull Expression expression) { + this.fieldName = dtoField; + this.expression = expression; + + if (logger.isDebugEnabled()) { + logger.debug("fieldName= " + fieldName); + } + } + + public String getFieldName() { + return fieldName; + } + + public abstract void initKeys(JPAQuery query, int page); + + protected abstract void initFirstId(JPAQuery query); + + protected abstract void initLastId(JPAQuery query); + + public abstract JPAQuery createQuery(JPAQuery query, int page); + + public abstract void resetCurrentId(T item); + + protected Object getFiledValue(T item) { + try { + Field field = item.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return field.get(item); + } catch (NoSuchFieldException | IllegalAccessException e) { + logger.error("Not Found or Not Access Field= " + fieldName, e); + throw new IllegalArgumentException("Not Found or Not Access Field"); + } + } + + public boolean isGroupByQuery(JPAQuery query) { + return isGroupByQuery(query.toString()); + } + + public boolean isGroupByQuery(String sql) { + return sql.contains("group by"); + + } +} \ No newline at end of file diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetStringOptions.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetStringOptions.java new file mode 100644 index 000000000..6cb8b60e8 --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/common/reader/options/QuerydslNoOffsetStringOptions.java @@ -0,0 +1,135 @@ +package kr.co.pennyway.batch.common.reader.options; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.StringPath; +import com.querydsl.jpa.impl.JPAQuery; +import jakarta.annotation.Nonnull; +import kr.co.pennyway.batch.common.reader.expression.Expression; + +public class QuerydslNoOffsetStringOptions extends QuerydslNoOffsetOptions { + private final StringPath field; + private String currentId; + private String lastId; + + private QuerydslNoOffsetStringOptions(@Nonnull StringPath field, + @Nonnull Expression expression) { + super(field, expression); + this.field = field; + } + + private QuerydslNoOffsetStringOptions(@Nonnull StringPath field, + @Nonnull Expression expression, + String idName) { + super(idName, expression); + this.field = null; + } + + /** + * QEntity의 StringPath 필드를 사용하여 offset을 설정하는 옵션을 생성합니다. + * + * @param field {@link StringPath} : offset으로 사용할 필드 + * @param expression {@link Expression} : 정렬 방향 + */ + public static QuerydslNoOffsetStringOptions of(@Nonnull StringPath field, @Nonnull Expression expression) { + return new QuerydslNoOffsetStringOptions<>(field, expression); + } + + /** + * QEntity의 StringPath 필드를 사용하여 offset을 설정하는 옵션을 생성합니다. + *

+ * 만약, 쿼리의 응답을 QEntity가 아닌 Dto를 사용한 경우 마지막으로 조회한 offset의 값이 저장된 필드를 idName으로 지정해야 하며, String 타입이어야 합니다. + *

+ * 해당 클래스는 idName의 유효성을 검사하지 않습니다. 따라서, idName에 해당하는 필드가 존재하지 않거나 타입이 다른 경우 예기치 못한 에러가 발생할 수 있습니다. + * + * @param field {@link StringPath} : offset으로 사용할 필드 + * @param expression {@link Expression} : 정렬 방향 + * @param idName {@link String} : 마지막으로 조회한 offset이 저장된 필드 이름 + */ + public static QuerydslNoOffsetStringOptions of(@Nonnull StringPath field, @Nonnull Expression expression, String idName) { + return new QuerydslNoOffsetStringOptions<>(field, expression, idName); + } + + public String getCurrentId() { + return currentId; + } + + public String getLastId() { + return lastId; + } + + @Override + public void initKeys(JPAQuery query, int page) { + if (page == 0) { + initFirstId(query); + initLastId(query); + + if (logger.isDebugEnabled()) { + logger.debug("First Key= " + currentId + ", Last Key= " + lastId); + } + } + } + + @Override + protected void initFirstId(JPAQuery query) { + JPAQuery clone = query.clone(); + boolean isGroupByQuery = isGroupByQuery(clone); + + if (isGroupByQuery) { + currentId = clone + .select(field) + .orderBy(expression.isAsc() ? field.asc() : field.desc()) + .fetchFirst(); + } else { + currentId = clone + .select(expression.isAsc() ? field.min() : field.max()) + .fetchFirst(); + } + + } + + @Override + protected void initLastId(JPAQuery query) { + JPAQuery clone = query.clone(); + boolean isGroupByQuery = isGroupByQuery(clone); + + if (isGroupByQuery) { + lastId = clone + .select(field) + .orderBy(expression.isAsc() ? field.desc() : field.asc()) + .fetchFirst(); + } else { + lastId = clone + .select(expression.isAsc() ? field.max() : field.min()) + .fetchFirst(); + } + } + + @Override + public JPAQuery createQuery(JPAQuery query, int page) { + if (currentId == null) { + return query; + } + + return query + .where(whereExpression(page)) + .orderBy(orderExpression()); + } + + private BooleanExpression whereExpression(int page) { + return expression.where(field, page, currentId) + .and(expression.isAsc() ? field.loe(lastId) : field.goe(lastId)); + } + + private OrderSpecifier orderExpression() { + return expression.order(field); + } + + @Override + public void resetCurrentId(T item) { + currentId = (String) getFiledValue(item); + if (logger.isDebugEnabled()) { + logger.debug("Current Select Key= " + currentId); + } + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyConfig.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyConfig.java new file mode 100644 index 000000000..8130ace3b --- /dev/null +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyConfig.java @@ -0,0 +1,45 @@ +package kr.co.pennyway.batch.job; + +import kr.co.pennyway.batch.common.dto.DeviceTokenOwner; +import kr.co.pennyway.batch.reader.ActiveDeviceTokenReader; +import kr.co.pennyway.batch.writer.NotificationWriter; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class DailySpendingNotifyConfig { + private final JobRepository jobRepository; + private final ActiveDeviceTokenReader reader; + private final NotificationWriter writer; + + @Bean + public Job dailyNotificationJob(PlatformTransactionManager transactionManager) { + return new JobBuilder("dailyNotificationJob", jobRepository) + .start(dailyNotificationStep(transactionManager)) + .on("FAILED") + .stopAndRestart(dailyNotificationStep(transactionManager)) + .on("*") + .end() + .end() + .build(); + } + + @Bean + @JobScope + public Step dailyNotificationStep(PlatformTransactionManager transactionManager) { + return new StepBuilder("sendSpendingNotifyStep", jobRepository) + .chunk(1000, transactionManager) + .reader(reader.querydslNoOffsetPagingItemReader()) + .writer(writer) + .build(); + } +} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyJobConfig.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyJobConfig.java deleted file mode 100644 index c65197ca9..000000000 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/job/DailySpendingNotifyJobConfig.java +++ /dev/null @@ -1,29 +0,0 @@ -package kr.co.pennyway.batch.job; - -import kr.co.pennyway.batch.step.SendSpendingNotifyStepConfig; -import lombok.RequiredArgsConstructor; -import org.springframework.batch.core.Job; -import org.springframework.batch.core.job.builder.JobBuilder; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; - -@Configuration -@RequiredArgsConstructor -public class DailySpendingNotifyJobConfig { - private final JobRepository jobRepository; - private final SendSpendingNotifyStepConfig sendSpendingNotifyStepConfig; - - @Bean - public Job dailyNotificationJob(PlatformTransactionManager transactionManager) { - return new JobBuilder("dailyNotificationJob", jobRepository) - .start(sendSpendingNotifyStepConfig.sendSpendingNotifyStep(transactionManager)) - .on("FAILED") - .stopAndRestart(sendSpendingNotifyStepConfig.sendSpendingNotifyStep(transactionManager)) - .on("*") - .end() - .end() - .build(); - } -} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/.gitkeep b/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/NotificationProcessor.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/NotificationProcessor.java deleted file mode 100644 index 560f61d35..000000000 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/processor/NotificationProcessor.java +++ /dev/null @@ -1,18 +0,0 @@ -package kr.co.pennyway.batch.processor; - -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; -import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.item.ItemProcessor; -import org.springframework.lang.NonNull; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -public class NotificationProcessor implements ItemProcessor { - - @Override - public DeviceTokenOwner process(@NonNull DeviceTokenOwner deviceTokenOwner) throws Exception { - log.info("NotificationProcessor 실행"); - return deviceTokenOwner; - } -} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java index cf6b2d450..2b7b704d3 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/reader/ActiveDeviceTokenReader.java @@ -1,33 +1,46 @@ package kr.co.pennyway.batch.reader; -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; -import kr.co.pennyway.domain.domains.device.repository.DeviceTokenRepository; +import com.querydsl.core.types.Projections; +import jakarta.persistence.EntityManagerFactory; +import kr.co.pennyway.batch.common.dto.DeviceTokenOwner; +import kr.co.pennyway.batch.common.reader.QuerydslNoOffsetPagingItemReader; +import kr.co.pennyway.batch.common.reader.expression.Expression; +import kr.co.pennyway.batch.common.reader.options.QuerydslNoOffsetNumberOptions; +import kr.co.pennyway.batch.common.reader.options.QuerydslNoOffsetOptions; +import kr.co.pennyway.domain.domains.device.domain.QDeviceToken; +import kr.co.pennyway.domain.domains.user.domain.QUser; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.batch.item.data.RepositoryItemReader; -import org.springframework.batch.item.data.builder.RepositoryItemReaderBuilder; +import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.context.annotation.Bean; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; -import java.util.HashMap; - @Slf4j @Component @RequiredArgsConstructor public class ActiveDeviceTokenReader { - private final DeviceTokenRepository deviceTokenRepository; + private final EntityManagerFactory emf; + + private final QUser user = QUser.user; + private final QDeviceToken deviceToken = QDeviceToken.deviceToken; @Bean - public RepositoryItemReader execute() { - return new RepositoryItemReaderBuilder() - .name("execute") - .repository(deviceTokenRepository) - .methodName("findActivatedDeviceTokenOwners") - .pageSize(100) - .sorts(new HashMap<>() {{ - put("id", Sort.Direction.ASC); - }}) - .build(); + @StepScope + public QuerydslNoOffsetPagingItemReader querydslNoOffsetPagingItemReader() { + QuerydslNoOffsetOptions options = QuerydslNoOffsetNumberOptions.of(user.id, Expression.ASC, "userId"); + + return new QuerydslNoOffsetPagingItemReader<>(emf, 1000, options, queryFactory -> queryFactory + .select( + Projections.constructor( + DeviceTokenOwner.class, + user.id, + user.name, + deviceToken.token + ) + ) + .from(deviceToken) + .innerJoin(user).on(deviceToken.user.id.eq(user.id)) + .where(deviceToken.activated.isTrue().and(user.notifySetting.accountBookNotify.isTrue())) + ); } } diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/step/SendSpendingNotifyStepConfig.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/step/SendSpendingNotifyStepConfig.java deleted file mode 100644 index 2ea94c62b..000000000 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/step/SendSpendingNotifyStepConfig.java +++ /dev/null @@ -1,32 +0,0 @@ -package kr.co.pennyway.batch.step; - -import kr.co.pennyway.batch.processor.NotificationProcessor; -import kr.co.pennyway.batch.reader.ActiveDeviceTokenReader; -import kr.co.pennyway.batch.writer.NotificationWriter; -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; -import lombok.RequiredArgsConstructor; -import org.springframework.batch.core.Step; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.transaction.PlatformTransactionManager; - -@Configuration -@RequiredArgsConstructor -public class SendSpendingNotifyStepConfig { - private final JobRepository jobRepository; - private final ActiveDeviceTokenReader reader; - private final NotificationProcessor processor; - private final NotificationWriter writer; - - @Bean - public Step sendSpendingNotifyStep(PlatformTransactionManager transactionManager) { - return new StepBuilder("sendSpendingNotifyStep", jobRepository) - .chunk(100, transactionManager) - .reader(reader.execute()) - .processor(processor) - .writer(writer) - .build(); - } -} diff --git a/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java b/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java index 2e7e85178..e7baad6f7 100644 --- a/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java +++ b/pennyway-batch/src/main/java/kr/co/pennyway/batch/writer/NotificationWriter.java @@ -1,12 +1,13 @@ package kr.co.pennyway.batch.writer; -import kr.co.pennyway.batch.dto.DailySpendingNotification; -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; +import kr.co.pennyway.batch.common.dto.DailySpendingNotification; +import kr.co.pennyway.batch.common.dto.DeviceTokenOwner; import kr.co.pennyway.domain.domains.notification.repository.NotificationRepository; import kr.co.pennyway.domain.domains.notification.type.Announcement; import kr.co.pennyway.infra.common.event.NotificationEvent; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.configuration.annotation.StepScope; import org.springframework.batch.item.Chunk; import org.springframework.batch.item.ItemWriter; import org.springframework.context.ApplicationEventPublisher; @@ -27,6 +28,7 @@ public class NotificationWriter implements ItemWriter { private final ApplicationEventPublisher publisher; @Override + @StepScope @Transactional public void write(@NonNull Chunk owners) throws Exception { log.info("Writer 실행: {}", owners.size()); diff --git a/pennyway-batch/src/main/resources/application.yml b/pennyway-batch/src/main/resources/application.yml index 57673c162..c8a285441 100644 --- a/pennyway-batch/src/main/resources/application.yml +++ b/pennyway-batch/src/main/resources/application.yml @@ -16,6 +16,10 @@ spring: await-termination: true # 애플리케이션 종료 시 모든 Task가 종료될 때까지 대기 await-termination-period: 60000 # 대기 시간 60초 + datasource: + hikari: + maximum-pool-size: 2 + --- spring: config: diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepository.java deleted file mode 100644 index c10dd3579..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepository.java +++ /dev/null @@ -1,31 +0,0 @@ -package kr.co.pennyway.domain.domains.device.repository; - -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; - -public interface DeviceTokenCustomRepository { - /** - * 사용자 아이디, 이름 그리고 디바이스 토큰 리스트를 조회하여, {@link DeviceTokenOwner} 객체로 반환한다. - *

- * 이 때, 사용자의 계좌북 알림 설정이 활성화되어 있어야 하며, 디바이스 토큰은 활성화되어 있어야 한다. - *

- * - * @apiNote 이 메서드는 페이징 처리를 하고 있으며, 사용자 아이디를 기준으로 오름차순 정렬한다. - * 이 때, size가 100이고 한 명의 사용자가 여러 개의 디바이스 토큰(각각 pk가 99, 100, 101)을 가지고 있다면, - * 101번에 대한 토큰은 다음 페이지로 넘어가게 되므로 이에 대한 예외 처리가 필요하다. - * - *

-     * {@code
-     *      SELECT d.token, u.id, u.name
-     *      FROM device_token d
-     *      LEFT JOIN user u ON d.user_id = u.id
-     *      WHERE d.activated = true AND u.account_book_notify = true
-     *      ORDER BY u.id ASC
-     *      LIMIT ${pageable.pageSize} OFFSET ${pageable.offset}
-     *      ;
-     * }
-     * 
- */ - Page findActivatedDeviceTokenOwners(Pageable pageable); -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepositoryImpl.java deleted file mode 100644 index 41f56b49c..000000000 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenCustomRepositoryImpl.java +++ /dev/null @@ -1,52 +0,0 @@ -package kr.co.pennyway.domain.domains.device.repository; - -import com.querydsl.core.types.Projections; -import com.querydsl.jpa.impl.JPAQuery; -import com.querydsl.jpa.impl.JPAQueryFactory; -import kr.co.pennyway.domain.domains.device.domain.QDeviceToken; -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; -import kr.co.pennyway.domain.domains.user.domain.QUser; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.support.PageableExecutionUtils; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -@RequiredArgsConstructor -public class DeviceTokenCustomRepositoryImpl implements DeviceTokenCustomRepository { - private final JPAQueryFactory queryFactory; - - private final QUser user = QUser.user; - private final QDeviceToken deviceToken = QDeviceToken.deviceToken; - - @Override - public Page findActivatedDeviceTokenOwners(Pageable pageable) { - List content = queryFactory - .select( - Projections.constructor( - DeviceTokenOwner.class, - user.id, - user.name, - deviceToken.token - ) - ) - .from(deviceToken) - .leftJoin(user).on(deviceToken.user.id.eq(user.id)) - .where(deviceToken.activated.isTrue().and(user.notifySetting.accountBookNotify.isTrue())) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()) - .orderBy(user.id.asc()) - .fetch(); - - JPAQuery count = queryFactory - .select(deviceToken.id.count()) - .from(deviceToken) - .leftJoin(user).on(deviceToken.user.id.eq(user.id)) - .where(deviceToken.activated.isTrue().and(user.notifySetting.accountBookNotify.isTrue())); - - return PageableExecutionUtils.getPage(content, pageable, () -> count.fetch().size()); - } -} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java index 510da3f9e..00a1cd53e 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java @@ -6,7 +6,7 @@ import java.util.List; import java.util.Optional; -public interface DeviceTokenRepository extends JpaRepository, DeviceTokenCustomRepository { +public interface DeviceTokenRepository extends JpaRepository { Optional findByUser_IdAndToken(Long userId, String token); List findAllByUser_Id(Long userId); diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java index aea64c3b4..200ca1feb 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java @@ -19,7 +19,7 @@ public class NotificationCustomRepositoryImpl implements NotificationCustomRepository { private final JdbcTemplate jdbcTemplate; - private final int BATCH_SIZE = 500; + private final int BATCH_SIZE = 1000; @Override public void saveDailySpendingAnnounceInBulk(List userIds, Announcement announcement) { @@ -54,7 +54,7 @@ private int batchInsert(int batchCount, List userIds, NoticeType noticeTyp " AND n.created_at < CURDATE() + INTERVAL 1 DAY " + " AND n.type = ? " + " AND n.announcement = ? " + - ");"; + ")"; jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { @Override diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml index d18e61916..c01c48e3f 100644 --- a/pennyway-domain/src/main/resources/application-domain.yml +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -5,7 +5,7 @@ spring: dev: common datasource: - url: ${DB_URL:jdbc:mysql://localhost:3300/pennyway?serverTimezone=UTC&characterEncoding=utf8} + url: ${DB_URL:jdbc:mysql://localhost:3300/pennyway?serverTimezone=Asia/Seoul&characterEncoding=utf8&postfileSQL=true&logger=Slf4JLogger&rewriteBatchedStatements=true} username: ${DB_USER_NAME:root} password: ${DB_PASSWORD:password} driver-class-name: com.mysql.cj.jdbc.Driver diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java index ff622f2fa..1256ab26a 100644 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java @@ -7,6 +7,7 @@ import kr.co.pennyway.domain.domains.coupon.TestCouponDecreaseService; import kr.co.pennyway.domain.domains.coupon.TestCouponRepository; import lombok.extern.slf4j.Slf4j; +import org.junit.Ignore; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.domain.EntityScan; @@ -19,6 +20,7 @@ import static org.assertj.core.api.Assertions.assertThat; +@Ignore @Slf4j @DomainIntegrationTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/device/repository/ActivatedDeviceSearchTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/device/repository/ActivatedDeviceSearchTest.java deleted file mode 100644 index d851aefbd..000000000 --- a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/device/repository/ActivatedDeviceSearchTest.java +++ /dev/null @@ -1,139 +0,0 @@ -package kr.co.pennyway.domain.domains.device.repository; - -import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; -import kr.co.pennyway.domain.config.JpaConfig; -import kr.co.pennyway.domain.config.TestJpaConfig; -import kr.co.pennyway.domain.domains.device.domain.DeviceToken; -import kr.co.pennyway.domain.domains.device.dto.DeviceTokenOwner; -import kr.co.pennyway.domain.domains.user.domain.NotifySetting; -import kr.co.pennyway.domain.domains.user.domain.User; -import kr.co.pennyway.domain.domains.user.repository.UserRepository; -import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; -import kr.co.pennyway.domain.domains.user.type.Role; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.context.annotation.Import; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.springframework.test.util.AssertionErrors.assertEquals; -import static org.springframework.test.util.AssertionErrors.assertNotEquals; - -@Slf4j -@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) -@ContextConfiguration(classes = JpaConfig.class) -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@Import(TestJpaConfig.class) -@ActiveProfiles("test") -public class ActivatedDeviceSearchTest extends ContainerMySqlTestConfig { - @Autowired - private DeviceTokenRepository deviceTokenRepository; - @Autowired - private UserRepository userRepository; - - @Test - @Transactional - @DisplayName("비활성화된 디바이스 토큰을 제외하고, 알림을 허용한 사용자의 활성화된 디바이스 토큰을 조회한다.") - public void selectActivatedDeviceTokenThatNotifyTrueUser() { - // given - User user = userRepository.save(createUser("jayang")); - List deviceTokens = List.of( - DeviceToken.of("deviceToken1", user), - DeviceToken.of("deviceToken2", user), - DeviceToken.of("deviceToken3", user) - ); - deviceTokens.get(1).deactivate(); - deviceTokenRepository.saveAll(deviceTokens); - Pageable pageable = Pageable.ofSize(100); - - // when - Page owners = deviceTokenRepository.findActivatedDeviceTokenOwners(pageable); - - // then - assertEquals("조회 결과 원소 개수는 2여야 합니다.", owners.getTotalElements(), 2L); - for (DeviceTokenOwner owner : owners) { - assertNotEquals("deviceToken2는 비활성화 토큰입니다.", "deviceToken2", owner.deviceToken()); - } - } - - @Test - @Transactional - @DisplayName("알림을 허용하지 않은 사용자의 활성화된 디바이스 토큰을 조회하지 않는다.") - public void notSelectNotifyFalseUser() { - // given - User activeUser = userRepository.save(createUser("jayang")); - User deactiveUser = userRepository.save(createUser("mock")); - - List deviceTokens = List.of( - DeviceToken.of("deviceToken1", activeUser), - DeviceToken.of("deviceToken2", deactiveUser)); - deviceTokens.get(1).deactivate(); - - deviceTokenRepository.saveAll(deviceTokens); - - Pageable pageable = Pageable.ofSize(100); - - // when - Page owners = deviceTokenRepository.findActivatedDeviceTokenOwners(pageable); - - // then - assertEquals("조회 결과는 하나여야 합니다.", 1L, owners.getTotalElements()); - assertEquals("알림을 허용하지 않은 사용자의 디바이스 토큰은 조회되지 않아야 합니다.", "jayang", owners.getContent().get(0).name()); - } - - @Test - @Transactional - @DisplayName("사용자 별로 디바이스 토큰 리스트를 받을 수 있다.") - public void selectDeviceTokenListByUserId() { - // given - User user1 = userRepository.save(createUser("jayang")); - User user2 = userRepository.save(createUser("mock")); - - List deviceTokens = List.of( - DeviceToken.of("deviceToken1", user1), - DeviceToken.of("deviceToken2", user1), - DeviceToken.of("deviceToken3", user1), - DeviceToken.of("deviceToken4", user2), - DeviceToken.of("deviceToken5", user2) - ); - deviceTokenRepository.saveAll(deviceTokens); - - Pageable pageable = Pageable.ofSize(100); - - // when - Page owners = deviceTokenRepository.findActivatedDeviceTokenOwners(pageable); - Map> deviceTokenMap = new HashMap<>(); - for (DeviceTokenOwner owner : owners) { - deviceTokenMap.computeIfAbsent(owner.name(), k -> new ArrayList<>()).add(owner.deviceToken()); - } - - // then - assertEquals("전체 결과 개수는 5개여야 합니다.", 5L, owners.getTotalElements()); - assertEquals("jayang의 디바이스 토큰 개수는 3개여야 합니다.", 3, deviceTokenMap.get("jayang").size()); - assertEquals("mock의 디바이스 토큰 개수는 2개여야 합니다.", 2, deviceTokenMap.get("mock").size()); - } - - private User createUser(String name) { - return User.builder() - .username("test") - .name(name) - .password("test") - .phone("010-1234-5678") - .role(Role.USER) - .profileVisibility(ProfileVisibility.PUBLIC) - .notifySetting(NotifySetting.of(true, true, true)) - .build(); - } -}