문제 상황
NEIS 공공데이터를 활용해 특정 학교의 시간표 데이터를 불러온 후, Course 엔티티로 변환하여 DB에 저장하는 API를 구현하였다.
이후 동일한 API를 재호출했을 때 java.lang.StackOverflowError 와 함께 500 Internal Server Error가 발생하였다.
Postman에서 확인 결과, 호출은 정상적으로 되나 데이터 저장 과정에서 에러가 발생.
원인 분석
시간표 API 호출 후 Course 엔티티를 생성하여 courseRepository.saveAll(courseList)로 저장할 때,
이미 저장된 동일한 Course가 중복으로 다시 저장되면서 무한 참조 또는 StackOverflow가 발생함.
중복 여부 확인이 애플리케이션 코드에서 불완전하게 이뤄졌으며, DB에서는 중복을 허용하고 있었음.
시도 방법
1. for문 마다 find로 검증
결과적으로 채택하진 않았다. 쿼리를 많이 날리긴 싫어서... 리소스가 많이 드는 느낌?
2. DB 차원에서 중복을 방지
애플리케이션 로직에서 중복을 검사하는 대신, DB에 복합 유니크 제약 조건을 설정하였다:
@Entity
@Table(
name = "course",
uniqueConstraints = @UniqueConstraint(
columnNames = {"school_id", "course_name", "semester", "grade"}
)
)
public class Course {
...
}
이렇게 시도하려 하니, 유니크 조건의 과목을 저장하려 할때 에러가 발생할 것이다.
그래서 서비단에서 try-catch 문 처리를 해줘야한다.
아래는 중복 처리를 유니크 조건 + try catch 로 예외처리 를 한 버전이다.
개선 코드 (DB 유니크 충돌 방지)
if (rowArray.isArray()) {
for (JsonNode node : rowArray) {
String courseName = node.path("ITRT_CNTNT").asText();
String grade = node.path("GRADE").asText(); // 학년 정보
String semester = node.path("SEM").asText();
Long schoolId = node.path("SD_SCHUL_CODE").asLong();
String courseKey = courseName + "_" + grade;
if (courseNameSet.contains(courseKey)) continue;
courseNameSet.add(courseKey);
System.out.println(courseNameSet);
Course course = Course.builder()
.school(schoolRepository.findBySchoolId(node.path("SD_SCHUL_CODE").asLong()))
.courseName(courseName)
.courseType("공통")
.courseArea(node.path("ORD_SC_NM").asText()) // 예: 일반계
.semester(node.path("GRADE").asText() + "학년 " + node.path("SEM").asText() + "학기") // 예: 1학년 1학기
.description(node.path("DGHT_CRSE_SC_NM").asText() + " " + node.path("GRADE").asText() + "학년") // 예: 주간 1학년
.updatedAt(LocalDateTime.now())
.maxStudents(0) // 임의 초기값
.build();
courseList.add(course);
}
}
} else {
throw new RuntimeException("NEIS API 호출 실패");
}
}
List<Course> savedCourses = new ArrayList<>();
for (Course course : courseList) {
try {
savedCourses.add(courseRepository.save(course));
} catch (DataIntegrityViolationException e) {
System.out.println("[중복 발생] : " + course.getCourseName());
}
}
return savedCourses;
}
첫 생각에는 합리적인 코드라고 생각했으니, 결과를 보고 생각이 달라졌다.
사실 새로 요청을 보낼때 변하지 않는 수업이 많아, 예외 처리를 너무 많이 불러와야 했다.
2025-05-20T16:11:25.882+09:00 DEBUG 82194 --- [kummiRoom] [nio-8080-exec-4] o.s.orm.jpa.JpaTransactionManager : Not closing pre-bound JPA EntityManager after transaction
[중복 발생] 이미 존재하는 과목: 생명과학 실험
2025-05-20T16:11:25.882+09:00 DEBUG 82194 --- [kummiRoom] [nio-8080-exec-4] o.s.orm.jpa.JpaTransactionManager : Found thread-bound EntityManager [SessionImpl(1923951690<open>)] for JPA transaction
2025-05-20T16:11:25.882+09:00 DEBUG 82194 --- [kummiRoom] [nio-8080-exec-4] o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [org.springframework.data.jpa.repository.support.SimpleJpaRepository.save]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
2025-05-20T16:11:25.882+09:00 DEBUG 82194 --- [kummiRoom] [nio-8080-exec-4] o.s.orm.jpa.JpaTransactionManager : Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@79874719]
Hibernate:
select
null,
s1_0.address,
s1_0.edu_id,
s1_0.homepage,
s1_0.school_name,
s1_0.school_type
from
school s1_0
where
s1_0.school_id=?
Hibernate:
insert
into
course
(course_area, course_name, course_type, description, max_students, school_id, semester, updated_at)
values
(?, ?, ?, ?, ?, ?, ?, ?)
2025-05-20T16:11:25.883+09:00 WARN 82194 --- [kummiRoom] [nio-8080-exec-4] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 1062, SQLState: 23000
2025-05-20T16:11:25.883+09:00 ERROR 82194 --- [kummiRoom] [nio-8080-exec-4] o.h.engine.jdbc.spi.SqlExceptionHelper : Duplicate entry '7010103-세계사-2학년 1학기' for key 'course.UKeta5fi3lrdie8ptrcj3f5cjgo'
2025-05-20T16:11:25.883+09:00 DEBUG 82194 --- [kummiRoom] [nio-8080-exec-4] o.s.orm.jpa.JpaTransactionManager : Initiating transaction rollback
2025-05-20T16:11:25.883+09:00 DEBUG 82194 --- [kummiRoom] [nio-8080-exec-4] o.s.orm.jpa.JpaTransactionManager : Rolling back JPA transaction on EntityManager [SessionImpl(1923951690<open>)]
2025-05-20T16:11:25.884+09:00 DEBUG 82194 --- [kummiRoom] [nio-8080-exec-4] o.s.orm.jpa.JpaTransactionManager : Not closing pre-bound JPA EntityManager after transaction
[중복 발생] 이미 존재하는 과목: 세계사
예외처리는 굉장히 비싼 연산이다.
(스택 트레이스 + 컨텍스트 전파 등)
그래서 다른 방법을 찿아봐야 했다.
하지만 더 이상 아이디어 고갈...
지선생의 도움을 받아보려 했다.
오?
레파지토리는 한번만 조회하고 (findAll)
메모리에 저장한 후, 서비스 단에서 중복 검사하고, 새로운 것만 saveAll().
들어보니 좋은 방법인거같다.
아래는 코드이다.
public List<Course> getCourseFromTimeTable(NeisTimetableRequestDto req) throws Exception {
Set<String> existingCourseKeys = new HashSet<>();
Set<String> courseNameSet = new HashSet<>();
List<Course> courseList = new ArrayList<>();
// 기존 Course를 한 번에 조회해서 중복 확인용 set 만들기
List<Course> existingCourses = courseRepository.findAllBySchool_SchoolId(req.getSdSchulCode());
for (Course course : existingCourses) {
String key = course.getCourseName() + "_" + course.getSemester();
existingCourseKeys.add(key);
}
for (int i = 1; i <= 2; i++) { // pindex 1과 2 두 번 반복
String url = UriComponentsBuilder.fromHttpUrl(testBaseUrl)
.queryParam("KEY", openApiKey)
.queryParam("Type", "json")
.queryParam("ATPT_OFCDC_SC_CODE", req.getAtptOfcdcScCode())
.queryParam("SD_SCHUL_CODE", req.getSdSchulCode())
.queryParam("TI_FROM_YMD", "20250407")
.queryParam("TI_TO_YMD", "20250411")
.queryParam("pSize", "750")
.queryParam("pindex", i)
.toUriString();
ResponseEntity<String> response = restTemplate.exchange(
url,
HttpMethod.GET,
HttpEntity.EMPTY,
String.class
);
System.out.println("[DEBUG] NEIS 요청: " + url);
System.out.println("[DEBUG] NEIS 응답: " + response.getBody());
if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) {
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(response.getBody());
JsonNode rowArray = root.path("hisTimetable").get(1).path("row");
System.out.println("[DEBUG] JSON 응답: " + rowArray);
if (rowArray.isArray()) {
for (JsonNode node : rowArray) {
String courseName = node.path("ITRT_CNTNT").asText();
String grade = node.path("GRADE").asText();
String semester = node.path("GRADE").asText() + "학년 " + node.path("SEM").asText() + "학기";
Long schoolId = node.path("SD_SCHUL_CODE").asLong();
String courseKey = courseName + "_" + grade;
String key = courseName + "_" + semester;
if (existingCourseKeys.contains(key)) continue; // 중복 → 스킵
if (courseNameSet.contains(courseKey)) continue;
courseNameSet.add(courseKey);
System.out.println(courseNameSet);
Course course = Course.builder()
.school(schoolRepository.findBySchoolId(node.path("SD_SCHUL_CODE").asLong()))
.courseName(courseName)
.courseType("공통")
.courseArea(node.path("ORD_SC_NM").asText()) // 예: 일반계
.semester(node.path("GRADE").asText() + "학년 " + node.path("SEM").asText() + "학기") // 예: 1학년 1학기
.description(node.path("DGHT_CRSE_SC_NM").asText())
.updatedAt(LocalDateTime.now())
.maxStudents(0) // 임의 초기값
.build();
courseList.add(course);
}
}
} else {
throw new RuntimeException("NEIS API 호출 실패");
}
}
return courseRepository.saveAll(courseList);
}
실행도 잘 된다.
결론
“DB 한 번만 조회하고 메모리에서 중복 판단 후 saveAll()”
회고 및 정리
“중복” 처리를 단순히 DB에 맡기거나 매번 쿼리로 확인하는 방식은 유지 보수와 성능 측면에서 취약하다.
데이터의 식별 조건을 명확히 정의한 후, 메모리 기반 필터링을 잘 활용하면 성능과 안정성을 모두 챙길 수 있다.
특히 데이터가 자주 갱신되지 않는 경우 (예: 학기별 과목 정보)라면, 이 전략은 훨씬 효율적이다.
'프로그래밍' 카테고리의 다른 글
나이스 교육 정보 OpenApi 사용 방법 및 후기 (1) | 2025.05.02 |
---|---|
MariaDB 설치 및 spring 연결(mac M1) (0) | 2024.04.06 |
github (1) - github 기초 사용법 (0) | 2023.08.29 |
해커톤(4) - youtube 자막 추출 및 python파일 java에서 실행 (0) | 2023.08.23 |
해커톤(3) - 백엔드의 역할 (0) | 2023.08.21 |