Skip to content

fix: 시간표/위시리스트 중복 삽입 방지(유니크 제약 + 트랜잭션)#15

Open
coldmans wants to merge 3 commits into
mainfrom
feat/unique-constraint-timetable-wishlist
Open

fix: 시간표/위시리스트 중복 삽입 방지(유니크 제약 + 트랜잭션)#15
coldmans wants to merge 3 commits into
mainfrom
feat/unique-constraint-timetable-wishlist

Conversation

@coldmans

Copy link
Copy Markdown
Owner

배경

동일 과목을 매우 짧은 간격으로 연속 요청할 때(더블클릭/중복 탭 요청) 중복 row가 저장되고,
이후 삭제 시 단건 가정 로직 때문에 제거 실패/혼선이 발생할 수 있었습니다.

변경 내용

  1. 엔티티 유니크 제약 추가
  • user_timetables: (user_id, subject_id, semester)
  • wishlist_items: (user_id, subject_id, semester)
  1. 서비스/리포지토리 보강
  • add 로직에 @Transactional 적용
  • 저장 시 DataIntegrityViolationException을 사용자 메시지("이미 추가된 과목")로 변환
  • remove 로직을 단건 조회 삭제에서 조건 삭제로 변경(삭제 건수 기반 검증)
  1. semester 기준 일관성 강화
  • 중복 체크를 userId + subjectId + semester 기준으로 수행
  • update/remove에서도 semester 파라미터를 선택적으로 받아 해당 학기 우선 처리
  1. 운영 DB 반영 SQL 추가
  • scripts/2026-02-13_add_unique_constraints.sql
    • 기존 중복 데이터 정리 후 유니크 제약 추가
  1. 문서 반영
  • README에 데이터 정합성(중복 삽입 방지) 섹션 추가

기대 효과

  • 프론트 디바운스(0.5초 제한) 없이도 DB 레벨에서 중복 삽입 원천 차단
  • 동시성 상황에서 삭제/수정 안정성 향상
  • 시간표/위시리스트 데이터 정합성 보장

참고

  • 운영 프로필은 ddl-auto: validate이므로, 실제 운영 반영 시 SQL 스크립트 실행이 필요합니다.

Copilot AI review requested due to automatic review settings February 13, 2026 14:06
@gemini-code-assist

Copy link
Copy Markdown

Summary of Changes

Hello @coldmans, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 Pull Request는 시간표 및 위시리스트 기능에서 동일 과목이 중복으로 추가되는 문제를 해결하고 데이터 정합성을 강화하는 것을 목표로 합니다. 데이터베이스 레벨에서 유니크 제약 조건을 추가하고, 서비스 로직에서 트랜잭션 처리 및 예외 처리를 개선하여 동시성 상황에서의 안정성을 높였습니다. 또한, 삭제 및 수정 로직을 개선하여 semester 기준으로 보다 정확한 작업을 수행할 수 있도록 했습니다.

