저희 프로젝트에서는 security_vitals 테이블이 각 테스트(test_id)마다
하나의 진단 결과를 저장하도록 설계되어 있었습니다.
그래서 처음 스캔이 이루어질 때마다 새 결과 저장했고
동일한 test_id로 다시 스캔되거나 보안 항목이 업데이트될 때는 기존 데이터를 갱신하도록 했습니다.
하지만 이런 흐름에서 다음 문제가 반복적으로 발생했습니다.
- 이미 존재하는 test_id에 대해 새로 인서트하려다 키 중복 오류가 발생
- 존재하지 않을 때는 INSERT 해야 하지만, 존재하면 UPDATE 해야 한다는 로직이 코드에 흩어져 복잡도 증가
- JPA로 처리할 때 조회 후 있으면 변경, 없으면 저장 형태로 구현했지만, 동시성 이슈가 있음
따라서 존재하면 업데이트, 없으면 삽입하는 구조를
효율적이고 일관되게 처리할 수 있는 방법을 찾고자 했습니다.
Upsert 개념 발견
이 문제를 해결하면서 찾은 개념이 바로 Upsert입니다.
이는 Insert 와 Update를 하나의 흐름으로 처리하는 방식입니다.
PostgreSQL에서는 아래와 같이 표현됩니다:
INSERT INTO security_vitals(…)
VALUES(…)
ON CONFLICT(test_id)
DO UPDATE SET …
이 구문이 의미하는 바는,
만약 test_id 에 충돌이 있다면(이미 존재한다면) UPDATE를 실행하라는 것입니다.
그렇다면 upsert의 장점은 무엇일까요?
- 별도의 조회 → 조건 판단 → 삽입/갱신 흐름을 줄일 수 있다.
- 단일 쿼리 수준에서 원자성을 보장한다. INSERT나 UPDATE 중 하나만 결과로 남는다.
- 동시성 상황에서도 중복 삽입이나 충돌을 효과적으로 방지할 수 있다.
이러한 개념과 장점을 확인하면서
프로젝트 코드에도 적용을 시작했습니다.
적용 방법 및 코드 흐름
프로젝트에서는 아래 흐름으로 코드를 작성했습니다:
securityVitalsRepository.findByTest_Id(testId).ifPresentOrElse(
found -> {
found.updateFrom(finalResult);
log.info("[SEC] updated testId={}", testId);
securityVitalsRepository.flush();
},
() -> {
SecurityVitalsEntity created = SecurityVitalsEntity.create(test, finalResult1);
securityVitalsRepository.saveAndFlush(created);
log.info("[SEC] inserted testId={}", testId);
}
);
왜 이렇게 작성했는가?
먼저 findByTest_Id(testId)로 조회해 존재 여부를 판단합니다.
존재할 경우엔 found.updateFrom(finalResult)로 엔티티 필드들을 갱신하고 flush() 호출하고
존재하지 않을 경우엔 새 엔티티 생성 후 saveAndFlush()로 저장 및 즉시 반영합니다.
해당 코드에서는 flush()를 명시했는데
flush()를 명시적으로 호출한 이유는 다음과 같습니다.
- 후속 로직(예: AI 분석, SSE 알림 등)에서 이 변경이 즉시 DB에 반영되어야 했습니다 → 트랜잭션이 끝나기 전에 다른 서비스가 읽도록 보장해야 했습니다
- JPA의 변경감지(dirty checking) 만으로는 영속성 컨텍스트에는 반영되었지만 DB에 아직 커밋/반영되지 않은 상태가 존재할 수 있었습니다. 이에 따른 타이밍 이슈를 줄이기 위함이었습니다.
- 조회 후 갱신 구조로 구현했기 때문에 조회와 저장 사이 다른 트랜잭션이 끼어들 수 있는 동시성 위험도 있었습니다.
Upsert 방식으로의 전환 고려
운영 단계에서는 위 코드 흐름 대신 DB 레벨에서 Upsert 쿼리로 처리하는 방안도 함께 검토했습니다.
INSERT INTO security_vitals(test_id, score, …)
VALUES(:testId, :score, …)
ON CONFLICT (test_id)
DO UPDATE SET score = EXCLUDED.score;
이처럼 하면 조회 없이 바로 삽입 또는 갱신이 가능해져 성능 상 이점이 있습니다.
다만 JPA 환경에서는 엔티티 생명주기 관리, 변경감지, 영속성 컨텍스트 등과의 조합을 고려해야 하기 때문에
무조건 Upsert를 쓰자라기보다는 현재 구조 및 요구사항에 맞춰 선택하는 것이 중요한 것 같습니다.
특징 및 주의사항
특징
- 단일 쿼리로 Insert or Update 처리 가능 → 코드 단순화 및 DB round-trip 감소
- PostgreSQL의 ON CONFLICT DO UPDATE는 충돌이 발생할 때 UPDATE가 실행되며, 충돌 대상이 될 유니크 인덱스 혹은 제약조건이 필요합니다.
- JPA에서 변경감지(dirty checking)가 자동으로 수행되며 별도 UPDATE 메서드를 호출하지 않아도 됩니다.
주의사항
- Upsert 방식은 유니크 제약조건을 기반으로 해야 합니다. 만약 제약조건이 없으면 충돌을 판단할 수 없고 오류가 발생할 수 있습니다.
- JPA 이용 시 Upsert 쿼리를 직접 쓰면 엔티티 캐시/영속성 컨텍스트 상태가 실제 DB와 불일치할 수 있습니다. → 이 경우 EntityManager#refresh() 등이 필요할 수 있습니다
- Upsert가 항상 최선은 아닙니다. 예컨대 엔티티 복잡도, 관계매핑, 트리거/이벤트 연동 등이 많다면, JPA 방식(조회 + 엔티티 수정)도 여전히 유효합니다
- 성능 최적화 시에는 동시성 문제도 고려해야 합니다. Upsert는 동시 삽입 충돌 상황에서 대비가 되어 있으나, 엔티티 수준 로직이 복잡할 경우 여전히 동시성 이슈가 남을 수 있습니다
- JPA의 변경감지 메커니즘을 잘 이해하고 있어야 합니다. 특정 필드만 바뀌었을 때 자동으로 UPDATE가 발생한다는 점을 알아야 합니다.
마무리
프로젝트를 진행하며 특정 test_id에 대해 이미 존재하면 갱신, 없으면 저장이라는 로직이 필요해서
upsert라는 개념에 대해 알아보고 적용해보았습니다.
Upsert는 코드 단순화, 성능 이점을 주지만 JPA 생태계에서의 엔티티 관리, 캐시, 이벤트 등과 함께 고려해야 합니다
- 결국 “어떤 구조에서 어떤 방식이 더 적절한가?”를 판단해야 하며, 이를 위해 변경감지(dirty checking), 엔티티 생명주기, 동시성 등을 이해하고 적용하는 것이 중요합니다
'프로젝트 > 웹 성능 테스트' 카테고리의 다른 글
| Server Sent Events(SSE): 구현 (0) | 2025.11.02 |
|---|---|
| Server Sent Events(SSE): 설계 (0) | 2025.11.02 |
| DB 마이그레이션 (0) | 2025.10.25 |
| Git Flow 전략 (1) | 2025.10.24 |
| 도커: 이론2(용어 정리) (0) | 2025.10.18 |