Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
NEIS_API_KEY=8ba18e6d2b1f42f9be762009bc3dfa90
Copy link

@coderabbitai coderabbitai bot Jul 8, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue

보안 경고: API 키가 버전 관리 시스템에 노출되었습니다.

NEIS API 키가 .env 파일에 하드코딩되어 버전 관리 시스템에 커밋되었습니다. 이는 심각한 보안 위험을 초래할 수 있습니다.

해결 방안:

  1. 이 파일을 .gitignore에 추가하여 향후 커밋을 방지
  2. 기존 커밋 히스토리에서 API 키 제거
  3. 노출된 API 키 교체 검토
  4. 프로덕션 환경에서는 환경 변수나 시크릿 관리 시스템 사용
🤖 Prompt for AI Agents
In the .env file at line 1, the NEIS_API_KEY is hardcoded and committed to
version control, posing a security risk. To fix this, add the .env file to
.gitignore to prevent future commits, remove the API key from the git history
using tools like git filter-branch or BFG Repo-Cleaner, replace the exposed API
key with a new one, and ensure that in production the API key is managed
securely via environment variables or a secrets management system.

Copy link

Choose a reason for hiding this comment

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

NEIS_API_KEY 잘 쓸게요.
감사합니다 ㅎㅎ

Copy link

Choose a reason for hiding this comment

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

@pdh0128 네, 알겠습니다! 🐰

API 키를 잘 활용하시길 바랍니다. 향후 프로덕션 환경에서는 보안을 위해 환경 변수나 시크릿 관리 시스템 사용을 고려해보시면 좋을 것 같습니다.

