기능 설명
일단 나는 뉴스 기사 백업+복구 기능을 맡았었다. S3에 매일 백업되는 뉴스 기사 데이터를 DB로 복원하는 기능이었다.
- S3에서 특정 기간의 백업 데이터를 읽는다.
- 그 기간의 백업 데이터와 현재 DB에 있는 그 기간의 데이터를 비교한다.
- 만약 DB에 데이터가 없으면 hard delete (물리적 삭제) 됐으므로, 새로운 백업된 뉴스를 가져와 id를 null값으로 해준다. (밑에ObjectOptimisticLockingFailureException 참고)
- 만약 DB에 데이터가 있는데 deletedAt이 null 이 아니면 soft delete(논리적 삭제) 됐으므로, 백업된 뉴스를 가져와 deletedAt을 null로 설정해주면 된다.
1. ObjectOptimisticLockingFailureException
2025-09-17T10:30:23.392+09:00 INFO 28016 --- [MoNew] [nio-8080-exec-6] s.t.m.d.a.s.b.BasicArticleStorageService : [뉴스 기사 복구] S3로부터 뉴스 기사 복구 시작. 기간: 2025-09-17 ~ 2025-09-17
2025-09-17T10:30:23.392+09:00 INFO 28016 --- [MoNew] [nio-8080-exec-6] s.t.m.d.a.s.b.BasicArticleStorageService : [뉴스 기사 복구] S3 디렉토리 처리 중: articles-2025-09-17/
2025-09-17T10:30:23.501+09:00 INFO 28016 --- [MoNew] [nio-8080-exec-6] s.t.m.d.a.s.b.BasicArticleStorageService : [뉴스 기사 복구] 청크 파일 읽는 중: articles-2025-09-17/chunk-f695ee80-4fcb-42ca-84de-b440ca93f8cb.json
2025-09-17T10:30:23.652+09:00 INFO 28016 --- [MoNew] [nio-8080-exec-6] s.t.m.d.a.s.b.BasicArticleStorageService : [뉴스 기사 복구] 날짜 2025-09-17에 1개의 유실된 기사를 찾았습니다.
복구 로그는 잘 뜨는 듯 했다......! 근데 새로운 에러가 생겼다:
org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [cohttp://m.sprint.team2.monew.domain.article.entity.Article#aefd9114-c7f3-468c-afeb-acb7108744dc]
at org.springframework.orhttp://m.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:329)
at org.springframework.orhttp://m.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:256)
at org.springframework.orhttp://m.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:244)
문제의 핵심은 이거였다: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect)
- S3에서 복구한 JSON 안에는 Article 데이터가 원래의 ID (UUID)를 포함한 채 들어있었다
- objectMapper는 이 JSON을 Article 객체로 변환하고, 당연히 이 객체도 원래의 ID 값을 그대로 가지고 있다
- articleRepository.saveAll(missingArticlesTotal)을 호출하면, JPA(Hibernate)는 리스트에 있는 Article 객체들을 본다.
- JPA는 Article 객체에 ID 값이 이미 존재하는 것을 보고, "아, 이건 새로운 데이터(INSERT)가 아니라, 기존 데이터를 수정(UPDATE)하려는 거구나!" 라고 생각한다
- 하지만 데이터베이스에는 해당 ID를 가진 행이 실제로 존재하지 않으므로(애초에 '유실된' 기사였으니까), JPA는 수정할 대상을 찾지 못하고 "내가 수정하려는데 그 사이에 누가 지웠나?" 라고 판단하며 ObjectOptimisticLockingFailureException 에러를 발생시키는 것이였다
- 결론적으로, ID가 있는 객체를 새로 저장하려고 해서 JPA가 혼란에 빠진 것이다
해결책:
저장하기 전에 이 Article 객체들이 새로운 데이터라는 것을 JPA에게 명확히 알려주면 된다. 그러려면 saveAll을 호출하기 전에 Article 객체 ID를 null로 설정해주면 되는데, 그렇게 하면 JPA는 무조건 새로운 데이터로 인식하고 INSERT를 제대로 실행할 수 있게 된다.
for (Article s3Article : allS3Articles) {
// 현재 db에서 가져온 기사
Article dbArticle = dbArticleMap.get(s3Article.getSourceUrl());
if (dbArticle == null) {
// hard-deleted -> INSERT
s3Article.setIdToNull();
articlesToInsert.add(s3Article);
} else if (dbArticle.getDeletedAt() != null) {
// soft-deleted (deletedAt != null) -> undelete
dbArticle.setDeletedAt(null);
articlesToUndelete.add(dbArticle);
}
}
여기서, hard delete와 soft delete를 구분을 해줘야 한다:
- hard delete 됐으면 id를 null로 만들기 -> JPA가 이 기사를 새로운 엔티티로 인식할 수 있게끔!
- soft delete 됐으면 deletedAt을 null로 만들기
2. DataIntegrityViolationException
잘 되니 싶더니.............. 또 이런 에러가 떴다:
2025-09-18T13:06:36.689+09:00 INFO 3104 --- [MoNew] [nio-8080-exec-4] s.t.m.d.a.s.b.BasicArticleStorageService : [뉴스 기사 복구] DB에서 162개의 기존 기사 정보를 확인했습니다.
........
2025-09-18T13:06:36.913+09:00 INFO 3104 --- [MoNew] [nio-8080-exec-4] s.t.m.d.a.s.b.BasicArticleStorageService : [뉴스 기사 복구] 총 2개의 뉴스 기사 복구 완료. 기간: 2025-09-17 ~ 2025-09-17
.......
2025-09-18T13:06:36.923+09:00 ERROR 3104 --- [MoNew] [nio-8080-exec-4] o.h.engine.jdbc.spi.SqlExceptionHelper : ERROR: duplicate key value violates unique constraint "articles_source_url_key"
Detail: Key (source_url)=(https://www.bntnews.co.kr/article/view/bnt202509170108) already exists.
2025-09-18T13:06:36.924+09:00 ERROR 3104 --- [MoNew] [nio-8080-exec-4] c.s.t.m.g.error.GlobalExceptionHandler : 예상치 못한 오류 발생: could not execute statement [ERROR: duplicate key value violates unique constraint "articles_source_url_key"
Detail: Key (source_url)=(https://www.bntnews.co.kr/article/view/bnt202509170108) already exists.] [insert into articles (comment_count,created_at,deleted_at,interest_id,publish_date,source,source_url,summary,title,updated_at,view_count,id) values (?,?,?,?,?,?,?,?,?,?,?,?)]; SQL [insert into articles
org.springframework.dao.DataIntegrityViolationException: could not execute statement [ERROR: duplicate key value violates unique constraint "articles_source_url_key"
Detail: Key (source_url)=(https://www.bntnews.co.kr/article/view/bnt202509170108) already exists.]....
등등..... 이 버그가 가장 골치아팠던것같다. 도대체 왜 이런 오류가 생긴건지 알 수가 없었다.
핵임은 이 줄이였다: ERROR: duplicate key value violates unique constraint "articles_source_url_key"
그래서 몇가지 가설들을 세우고 하나씩 검증해봤는데, 실패했다:
- 1차 가설: 소프트 삭제된 데이터를 제대로 못 거르나? → 로직 수정 후에도 실패
- 2차 가설: 운영 서버와 로컬의 시간대(Timezone)가 다른가? → 시간대 고정 후에도 실패
- 3차 가설: S3 백업 데이터 자체에 중복이 있나? → 데이터 중복 제거 후에도 실패
결국 미쳐갈 쯤, 팀원 한분이 복구 시작일을 어제나 오늘로 잡으면 에러가 나는데 이틀 전 테이저부터는 정상적으로 확인된다는 메세지를 받았다.
여러 디버깅 후에, 결국 기사의 createdAt과 publishDate가 다르다는 점을 알아챘다.
밑에는 두 필드가 다른 article을 필터링 리스트다:

- publishDate: 기사가 언론사에서 실제로 발행한 시간 (이벤트 발생 시간)
- createdAt: 내 시스템이 기사를 수집해 DB에 저장한 시간 (데이터 처리 시간)
그래서 문제는 복원 로직과 백업 로직 둘다 안에 있었다. 내가 짠 코드는 createdAt과 publishDate를 혼동하고 있었다:
BackupBatchConfig
return new JpaPagingItemReaderBuilder<Article>()
.name("articleJpaPagingItemReader")
.entityManagerFactory(entityManagerFactory)
.queryString("SELECT a FROM Article a "
+ "WHERE a.createdAt >= :startDate AND a.createdAt < :endDate")
.parameterValues(parameters)
.pageSize(chunkSize)
.build();
BasicArticleStorageService
@Override
@Transactional
public ArticleRestoreResultDto restoreArticle(LocalDate from, LocalDate to) {
String bucket = s3Properties.bucket();
List<Article> allS3Articles = new ArrayList<>();
log.info("[뉴스 기사 복구] DB에서 기간 내 모든 기사(삭제 포함)의 현재 상태를 조회합니다: {} ~ {}", from, to);
Map<String, Article> dbArticleMap = articleRepository.findByPublishDateBetween(
from.atStartOfDay(),
to.plusDays(1).atStartOfDay()
).stream()
.collect(Collectors.toMap(Article::getSourceUrl, Function.identity()));
log.info("[뉴스 기사 복구] DB에서 {}개의 기존 기사 정보를 확인했습니다.", dbArticleMap.size());
....
ArticleRepository
List<Article> findByPublishDateBetween(LocalDateTime start, LocalDateTime end);
여기서 문제점은, publishDate와 createdAt은 항상 같지 않고 처리 지연이 생길 수 있다는 점이였다.
예를 들면,
- 한 기사가 2025년 9월 17일 23시 59분에 발행됨 (publishDate = 9월 17일)
- Spring Batch는 2025년 9월 18일 00시 05분에 이 기사를 수집하여 DB에 저장함. (createdAt = 9월 18일)
이 백업 파일을 가지고 복원을 시도하니, 이미 DB에 존재하는 17일자 기사를 복구해야할 18일자 데이터로 착각하고 다시 INSERT하려다 Duplicate Key 에러가 발생한 것이였다...
- 백업 (createdAt 기준)→ 백업이 될 때, createdAt기준으로 S3로 백업해서 9.18일 폴더에 잘못 저장이 된다.
- 결과적으로 9월 17일에 발행된 기사가 9월 18일자 백업 파일에 잘못 포함되는 것이다.
- 복구 (publishDate 기준)→ 18일자 복원 시, 9.18일 폴더 안에 기사 목록을 조회한다. 이때 잘못 저장된 기사도 포함되어있다.
- 그래서 publishDate 기준으로 DB에 있는 기사 목록을 조회하고 비교할때, 문제가 되는 기사를 DB에 없는 기사로 착각하여 재삽입을 시도하다 Duplicate Key 에러가 발생하는 것이였다.
해결책: publishDate로 대통합
시스템의 처리 시간이 아닌, 데이터의 실제 사건 발생 시간인 publishDate를 백업과 복원 로직의 기준으로 삼아야했다.
수정한 코드:
BackupBatchConfig.java
return new JpaPagingItemReaderBuilder<Article>()
.name("articleJpaPagingItemReader")
.entityManagerFactory(entityManagerFactory)
= .queryString("SELECT a FROM Article a "
+ "WHERE a.publishDate >= :startDate AND a.publishDate < :endDate") // 여기
.parameterValues(parameters)
.pageSize(chunkSize)
.build();
이렇게 두 로직의 기준을 publishDate로 통일하니까, 자정을 넘어가는 기사도 정확히 자신의 발행일 폴더에 백업되었고, 복원 시에도 더 이상 충돌이 발생하지 않았다...!