카테고리 없음

[Monew] 트러블슈팅 - 뉴스 기사 복구

leejunkim 2025. 9. 18. 18:25

기능 설명

일단 나는 뉴스 기사 백업+복구 기능을 맡았었다. S3에 매일 백업되는 뉴스 기사 데이터를 DB로 복원하는 기능이었다.

  1. S3에서 특정 기간의 백업 데이터를 읽는다.
  2. 그 기간의 백업 데이터와 현재 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)

  1. S3에서 복구한 JSON 안에는 Article 데이터가 원래의 ID (UUID)를 포함한 채 들어있었다
  2. objectMapper는 이 JSON을 Article 객체로 변환하고, 당연히 이 객체도 원래의 ID 값을 그대로 가지고 있다
  3. articleRepository.saveAll(missingArticlesTotal)을 호출하면, JPA(Hibernate)는 리스트에 있는 Article 객체들을 본다.
  4. JPA는 Article 객체에 ID 값이 이미 존재하는 것을 보고, "아, 이건 새로운 데이터(INSERT)가 아니라, 기존 데이터를 수정(UPDATE)하려는 거구나!" 라고 생각한다 
  5. 하지만 데이터베이스에는 해당 ID를 가진 행이 실제로 존재하지 않으므로(애초에 '유실된' 기사였으니까), JPA는 수정할 대상을 찾지 못하고 "내가 수정하려는데 그 사이에 누가 지웠나?" 라고 판단하며 ObjectOptimisticLockingFailureException 에러를 발생시키는 것이였다
  6. 결론적으로, 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. 1차 가설: 소프트 삭제된 데이터를 제대로 못 거르나? → 로직 수정 후에도 실패
  2. 2차 가설: 운영 서버와 로컬의 시간대(Timezone)가 다른가? → 시간대 고정 후에도 실패
  3. 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은 항상 같지 않고 처리 지연이 생길 수 있다는 점이였다.

예를 들면,

  1. 한 기사가 2025년 9월 17일 23시 59분에 발행됨 (publishDate = 9월 17일)
  2. 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로 통일하니까, 자정을 넘어가는 기사도 정확히 자신의 발행일 폴더에 백업되었고, 복원 시에도 더 이상 충돌이 발생하지 않았다...!