Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,10 @@ public enum SwaggerResponseDescription {
ATTENDANCE_CHECK_WRITE_LIMIT_EXCEEDED
))),

ATTENDANCE_CHECK_SHOW(new LinkedHashSet<>(Set.of(
ROOM_ACCESS_FORBIDDEN
))),

;
private final Set<ErrorCode> errorCodeList;
SwaggerResponseDescription(Set<ErrorCode> errorCodeList) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import konkuk.thip.common.dto.BaseResponse;
import konkuk.thip.common.security.annotation.UserId;
import konkuk.thip.common.swagger.annotation.ExceptionDescription;
import konkuk.thip.roompost.adapter.in.web.response.AttendanceCheckShowResponse;
import konkuk.thip.roompost.adapter.in.web.response.RecordPinResponse;
import konkuk.thip.roompost.adapter.in.web.response.RoomPostSearchResponse;
import konkuk.thip.roompost.application.port.in.AttendanceCheckShowUseCase;
import konkuk.thip.roompost.application.port.in.RecordPinUseCase;
import konkuk.thip.roompost.application.port.in.RoomPostSearchUseCase;
import konkuk.thip.roompost.application.port.in.dto.record.RecordPinQuery;
Expand All @@ -18,8 +20,7 @@
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import static konkuk.thip.common.swagger.SwaggerResponseDescription.RECORD_PIN;
import static konkuk.thip.common.swagger.SwaggerResponseDescription.RECORD_SEARCH;
import static konkuk.thip.common.swagger.SwaggerResponseDescription.*;

