배경
고객의 민감정보를 포함한 시계열 데이터를 수집해야 하는 상황에서, 직접적인 데이터 접근이 불가능했습니다. 업체 간 협약과 복잡한 이해관계로 인해 반드시 중계 플랫폼을 통해서만 데이터를 받아올 수 있는 제약이 있었고, 이러한 외부 의존성 하에서 안정적인 데이터 처리 시스템을 구축해야 했습니다.
실제 업무에서 경험한 문제와 해결 과정을 공유하고자 하지만, 실무 코드를 그대로 공개할 수는 없어 비슷한 상황을 가정한 예시로 설명하겠습니다.

상황 설정
- 외부 API에서 센서에 기록된 시계열 데이터를 받아오는 시스템을 개발한다고 가정합니다.
- 대략, 15분 마다 센서를 사용중인 회원들의 데이터들이 각각의 요청으로 들어옵니다.
- 특이 사항으로는 매번 누적된 데이터를 전송한다는 점입니다.
- 일 단위로 00시00분에 누적값이 초기화 되며, 데이터를 전송합니다.
- 같은 형식의 데이터가 두가지 상태(일반, 상세)로 나눠져서 옵니다.
- 두 형식의 필드는 완전히 같으며 측정된 시간은 겹치지 않습니다.
1차 호출: [데이터A]
2차 호출: [데이터A, 데이터B]
3차 호출: [데이터A, 데이터B, 데이터C]
4차 호출: [데이터A, 데이터B, 데이터C, 데이터D
이런 패턴에서 중복 저장을 어떻게 해결할 것인지가 문제입니다.
Entity 정의
먼저, Entity 부터 정의 해보겠습니다.
센서는 4개의 필드를 가집니다.(식별자, 측정시간, 값, 타입)
CREATE TABLE sensor (
id BIGINT PRIMARY KEY,
measure_date DATETIME(6) NOT NULL,
value DECIMAL(10,2),
data_type VARCHAR(50),
UNIQUE KEY uk_id_measure_date (id, measure_date, data_type)
);
public enum DataType {
REGULAR, // 일반
SPECIAL // 특이
}
@Entity
public class SensorData {
private Long id
private LocalDateTime measureDate
private Double value;
private DataType dataType;
}
Entity 각 필드의 어노테이션은 생략했습니다.
방법 1: 최신 날짜 조회 후 필터링
매번 쿼리를 짜면 가장 일반적으로 생각할 수 있는 방법입니다.
@Repository
public interface SensorRepository extends JpaRepository<Sensor, Long> {
Optional<Sensor> findTopByIdAndDataTypeOrderByMeasureDateDesc(
Long id, DataType dataType);
}
// ApiResponse 클래스 구조
public class ApiResponse {
private Long id;
private List<Reading> regularReadings; // 일반 데이터 리스트
private List<Reading> detailedReadings; // 상세 데이터 리스트
// getter 메서드들...
}
// Reading 클래스 구조
public class Reading {
private LocalDateTime MeasureDate;
private Double value;
private String unit;
}
@Service
public class SensorService {
@Transactional
public List<Sensor> saveData(ApiResponse response) {
Long sensorId = response.getSensorId();
// 1. 각 타입별로 최신 측정 시간 조회
LocalDateTime lastRegularTime = getLastMeasureDate(
sensorId, DataType.REGULAR
);
LocalDateTime lastSpecialTime = getLastMeasureDate(
sensorId, DataType.SPECIAL
);
List<Sensor> newData = new ArrayList<>();
// 2. 일반 데이터 필터링
List<Sensor> regularData = response.getRegularReadings()
.stream()
.filter(reading -> reading.getMeasureDate().isAfter(lastRegularTime))
.map(reading -> new Sensor(sensorId, reading, DataType.REGULAR))
.toList();
newData.addAll(regularData);
// 3. 특이 데이터 필터링
List<Sensor> specialData = response.getSpecialReadings()
.stream()
.filter(reading -> reading.getMeasureDate().isAfter(lastSpecialTime))
.map(reading -> new Sensor(sensorId, reading, DataType.SPECIAL))
.toList();
newData.addAll(specialData);
// 4. 저장
if (!newData.isEmpty()) {
return repository.saveAll(newData);
}
return Collections.emptyList();
}
private LocalDateTime getLastMeasureDate(Long id, DataType dataType) {
return repository
.findTopByIdAndDataTypeOrderByMeasureDateDesc(id, dataType)
.map(Sensor::getMeasureDate)
.orElse(LocalDateTime.MIN);
}
}
위 방식의 문제점
1. Race Condition (경쟁 상태)
// 시나리오: 두 요청이 동시에 들어옴
Thread A: getLastMeasureDate() → 2025-07-14 10:00:00
Thread B: getLastMeasureDate() → 2025-07-14 10:00:00 (A가 아직 저장 안함)
Thread A: 10:01 데이터 저장
Thread B: 10:01 데이터 저장 → 중복 키 오류!
2. 중복 키 오류 발생
중복 키 오류로 인해 무조건적으로 저장되어야 할 데이터가 유니크 제약조건으로 저장되지 않는 경우가 자주 생겼습니다.
SQL Error: 1062, SQLState: 23000
Duplicate entry 'xx-2025-07-14 00:00:00.000000' for key 'xxxx'
SQL Error: Duplicate entry 'xx-2025-07-14 00:00:00.000000' for key 'xxx'
3. 복잡한 예외처리 필요
예외 처리를 하자니 코드가 너무 지저분해 졌습니다.
@Transactional
public List<Sensor> saveData(ApiResponse response) {
try {
// ... 필터링 로직
return repository.saveAll(newData);
} catch (DataIntegrityViolationException e) {
if (e.getMessage().contains("uk_id_measure_date")) {
// 중복 발생 시 개별 저장으로 전환
return handleDuplicates(newData);
}
throw e;
}
}
private List<Sensor> handleDuplicates(List<Sensor> dataList) {
List<Sensor> saved = new ArrayList<>();
for (Sensor data : dataList) {
try {
saved.add(repository.save(data));
} catch (DataIntegrityViolationException e) {
// 중복은 무시
log.warn("중복 데이터 무시: {}", data.getMeasureDate());
}
}
return saved;
}
4. 시간 정밀도 문제
현재는 하나의 벤더사에서만 데이터를 받고 있지만 중계 플랫폼에서 데이터를 받는 각 벤더사의 데이터가 초까지 밖에 없어 시간이 겹치는 문제도 있었습니다. 물론 마이크로미터까지 하면.. 거의 겹치진 않겠지만 그래도 가능성이 존재합니다.

// 같은 시간대의 여러 데이터
2025-07-11 00:12:37 // 벤더사 A
2025-07-11 00:12:37 // 벤더사 B
2025-07-11 00:12:37 // 벤더사 C
방법 2: INSERT IGNORE
데이터베이스 레벨에서 중복을 처리하는 방식입니다.
Repository 변경
@Repository
public interface SensorRepository extends JpaRepository<Sensor, Long> {
@Modifying
@Transactional
@Query(value = """
INSERT IGNORE INTO sensor (
id,
measure_date,
value,
data_type,
created_at
) VALUES (
:id,
:measureDate,
:value,
:dataType,
NOW(6)
)
""", nativeQuery = true)
void insertIgnoreData(
Long id,
LocalDateTime measureDate,
Double value,
String dataType
);
}
@Service
public class SensorService {
@Transactional
public List<Sensor> saveData(ApiResponse response) {
Long sensorId = response.getSensorId();
List<Sensor> allData = new ArrayList<>();
// 1. 복잡한 필터링 없이 모든 데이터 준비
response.getRegularReadings().forEach(reading ->
allData.add(new Sensor(sensorId, reading, DataType.REGULAR))
);
response.getSpecialReadings().forEach(reading ->
allData.add(new Sensor(sensorId, reading, DataType.SPECIAL))
);
// 2. INSERT IGNORE로 중복 자동 처리
allData.forEach(data ->
repository.insertIgnoreData(
data.getId(),
data.getMeasureDate(),
data.getValue(),
data.getDataType().name()
)
);
log.info("데이터 처리 완료 - 전송: {}개", allData.size());
return allData;
}
}
성능비교
| 시나리오 | 최신 날짜 조회 방식 | INSERT IGNORE 방식 | 비고 |
| 신규 데이터 100개 | SELECT 2회 + INSERT 100회 | INSERT 100회 | INSERT IGNORE |
| 중복 50개 + 신규 50개 | SELECT 2회 + INSERT 50회 | INSERT 100회 (50개 무시) | 최신 날짜 |
| 모든 데이터 중복 | SELECT 2회 + INSERT 0회 | INSERT 100회 (모두 무시) | 최신 날짜 |
| 동시 요청 환경 | 락킹 + 예외처리 필요 | 자동 처리 | INSERT IGNORE |
코드 복잡성
// 최신 날짜 조회 방식
- 각 타입별 최신 시간 조회 로직
- 복잡한 필터링 로직
- 예외 처리 로직
- 동시성 제어 로직
- 많은 코드량
// INSERT IGNORE 방식
- 단순한 데이터 준비
- INSERT IGNORE 실행
- 적은 코드량
안정성 비교
// 최신 날짜 조회 방식의 위험 요소
❌ Race Condition
❌ 시간 정밀도 문제
❌ 예외 처리 누락 가능성
❌ 복잡한 로직으로 인한 버그
// INSERT IGNORE 방식
✅ DB 레벨에서 보장되는 중복 방지
✅ 동시성 문제 원천 차단
✅ 단순한 로직
✅ 데이터 무결성 보장
단점
Native Query 사용
JPA의 추상화를 포기하는 점에서 조금 아쉬웠습니다.
성능
아무래도 최신 날짜 조회 방식이 처음 데이터를 등록하는 경우를 제외하고는 이론적으로나 실제 돌려봐도 빠르긴 했습니다.
마지막 측정 날짜를 조회하여 최근 데이터를 가져오는 방식이다 보니 중복률이 높을수록 당연히 최신 날짜가 당연히 빠르지만,
실제 성능 및 운영환경에서는 동시성과 예외처리 및 코드 복잡성을 고려 했을 때, INSERT IGNORE가 더 안정적이라 생각했습니다.
결론
센서 사용자가 현재는 적고 안정성이 중요하여 현재는 INSERT IGNORE로 리팩토링 하였습니다만...
외부 API를 받아오는 벤더사가 외국 플랫폼이다 보니 데이터가 어떻게 오는지에 대한 공식문서의 내용이 없었고, 데이터를 받으면서 만들다보니 안정성과 코드 복잡도를 낮추는데 목적을 가졌으나....
한 명당 하루 96번(15분 주기) + N개(상세 데이터)의 호출이 이루어지며, 최악의 경우 한 사람당 96 + N번의 불필요한 INSERT 쿼리가 실행되는 심각한 성능 문제가 드러났습니다.
실제로는 사용자별로 다른 ID를 가지므로 같은 ID에 대한 레이스 컨디션도.. 고려 대상이 아니고..
실제 가정한 것과 다르게 데이터가 점진적으로 증가하고 이전 데이터의 수정이 필요없어 중복률이 높아 사용자가 늘어나는 만큼 중복률이 기하급수적으로 증가하고 비효율적인 멍청 코드가 만들어졌네요....
첫 등록만 insert ignore 쓰던가.. 최신 날짜 방식이랑 섞어서 하이브리드로 쓰던가 해야 할 것 같습니다.
오늘의 교훈
외부 API의 데이터를 다양한 방식으로 받아서 패턴을 파악하지 못한 상태에서 멍청 비용을 지불하며 개발을 했다는 점에서 역시 생각하고 개발을 해야한다는 반성과 교훈을 얻었습니다.
중복 비율이 높고 안정성이 중요한 부분에서 INSERT IGNORE 방식으로 적용하면 되겠다는 교훈을 얻었습니다.
기존 데이터가 수정되면 UPSERT도 고려해 볼 수 있을 것 같습니다.

'BackEnd > Java' 카테고리의 다른 글
| Scanner VS BufferedReader 차이 (1) | 2023.11.11 |
|---|---|
| Spring의 3대 요소 (IoC, DI, PSA, AOP) (0) | 2023.10.13 |
| [자료 구조] - 자료구조와 배열, 리스트에 대해 알아보자 - Java (0) | 2023.08.24 |
| Spring Boot 설치 세팅 (STS 설치) - Oracle, Mybatis (4) | 2023.02.06 |