프로젝트를 지워도 크레딧 내역과 AI 사용 기록은 남기기
사용자가 지운 프로젝트는 화면에서 사라져야 하지만, 크레딧을 쓴 내역과 AI 비용 기록은 남아야 해요. 행을 지우지 않고 삭제 시각만 남긴 방법과, 그 때문에 생긴 버그를 정리했어요.
DropThePitch에서 사용자는 다 쓴 프로젝트를 지울 수 있어요. 지운 프로젝트는 사이드바에서 사라지고, 올린 파일도 저장소에서 지워져요. 그런데 프로젝트에 딸린 기록 중에는 지우면 안 되는 것이 있었어요.
지우면 안 되는 기록
| 기록 | 남겨야 하는 이유 |
|---|---|
| 크레딧 사용 내역 | 사용자가 언제 어디에 크레딧을 썼는지 확인할 수 있어야 해요 |
| AI 사용량 | 실제로 든 비용을 계산하고 요금을 정하는 근거예요 |
두 기록 모두 프로젝트를 가리키고 있어요. 프로젝트 행을 지우면 외래 키 때문에 같이 지우거나, 연결을 끊어야 해요. 같이 지우면 기록이 사라지고, 연결을 끊으면 "어느 프로젝트에서 쓴 것인지"를 잃어요.
행은 두고 삭제 시각만 남겨요
그래서 프로젝트 행을 지우지 않기로 했어요. 대신 지운 시각을 적어 둬요.
private LocalDateTime deletedAt;
public void delete() {
this.deletedAt = LocalDateTime.now();
}
그리고 엔티티에 조건을 붙여서, 프로젝트를 조회할 때마다 지운 것은 자동으로 빠지게 했어요.
@Entity
@Table(name = "project")
@SQLRestriction("deleted_at is null")
public class Project extends BaseEntity {
@SQLRestriction을 붙이면 이 엔티티를 읽는 모든 쿼리에 deleted_at is null 조건이 붙어요. 사이드바 목록, 프로젝트 상세, 상태 조회 같은 곳을 하나하나 고치지 않아도 지운 프로젝트가 보이지 않아요.
프로젝트에 딸린 파일 정보, 분석 결과, 의견, 리포트는 실제로 지워요. 이 데이터는 사용자가 올린 자료에서 나온 것이라, 사용자가 지우겠다고 하면 남겨 둘 이유가 없어요. 남는 것은 프로젝트 행 하나와, 크레딧과 AI 사용량 기록뿐이에요.
파일은 커밋이 끝난 뒤에 지워요
올린 파일과 썸네일은 S3에 있어요. 데이터베이스와 S3는 하나의 트랜잭션으로 묶을 수 없어요. 순서를 잘못 정하면 둘이 어긋나요.
- S3를 먼저 지우고 데이터베이스 삭제가 실패하면, 프로젝트는 남아 있는데 파일이 없어요.
- 데이터베이스를 지우고 S3 삭제가 실패하면, 아무도 가리키지 않는 파일이 남아요.
둘 중 덜 나쁜 쪽은 두 번째예요. 주인 없는 파일은 저장 공간을 조금 차지할 뿐 사용자에게 보이지 않아요. 그래서 데이터베이스 삭제가 확실히 끝난 뒤에 S3를 지우도록 했어요.
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProjectDeleted(ProjectDeletedEvent event) {
log.info("[onProjectDeleted] S3 파일 삭제: projectId={}, 파일 {}개", event.projectId(), event.fileKeys().size());
event.fileKeys().forEach(s3Service::delete);
}
삭제 이벤트에는 지울 파일의 키를 미리 담아 둬요. 커밋이 끝난 뒤에는 파일 정보 행이 이미 지워져서 키를 다시 읽을 수 없기 때문이에요. 이미지 파일은 원본이 곧 썸네일이라 키가 같을 수 있어서, 중복을 빼고 담아요.
같은 프로젝트를 동시에 두 번 지우면
삭제 버튼을 두 번 누르면 삭제 요청이 두 번 와요. 둘 다 프로젝트를 찾고, 둘 다 딸린 데이터를 지우려고 하면 두 번째 요청에서 오류가 나요.
그래서 삭제할 때는 프로젝트 행을 잠그고 읽어요.
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Project> findWithLockByIdAndUserEmail(Long id, String email);
늦게 온 요청은 앞 요청이 끝날 때까지 기다려요. 앞 요청이 끝나면 프로젝트에는 삭제 시각이 적혀 있고, @SQLRestriction 때문에 조회 결과가 비어요. 그래서 늦게 온 요청은 "프로젝트를 찾을 수 없음"을 받고 끝나요.
지운 프로젝트의 크레딧 내역이 사라졌어요
이렇게 만들고 나서 버그가 하나 나왔어요. 프로젝트를 지우면 크레딧 내역에서 그 프로젝트에 쓴 기록도 같이 사라졌어요. 기록을 남기려고 행을 지우지 않았는데, 내역 화면에서는 안 보이는 거예요.
원인은 크레딧 내역을 읽는 쿼리였어요. 내역에 프로젝트 제목을 함께 보여주려고 프로젝트를 조인하고 있었어요.
@Query("select f from FileAnalysisCredit f join fetch f.project where f.credit.id = :creditId")
List<FileAnalysisCredit> findAllByCreditIdWithProject(@Param("creditId") Long creditId);
JPQL로 프로젝트를 조인하면 @SQLRestriction의 조건도 따라 붙어요. 지운 프로젝트는 조인에서 빠지고, 조인이 내부 조인이라 크레딧 기록까지 같이 빠졌어요. 지운 것을 숨기려고 붙인 조건이, 숨기면 안 되는 곳에서도 동작한 거예요.
그래서 이 쿼리만 네이티브 SQL로 바꿨어요. 네이티브 SQL에는 엔티티의 조건이 붙지 않아요.
select c.project_id as projectId,
p.title as projectTitle,
c.use_credit as useCredit,
p.deleted_at as projectDeletedAt,
c.created_at as occurredAt
from file_analysis_credit c
join project p on p.id = c.project_id
where c.credit_id = :creditId
삭제 시각도 같이 읽어서, 화면에서 지운 프로젝트의 내역은 지운 프로젝트라고 표시할 수 있게 했어요. 의견 수집 크레딧 내역도 같은 방식으로 고쳤어요.
정리
- 다른 기록이 가리키는 행은 지우지 않고 삭제 시각만 남기면, 기록과의 연결을 잃지 않아요.
@SQLRestriction을 쓰면 지운 행이 모든 조회에서 자동으로 빠져요.- 그 조건은 조인에도 붙기 때문에, 지운 행까지 봐야 하는 쿼리는 따로 확인해야 해요.
- 외부 저장소의 파일은 데이터베이스 커밋이 끝난 뒤에 지워요. 어긋나더라도 사용자에게 보이지 않는 쪽으로 어긋나게 해요.
- 같은 행을 동시에 지우는 요청은 잠금으로 줄을 세워요.