Skip to content
Merged
4 changes: 3 additions & 1 deletion .github/workflows/cd-workflow-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ jobs:
cd ${{ env.COMPOSE_PATH }}

echo "✋🏻Stopping existing container and Cleaning up old images"
sudo docker-compose down --rmi all
# sudo docker-compose down --rmi all
sudo docker-compose stop ${{ secrets.DOCKER_IMAGE }}
sudo docker rm -f ${{ secrets.DOCKER_IMAGE }}
Comment on lines +71 to +73
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

굳굳 기존 컨테이너 강제로 내리면 개발서버가 좀 더 안정화될수도 있겠네요

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.

기존에는 mysql redis까지 모두 내렸다가 다시 올렸는데 생각해보니 두 컨테이너는 굳이 내릴 필요가 없다고 판단해서 그냥 서버 컨테이너만 내렸다가 올리려고 합니다!

Comment on lines +71 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue

YAML 블록 들여쓰기 오류 + 잘못된 대상(이미지명)으로 컨테이너 중지/삭제 시도

  • Line 71: script: | 블록 내부 줄은 반드시 들여쓰기 되어야 합니다. 현재 줄 시작에 들여쓰기가 없어 블록이 조기 종료되어 YAML 파싱 오류가 납니다. YAMLlint 에러(“expected …”)가 이 문제를 가리킵니다.
  • Lines 72-73: docker-compose stop은 “서비스명” 기준이며, docker rm은 “컨테이너명/ID” 기준입니다. secrets.DOCKER_IMAGE는 일반적으로 “이미지명”이라 중지/삭제 대상이 달라 실행 실패할 가능성이 높습니다.

단순하고 안전한 방법은 compose 프로젝트 단위로 컨테이너를 중지/정리하는 것입니다. 아래처럼 교체하면:

  • YAML 들여쓰기 문제를 해소
  • 대상 식별 오류를 제거
  • 이미지는 유지(아래에서 prune로 정리됨)

적용 제안:

-#            sudo docker-compose down --rmi all
-            sudo docker-compose stop ${{ secrets.DOCKER_IMAGE }}
-            sudo docker rm -f ${{ secrets.DOCKER_IMAGE }}
+            # docker-compose down --rmi all  # 이미지는 유지하고 컨테이너만 교체합니다 (이미지는 아래 prune에서 정리)
+            sudo docker-compose down --remove-orphans

참고:

  • 특정 서비스만 중지/삭제하려면 “이미지명”이 아니라 “서비스명”이 필요합니다. 해당 경우 별도 secret(예: DOCKER_SERVICE)을 두고 docker-compose stop ${{ secrets.DOCKER_SERVICE }} / docker-compose rm -f ${{ secrets.DOCKER_SERVICE }} 형태로 사용하세요.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# sudo docker-compose down --rmi all
sudo docker-compose stop ${{ secrets.DOCKER_IMAGE }}
sudo docker rm -f ${{ secrets.DOCKER_IMAGE }}
# docker-compose down --rmi all # 이미지는 유지하고 컨테이너만 교체합니다 (이미지는 아래 prune에서 정리)
sudo docker-compose down --remove-orphans
🧰 Tools
🪛 YAMLlint (1.37.1)

[error] 72-72: syntax error: expected , but found ''

(syntax)

🤖 Prompt for AI Agents
In .github/workflows/cd-workflow-dev.yml around lines 71–73 the lines inside the
script: | block are not indented and therefore break YAML parsing, and the
commands use secrets.DOCKER_IMAGE (an image name) with docker-compose stop /
docker rm which expect a service name and a container id/name respectively. Fix
by indenting the lines so they are inside the script block, and replace the two
lines with project-level compose teardown (e.g. run docker-compose stop and
docker-compose rm -f with no argument or run docker-compose down) so you
stop/remove the compose project instead of referencing an image; if you really
need to target a single service, introduce a secret like DOCKER_SERVICE and call
docker-compose stop ${{ secrets.DOCKER_SERVICE }} / docker-compose rm -f ${{
secrets.DOCKER_SERVICE }}.