Highlights

  • 유니크 제약 조건 추가: user_timetableswishlist_items 테이블에 (user_id, subject_id, semester) 기준으로 유니크 제약 조건을 추가하여 데이터 중복을 방지했습니다.
  • 서비스/리포지토리 로직 강화: 과목 추가 로직에 @Transactional을 적용하고, DataIntegrityViolationException 발생 시 사용자 친화적인 메시지로 변환하도록 개선했습니다. 또한, 삭제 로직을 단건 조회 후 삭제 방식에서 조건 기반 삭제로 변경하여 안정성을 높였습니다.
  • 학기(semester) 기준 일관성 강화: 중복 체크 및 업데이트/삭제 작업에서 semester 매개변수를 선택적으로 활용하여 학기별 데이터 처리에 대한 일관성을 강화했습니다.
  • 운영 DB 반영 SQL 스크립트 추가: 기존 중복 데이터를 정리하고 새로운 유니크 제약 조건을 추가하는 SQL 스크립트(scripts/2026-02-13_add_unique_constraints.sql)를 생성했습니다.
  • 문서 업데이트: README.md 파일에 데이터 정합성(중복 삽입 방지)에 대한 새로운 섹션을 추가하여 변경 사항을 문서화했습니다.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • .gitignore
    • 스크립트 디렉토리 내의 SQL 파일이 Git에 의해 추적되도록 예외 규칙을 추가했습니다.
  • README.md
    • 데이터 정합성 및 유니크 제약 조건에 대한 새로운 섹션을 추가했습니다.
  • scripts/2026-02-13_add_unique_constraints.sql
    • 기존 중복 데이터를 정리하고 user_timetableswishlist_items 테이블에 유니크 제약 조건을 추가하는 SQL 스크립트를 새로 생성했습니다.
  • src/main/java/inu/timetable/controller/TimetableController.java
    • removeSubjectFromTimetableupdateMemo 엔드포인트에 semester 매개변수를 추가하여 학기별 처리를 지원하도록 수정했습니다.
  • src/main/java/inu/timetable/controller/WishlistController.java
    • removeFromWishlist, updatePriority, updateRequired 엔드포인트에 semester 매개변수를 추가하여 학기별 처리를 지원하도록 수정했습니다.
  • src/main/java/inu/timetable/entity/UserTimetable.java
    • user_timetables 테이블에 (user_id, subject_id, semester) 조합에 대한 유니크 제약 조건을 @UniqueConstraint 어노테이션을 사용하여 추가했습니다.
  • src/main/java/inu/timetable/entity/WishlistItem.java
    • wishlist_items 테이블에 (user_id, subject_id, semester) 조합에 대한 유니크 제약 조건을 @UniqueConstraint 어노테이션을 사용하여 추가했습니다.
  • src/main/java/inu/timetable/repository/UserTimetableRepository.java
    • 학기 기반 조회 및 삭제 메서드를 추가하고, 기존 조회 메서드를 학기 포함 버전으로 업데이트했습니다.
    • 기존 findByUserIdAndSubjectId 메서드 외에 findByUserIdAndSubjectIdAndSemester 메서드를 추가했습니다.
    • deleteByUserIdAndSubjectIdAndSemester 메서드를 추가하여 학기별 조건부 삭제를 지원했습니다.
  • src/main/java/inu/timetable/repository/WishlistRepository.java
    • 학기 기반 조회 및 삭제 메서드를 추가하고, 기존 조회 메서드를 학기 포함 버전으로 업데이트했습니다.
    • 기존 findByUserIdAndSubjectId 메서드 외에 findByUserIdAndSubjectIdAndSemester 메서드를 추가했습니다.
    • deleteByUserIdAndSubjectIdAndSemester 메서드를 추가하여 학기별 조건부 삭제를 지원했습니다.
  • src/main/java/inu/timetable/service/TimetableService.java
    • 시간표 추가 및 제거 로직에 @Transactional 어노테이션을 추가했습니다.
    • addSubjectToTimetable에서 DataIntegrityViolationException을 처리하여 중복 삽입 시 사용자 정의 예외 메시지를 반환하도록 변경했습니다.
    • removeSubjectFromTimetable은 삭제된 행 수를 확인하여 예외를 발생시키도록 수정했습니다.
    • updateMemo 메서드는 semester 매개변수를 선택적으로 받아 해당 학기의 데이터를 업데이트하도록 로직을 변경했습니다.
  • src/main/java/inu/timetable/service/WishlistService.java
    • 위시리스트 추가 및 제거 로직에 @Transactional 어노테이션을 추가했습니다.
    • addToWishlist에서 DataIntegrityViolationException을 처리하여 중복 삽입 시 사용자 정의 예외 메시지를 반환하도록 변경했습니다.
    • removeFromWishlist는 삭제된 행 수를 확인하여 예외를 발생시키도록 수정했습니다.
    • updatePriorityupdateRequired 메서드는 semester 매개변수를 선택적으로 받아 해당 학기의 데이터를 업데이트하도록 로직을 변경했습니다.
Activity
  • 이 Pull Request에 기록된 활동은 없습니다.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

이번 PR은 시간표와 위시리스트에 중복된 과목이 삽입되는 문제를 해결하기 위해 데이터베이스 유니크 제약 조건 추가, 서비스 로직 보강 등 중요한 개선 작업을 포함하고 있습니다. 전체적인 접근 방식이 훌륭하며, 데이터 정합성을 크게 향상시킬 것으로 기대됩니다. 몇 가지 코드 개선 및 잠재적인 버그 수정에 대한 의견을 제안합니다.

Comment on lines +25 to +26
@Query("SELECT w FROM WishlistItem w JOIN FETCH w.subject s WHERE w.user.id = :userId AND s.id = :subjectId")
Optional<WishlistItem> findByUserIdAndSubjectId(@Param("userId") Long userId, @Param("subjectId") Long subjectId);
Optional<WishlistItem> findByUserIdAndSubjectIdAndSemester(@Param("userId") Long userId, @Param("subjectId") Long subjectId, @Param("semester") String semester);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