@Tag(name = "RoomPost Query API", description = "방 게시글 조회 관련 API")
@RestController
Expand All @@ -28,6 +29,7 @@ public class RoomPostQueryController {

private final RoomPostSearchUseCase roomPostSearchUseCase;
private final RecordPinUseCase recordPinUseCase;
private final AttendanceCheckShowUseCase attendanceCheckShowUseCase;

@Operation(
summary = "방의 게시글(기록, 투표) 목록 조회",
Expand Down Expand Up @@ -81,4 +83,17 @@ public BaseResponse<RecordPinResponse> pinRecord(
return BaseResponse.ok(recordPinUseCase.pinRecord(new RecordPinQuery(roomId, recordId, userId)));
}

@Operation(
summary = "오늘의 한마디 조회",
description = "방 참여자가 오늘의 한마디를 조회합니다."
)
@ExceptionDescription(ATTENDANCE_CHECK_SHOW)
@GetMapping("/rooms/{roomId}/daily-greeting")
public BaseResponse<AttendanceCheckShowResponse> showDailyGreeting(
@Parameter(description = "게시글을 조회할 방 ID", example = "1") @PathVariable final Long roomId,
@Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)")
@RequestParam(required = false) final String cursor,
@Parameter(hidden = true) @UserId final Long userId) {
return BaseResponse.ok(attendanceCheckShowUseCase.showDailyGreeting(userId, roomId, cursor));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package konkuk.thip.roompost.adapter.in.web.response;

import io.swagger.v3.oas.annotations.media.Schema;

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

@Schema(
description = "오늘의 한마디 조회 응답. 작성 시각 기준으로 최신순으로 정렬하여 응답합니다."
)
public record AttendanceCheckShowResponse(
@Schema(
description = "오늘의 한마디 목록, 10개씩 끊어서 응답합니다."
)
List<AttendanceCheckShowDto> todayCommentList,

@Schema(
description = "다음 페이지 조회를 위한 커서(없으면 null)"
)
String nextCursor,

boolean isLast
) {
public record AttendanceCheckShowDto(
Long attendanceCheckId,
Long creatorId,
String creatorNickname,
String creatorProfileImageUrl,
String todayComment,

@Schema(description = "작성 시각(상대 시간 등 가공된 문자열)", example = "5분 전")
String postDate,

@Schema(description = "작성 날짜(yyyy-MM-dd), 이걸로 날짜별로 끊어서 화면에 보여주시면 됩니다.", example = "2025-08-17")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

친절하시네여 LGTM

LocalDate date, // 해당 오늘의 한마디 데이터의 작성 날짜

@Schema(description = "현재 사용자가 해당 글을 작성했는지 여부")
boolean isWriter
) { }
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package konkuk.thip.roompost.adapter.out.persistence;

import konkuk.thip.common.util.Cursor;
import konkuk.thip.common.util.CursorBasedList;
import konkuk.thip.roompost.adapter.out.mapper.AttendanceCheckMapper;
import konkuk.thip.roompost.adapter.out.persistence.repository.attendancecheck.AttendanceCheckJpaRepository;
import konkuk.thip.roompost.application.port.out.AttendanceCheckQueryPort;
import konkuk.thip.roompost.application.port.out.dto.AttendanceCheckQueryDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

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

import static konkuk.thip.common.entity.StatusType.ACTIVE;

Expand All @@ -24,4 +28,17 @@ public int countAttendanceChecksOnTodayByUser(Long userId, Long roomId) {

return attendanceCheckJpaRepository.countByUserIdAndRoomIdAndCreatedAtBetween(userId, roomId, startOfDay, endOfDay, ACTIVE);
}

@Override
public CursorBasedList<AttendanceCheckQueryDto> findAttendanceChecksByCreatedAtDesc(Long roomId, Cursor cursor) {
LocalDateTime lastCreateAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0);
int size = cursor.getPageSize();

List<AttendanceCheckQueryDto> attendanceCheckQueryDtos = attendanceCheckJpaRepository.findAttendanceChecksByCreatedAtDesc(roomId, lastCreateAt, size);

return CursorBasedList.of(attendanceCheckQueryDtos, size, attendanceCheckQueryDto -> {
Cursor nextCursor = new Cursor(List.of(attendanceCheckQueryDto.createdAt().toString()));
return nextCursor.toEncodedString();
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import java.time.LocalDateTime;

public interface AttendanceCheckJpaRepository extends JpaRepository<AttendanceCheckJpaEntity, Long> {
public interface AttendanceCheckJpaRepository extends JpaRepository<AttendanceCheckJpaEntity, Long>, AttendanceCheckQueryRepository {

// TODO : count 값을 매번 쿼리를 통해 계산하는게 아니라 DB에 저장 or redis 캐시에 저장하는 방법도 좋을 듯
@Query("SELECT COUNT(a) FROM AttendanceCheckJpaEntity a " +
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package konkuk.thip.roompost.adapter.out.persistence.repository.attendancecheck;

import konkuk.thip.roompost.application.port.out.dto.AttendanceCheckQueryDto;

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

public interface AttendanceCheckQueryRepository {

List<AttendanceCheckQueryDto> findAttendanceChecksByCreatedAtDesc(Long roomId, LocalDateTime lastCreatedAt, int size);
}
Comment on lines +10 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

페이지네이션 안정성(배타 경계·타이브레이커) 명시 필요

  • lastCreatedAt는 정렬키가 createdAt 내림차순일 때 “배타 경계(created_at < :lastCreatedAt)”여야 중복/누락 없이 페이징됩니다. 인터페이스 수준 Javadoc으로 이 규약을 명확히 해두는 것을 권장합니다.
  • 동일한 createdAt 다건 사례에서 안정 정렬을 위해 2차 키(예: attendanceCheckId DESC)를 구현체(QueryDSL)에서 함께 적용해 주세요. 단일 LocalDateTime 커서를 선호하시더라도(리소스 참고) 서버 쿼리에서는 안정 정렬이 필수입니다.
    [suggest_optional_refactor]

다음 스크립트로 구현체에서 배타 경계(<), ACTIVE 필터, 2차 정렬 키 적용 여부를 확인해 주세요.


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) 커스텀 구현체 확인
fd -t f 'AttendanceCheckQueryRepositoryImpl.java' | while read -r file; do
  echo "== $file =="
  rg -n -C3 -e 'findAttendanceChecksByCreatedAtDesc' -e 'where' -e 'status' -e 'createdAt' -e 'order' "$file" || sed -n '1,200p' "$file"
done

# 2) JPA 리포지토리에 커스텀 인터페이스가 혼합되어 있는지 확인
fd -t f 'AttendanceCheckJpaRepository.java' | while read -r file; do
  echo "== $file =="
  sed -n '1,200p' "$file"
done

Length of output: 3628


페이지네이션 안정성: 배타 경계 확인됐으나 2차 정렬 키·상태 필터 누락

현재 QueryDSL 구현체에서

  • attendanceCheck.createdAt.lt(lastCreatedAt) 로 배타 경계는 맞게 적용되어 있습니다.
    하지만 아래 두 가지가 빠져 있어 안정적인 페이지네이션을 보장하지 못합니다:

    • 상태 필터(status = ACTIVE)
    • 동일한 createdAt 동시여러건에 대비한 2차 정렬 키(e.g. attendanceCheckId DESC)

수정 제안:

  • 인터페이스 Javadoc (AttendanceCheckQueryRepository.java)에 배타 경계·2차 정렬 키·상태 필터 규약을 명시
  • 구현체 (AttendanceCheckQueryRepositoryImpl.java)의 쿼리 체인 수정
// 기존
.where(roomPredicate, cursorPredicate)
.orderBy(
    attendanceCheck.createdAt.desc()
)

// 제안
.where(
    roomPredicate,
    cursorPredicate,
+   attendanceCheck.status.eq(StatusType.ACTIVE)
)
.orderBy(
    attendanceCheck.createdAt.desc(),
+   attendanceCheck.attendanceCheckId.desc()
)

AttendanceCheckQueryRepository.java 메소드 Javadoc 예시:

/**
 * roomId에 해당하는 출석 체크를 createdAt 내림차순으로 조회합니다.
 * • 배타경계(cursor): createdAt < :lastCreatedAt  
 * • 2차정렬(stable sort): attendanceCheckId DESC  
 * • 상태필터: status = ACTIVE
 * @param roomId 조회할 방 ID
 * @param lastCreatedAt 커서(Null 시 최신부터 조회)
 * @param size 페이지당 개수
 */
List<AttendanceCheckQueryDto> findAttendanceChecksByCreatedAtDesc(...);
🤖 Prompt for AI Agents
In
src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepository.java
around lines 10-11, the method lacks Javadoc documenting the pagination contract
and the implementation is missing the ACTIVE status filter and a stable
secondary sort key; add Javadoc that states: exclusive cursor createdAt <
:lastCreatedAt, secondary sort attendanceCheckId DESC, and status = ACTIVE (and
that null lastCreatedAt means start from newest), then update the implementation
(AttendanceCheckQueryRepositoryImpl) to apply where(status.eq(ACTIVE)) in the
predicate, use createdAt.lt(lastCreatedAt) for the exclusive cursor (skip when
lastCreatedAt is null), and orderBy(createdAt.desc(), attendanceCheckId.desc())
while limiting results to size to ensure stable, correct pagination.

Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package konkuk.thip.roompost.adapter.out.persistence.repository.attendancecheck;

import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQueryFactory;
import konkuk.thip.roompost.adapter.out.jpa.QAttendanceCheckJpaEntity;
import konkuk.thip.roompost.application.port.out.dto.AttendanceCheckQueryDto;
import konkuk.thip.roompost.application.port.out.dto.QAttendanceCheckQueryDto;
import konkuk.thip.user.adapter.out.jpa.QAliasJpaEntity;
import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

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

@Repository
@RequiredArgsConstructor
public class AttendanceCheckQueryRepositoryImpl implements AttendanceCheckQueryRepository {

private final JPAQueryFactory jpaQueryFactory;

@Override
public List<AttendanceCheckQueryDto> findAttendanceChecksByCreatedAtDesc(Long roomId, LocalDateTime lastCreatedAt, int size) {
QAttendanceCheckJpaEntity attendanceCheck = QAttendanceCheckJpaEntity.attendanceCheckJpaEntity;
QUserJpaEntity user = QUserJpaEntity.userJpaEntity;
QAliasJpaEntity alias = QAliasJpaEntity.aliasJpaEntity;

BooleanExpression roomPredicate = attendanceCheck.roomJpaEntity.roomId.eq(roomId);
BooleanExpression cursorPredicate = (lastCreatedAt == null) ? null : attendanceCheck.createdAt.lt(lastCreatedAt);

return jpaQueryFactory
.select(new QAttendanceCheckQueryDto(
attendanceCheck.attendanceCheckId,
user.userId,
user.nickname,
alias.imageUrl,
attendanceCheck.todayComment,
attendanceCheck.createdAt
))
.from(attendanceCheck)
.join(attendanceCheck.userJpaEntity, user)
.join(user.aliasForUserJpaEntity, alias)
.where(roomPredicate, cursorPredicate)
.orderBy(
attendanceCheck.createdAt.desc()
)
.limit(size + 1) // 다음 페이지 존재 여부를 확인하기 위해
.fetch();
}
Comment on lines +22 to +49
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

오호 가독성 굿굿입니다!!

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package konkuk.thip.roompost.application.mapper;

import konkuk.thip.common.util.DateUtil;
import konkuk.thip.roompost.adapter.in.web.response.AttendanceCheckShowResponse;
import konkuk.thip.roompost.application.port.out.dto.AttendanceCheckQueryDto;
import org.mapstruct.*;

import java.util.List;

@Mapper(
componentModel = "spring",
imports = DateUtil.class,
unmappedTargetPolicy = ReportingPolicy.ERROR
)
public interface AttendanceCheckQueryMapper {

@Mapping(target = "postDate", expression = "java(DateUtil.formatBeforeTime(dto.createdAt()))")
@Mapping(target = "date", expression = "java(dto.createdAt().toLocalDate())")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

굿입니다

@Mapping(target = "isWriter", source = "dto.creatorId", qualifiedByName = "isWriter")
AttendanceCheckShowResponse.AttendanceCheckShowDto toAttendanceCheckShowDto(AttendanceCheckQueryDto dto, @Context Long userId);

List<AttendanceCheckShowResponse.AttendanceCheckShowDto> toAttendanceCheckShowResponse(List<AttendanceCheckQueryDto> dtos, @Context Long userId);

@Named("isWriter")
default boolean isWriter(Long creatorId, @Context Long userId) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

굿굿

return creatorId != null && creatorId.equals(userId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package konkuk.thip.roompost.application.port.in;

import konkuk.thip.roompost.adapter.in.web.response.AttendanceCheckShowResponse;

public interface AttendanceCheckShowUseCase {

AttendanceCheckShowResponse showDailyGreeting(Long userId, Long roomId, String cursorStr);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package konkuk.thip.roompost.application.port.out;

import konkuk.thip.common.util.Cursor;
import konkuk.thip.common.util.CursorBasedList;
import konkuk.thip.roompost.application.port.out.dto.AttendanceCheckQueryDto;

public interface AttendanceCheckQueryPort {

int countAttendanceChecksOnTodayByUser(Long userId, Long roomId);

CursorBasedList<AttendanceCheckQueryDto> findAttendanceChecksByCreatedAtDesc(Long roomId, Cursor cursor);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package konkuk.thip.roompost.application.port.out.dto;

import com.querydsl.core.annotations.QueryProjection;

import java.time.LocalDateTime;

public record AttendanceCheckQueryDto(
Long attendanceCheckId,
Long creatorId,
String creatorNickname,
String creatorProfileImageUrl,
String todayComment,
LocalDateTime createdAt
) {
@QueryProjection
public AttendanceCheckQueryDto {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package konkuk.thip.roompost.application.service;

import konkuk.thip.common.util.Cursor;
import konkuk.thip.common.util.CursorBasedList;
import konkuk.thip.room.application.service.validator.RoomParticipantValidator;
import konkuk.thip.roompost.adapter.in.web.response.AttendanceCheckShowResponse;
import konkuk.thip.roompost.application.mapper.AttendanceCheckQueryMapper;
import konkuk.thip.roompost.application.port.in.AttendanceCheckShowUseCase;
import konkuk.thip.roompost.application.port.out.AttendanceCheckQueryPort;
import konkuk.thip.roompost.application.port.out.dto.AttendanceCheckQueryDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class AttendanceCheckShowService implements AttendanceCheckShowUseCase {

private static final int PAGE_SIZE = 10;
private final RoomParticipantValidator roomParticipantValidator;
private final AttendanceCheckQueryPort attendanceCheckQueryPort;
private final AttendanceCheckQueryMapper attendanceCheckQueryMapper;

@Override
@Transactional(readOnly = true)
public AttendanceCheckShowResponse showDailyGreeting(Long userId, Long roomId, String cursorStr) {
// 1. 유저가 방 멤버가 맞는지 검사
roomParticipantValidator.validateUserIsRoomMember(roomId, userId);

// 2. Cursor 생성
Cursor cursor = Cursor.from(cursorStr, PAGE_SIZE);

// 3. 오늘의 한마디 조회
CursorBasedList<AttendanceCheckQueryDto> dtos = attendanceCheckQueryPort.findAttendanceChecksByCreatedAtDesc(roomId, cursor);

// 4. response 로 매핑 후 반환
return new AttendanceCheckShowResponse(
attendanceCheckQueryMapper.toAttendanceCheckShowResponse(dtos.contents(), userId),
dtos.nextCursor(),
dtos.isLast()
);
}
}
Loading