Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import com.chukchuk.haksa.domain.graduation.dto.AreaRequirementDto;
import com.chukchuk.haksa.domain.graduation.dto.CourseDto;
import com.chukchuk.haksa.domain.graduation.dto.CourseInternalDto;
import com.chukchuk.haksa.global.exception.code.ErrorCode;
import com.chukchuk.haksa.global.exception.type.CommonException;
import com.chukchuk.haksa.global.logging.annotation.LogTime;
import com.chukchuk.haksa.infrastructure.redis.RedisCacheStore;
import jakarta.persistence.EntityManager;
Expand Down Expand Up @@ -144,15 +146,30 @@ public List<AreaProgressDto> getDualMajorAreaProgress(UUID studentId, Long prima
// 주전공 졸업 요건 조회
List<AreaRequirementDto> primaryReqs = getAreaRequirementsWithCache(primaryMajorId, admissionYear);

// 주전공 졸업 요건 중 '전선' 제외
// 주전공 졸업 요건 데이터 부재 시 404 예외 처리
if (primaryReqs == null || primaryReqs.isEmpty()) {
throw new CommonException(ErrorCode.GRADUATION_REQUIREMENTS_DATA_NOT_FOUND);
}

// 주전공 졸업 요건 중 전선/일선 제외
List<AreaRequirementDto> primaryFiltered = primaryReqs.stream()
.filter(req -> !req.areaType().equalsIgnoreCase(AREA_MAJOR_ELECTIVE))
.filter(req -> !req.areaType().equalsIgnoreCase(AREA_GENERAL_ELECTIVE))
.toList();

// 복수전공 및 주전공 전선1 졸업 요건 조회
// 필터링 후 빈 리스트가 되는 경우를 대비한 방어 코드
if (primaryFiltered.isEmpty()) {
throw new CommonException(ErrorCode.GRADUATION_REQUIREMENTS_DATA_NOT_FOUND);
}

// 복수전공 졸업 요건 조회
List<AreaRequirementDto> dualMajorReqs = getDualMajorRequirementsWithCache(primaryMajorId, secondaryMajorId, admissionYear);

// 복수 전공 졸업 요건 데이터 부재 시 404 예외 처리
if (dualMajorReqs == null || dualMajorReqs.isEmpty()) {
throw new CommonException(ErrorCode.GRADUATION_REQUIREMENTS_DATA_NOT_FOUND);
}

Comment on lines +161 to +172
Copy link
Collaborator

Choose a reason for hiding this comment

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

여기서 주전공이랑 복수전공이랑 예외 똑같이 놔두면 이슈에서 작성해준 '복수전공 정보 누락인 422 예외'는 못 던지는거 아니야? 여기서는 다른 상황이야?

Copy link
Member Author

Choose a reason for hiding this comment

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

이슈에 있는 복수전공 정보 누락 422 예외는 운영 서버에만 적용되는 사항입니다.
dev에는 복수전공에 대한 처리가 되어 있기 때문에, 졸업 요건 분석 로직에서
단일 전공, 복수 전공 각각의 경우로 예외 처리를 나누지 않고, 동일한 예외 처리를 한 후

졸업 요건 부재 예외 처리 메서드인 GraduationService.throwGraduationRequirementNotFound에서 단일/복수 전공 별 분기 처리를 통해 MDC 값을 다르게 주입하여 판별 가능하도록 했습니다.

// 전체 병합
List<AreaRequirementDto> mergedRequirements = new ArrayList<>();
mergedRequirements.addAll(primaryFiltered); // 주전공 졸업 요건 (전선 제외)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
package com.chukchuk.haksa.domain.graduation.service;

import com.chukchuk.haksa.domain.department.model.Department;
import com.chukchuk.haksa.domain.graduation.dto.AreaProgressDto;
import com.chukchuk.haksa.domain.graduation.dto.GraduationProgressResponse;
import com.chukchuk.haksa.domain.graduation.repository.GraduationQueryRepository;
import com.chukchuk.haksa.domain.student.model.Student;
import com.chukchuk.haksa.domain.student.service.StudentService;
import com.chukchuk.haksa.global.exception.code.ErrorCode;
import com.chukchuk.haksa.global.exception.type.CommonException;
import com.chukchuk.haksa.infrastructure.redis.RedisCacheStore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand Down Expand Up @@ -45,25 +47,18 @@ public GraduationProgressResponse getGraduationProgress(UUID studentId) {
}

Student student = studentService.getStudentById(studentId);
Department dept = student.getDepartment();
// 전공 코드가 없는 학과도 있으므로 majorId가 없으면 departmentId를 사용
Long primaryMajorId = student.getMajor() != null ? student.getMajor().getId() : dept.getId();
validateTransferStudent(student);

Long primaryMajorId = resolvePrimaryMajorId(student);
int admissionYear = student.getAcademicInfo().getAdmissionYear();

List<AreaProgressDto> areaProgress = null;
if (student.getSecondaryMajor() != null) { // 복수전공 존재
Long secondaryMajorId = student.getSecondaryMajor().getId();
areaProgress = graduationQueryRepository.getDualMajorAreaProgress(studentId, primaryMajorId, secondaryMajorId, admissionYear);
} else {
// 졸업 요건 충족 여부 조회
areaProgress = graduationQueryRepository.getStudentAreaProgress(studentId, primaryMajorId, admissionYear);
}
List<AreaProgressDto> areaProgress =
resolveAreaProgress(student, studentId, primaryMajorId, admissionYear);

GraduationProgressResponse response = new GraduationProgressResponse(areaProgress);

if (isDifferentGradRequirement(primaryMajorId, admissionYear)) {
response.setHasDifferentGraduationRequirement();
log.info("[BIZ] graduation.progress.flag.set studentId={} deptId={} year={}", studentId, primaryMajorId, admissionYear);
}

try {
Expand All @@ -75,7 +70,110 @@ public GraduationProgressResponse getGraduationProgress(UUID studentId) {
return response;
}

// 편입생인 경우 예외 처리, TODO: 편입생 졸업 요건 추가 후 삭제
private void validateTransferStudent(Student student) {
if (student.isTransferStudent()) {
throw new CommonException(ErrorCode.TRANSFER_STUDENT_UNSUPPORTED);
}
}

private Long resolvePrimaryMajorId(Student student) {
return student.getMajor() != null
? student.getMajor().getId()
: student.getDepartment().getId();
}

private boolean isDifferentGradRequirement(Long departmentId, int admissionYear) {
return admissionYear == SPECIAL_YEAR && departmentId != null && SPECIAL_DEPTS.contains(departmentId);
}

private List<AreaProgressDto> resolveAreaProgress(
Student student,
UUID studentId,
Long primaryMajorId,
int admissionYear
) {
if (student.getSecondaryMajor() != null) {
return getDualMajorProgressOrThrow(
student, studentId, primaryMajorId, admissionYear
);
}
return getSingleMajorProgressOrThrow(
student, studentId, primaryMajorId, admissionYear
);
}

// 단일 전공 처리 메서드
private List<AreaProgressDto> getSingleMajorProgressOrThrow(
Student student,
UUID studentId,
Long departmentId,

Choose a reason for hiding this comment

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

medium

getSingleMajorProgressOrThrow 메서드의 파라미터 이름인 departmentIdprimaryMajorId로 변경하는 것을 제안합니다. 이 메서드를 호출하는 resolveAreaProgress에서 primaryMajorId를 전달하고 있으며, 다른 메서드들(getDualMajorProgressOrThrow 등)에서도 일관되게 primaryMajorId라는 이름을 사용하고 있어 코드의 통일성과 가독성을 높일 수 있습니다.

Suggested change
Long departmentId,
Long primaryMajorId,

int admissionYear
) {
List<AreaProgressDto> result =
graduationQueryRepository.getStudentAreaProgress(
studentId, departmentId, admissionYear
);

if (result.isEmpty()) {
throwGraduationRequirementNotFound(
student,
departmentId,
null,
admissionYear);
}

return result;
}

// 복수 전공 처리 메서드
private List<AreaProgressDto> getDualMajorProgressOrThrow(
Student student,
UUID studentId,
Long primaryMajorId,
int admissionYear
) {
Long secondaryMajorId = student.getSecondaryMajor().getId();

try {
return graduationQueryRepository.getDualMajorAreaProgress(
studentId, primaryMajorId, secondaryMajorId, admissionYear
);
} catch (CommonException e) {
if (ErrorCode.GRADUATION_REQUIREMENTS_DATA_NOT_FOUND.code().equals(e.getCode())) {
throwGraduationRequirementNotFound(
student,
primaryMajorId,
secondaryMajorId,
admissionYear
);
}
throw e;
}
Comment on lines +138 to +152

Choose a reason for hiding this comment

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

high

getDualMajorProgressOrThrow 메서드 내 catch 블록의 구조가 다소 혼란을 줄 수 있습니다. throwGraduationRequirementNotFound 메서드는 항상 예외를 던지지만, 반환 타입이 void이기 때문에 코드 흐름을 파악하기 어렵습니다. 이로 인해 if 블록 다음에 오는 throw e;가 어떤 조건에서 실행되는지 즉시 이해하기 힘듭니다.

코드의 명확성을 높이기 위해 throwGraduationRequirementNotFound 메서드가 예외 객체를 생성하여 반환하고(newGraduationRequirementNotFoundException 등으로 이름 변경), 호출하는 쪽에서 throw 하도록 수정하는 것을 제안합니다. 이렇게 하면 제어 흐름이 명확해지고, 메서드의 역할(예외 생성)이 이름에 더 잘 드러나 유지보수성이 향상됩니다.

아래는 getDualMajorProgressOrThrow 메서드에 대한 제안이며, throwGraduationRequirementNotFound 메서드와 getSingleMajorProgressOrThrow의 호출 부분도 함께 수정되어야 합니다.

Suggested change
try {
return graduationQueryRepository.getDualMajorAreaProgress(
studentId, primaryMajorId, secondaryMajorId, admissionYear
);
} catch (CommonException e) {
if (ErrorCode.GRADUATION_REQUIREMENTS_DATA_NOT_FOUND.code().equals(e.getCode())) {
throwGraduationRequirementNotFound(
student,
primaryMajorId,
secondaryMajorId,
admissionYear
);
}
throw e;
}
try {
return graduationQueryRepository.getDualMajorAreaProgress(
studentId, primaryMajorId, secondaryMajorId, admissionYear
);
} catch (CommonException e) {
if (ErrorCode.GRADUATION_REQUIREMENTS_DATA_NOT_FOUND.code().equals(e.getCode())) {
throw newGraduationRequirementNotFoundException(
student,
primaryMajorId,
secondaryMajorId,
admissionYear
);
}
throw e;
}

}

/**
* 졸업 요건 부재 예외 처리 메서드
* MDC 정리는 GlobalExceptionHandler에서 수행됨

Choose a reason for hiding this comment

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

medium

Javadoc 주석에 "MDC 정리는 GlobalExceptionHandler에서 수행됨"이라고 명시되어 있지만, 이번 변경으로 추가된 MdcCleanupFilter가 실제 MDC 정리 작업을 수행합니다. 주석의 내용이 현재 구현과 일치하지 않으므로, MdcCleanupFilter에서 처리된다는 내용으로 수정하거나, 구체적인 구현을 명시하기보다 "요청 처리 마지막에 정리됨"과 같이 좀 더 일반적인 내용으로 변경하는 것이 좋겠습니다.

Suggested change
* MDC 정리는 GlobalExceptionHandler에서 수행됨
* MDC는 요청 처리 마지막에 MdcCleanupFilter에 의해 정리됨

*/
private void throwGraduationRequirementNotFound(
Student student,
Long primaryMajorId,
Long secondaryMajorId,
int admissionYear
) {
MDC.put("student_code", student.getStudentCode());
MDC.put("admission_year", String.valueOf(admissionYear));
MDC.put("primary_department_id", String.valueOf(primaryMajorId));

if (secondaryMajorId == null) {
MDC.put("major_type", "SINGLE");
MDC.put("secondary_department_id", "NONE");
} else {
MDC.put("major_type", "DUAL");
MDC.put("secondary_department_id", String.valueOf(secondaryMajorId));
}

throw new CommonException(ErrorCode.GRADUATION_REQUIREMENTS_DATA_NOT_FOUND);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,21 @@ public void updateInfo(String name, Department department, Department major, Dep
.build();
}

public boolean isTransferStudent() {
if (this.studentCode == null || this.academicInfo == null || this.academicInfo.getAdmissionYear() == null) {
return false;
}

if (this.studentCode.length() < 2) {
return false;
}

String codePrefix = this.studentCode.substring(0, 2); // 학번 앞 2자리
String yearSuffix = String.valueOf(this.academicInfo.getAdmissionYear()).substring(2); // 입학년도 뒤 2자리

return !codePrefix.equals(yearSuffix);
}

public void addStudentCourse(StudentCourse course) {
this.studentCourses.add(course);
course.setStudent(this);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@

public enum ErrorCode {

// 공통(Common)
INVALID_ARGUMENT("C01", "잘못된 요청입니다.", HttpStatus.BAD_REQUEST),
NOT_FOUND("C05", "요청한 API를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),

// 인증 및 세션 관련
SESSION_EXPIRED("A04", "로그인 세션이 만료되었습니다.", HttpStatus.UNAUTHORIZED),
// 인증 및 세션 관련
AUTHENTICATION_REQUIRED("A05", "인증이 필요한 요청입니다.", HttpStatus.UNAUTHORIZED),

// 서버 오류 관련
SCRAPING_FAILED("C02", "포털 크롤링 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
REFRESH_FAILED("C03", "포털 정보 재연동 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
FORBIDDEN("C04", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN),

// Token 관련
TOKEN_INVALID_FORMAT("T01", "ID 토큰 형식이 올바르지 않습니다.", HttpStatus.UNAUTHORIZED),
TOKEN_NO_MATCHING_KEY("T02", "일치하는 공개키가 없습니다.", HttpStatus.UNAUTHORIZED),
Expand All @@ -23,36 +37,27 @@ public enum ErrorCode {
STUDENT_ACADEMIC_RECORD_NOT_FOUND("U02", "해당 학생의 학적 정보가 존재하지 않습니다.", HttpStatus.NOT_FOUND),
USER_ALREADY_CONNECTED("U03", "이미 포털과 연동된 사용자입니다.", HttpStatus.BAD_REQUEST),
USER_NOT_CONNECTED("U04", "아직 포털과 연동되지 않은 사용자입니다.", HttpStatus.BAD_REQUEST),

// Student 관련
STUDENT_NOT_FOUND("S01", "해당 학생이 존재하지 않습니다.", HttpStatus.NOT_FOUND),
INVALID_TARGET_GPA("S02", "유효하지 않은 목표 학점입니다.", HttpStatus.BAD_REQUEST),
STUDENT_ID_REQUIRED("S03", "Student ID는 필수입니다.", HttpStatus.BAD_REQUEST),
TRANSFER_STUDENT_UNSUPPORTED("T13", "편입생 학적 정보는 현재 지원되지 않습니다.", HttpStatus.UNPROCESSABLE_ENTITY),

Choose a reason for hiding this comment

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

medium

TRANSFER_STUDENT_UNSUPPORTED 오류 코드의 prefix가 T로 시작하여 토큰 관련 오류(Token)와 혼동될 수 있습니다. 학생(Student) 관련 오류 코드들은 S prefix를 사용하고 있으므로, 일관성을 위해 S04와 같이 S prefix를 사용하는 코드로 변경하는 것을 권장합니다. 이는 오류 코드를 관리하고 식별하는 데 도움이 됩니다.

Suggested change
TRANSFER_STUDENT_UNSUPPORTED("T13", "편입생 학적 정보는 현재 지원되지 않습니다.", HttpStatus.UNPROCESSABLE_ENTITY),
TRANSFER_STUDENT_UNSUPPORTED("S04", "편입생 학적 정보는 현재 지원되지 않습니다.", HttpStatus.UNPROCESSABLE_ENTITY),

Copy link
Collaborator

Choose a reason for hiding this comment

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

제미나이 피드백처럼 T13으로 둔 이유가 있어? 학생 예외면 S로 두는 게 더 직관적이 않을까 싶은데, 어떻게 생각해?

Copy link
Member Author

Choose a reason for hiding this comment

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

저도 그 피드백을 받고 S로 두는게 더 좋을것 같긴 한데, 클라이언트에서 처리한 다음이라서 마음대로 바꿨다가 문제 생길 것 같아서 우선 건드리지는 않았어요.
프론트 쪽과 소통한 후 문제가 없다면 S04 정도로 변경하는게 좋아보입니다!


// 학업 관련
SEMESTER_RECORD_NOT_FOUND("A01", "해당 학기의 성적 데이터를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
SEMESTER_RECORD_EMPTY("A02", "학기 성적 데이터를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
FRESHMAN_NO_SEMESTER("A03", "신입생은 학기 기록이 없습니다.", HttpStatus.BAD_REQUEST),
GRADUATION_REQUIREMENTS_NOT_FOUND("G01", "졸업 요건 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
// 학업 관련
INVALID_GRADE_TYPE("A06", "존재하지 않는 성적 등급입니다.", HttpStatus.BAD_REQUEST),

// 졸업 요건 관련
GRADUATION_REQUIREMENTS_NOT_FOUND("G01", "졸업 요건 정보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),
GRADUATION_REQUIREMENTS_DATA_NOT_FOUND("G02", "사용자에게 맞는 졸업 요건 데이터가 존재하지 않습니다.", HttpStatus.NOT_FOUND),

Comment on lines +53 to +56
Copy link
Collaborator

Choose a reason for hiding this comment

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

혹시 요거 두 개가 의미적으로 어떤 차이가 있어? 똑같아보여서 의미 크게 다르지 않으면 합치는 게 낫지 않을까 싶어. 헷갈리는 거 같아

Copy link
Member Author

Choose a reason for hiding this comment

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

기존에 사용하던게 G01인데, 혹시 몰라서 유지하긴 했어요
이제 사용 안하는 G01을 제거하겠습니다

// 포털 관련
PORTAL_LOGIN_FAILED("P01", "아이디나 비밀번호가 일치하지 않습니다.\n학교 홈페이지에서 확인해주세요.", HttpStatus.UNAUTHORIZED),
PORTAL_SCRAPE_FAILED("P02", "포털 크롤링 실패", HttpStatus.INTERNAL_SERVER_ERROR),
PORTAL_ACCOUNT_LOCKED("P03", "계정이 잠겼습니다. 포털사이트로 돌아가서 학번/사번 찾기 및 비밀번호 재발급을 진행해주세요", HttpStatus.LOCKED),

// 공통(Common)
INVALID_ARGUMENT("C01", "잘못된 요청입니다.", HttpStatus.BAD_REQUEST),
NOT_FOUND("C05", "요청한 API를 찾을 수 없습니다.", HttpStatus.NOT_FOUND),

// 인증 및 세션 관련
SESSION_EXPIRED("A04", "로그인 세션이 만료되었습니다.", HttpStatus.UNAUTHORIZED),
// 인증 및 세션 관련
AUTHENTICATION_REQUIRED("A05", "인증이 필요한 요청입니다.", HttpStatus.UNAUTHORIZED),

// 서버 오류 관련
SCRAPING_FAILED("C02", "포털 크롤링 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
REFRESH_FAILED("C03", "포털 정보 재연동 중 오류가 발생했습니다.", HttpStatus.INTERNAL_SERVER_ERROR),
FORBIDDEN("C04", "접근 권한이 없습니다.", HttpStatus.FORBIDDEN);
PORTAL_ACCOUNT_LOCKED("P03", "계정이 잠겼습니다. 포털사이트로 돌아가서 학번/사번 찾기 및 비밀번호 재발급을 진행해주세요", HttpStatus.LOCKED);

private final String code;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.chukchuk.haksa.global.exception.type.BaseException;
import com.chukchuk.haksa.global.exception.type.EntityNotFoundException;
import com.chukchuk.haksa.global.logging.sanitize.LogSanitizer;
import io.sentry.IScope;
import io.sentry.Sentry;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
Expand Down Expand Up @@ -34,12 +35,28 @@ public ResponseEntity<ErrorResponse> handleBase(BaseException ex, HttpServletReq
scope.setTag("error.code", ex.getCode());
scope.setFingerprint(List.of("BASE_EXCEPTION", ex.getCode()));
scope.setLevel(io.sentry.SentryLevel.WARNING); // 4xx 의미 유지

// MDC → Sentry Tag 승격 (있을 때만)
putIfPresent(scope, "student_code");
putIfPresent(scope, "admission_year");
putIfPresent(scope, "primary_department_id");
putIfPresent(scope, "secondary_department_id");
putIfPresent(scope, "major_type");

Sentry.captureException(ex);
});

return ResponseEntity.status(ex.getStatus())
.body(ErrorResponse.of(ex.getCode(), ex.getMessage(), null));
}

private void putIfPresent(IScope scope, String key) {
String value = MDC.get(key);
if (value != null) {
scope.setTag(key, value);
}
}

/** 404 */
@ExceptionHandler(org.springframework.web.servlet.NoHandlerFoundException.class)
public ResponseEntity<ErrorResponse> handleNoHandler(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.chukchuk.haksa.global.logging.filter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@Order(Ordered.LOWEST_PRECEDENCE)
public class MdcCleanupFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} finally {
MDC.clear(); // 요청 종료 지점
}
}
}