findByUserIdAndSubjectIdAndSemester 메소드의 JPQL 쿼리에 semester 조건이 누락되었습니다.
@Query 어노테이션에 AND w.semester = :semester 조건을 추가해야 의도대로 특정 학기의 항목만 조회할 수 있습니다. 현재 구현은 학기에 상관없이 첫 번째로 찾아진 과목을 반환하여, 다른 학기에 동일 과목이 있을 경우 잘못된 데이터를 다룰 수 있습니다.

Suggested change
@Query("SELECT w FROM WishlistItem w JOIN FETCH w.subject s WHERE w.user.id = :userId AND s.id = :subjectId")
Optional<WishlistItem> findByUserIdAndSubjectId(@Param("userId") Long userId, @Param("subjectId") Long subjectId);
Optional<WishlistItem> findByUserIdAndSubjectIdAndSemester(@Param("userId") Long userId, @Param("subjectId") Long subjectId, @Param("semester") String semester);
@Query("SELECT w FROM WishlistItem w JOIN FETCH w.subject s WHERE w.user.id = :userId AND s.id = :subjectId AND w.semester = :semester")
Optional<WishlistItem> findByUserIdAndSubjectIdAndSemester(@Param("userId") Long userId, @Param("subjectId") Long subjectId, @Param("semester") String semester);