sudo docker ps -a

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface SavedBookJpaRepository extends JpaRepository<SavedBookJpaEntity, Long> {
@Query("SELECT CASE WHEN COUNT(s) > 0 THEN true ELSE false END FROM SavedBookJpaEntity s " +
"WHERE s.userJpaEntity.userId = :userId AND s.bookJpaEntity.bookId = :bookId")
boolean existsByUserIdAndBookId(Long userId, Long bookId);
boolean existsByUserIdAndBookId(@Param("userId") Long userId, @Param("bookId") Long bookId);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("DELETE FROM SavedBookJpaEntity s WHERE s.userJpaEntity.userId = :userId AND s.bookJpaEntity.bookId = :bookId")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,6 @@ public enum ErrorCode implements ResponseCode {
TAG_NOT_FOUND(HttpStatus.NOT_FOUND, 160002, "존재하지 않는 TAG 입니다."),
INVALID_FEED_COMMAND(HttpStatus.BAD_REQUEST, 160003, "유효하지 않은 FEED 생성/수정 요청 입니다."),
FEED_ACCESS_FORBIDDEN(HttpStatus.FORBIDDEN, 160004, "피드 접근 권한이 없습니다."),
DUPLICATED_FEEDS_IN_COLLECTION(HttpStatus.INTERNAL_SERVER_ERROR, 160005, "중복된 피드가 존재합니다."),
FEED_ALREADY_SAVED(HttpStatus.BAD_REQUEST, 160006, "사용자가 이미 저장한 피드입니다."),
FEED_NOT_SAVED_CANNOT_DELETE(HttpStatus.BAD_REQUEST, 160007, "사용자가 저장하지 않은 피드는 저장삭제 할 수 없습니다."),
FEED_CAN_NOT_SHOW_PRIVATE_ONE(HttpStatus.BAD_REQUEST, 160008, "비공개 피드는 피드 작성자 이외에는 조회할 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,22 @@
package konkuk.thip.feed.adapter.out.persistence;

import konkuk.thip.common.entity.StatusType;
import konkuk.thip.common.exception.EntityNotFoundException;
import konkuk.thip.common.util.Cursor;
import konkuk.thip.common.util.CursorBasedList;
import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity;
import konkuk.thip.feed.adapter.out.jpa.SavedFeedJpaEntity;
import konkuk.thip.feed.adapter.out.jpa.TagJpaEntity;
import konkuk.thip.feed.adapter.out.mapper.FeedMapper;
import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository;
import konkuk.thip.feed.adapter.out.persistence.repository.FeedTag.FeedTagJpaRepository;
import konkuk.thip.feed.adapter.out.persistence.repository.SavedFeedJpaRepository;
import konkuk.thip.feed.application.port.out.FeedQueryPort;
import konkuk.thip.feed.application.port.out.dto.TagCategoryQueryDto;
import konkuk.thip.feed.application.port.out.dto.FeedIdAndTagProjection;
import konkuk.thip.feed.application.port.out.dto.FeedQueryDto;
import konkuk.thip.feed.domain.Feed;
import konkuk.thip.feed.domain.SavedFeeds;
import konkuk.thip.user.adapter.out.jpa.UserJpaEntity;
import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;

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

import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND;

@Repository
@RequiredArgsConstructor
Expand Down Expand Up @@ -112,40 +100,13 @@ public int countPublicFeedsByUserId(Long userId) {
}

@Override
public SavedFeeds findSavedFeedsByUserId(Long userId) {
UserJpaEntity user = userJpaRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND));

List<SavedFeedJpaEntity> savedFeedEntities =
savedFeedJpaRepository.findAllByUserId(user.getUserId());

List<Long> feedIds = savedFeedEntities.stream()
.map(entity -> entity.getFeedJpaEntity().getPostId())
.toList();

// Projection 기반 조회
List<FeedIdAndTagProjection> results = feedTagJpaRepository.findFeedIdAndTagsByFeedIds(feedIds);

Map<Long, List<TagJpaEntity>> feedTagsMap = results.stream()
.collect(Collectors.groupingBy(
FeedIdAndTagProjection::getFeedId,
Collectors.mapping(FeedIdAndTagProjection::getTagJpaEntity, Collectors.toList())
));

List<Feed> feeds = savedFeedEntities.stream()
.map(entity -> {
FeedJpaEntity feedJpa = entity.getFeedJpaEntity();
List<TagJpaEntity> tags = feedTagsMap.getOrDefault(feedJpa.getPostId(), List.of());
return feedMapper.toDomainEntity(feedJpa, tags);
})
.toList();

return new SavedFeeds(feeds);
public Set<Long> findSavedFeedIdsByUserIdAndFeedIds(Set<Long> feedIds, Long userId) {
return savedFeedJpaRepository.findSavedFeedIdsByUserIdAndFeedIds(userId, feedIds);
}

@Override
public Set<Long> findSavedFeedIdsByUserIdAndFeedIds(Set<Long> feedIds, Long userId) {
return savedFeedJpaRepository.findSavedFeedIdsByUserIdAndFeedIds(userId, feedIds);
public boolean existsSavedFeedByUserIdAndFeedId(Long userId, Long feedId) {
return savedFeedJpaRepository.existsByUserIdAndFeedId(userId, feedId);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,8 @@ public interface SavedFeedJpaRepository extends JpaRepository<SavedFeedJpaEntity
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("DELETE FROM SavedFeedJpaEntity sf WHERE sf.feedJpaEntity.postId = :feedId")
int deleteAllByFeedId(@Param("feedId") Long feedId);

@Query("SELECT CASE WHEN COUNT(s) > 0 THEN true ELSE false END FROM SavedFeedJpaEntity s " +
"WHERE s.userJpaEntity.userId = :userId AND s.feedJpaEntity.postId = :feedId")
boolean existsByUserIdAndFeedId(@Param("userId") Long userId, @Param("feedId") Long feedId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import konkuk.thip.common.util.CursorBasedList;
import konkuk.thip.feed.application.port.out.dto.TagCategoryQueryDto;
import konkuk.thip.feed.application.port.out.dto.FeedQueryDto;
import konkuk.thip.feed.domain.SavedFeeds;

import java.util.List;
import java.util.Set;
Expand Down Expand Up @@ -36,11 +35,10 @@ public interface FeedQueryPort {
/**
* 저장된 피드 조회
*/
SavedFeeds findSavedFeedsByUserId(Long userId);

Set<Long> findSavedFeedIdsByUserIdAndFeedIds(Set<Long> feedIds, Long userId);

List<TagCategoryQueryDto> findAllTags();
boolean existsSavedFeedByUserIdAndFeedId(Long userId, Long feedId);

/**
* 특정 책으로 작성된 피드 조회
Expand All @@ -50,4 +48,9 @@ public interface FeedQueryPort {
CursorBasedList<FeedQueryDto> findFeedsByBookIsbnOrderByLatest(String isbn, Long userId, Cursor cursor);

List<Long> findLatestPublicFeedCreatorsIn(Set<Long> userIds, int size);

/**
* 모든 태그 조회
*/
List<TagCategoryQueryDto> findAllTags();
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
package konkuk.thip.feed.application.service;

import konkuk.thip.common.exception.BusinessException;
import konkuk.thip.feed.application.port.in.FeedSavedUseCase;
import konkuk.thip.feed.application.port.in.dto.FeedIsSavedCommand;
import konkuk.thip.feed.application.port.in.dto.FeedIsSavedResult;
import konkuk.thip.feed.application.port.out.FeedCommandPort;
import konkuk.thip.feed.application.port.out.FeedQueryPort;
import konkuk.thip.feed.domain.Feed;
import konkuk.thip.feed.domain.SavedFeeds;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import static konkuk.thip.common.exception.code.ErrorCode.*;

@Service
@RequiredArgsConstructor
public class FeedSavedService implements FeedSavedUseCase {
Expand All @@ -20,24 +22,31 @@ public class FeedSavedService implements FeedSavedUseCase {

@Override
@Transactional
public FeedIsSavedResult changeSavedFeed(FeedIsSavedCommand feedIsSavedCommand) {
public FeedIsSavedResult changeSavedFeed(FeedIsSavedCommand command) {

// 1. 피드 검증 및 조회
Feed feed = feedCommandPort.getByIdOrThrow(feedIsSavedCommand.feedId());
Feed feed = feedCommandPort.getByIdOrThrow(command.feedId());

// 2. 유저가 저장한 피드 목록 조회
SavedFeeds savedFeeds = feedQueryPort.findSavedFeedsByUserId(feedIsSavedCommand.userId());
// 2. 유저가 해당 피드를 저장했는지 여부 조회
boolean alreadySaved = feedQueryPort.existsSavedFeedByUserIdAndFeedId(command.userId(), feed.getId());
validateSaveFeedAction(command.isSaved(), alreadySaved);
Comment on lines +30 to +32
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

굳굳


if (feedIsSavedCommand.isSaved()) {
// 저장 요청 시 이미 저장되어 있으면 예외 발생
savedFeeds.validateNotAlreadySaved(feed);
feedCommandPort.saveSavedFeed(feedIsSavedCommand.userId(), feed.getId());
if (command.isSaved()) {
feedCommandPort.saveSavedFeed(command.userId(), feed.getId());
} else {
// 삭제 요청 시 저장되어 있지 않으면 예외 발생
savedFeeds.validateCanDelete(feed);
feedCommandPort.deleteSavedFeed(feedIsSavedCommand.userId(), feed.getId());
feedCommandPort.deleteSavedFeed(command.userId(), feed.getId());
}

return FeedIsSavedResult.of(feed.getId(), feedIsSavedCommand.isSaved());
return FeedIsSavedResult.of(feed.getId(), command.isSaved());
}
Comment on lines +25 to +41
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

check-then-act 경쟁 상태 가능성: 유니크 제약 및 예외 매핑으로 보강 권장

현재 플로우는

  • Line 31-33: 사전 존재 여부 조회
  • Line 34-38: 결과에 따라 저장/삭제

형태의 check-then-act 패턴입니다. 동시 요청이 들어오면, 존재 여부 확인 직후 상태가 바뀌는 경쟁 상태가 발생할 수 있습니다. 이를 안전하게 만들기 위해 다음을 권장합니다.

필수 보강(권장 구현):

  • SavedFeed(userId, feedId) 조합에 DB 유니크 제약을 두고,
  • 저장 시 DataIntegrityViolationException을 잡아 FEED_ALREADY_SAVED로 변환하여 최종 일관성을 보장

예시 패치:

@@
-        if (command.isSaved()) {
-            feedCommandPort.saveSavedFeed(command.userId(), feed.getId());
-        } else {
-            feedCommandPort.deleteSavedFeed(command.userId(), feed.getId());
-        }
+        if (command.isSaved()) {
+            try {
+                feedCommandPort.saveSavedFeed(command.userId(), feed.getId());
+            } catch (org.springframework.dao.DataIntegrityViolationException e) {
+                // 동시성 등으로 인한 중복 저장 시도를 비즈니스 예외로 변환
+                throw new BusinessException(FEED_ALREADY_SAVED);
+            }
+        } else {
+            // 사전 검증으로 not-saved 삭제는 방지되나, 동시성으로 사라진 경우도 고려하여
+            // deleteSavedFeed 구현이 영향 행 수를 반환한다면 체크/로깅을 고려할 수 있습니다.
+            feedCommandPort.deleteSavedFeed(command.userId(), feed.getId());
+        }

또한, SavedFeed 테이블에 유니크 인덱스가 없으면 위 보강만으로는 완전하지 않습니다. 아래 스크립트로 유니크 제약 및 예외 매핑 유무를 확인해 주세요. 필요 시 후속 PR에서 제약 추가/예외 매핑을 도와드릴 수 있습니다.


🏁 Script executed:

#!/bin/bash
# SavedFeed 유니크 제약 및 저장/삭제 구현에서의 예외 매핑 여부 확인

echo "== SavedFeedJpaEntity 정의 위치 =="
rg -n "class\s+SavedFeedJpaEntity\b" -C2

echo
echo "== @Table(uniqueConstraints=...), unique=true 등 유니크 제약 여부 =="
rg -n "@Table\(" -C2
rg -n "uniqueConstraints" -C2
rg -n "unique\s*=\s*true" -C2

echo
echo "== saveSavedFeed/deleteSavedFeed 구현 및 예외 매핑 흔적 =="
rg -n "saveSavedFeed\s*\(" -C3
rg -n "deleteSavedFeed\s*\(" -C3
rg -n "DataIntegrityViolationException|DuplicateKeyException|FEED_ALREADY_SAVED|FEED_NOT_SAVED_CANNOT_DELETE" -C2

Length of output: 21004


DB 유니크 제약 및 예외 처리 보강 필요

현재 SavedFeed 엔티티에는 (user_id, feed_id) 복합 유니크 제약이 없고, 저장 로직에서도 동시 중복 저장 시 발생하는 예외를 잡아 변환하는 처리가 빠져 있습니다. 경쟁 상태를 방지하고 최종 일관성을 보장하기 위해 아래와 같이 반영을 권장합니다.

  • src/main/java/konkuk/thip/feed/adapter/out/jpa/SavedFeedJpaEntity.java

    • @table 어노테이션에 uniqueConstraints 추가
      @Entity
      @Table(
        name = "saved_feeds",
        uniqueConstraints = @UniqueConstraint(
          name = "uq_saved_feeds_user_feed",
          columnNames = {"user_id", "feed_id"}
        )
      )
      public class SavedFeedJpaEntity extends BaseJpaEntity { … }
  • src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java

    • feedCommandPort.saveSavedFeed(...) 호출부를 DataIntegrityViolationException 핸들링으로 래핑
       if (command.isSaved()) {
      -    feedCommandPort.saveSavedFeed(command.userId(), feed.getId());
      +    try {
      +        feedCommandPort.saveSavedFeed(command.userId(), feed.getId());
      +    } catch (DataIntegrityViolationException e) {
      +        // 동시 중복 저장 시 비즈니스 예외로 변환
      +        throw new BusinessException(FEED_ALREADY_SAVED);
      +    }
       } else {
         feedCommandPort.deleteSavedFeed(command.userId(), feed.getId());
       }
  • 선택 사항: delete 로직도 long deleteByUserIdAndFeedId(...) 처럼 삭제 행 수를 반환하도록 리포지토리 시그니처를 변경하고, 0건 삭제 시 로깅 또는 예외 처리 고려

위 보강 작업 후, FeedChangeSavedApiTest에서 중복 저장/삭제 시나리오가 여전히 올바르게 동작하는지 재검증해주세요.

🤖 Prompt for AI Agents
In src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java
around lines 25–41, the save path does not handle DB uniqueness violations under
concurrent saves; wrap the call to feedCommandPort.saveSavedFeed(...) in a
try-catch that catches DataIntegrityViolationException (or the project's
equivalent), treat it as idempotent (log and continue or translate to a
domain-safe response) so concurrent duplicate-insert errors do not bubble up,
and preserve current return semantics; additionally consider changing delete
flow to use a deleteByUserIdAndFeedId(...) that returns deleted count and log or
handle a 0-delete result. Also add the unique constraint to
src/main/java/konkuk/thip/feed/adapter/out/jpa/SavedFeedJpaEntity.java as
described by the reviewer to enforce (user_id, feed_id) uniqueness at the DB
level.


private void validateSaveFeedAction(boolean isSaveRequest, boolean alreadySaved) {
if (isSaveRequest && alreadySaved) {
// 이미 저장되어 있는 피드를 다시 저장하려는 경우 예외 처리
throw new BusinessException(FEED_ALREADY_SAVED);
} else if (!isSaveRequest && !alreadySaved) {
// 저장되지 않은 피드를 삭제하려는 경우 예외 처리
throw new BusinessException(FEED_NOT_SAVED_CANNOT_DELETE);
}
Comment on lines +43 to +50
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.

굿굿 깔끔쓰하네여

}
}
42 changes: 0 additions & 42 deletions src/main/java/konkuk/thip/feed/domain/SavedFeeds.java

This file was deleted.