(´∀`)♡

6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'

implementation 'org.projectlombok:lombok:1.18.30'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-validation'
testImplementation 'io.projectreactor:reactor-test'
annotationProcessor 'org.projectlombok:lombok:1.18.30'

developmentOnly 'org.springframework.boot:spring-boot-devtools'
Expand All @@ -53,6 +56,9 @@ dependencies {

asciidoctorExt 'org.springframework.restdocs:spring-restdocs-asciidoctor'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'

testImplementation 'org.mockito:mockito-core'
testImplementation 'org.mockito:mockito-junit-jupiter'
}

ext {
Expand Down
Binary file modified postgres-data/base/1/1259
Binary file not shown.
Binary file modified postgres-data/base/1/13398
Binary file not shown.
Binary file modified postgres-data/base/1/2619
Binary file not shown.
Binary file modified postgres-data/base/1/2619_fsm
Binary file not shown.
Binary file modified postgres-data/base/1/2619_vm
Binary file not shown.
Binary file modified postgres-data/base/1/2696
Binary file not shown.
Binary file modified postgres-data/base/1/2840
Binary file not shown.
Binary file modified postgres-data/base/1/2840_fsm
Binary file not shown.
Binary file modified postgres-data/base/1/2840_vm
Binary file not shown.
Binary file modified postgres-data/base/1/2841
Binary file not shown.
Binary file modified postgres-data/base/16384/1247
Binary file not shown.
Binary file modified postgres-data/base/16384/1249
Binary file not shown.
Binary file modified postgres-data/base/16384/1259
Binary file not shown.
Binary file modified postgres-data/base/16384/13398
Binary file not shown.
Binary file not shown.
File renamed without changes.
Binary file modified postgres-data/base/16384/16390
Binary file not shown.
Binary file not shown.
Binary file added postgres-data/base/16384/16392
Binary file not shown.
Binary file removed postgres-data/base/16384/16396
Binary file not shown.
Binary file modified postgres-data/base/16384/2224
Binary file not shown.
Binary file modified postgres-data/base/16384/2579
Binary file not shown.
Binary file modified postgres-data/base/16384/2604
Binary file not shown.
Binary file modified postgres-data/base/16384/2606
Binary file not shown.
Binary file modified postgres-data/base/16384/2608
Binary file not shown.
Binary file modified postgres-data/base/16384/2610
Binary file not shown.
Binary file modified postgres-data/base/16384/2619
Binary file not shown.
Binary file modified postgres-data/base/16384/2656
Binary file not shown.
Binary file modified postgres-data/base/16384/2657
Binary file not shown.
Binary file modified postgres-data/base/16384/2658
Binary file not shown.
Binary file modified postgres-data/base/16384/2659
Binary file not shown.
Binary file modified postgres-data/base/16384/2662
Binary file not shown.
Binary file modified postgres-data/base/16384/2663
Binary file not shown.
Binary file modified postgres-data/base/16384/2664
Binary file not shown.
Binary file modified postgres-data/base/16384/2665
Binary file not shown.
Binary file modified postgres-data/base/16384/2666
Binary file not shown.
Binary file modified postgres-data/base/16384/2667
Binary file not shown.
Binary file modified postgres-data/base/16384/2673
Binary file not shown.
Binary file modified postgres-data/base/16384/2674
Binary file not shown.
Binary file modified postgres-data/base/16384/2678
Binary file not shown.
Binary file modified postgres-data/base/16384/2679
Binary file not shown.
Binary file modified postgres-data/base/16384/2703
Binary file not shown.
Binary file modified postgres-data/base/16384/2704
Binary file not shown.
Binary file modified postgres-data/base/16384/3455
Binary file not shown.
Binary file modified postgres-data/base/16384/5002
Binary file not shown.
Binary file modified postgres-data/base/16384/pg_internal.init
Binary file not shown.
Binary file modified postgres-data/base/4/13398
Binary file not shown.
Binary file modified postgres-data/base/4/2619
Binary file not shown.
Binary file modified postgres-data/base/5/13398
Binary file not shown.
Binary file modified postgres-data/base/5/2619
Binary file not shown.
Binary file modified postgres-data/global/1260
Binary file not shown.
Binary file modified postgres-data/global/pg_control
Binary file not shown.
Binary file modified postgres-data/global/pg_internal.init
Binary file not shown.
Binary file modified postgres-data/pg_wal/000000010000000000000001
Binary file not shown.
Binary file modified postgres-data/pg_xact/0000
Binary file not shown.
4 changes: 2 additions & 2 deletions postgres-data/postmaster.pid
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
1
/var/lib/postgresql/data
1750235928
1751931356
5432
/var/run/postgresql
*
5 0
13 0
ready
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package hello.cluebackend.domain.timetable.presentation;

import hello.cluebackend.domain.timetable.presentation.dto.request.TimetableRequestDto;
import hello.cluebackend.domain.timetable.presentation.dto.response.TimetableResponseDto;
import hello.cluebackend.domain.timetable.service.TimetableService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;

import java.util.List;

@RestController
@RequestMapping("/api/timetable")
@RequiredArgsConstructor
public class TimetableController {
private final TimetableService timetableService;

@GetMapping("/test")
public String test(){
return "Test";
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

테스트 엔드포인트 제거 필요

프로덕션 코드에 테스트 목적의 엔드포인트가 포함되어 있습니다. 보안상 위험하므로 제거해야 합니다.

-  @GetMapping("/test")
-  public String test(){
-    return "Test";
-  }
📝 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
@GetMapping("/test")
public String test(){
return "Test";
}
🤖 Prompt for AI Agents
In
src/main/java/hello/cluebackend/domain/timetable/presentation/TimetableController.java
around lines 19 to 22, there is a test endpoint mapped to "/test" returning a
simple string. This test endpoint should be removed entirely from the production
code to eliminate potential security risks. Delete the entire method annotated
with @GetMapping("/test") including its signature and body.


@GetMapping("/today")
public Mono<ResponseEntity<List<TimetableResponseDto>>> getTodayTimetable(
@RequestParam(required = true) String grade,
@RequestParam(required = true) String classNumber
) {
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

컨트롤러 레벨 validation 추가 필요

요청 파라미터에 대한 검증이 없습니다. @Valid 어노테이션과 함께 적절한 검증을 추가해야 합니다.

  @GetMapping("/today")
  public Mono<ResponseEntity<List<TimetableResponseDto>>> getTodayTimetable(
-          @RequestParam(required = true) String grade,
-          @RequestParam(required = true) String classNumber
+          @RequestParam @Pattern(regexp = "^[1-3]$", message = "학년은 1~3 사이여야 합니다.") String grade,
+          @RequestParam @Pattern(regexp = "^[1-4]$", message = "반은 1~4 사이여야 합니다.") String classNumber
) {

동일한 패턴을 /weekly 엔드포인트에도 적용하세요.

Also applies to: 37-40

🤖 Prompt for AI Agents
In
src/main/java/hello/cluebackend/domain/timetable/presentation/TimetableController.java
around lines 25-28 and 37-40, the request parameters lack validation. Add
validation annotations such as @Valid and appropriate constraints (e.g.,
@NotBlank, @NotNull) to the method parameters or wrap them in a validated DTO
class. Apply the same validation pattern to both the getTodayTimetable and the
/weekly endpoint methods to ensure request parameters are properly validated at
the controller level.

TimetableRequestDto request = new TimetableRequestDto(grade, classNumber);
return timetableService.getTodayTimetable(request)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.noContent().build())
.onErrorResume(e -> Mono.just(ResponseEntity.internalServerError().build()));
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

에러 처리 개선 필요

현재 모든 에러를 500 Internal Server Error로 처리하고 있습니다. 에러 타입에 따라 적절한 HTTP 상태 코드를 반환해야 합니다.

-            .onErrorResume(e -> Mono.just(ResponseEntity.internalServerError().build()));
+            .onErrorResume(e -> {
+                if (e instanceof IllegalArgumentException) {
+                    return Mono.just(ResponseEntity.badRequest().build());
+                }
+                return Mono.just(ResponseEntity.internalServerError().build());
+            });

Also applies to: 45-45

🤖 Prompt for AI Agents
In
src/main/java/hello/cluebackend/domain/timetable/presentation/TimetableController.java
at lines 33 and 45, the error handling currently returns a generic 500 Internal
Server Error for all exceptions. Modify the onErrorResume handlers to inspect
the exception type and return appropriate HTTP status codes based on the error,
such as 404 for not found or 400 for bad requests, instead of always returning
500.

}

@GetMapping("/weekly")
public Mono<ResponseEntity<List<TimetableResponseDto>>> getWeeklyTimetable(
@RequestParam(required = true) String grade,
@RequestParam(required = true) String classNumber
) {
TimetableRequestDto request = new TimetableRequestDto(grade, classNumber);
return timetableService.getWeeklyTimetable(request)
.map(ResponseEntity::ok)
.defaultIfEmpty(ResponseEntity.noContent().build())
.onErrorResume(e -> Mono.just(ResponseEntity.internalServerError().build()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package hello.cluebackend.domain.timetable.presentation.dto.request;

import hello.cluebackend.domain.timetable.service.TimetableService;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

DTO에서 서비스 클래스 import 제거 필요

DTO는 데이터 전송만 담당해야 하므로 서비스 레이어를 import하면 안 됩니다. 계층 간 의존성이 잘못되었습니다.

-import hello.cluebackend.domain.timetable.service.TimetableService;
📝 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
import hello.cluebackend.domain.timetable.service.TimetableService;
🤖 Prompt for AI Agents
In
src/main/java/hello/cluebackend/domain/timetable/presentation/dto/request/TimetableRequestDto.java
at line 3, remove the import statement for TimetableService because DTO classes
should not depend on service layer classes to maintain proper separation of
concerns.

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.processing.Pattern;

@Getter
public class TimetableRequestDto {
@NotBlank(message = "학년은 필수입니다.")
@Min(value = 1)
@Max(value = 3)
private String grade;

@NotBlank(message = "반은 필수입니다.")
@Min(value = 1)
@Max(value = 4)
private String classNumber;
Comment on lines +14 to +22
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

String 필드에 대한 잘못된 validation 수정 필요

@Min@Max 어노테이션은 String 타입에 적용되지 않습니다. String 값의 숫자 범위를 검증하려면 @Pattern 어노테이션을 사용해야 합니다.

-  @NotBlank(message = "학년은 필수입니다.")
-  @Min(value = 1)
-  @Max(value = 3)
-  private String grade;
-
-  @NotBlank(message = "반은 필수입니다.")
-  @Min(value = 1)
-  @Max(value = 4)
-  private String classNumber;
+  @NotBlank(message = "학년은 필수입니다.")
+  @Pattern(regexp = "^[1-3]$", message = "학년은 1~3 사이여야 합니다.")
+  private String grade;
+
+  @NotBlank(message = "반은 필수입니다.")
+  @Pattern(regexp = "^[1-4]$", message = "반은 1~4 사이여야 합니다.")
+  private String classNumber;
📝 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
@NotBlank(message = "학년은 필수입니다.")
@Min(value = 1)
@Max(value = 3)
private String grade;
@NotBlank(message = "반은 필수입니다.")
@Min(value = 1)
@Max(value = 4)
private String classNumber;
@NotBlank(message = "학년은 필수입니다.")
@Pattern(regexp = "^[1-3]$", message = "학년은 1~3 사이여야 합니다.")
private String grade;
@NotBlank(message = "반은 필수입니다.")
@Pattern(regexp = "^[1-4]$", message = "반은 1~4 사이여야 합니다.")
private String classNumber;
🤖 Prompt for AI Agents
In
src/main/java/hello/cluebackend/domain/timetable/presentation/dto/request/TimetableRequestDto.java
around lines 14 to 22, the fields grade and classNumber are String types but
incorrectly use @Min and @Max annotations which only apply to numeric types.
Replace these with @Pattern annotations that enforce the numeric range as a
regex pattern to validate the string values correctly.


public TimetableRequestDto(String grade, String classNumber) {
this.grade = grade;
this.classNumber = classNumber;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package hello.cluebackend.domain.timetable.presentation.dto.response;

import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.TextStyle;
import java.util.Locale;
import java.util.Map;

@Getter @Setter
@NoArgsConstructor
public class TimetableResponseDto {

private String period; // 교시
private String subject; // 과목 이름
private String date; // 날짜
private String dayOfWeek; // 요일

public static TimetableResponseDto fromMap(Map<String, Object> map) {
TimetableResponseDto dto = new TimetableResponseDto();

dto.period = (String) map.getOrDefault("PERIO", "");
dto.subject = (String) map.getOrDefault("ITRT_CNTNT", "");
dto.date = (String) map.getOrDefault("ALL_TI_YMD", "");

if (!dto.date.isEmpty() && dto.date.length() == 8) {
try {
LocalDate localDate = LocalDate.parse(dto.date, DateTimeFormatter.ofPattern("yyyyMMdd"));
DayOfWeek dayOfWeek = localDate.getDayOfWeek();
dto.dayOfWeek = dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.KOREAN);
} catch (Exception e) {
dto.dayOfWeek = "";
}
}else{
dto.dayOfWeek="error";
}
Comment on lines +38 to +40
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

코드 포맷팅 및 에러 처리 개선 필요

else 블록의 포맷팅이 일관되지 않고, dayOfWeek에 "error" 문자열을 설정하는 것은 사용자 친화적이지 않습니다.

-    }else{
-      dto.dayOfWeek="error";
-    }
+    } else {
+      dto.dayOfWeek = "";
+    }
📝 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
}else{
dto.dayOfWeek="error";
}
} else {
dto.dayOfWeek = "";
}
🤖 Prompt for AI Agents
In
src/main/java/hello/cluebackend/domain/timetable/presentation/dto/response/TimetableResponseDto.java
around lines 38 to 40, the else block formatting is inconsistent and setting
dayOfWeek to "error" is not user-friendly. Reformat the else block to match the
surrounding code style and replace the "error" string with a more appropriate
error handling approach, such as setting dayOfWeek to null or throwing a
meaningful exception to handle unexpected cases gracefully.

return dto;
}
Comment on lines +23 to +42
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

입력 검증 및 예외 처리 개선 필요

map 파라미터에 대한 null 검사가 없고, 너무 광범위한 예외 처리를 하고 있습니다.

  public static TimetableResponseDto fromMap(Map<String, Object> map) {
+    if (map == null) {
+      return new TimetableResponseDto();
+    }
+    
    TimetableResponseDto dto = new TimetableResponseDto();
    
    dto.period = (String) map.getOrDefault("PERIO", "");
    dto.subject = (String) map.getOrDefault("ITRT_CNTNT", "");
    dto.date = (String) map.getOrDefault("ALL_TI_YMD", "");
    
    if (!dto.date.isEmpty() && dto.date.length() == 8) {
      try {
        LocalDate localDate = LocalDate.parse(dto.date, DateTimeFormatter.ofPattern("yyyyMMdd"));
        DayOfWeek dayOfWeek = localDate.getDayOfWeek();
        dto.dayOfWeek = dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.KOREAN);
-      } catch (Exception e) {
+      } catch (DateTimeParseException e) {
        dto.dayOfWeek = "";
      }
    } else {
-      dto.dayOfWeek = "";
+      dto.dayOfWeek = "";
    }
    return dto;
  }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
src/main/java/hello/cluebackend/domain/timetable/presentation/dto/response/TimetableResponseDto.java
around lines 23 to 42, add a null check for the input map parameter at the start
of the fromMap method to prevent NullPointerException. Also, replace the broad
Exception catch block with a more specific exception such as
DateTimeParseException to handle date parsing errors more precisely. Adjust the
else block to set dayOfWeek to an empty string or a more appropriate default
instead of "error" for consistency.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package hello.cluebackend.domain.timetable.service;

import hello.cluebackend.domain.timetable.presentation.dto.request.TimetableRequestDto;
import hello.cluebackend.domain.timetable.presentation.dto.response.TimetableResponseDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpStatusCode;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;

import java.time.Duration;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class TimetableService {

// Constants
private static final String TIMETABLE_PATH = "/hisTimetable";
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final DateTimeFormatter YEAR_FORMATTER = DateTimeFormatter.ofPattern("yyyy");
private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("MM");
private static final ZoneId KOREA_TIMEZONE = ZoneId.of("Asia/Seoul");
private static final int FIRST_SEMESTER_END_MONTH = 8;
private static final String FIRST_SEMESTER = "1";
private static final String SECOND_SEMESTER = "2";
private static final int API_TIMEOUT_SECONDS = 10;
private static final int RETRY_ATTEMPTS = 2;
private static final Duration RETRY_DELAY = Duration.ofSeconds(1);

@Value("${spring.neis.api.host}")
private String neisApiHost;

@Value("${spring.neis.api.key}")
private String neisApiKey;

@Value("${spring.neis.api.atpt-code:C10}")
private String atptCode;

@Value("${spring.neis.api.school-code:7150658}")
private String schoolCode;

private final WebClient webClient;

public Mono<List<TimetableResponseDto>> getTodayTimetable(TimetableRequestDto request) {
LocalDate today = LocalDate.now(KOREA_TIMEZONE);
String todayStr = today.format(DATE_FORMATTER);

log.info("오늘 시간표 조회 요청 - Grade: {}, Class: {}, Date: {}",
request.getGrade(), request.getClassNumber(), todayStr);

return fetchTimetable(request, todayStr, todayStr);
}
Comment on lines +57 to +65
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

입력 검증 로직 추가 필요

TimetableRequestDto에 대한 검증 없이 바로 API 호출을 수행하고 있습니다. 서비스 레이어에서도 입력 검증을 수행해야 합니다.

각 메서드 시작 부분에 검증 로직을 추가하세요:

public Mono<List<TimetableResponseDto>> getTodayTimetable(TimetableRequestDto request) {
    if (request == null) {
        return Mono.error(new IllegalArgumentException("요청 정보가 없습니다."));
    }
    
    LocalDate today = LocalDate.now(KOREA_TIMEZONE);
    // ... 나머지 로직
}

Also applies to: 67-76

🤖 Prompt for AI Agents
In
src/main/java/hello/cluebackend/domain/timetable/service/TimetableService.java
around lines 57 to 65, the method getTodayTimetable lacks input validation for
the TimetableRequestDto parameter. Add a null check at the start of the method
to return a Mono.error with an IllegalArgumentException if the request is null.
This validation should also be applied similarly to the method spanning lines 67
to 76.


public Mono<List<TimetableResponseDto>> getWeeklyTimetable(TimetableRequestDto request) {
LocalDate now = LocalDate.now(KOREA_TIMEZONE);
String from = now.with(java.time.DayOfWeek.MONDAY).format(DATE_FORMATTER);
String to = now.with(java.time.DayOfWeek.FRIDAY).format(DATE_FORMATTER);

log.info("주간 시간표 조회 요청 - Grade: {}, Class: {}, Period: {} ~ {}",
request.getGrade(), request.getClassNumber(), from, to);

return fetchTimetable(request, from, to);
}

private Mono<List<TimetableResponseDto>> fetchTimetable(TimetableRequestDto request, String from, String to) {
LocalDate now = LocalDate.now(KOREA_TIMEZONE);
String year = now.format(YEAR_FORMATTER);
String semester = determineSemester(now);

log.debug("NEIS API 호출 파라미터 - Year: {}, Semester: {}, From: {}, To: {}",
year, semester, from, to);

return webClient.get()
.uri("https://" + neisApiHost + TIMETABLE_PATH +
"?KEY=" + neisApiKey +
"&Type=json" +
"&pIndex=1" +
"&pSize=100" +
"&ATPT_OFCDC_SC_CODE=" + atptCode +
"&SD_SCHUL_CODE=" + schoolCode +
"&AY=" + year +
"&SEM=" + semester +
"&GRADE=" + request.getGrade() +
"&CLASS_NM=" + request.getClassNumber() +
"&TI_FROM_YMD=" + from +
"&TI_TO_YMD=" + to)
Comment on lines +86 to +99
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

수동 URI 생성 대신 URI Builder 사용 필요

문자열 연결을 통한 URI 생성은 오류가 발생하기 쉽고 보안에 취약합니다. 주석으로 처리된 올바른 URI 빌더 방식을 사용해야 합니다.

-            .uri("https://" + neisApiHost + TIMETABLE_PATH +
-                    "?KEY=" + neisApiKey +
-                    "&Type=json" +
-                    "&pIndex=1" +
-                    "&pSize=100" +
-                    "&ATPT_OFCDC_SC_CODE=" + atptCode +
-                    "&SD_SCHUL_CODE=" + schoolCode +
-                    "&AY=" + year +
-                    "&SEM=" + semester +
-                    "&GRADE=" + request.getGrade() +
-                    "&CLASS_NM=" + request.getClassNumber() +
-                    "&TI_FROM_YMD=" + from +
-                    "&TI_TO_YMD=" + to)
+            .uri(uriBuilder -> uriBuilder
+                    .scheme("https")
+                    .host(neisApiHost)
+                    .path(TIMETABLE_PATH)
+                    .queryParam("KEY", neisApiKey)
+                    .queryParam("Type", "json")
+                    .queryParam("pIndex", "1")
+                    .queryParam("pSize", "100")
+                    .queryParam("ATPT_OFCDC_SC_CODE", atptCode)
+                    .queryParam("SD_SCHUL_CODE", schoolCode)
+                    .queryParam("AY", year)
+                    .queryParam("SEM", semester)
+                    .queryParam("GRADE", request.getGrade())
+                    .queryParam("CLASS_NM", request.getClassNumber())
+                    .queryParam("TI_FROM_YMD", from)
+                    .queryParam("TI_TO_YMD", to)
+                    .build())
📝 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
return webClient.get()
.uri("https://" + neisApiHost + TIMETABLE_PATH +
"?KEY=" + neisApiKey +
"&Type=json" +
"&pIndex=1" +
"&pSize=100" +
"&ATPT_OFCDC_SC_CODE=" + atptCode +
"&SD_SCHUL_CODE=" + schoolCode +
"&AY=" + year +
"&SEM=" + semester +
"&GRADE=" + request.getGrade() +
"&CLASS_NM=" + request.getClassNumber() +
"&TI_FROM_YMD=" + from +
"&TI_TO_YMD=" + to)
return webClient.get()
.uri(uriBuilder -> uriBuilder
.scheme("https")
.host(neisApiHost)
.path(TIMETABLE_PATH)
.queryParam("KEY", neisApiKey)
.queryParam("Type", "json")
.queryParam("pIndex", "1")
.queryParam("pSize", "100")
.queryParam("ATPT_OFCDC_SC_CODE", atptCode)
.queryParam("SD_SCHUL_CODE", schoolCode)
.queryParam("AY", year)
.queryParam("SEM", semester)
.queryParam("GRADE", request.getGrade())
.queryParam("CLASS_NM", request.getClassNumber())
.queryParam("TI_FROM_YMD", from)
.queryParam("TI_TO_YMD", to)
.build())
🤖 Prompt for AI Agents
In
src/main/java/hello/cluebackend/domain/timetable/service/TimetableService.java
lines 86 to 99, the URI for the webClient.get() call is constructed using string
concatenation, which is error-prone and insecure. Replace this manual string
building with the URI builder approach provided by WebClient, using
uriBuilder.queryParam() methods to safely add each query parameter. This ensures
proper encoding and improves security and maintainability.

// .uri(uriBuilder -> uriBuilder
// .scheme("https")
// .host(neisApiHost)
// .path(TIMETABLE_PATH)
// .queryParam("KEY", neisApiKey)
// .queryParam("Type", "json")
// .queryParam("pIndex", "1")
// .queryParam("pSize", "100")
// .queryParam("ATPT_OFCDC_SC_CODE", atptCode)
// .queryParam("SD_SCHUL_CODE", schoolCode)
// .queryParam("AY", year)
// .queryParam("SEM", semester)
// .queryParam("GRADE", request.getGrade())
// .queryParam("CLASS_NM", request.getClassNumber())
// .queryParam("TI_FROM_YMD", from)
// .queryParam("TI_TO_YMD", to)
// .build())
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError,
response -> response.bodyToMono(String.class)
.flatMap(body -> {
log.error("NEIS API 클라이언트 오류 - Status: {}, Body: {}",
response.statusCode(), body);
return Mono.error(new IllegalArgumentException("잘못된 요청: " + body));
}))
.onStatus(HttpStatusCode::is5xxServerError,
response -> {
log.error("NEIS API 서버 오류 - Status: {}", response.statusCode());
return Mono.error(new RuntimeException("NEIS 서버 오류 발생"));
})
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.timeout(Duration.ofSeconds(API_TIMEOUT_SECONDS))
.retryWhen(Retry.backoff(RETRY_ATTEMPTS, RETRY_DELAY)
.filter(throwable -> !(throwable instanceof WebClientResponseException.BadRequest))
.onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> {
log.error("NEIS API 재시도 횟수 초과");
return new RuntimeException("NEIS API 호출 실패 - 호출 시간 초과");
}))
Comment on lines +132 to +137
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

재시도 로직 개선 필요

현재 재시도 로직이 BadRequest만 제외하고 있지만, 다른 4xx 에러들도 재시도하면 안 됩니다.

-                    .filter(throwable -> !(throwable instanceof WebClientResponseException.BadRequest))
+                    .filter(throwable -> {
+                        if (throwable instanceof WebClientResponseException) {
+                            WebClientResponseException ex = (WebClientResponseException) throwable;
+                            return !ex.getStatusCode().is4xxClientError();
+                        }
+                        return true;
+                    })
📝 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
.retryWhen(Retry.backoff(RETRY_ATTEMPTS, RETRY_DELAY)
.filter(throwable -> !(throwable instanceof WebClientResponseException.BadRequest))
.onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> {
log.error("NEIS API 재시도 횟수 초과");
return new RuntimeException("NEIS API 호출 실패 - 호출 시간 초과");
}))
.retryWhen(Retry.backoff(RETRY_ATTEMPTS, RETRY_DELAY)
.filter(throwable -> {
if (throwable instanceof WebClientResponseException) {
WebClientResponseException ex = (WebClientResponseException) throwable;
return !ex.getStatusCode().is4xxClientError();
}
return true;
})
.onRetryExhaustedThrow((retryBackoffSpec, retrySignal) -> {
log.error("NEIS API 재시도 횟수 초과");
return new RuntimeException("NEIS API 호출 실패 - 호출 시간 초과");
}))
🤖 Prompt for AI Agents
In
src/main/java/hello/cluebackend/domain/timetable/service/TimetableService.java
around lines 132 to 137, the retry logic currently excludes only BadRequest
(400) errors from retrying, but other 4xx client errors should also not trigger
retries. Update the filter in the retryWhen clause to exclude all 4xx HTTP
errors by checking if the throwable is an instance of WebClientResponseException
and its status code is in the 4xx range, and prevent retries for those errors.

.map(this::parseTimetableResponse)
.doOnSuccess(result -> log.info("시간표 조회 성공 - 결과 개수: {}", result.size()))
.doOnError(error -> log.error("시간표 조회 실패", error));
}

private String determineSemester(LocalDate date) {
int month = Integer.parseInt(date.format(MONTH_FORMATTER));
return (month <= FIRST_SEMESTER_END_MONTH) ? FIRST_SEMESTER : SECOND_SEMESTER;
}

private List<TimetableResponseDto> parseTimetableResponse(Map<String, Object> response) {
try {
return Optional.ofNullable(response.get("hisTimetable"))
.filter(List.class::isInstance)
.map(obj -> (List<?>) obj)
.filter(list -> list.size() >= 2)
.map(list -> list.get(1))
.filter(Map.class::isInstance)
.map(obj -> (Map<?, ?>) obj)
.map(map -> map.get("row"))
.filter(List.class::isInstance)
.map(obj -> (List<?>) obj)
.map(this::convertToTimetableResponseList)
.orElse(List.of());
} catch (Exception e) {
log.error("시간표 파싱 오류", e);
return List.of();
}
}

@SuppressWarnings("unchecked")
private List<TimetableResponseDto> convertToTimetableResponseList(List<?> rowList) {
return rowList.stream()
.filter(Map.class::isInstance)
.map(item -> (Map<String, Object>) item)
.map(TimetableResponseDto::fromMap)
.collect(Collectors.toList());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {

Optional<UserEntity> findByUsername(String username);

Optional<UserEntity> findByEmail(String email);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

@RestController
public class RegisterController {

private final RegisterUserService registerUserService;

public RegisterController(RegisterUserService registerUserService) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@ public RegisterUserService(UserRepository userRepository) {
}

public void registerUser(DefaultRegisterUserDTO userDTO) {
UserEntity userEntity = new UserEntity(userDTO.getStudentId(), userDTO.getUsername(), userDTO.getStudentId()+userDTO.getUsername(), userDTO.getEmail(), userDTO.getRole());
UserEntity userEntity = new UserEntity(
userDTO.getStudentId(),
userDTO.getUsername(),
userDTO.getStudentId()+userDTO.getUsername(),
userDTO.getEmail(),
userDTO.getRole()
);
Comment on lines +17 to +23
Copy link

@coderabbitai coderabbitai bot Jul 8, 2025

Choose a reason for hiding this comment

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

⚠️ Potential issue

평문(plain-text) 패스워드 저장은 심각한 보안 취약점입니다
현재 password 파라미터에 studentId + username을 그대로 넣고 있으며, 암호화(BCrypt 등)가 전혀 적용되지 않았습니다.
사용자 정보 DB 유출 시 즉시 계정 탈취가 가능하므로, 반드시 패스워드 필드를 DTO로 받아서 해시 후 저장하도록 수정해 주세요.

-        UserEntity userEntity = new UserEntity(
-                userDTO.getStudentId(),
-                userDTO.getUsername(),
-                userDTO.getStudentId()+userDTO.getUsername(),
-                userDTO.getEmail(),
-                userDTO.getRole()
-        );
+        // 예시: Spring Security PasswordEncoder 주입 후 사용
+        String encodedPassword = passwordEncoder.encode(userDTO.getPassword());
+        UserEntity userEntity = new UserEntity(
+                userDTO.getStudentId(),
+                userDTO.getUsername(),
+                encodedPassword,
+                userDTO.getEmail(),
+                userDTO.getRole()
+        );
🤖 Prompt for AI Agents
In src/main/java/hello/cluebackend/domain/user/service/RegisterUserService.java
around lines 17 to 23, the password is currently set as a plain-text
concatenation of studentId and username without any encryption. To fix this,
modify the code to accept the password from the DTO, then hash it securely using
a strong algorithm like BCrypt before storing it in the UserEntity. Replace the
plain-text password assignment with the hashed password to ensure secure
password storage.

Copy link
Member Author

Choose a reason for hiding this comment

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

oauth2를 이용해서 비밀번호 따위는 존재하지 않음

Copy link

Choose a reason for hiding this comment

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

@fixgramwork OAuth2를 사용한다면 패스워드 저장이 필요 없다는 점을 이해했습니다.

하지만 현재 코드에서는 여전히 studentId + username을 패스워드 필드에 저장하고 있습니다. OAuth2 전용 인증을 사용한다면 다음과 같은 개선을 제안합니다:

public void registerUser(DefaultRegisterUserDTO userDTO) {
    UserEntity userEntity = new UserEntity(
            userDTO.getStudentId(),
            userDTO.getUsername(),
            null, // OAuth2 사용으로 패스워드 불필요
            userDTO.getEmail(),
            userDTO.getRole()
    );
    userRepository.save(userEntity);
}

또는 UserEntity 구조를 수정하여 패스워드 필드를 아예 제거하는 것이 더 명확할 것 같습니다.


✏️ Learnings added
Learnt from: fixgramwork
PR: EscapeFrame/clueBackend#23
File: src/main/java/hello/cluebackend/domain/user/service/RegisterUserService.java:17-23
Timestamp: 2025-07-08T23:38:29.625Z
Learning: This project uses OAuth2 authentication and does not require traditional password storage or validation. The RegisterUserService in src/main/java/hello/cluebackend/domain/user/service/RegisterUserService.java should not store passwords since OAuth2 handles authentication externally.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

userRepository.save(userEntity);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, RefreshTokenSe
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration configuration = new CorsConfiguration();

configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
configuration.setAllowedOriginPatterns(Collections.singletonList("http://localhost:3000"));
configuration.setAllowedMethods(Collections.singletonList("*"));
configuration.setAllowCredentials(true);
configuration.setAllowedHeaders(Collections.singletonList("*"));
configuration.setMaxAge(3600L);
configuration.setExposedHeaders(Collections.singletonList("Authorization"));

configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
return configuration;
}
}));
Expand Down Expand Up @@ -116,12 +114,12 @@ public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
// 경로별 인가 작업
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "refresh-token", "/register", "/first-register").permitAll()
.requestMatchers("/", "/refresh-token", "/register", "/first-register", "/api/timetable/**").permitAll()
.anyRequest().authenticated());


http
.securityMatcher("/first-register", "/register")
.securityMatcher("/first-register", "/register","api/timetable/**")
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

보안 매처 경로 패턴 불일치를 확인하세요.

securityMatcher에서 "api/timetable/**"로 설정했지만, 실제 요청 경로는 /api/timetable/**입니다. 앞의 슬래시(/) 누락으로 인해 매처가 올바르게 작동하지 않을 수 있습니다.

다음 스크립트를 실행하여 애플리케이션의 다른 부분에서 일관된 경로 패턴을 사용하는지 확인하세요:


🏁 Script executed:

#!/bin/bash
# 설명: 시간표 API 경로 패턴의 일관성을 확인합니다.

# 테스트: 프로젝트 내에서 시간표 API 경로 사용을 검색합니다.
echo "=== 시간표 API 경로 패턴 검색 ==="
rg -A 2 -B 2 "timetable" --type java

echo "=== 보안 매처 패턴 검색 ==="
rg -A 2 -B 2 "securityMatcher" --type java

Length of output: 7496


보안 매처 경로 패턴 수정 필요

securityMatcher에 등록된 "api/timetable/**" 패턴은 앞에 슬래시가 빠져 있어 실제 컨트롤러 매핑(/api/timetable/**)과 불일치합니다. 다음 위치를 변경해 주세요:

  • 파일: src/main/java/hello/cluebackend/global/config/SecurityConfig.java
  • 라인: 약 122

변경 전:

.securityMatcher("/first-register", "/register","api/timetable/**")

변경 후:

-.securityMatcher("/first-register", "/register","api/timetable/**")
+.securityMatcher("/first-register", "/register", "/api/timetable/**")
🤖 Prompt for AI Agents
In src/main/java/hello/cluebackend/global/config/SecurityConfig.java at line
122, the securityMatcher method includes the pattern "api/timetable/**" missing
a leading slash, causing a mismatch with the actual controller mapping
"/api/timetable/**". Fix this by adding the leading slash to the pattern so it
reads "/api/timetable/**" to correctly match the controller paths.

.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
)
Expand Down
Loading