Comment on lines 42 to 45
Optional<UserTimetable> existing = userTimetableRepository.findByUserIdAndSubjectIdAndSemester(userId, subjectId, semester);
if (existing.isPresent()) {
throw new RuntimeException("이미 시간표에 추가된 과목입니다.");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

데이터베이스에 UNIQUE 제약 조건이 있고, DataIntegrityViolationExceptiontry-catch로 처리하여 중복 삽입을 방지하고 있으므로, save() 호출 전 existing.isPresent()를 통한 중복 확인 로직은 불필요해 보입니다.
이 사전 검사는 동시성 환경에서 완벽한 해결책이 되지 못하며(race condition 발생 가능), try-catch 블록이 이미 최종적인 데이터 정합성을 보장하고 있습니다.
사전 검사를 제거하면 코드가 더 간결해지고, 데이터 정합성 보장 책임을 데이터베이스 제약 조건으로 일원화할 수 있습니다.

Comment on lines 46 to 49
Optional<WishlistItem> existing = wishlistRepository.findByUserIdAndSubjectIdAndSemester(userId, subjectId, semester);
if (existing.isPresent()) {
throw new RuntimeException("이미 위시리스트에 추가된 과목입니다.");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

TimetableService와 마찬가지로, 여기서도 save() 호출 전의 중복 확인 로직은 DataIntegrityViolationException 처리 로직과 중복됩니다.
데이터베이스의 UNIQUE 제약 조건을 신뢰하고 예외 처리를 통해 중복 삽입을 막는 것이 더 견고하고 간결한 방법입니다. 이 사전 검사를 제거하는 것을 고려해보세요.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR enhances data integrity for the timetable and wishlist features by implementing database-level unique constraints and improving concurrent request handling. The changes prevent duplicate entries that could occur during rapid successive requests (double-clicks, concurrent tabs) by enforcing uniqueness at the database level using (user_id, subject_id, semester) composite keys.

Changes:

  • Added unique constraints on user_timetables and wishlist_items tables for (user_id, subject_id, semester) combination
  • Enhanced service layer with @Transactional annotations and DataIntegrityViolationException handling
  • Updated repository methods and controllers to consistently use semester parameter for CRUD operations

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/main/java/inu/timetable/entity/UserTimetable.java Added unique constraint annotation for user-subject-semester combination
src/main/java/inu/timetable/entity/WishlistItem.java Added unique constraint annotation for user-subject-semester combination
src/main/java/inu/timetable/repository/UserTimetableRepository.java Added semester-aware query methods and conditional delete operation
src/main/java/inu/timetable/repository/WishlistRepository.java Added semester-aware query methods and conditional delete operation
src/main/java/inu/timetable/service/TimetableService.java Added transactions, semester-based duplicate checks, and exception handling
src/main/java/inu/timetable/service/WishlistService.java Added transactions, semester-based duplicate checks, and exception handling
src/main/java/inu/timetable/controller/TimetableController.java Updated endpoints to accept optional semester parameter
src/main/java/inu/timetable/controller/WishlistController.java Updated endpoints to accept optional semester parameter
scripts/2026-02-13_add_unique_constraints.sql PostgreSQL migration script to cleanup duplicates and add constraints
README.md Documented data integrity feature and race condition prevention
.gitignore Added exception to allow SQL scripts in the scripts directory

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -23,9 +23,11 @@ public interface WishlistRepository extends JpaRepository<WishlistItem, Long> {
List<WishlistItem> findByUserIdAndSemesterWithSubjectAndSchedules(@Param("userId") Long userId, @Param("semester") String semester);

@Query("SELECT w FROM WishlistItem w JOIN FETCH w.subject s WHERE w.user.id = :userId AND s.id = :subjectId")

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

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

The JPQL query does not include the semester parameter in the WHERE clause. The query should filter by semester as well to match the method signature. The current query "SELECT w FROM WishlistItem w JOIN FETCH w.subject s WHERE w.user.id = :userId AND s.id = :subjectId" is missing "AND w.semester = :semester".

Suggested change
@Query("SELECT w FROM WishlistItem w JOIN FETCH w.subject s WHERE w.user.id = :userId AND s.id = :subjectId")
@Query("SELECT w FROM WishlistItem w JOIN FETCH w.subject s WHERE w.user.id = :userId AND s.id = :subjectId AND w.semester = :semester")

Copilot uses AI. Check for mistakes.
Comment on lines +78 to +87
public WishlistItem updatePriority(Long userId, Long subjectId, String semester, Integer priority) {
WishlistItem wishlistItem;
if (semester != null && !semester.isBlank()) {
wishlistItem = wishlistRepository.findByUserIdAndSubjectIdAndSemester(userId, subjectId, semester)
.orElseThrow(() -> new RuntimeException("위시리스트에서 해당 과목을 찾을 수 없습니다."));
} else {
wishlistItem = wishlistRepository.findByUserIdAndSubjectId(userId, subjectId).stream()
.findFirst()
.orElseThrow(() -> new RuntimeException("위시리스트에서 해당 과목을 찾을 수 없습니다."));
}

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

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

The update methods have fallback logic that calls findByUserIdAndSubjectId when semester is null or blank, but this method returns a List, not an Optional. Using .stream().findFirst() on a list that could contain multiple items from different semesters introduces ambiguity. Since the unique constraint is now on (user_id, subject_id, semester), the same user-subject combination can exist across multiple semesters. When semester is not provided, it's unclear which one to update. Consider either making semester required for these update operations, or documenting that the fallback behavior updates an arbitrary entry when multiple exist.

Copilot uses AI. Check for mistakes.
Comment on lines +93 to +102
public WishlistItem updateRequired(Long userId, Long subjectId, String semester, Boolean isRequired) {
WishlistItem wishlistItem;
if (semester != null && !semester.isBlank()) {
wishlistItem = wishlistRepository.findByUserIdAndSubjectIdAndSemester(userId, subjectId, semester)
.orElseThrow(() -> new RuntimeException("위시리스트에서 해당 과목을 찾을 수 없습니다."));
} else {
wishlistItem = wishlistRepository.findByUserIdAndSubjectId(userId, subjectId).stream()
.findFirst()
.orElseThrow(() -> new RuntimeException("위시리스트에서 해당 과목을 찾을 수 없습니다."));
}

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

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

The update methods have fallback logic that calls findByUserIdAndSubjectId when semester is null or blank, but this method returns a List, not an Optional. Using .stream().findFirst() on a list that could contain multiple items from different semesters introduces ambiguity. Since the unique constraint is now on (user_id, subject_id, semester), the same user-subject combination can exist across multiple semesters. When semester is not provided, it's unclear which one to update. Consider either making semester required for these update operations, or documenting that the fallback behavior updates an arbitrary entry when multiple exist.

Copilot uses AI. Check for mistakes.
@Modifying
@Query("DELETE FROM WishlistItem w WHERE w.user.id = :userId AND w.subject.id = :subjectId")
void deleteByUserIdAndSubjectId(@Param("userId") Long userId, @Param("subjectId") Long subjectId);
@Query("DELETE FROM WishlistItem w WHERE w.user.id = :userId AND w.subject.id = :subjectId AND (:semester IS NULL OR w.semester = :semester)")

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

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

The delete query uses "(:semester IS NULL OR w.semester = :semester)" which allows deleting all entries for a user-subject pair when semester is null. However, the service method removeFromWishlist now accepts an optional semester parameter from the controller. This creates ambiguity: if the frontend doesn't provide semester, all matching entries across all semesters will be deleted. Given that the PR's goal is to enforce semester-based uniqueness, consider either making semester required for delete operations or clearly documenting this behavior, as it may not be the intended user experience (deleting from one semester but accidentally removing from all semesters).

Suggested change
@Query("DELETE FROM WishlistItem w WHERE w.user.id = :userId AND w.subject.id = :subjectId AND (:semester IS NULL OR w.semester = :semester)")
@Query("DELETE FROM WishlistItem w WHERE w.user.id = :userId AND w.subject.id = :subjectId AND w.semester = :semester")

Copilot uses AI. Check for mistakes.
Comment on lines +83 to 96
public UserTimetable updateMemo(Long userId, Long subjectId, String semester, String memo) {
UserTimetable userTimetable;
if (semester != null && !semester.isBlank()) {
userTimetable = userTimetableRepository.findByUserIdAndSubjectIdAndSemester(userId, subjectId, semester)
.orElseThrow(() -> new RuntimeException("시간표에서 해당 과목을 찾을 수 없습니다."));
} else {
userTimetable = userTimetableRepository.findByUserIdAndSubjectId(userId, subjectId).stream()
.findFirst()
.orElseThrow(() -> new RuntimeException("시간표에서 해당 과목을 찾을 수 없습니다."));
}

userTimetable.setMemo(memo);
return userTimetableRepository.save(userTimetable);
}

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

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

The updateMemo method performs database writes but is not marked with @transactional. While Spring Data JPA handles simple saves within a transaction context, it's a best practice to explicitly mark write operations with @transactional for consistency with other write methods in this service (addSubjectToTimetable and removeSubjectFromTimetable are both marked @transactional). This ensures transaction boundaries are clearly defined and consistent.

Copilot uses AI. Check for mistakes.
Comment on lines +84 to +93
UserTimetable userTimetable;
if (semester != null && !semester.isBlank()) {
userTimetable = userTimetableRepository.findByUserIdAndSubjectIdAndSemester(userId, subjectId, semester)
.orElseThrow(() -> new RuntimeException("시간표에서 해당 과목을 찾을 수 없습니다."));
} else {
userTimetable = userTimetableRepository.findByUserIdAndSubjectId(userId, subjectId).stream()
.findFirst()
.orElseThrow(() -> new RuntimeException("시간표에서 해당 과목을 찾을 수 없습니다."));
}

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

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

The update method has fallback logic that calls findByUserIdAndSubjectId when semester is null or blank, but this method returns a List, not an Optional. Using .stream().findFirst() on a list that could contain multiple items from different semesters introduces ambiguity. Since the unique constraint is now on (user_id, subject_id, semester), the same user-subject combination can exist across multiple semesters. When semester is not provided, it's unclear which one to update. Consider either making semester required for this update operation, or documenting that the fallback behavior updates an arbitrary entry when multiple exist.

Suggested change
UserTimetable userTimetable;
if (semester != null && !semester.isBlank()) {
userTimetable = userTimetableRepository.findByUserIdAndSubjectIdAndSemester(userId, subjectId, semester)
.orElseThrow(() -> new RuntimeException("시간표에서 해당 과목을 찾을 수 없습니다."));
} else {
userTimetable = userTimetableRepository.findByUserIdAndSubjectId(userId, subjectId).stream()
.findFirst()
.orElseThrow(() -> new RuntimeException("시간표에서 해당 과목을 찾을 수 없습니다."));
}
if (semester == null || semester.isBlank()) {
throw new IllegalArgumentException("semester는 메모 수정 시 필수 입력 값입니다.");
}
UserTimetable userTimetable = userTimetableRepository
.findByUserIdAndSubjectIdAndSemester(userId, subjectId, semester)
.orElseThrow(() -> new RuntimeException("시간표에서 해당 과목을 찾을 수 없습니다."));

Copilot uses AI. Check for mistakes.
List<UserTimetable> findByUserIdAndSubjectId(Long userId, Long subjectId);

@Modifying
@Query("DELETE FROM UserTimetable ut WHERE ut.user.id = :userId AND ut.subject.id = :subjectId AND (:semester IS NULL OR ut.semester = :semester)")

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

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

The delete query uses "(:semester IS NULL OR ut.semester = :semester)" which allows deleting all entries for a user-subject pair when semester is null. However, the service method removeSubjectFromTimetable now accepts an optional semester parameter from the controller. This creates ambiguity: if the frontend doesn't provide semester, all matching entries across all semesters will be deleted. Given that the PR's goal is to enforce semester-based uniqueness, consider either making semester required for delete operations or clearly documenting this behavior, as it may not be the intended user experience (deleting from one semester but accidentally removing from all semesters).

Suggested change
@Query("DELETE FROM UserTimetable ut WHERE ut.user.id = :userId AND ut.subject.id = :subjectId AND (:semester IS NULL OR ut.semester = :semester)")
@Query("DELETE FROM UserTimetable ut WHERE ut.user.id = :userId AND ut.subject.id = :subjectId AND ut.semester = :semester")

Copilot uses AI. Check for mistakes.
Comment on lines +78 to 91
public WishlistItem updatePriority(Long userId, Long subjectId, String semester, Integer priority) {
WishlistItem wishlistItem;
if (semester != null && !semester.isBlank()) {
wishlistItem = wishlistRepository.findByUserIdAndSubjectIdAndSemester(userId, subjectId, semester)
.orElseThrow(() -> new RuntimeException("위시리스트에서 해당 과목을 찾을 수 없습니다."));
} else {
wishlistItem = wishlistRepository.findByUserIdAndSubjectId(userId, subjectId).stream()
.findFirst()
.orElseThrow(() -> new RuntimeException("위시리스트에서 해당 과목을 찾을 수 없습니다."));
}

wishlistItem.setPriority(priority);
return wishlistRepository.save(wishlistItem);
}

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

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

The updatePriority and updateRequired methods perform database writes but are not marked with @transactional. While Spring Data JPA handles simple saves within a transaction context, it's a best practice to explicitly mark write operations with @transactional for consistency with other write methods in this service (addToWishlist and removeFromWishlist are both marked @transactional). This ensures transaction boundaries are clearly defined and consistent.

Copilot uses AI. Check for mistakes.
Comment on lines +93 to 106
public WishlistItem updateRequired(Long userId, Long subjectId, String semester, Boolean isRequired) {
WishlistItem wishlistItem;
if (semester != null && !semester.isBlank()) {
wishlistItem = wishlistRepository.findByUserIdAndSubjectIdAndSemester(userId, subjectId, semester)
.orElseThrow(() -> new RuntimeException("위시리스트에서 해당 과목을 찾을 수 없습니다."));
} else {
wishlistItem = wishlistRepository.findByUserIdAndSubjectId(userId, subjectId).stream()
.findFirst()
.orElseThrow(() -> new RuntimeException("위시리스트에서 해당 과목을 찾을 수 없습니다."));
}

wishlistItem.setIsRequired(isRequired);
return wishlistRepository.save(wishlistItem);
}

Copilot AI Feb 13, 2026

Copy link

Choose a reason for hiding this comment

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

The updatePriority and updateRequired methods perform database writes but are not marked with @transactional. While Spring Data JPA handles simple saves within a transaction context, it's a best practice to explicitly mark write operations with @transactional for consistency with other write methods in this service (addToWishlist and removeFromWishlist are both marked @transactional). This ensures transaction boundaries are clearly defined and consistent.

Copilot uses AI. Check for mistakes.
@coldmans

Copy link
Copy Markdown
Owner Author

리뷰 코멘트 반영해서 추가 커밋 푸시했습니다.

반영 사항

  • WishlistRepository.findByUserIdAndSubjectIdAndSemester의 JPQL에 semester 조건 누락 수정
  • add 로직의 사전 중복 조회 제거 (DB UNIQUE + DataIntegrityViolationException 처리로 일원화)
    • TimetableService.addSubjectToTimetable
    • WishlistService.addToWishlist

추가 확인 부탁드립니다.

@coldmans

Copy link
Copy Markdown
Owner Author

추가 요청 반영하여 백엔드에도 회원탈퇴 기능을 이어서 반영했습니다.

추가 반영 사항

  • DELETE /api/auth/withdraw 엔드포인트 추가
  • AuthService.withdraw(userId, password) 추가 (@Transactional)
    • 비밀번호 검증 후
    • wishlist_items, user_timetables 사용자 데이터 삭제
    • users 삭제
  • 사용자 연관 데이터 일괄 삭제용 Repository 메서드 추가
    • WishlistRepository.deleteByUserId(...)
    • UserTimetableRepository.deleteByUserId(...)

프론트 연동 PR은 별도로 생성했습니다:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants