우리가 66ms에 16.8M SIRENE 사업장을 조회하는 방법

발행: (2026년 3월 7일 오후 09:25 GMT+9)
6 분 소요
원문: Dev.to

Source: Dev.to

위에 제공된 링크에 포함된 전체 텍스트를 알려주시면, 해당 내용을 한국어로 번역해 드리겠습니다. (코드 블록, URL 및 마크다운 형식은 그대로 유지됩니다.)

도전 과제

Our establishment table has 16.8 million rows. Users need to search by:

필드유형비고
SIREN9 자리정확히 일치 – B‑tree 인덱스로 간단히 처리
SIRET14 자리정확히 일치 – 동일
Company name텍스트퍼지 매치 – 흥미로운 부분

The name search must handle:

  • Partial matches – “Total” should find “TotalEnergies SE”
  • Typos – “Miclein” should find “Michelin”
  • Accent insensitivity – “Societe Generale” should match “Société Générale”

순진한 접근법: ILIKE

SELECT *
FROM georefer.establishment
WHERE company_name ILIKE '%total%'
LIMIT 25;

EXPLAIN ANALYZE

Seq Scan on establishment
  Filter: (company_name ~~* '%total%')
  Rows Removed by Filter: 16799975
  Planning Time: 0.1ms
  Execution Time: 12,847ms

12.8 초 → 1,680만 행에 대한 전체 순차 스캔. 사용 불가.

Source:

pg_trgm 사용

PostgreSQL의 pg_trgm 확장은 문자열을 3‑문자 시퀀스(트라이그램)로 분할하고, GIN 인덱스를 활용해 유사 문자열을 효율적으로 찾습니다.

CREATE EXTENSION IF NOT EXISTS pg_trgm;

CREATE INDEX idx_establishment_name_trgm
ON georefer.establishment
USING GIN (company_name gin_trgm_ops);

이제 트라이그램 유사성을 사용할 수 있습니다:

SELECT *,
       similarity(company_name, 'total') AS sim
FROM georefer.establishment
WHERE company_name % 'total'
ORDER BY sim DESC
LIMIT 25;

EXPLAIN ANALYZE

Bitmap Heap Scan on establishment
  Recheck Cond: (company_name % 'total')
  -> Bitmap Index Scan on idx_establishment_name_trgm
       Index Cond: (company_name % 'total')
  Planning Time: 0.3ms
  Execution Time: 66ms

66 ms → 194배 향상 (naïve 접근 방식 대비).

가져오기 전략: 3단계

1,680만 행을 가져오는 일은 간단하지 않습니다. 우리는 3단계 접근 방식을 사용합니다.

1단계 – 스키마 + 스테이징

CREATE TABLE georefer.establishment (
    id               SERIAL PRIMARY KEY,
    siren            VARCHAR(9)  NOT NULL,
    siret            VARCHAR(14) NOT NULL UNIQUE,
    company_name     VARCHAR(255),
    commercial_name  VARCHAR(255),
    legal_form       VARCHAR(10),
    naf_code         VARCHAR(6),
    employee_range   VARCHAR(5),
    postal_code      VARCHAR(5),
    city             VARCHAR(100),
    department_code  VARCHAR(3),
    is_headquarters  BOOLEAN DEFAULT FALSE,
    is_active        BOOLEAN DEFAULT TRUE,
    created_date     DATE,
    last_update      DATE
);

2단계 – 대량 COPY

COPY georefer.establishment
    (siren, siret, company_name, ...)
FROM '/tmp/sirene_active.csv'
WITH (FORMAT csv, HEADER true, DELIMITER ',');

COPY는 배치 INSERT보다 10~50배 빠릅니다. 1,680만 행을 약 8 분 안에 로드합니다.

3단계 – 인덱스 생성

대량 가져오기 후에 인덱스를 생성합니다(미리 만들면 로드가 느려집니다).

-- Exact lookups
CREATE INDEX idx_establishment_siren ON georefer.establishment(siren);
CREATE INDEX idx_establishment_siret ON georefer.establishment(siret);

-- Geographic filtering
CREATE INDEX idx_establishment_postal ON georefer.establishment(postal_code);
CREATE INDEX idx_establishment_dept   ON georefer.establishment(department_code);
CREATE INDEX idx_establishment_city   ON georefer.establishment(city);

-- Fuzzy name search
CREATE INDEX idx_establishment_naf   ON georefer.establishment(naf_code);
CREATE INDEX idx_establishment_name_trgm
    ON georefer.establishment USING GIN (company_name gin_trgm_ops);

결합된 쿼리: 이름 + 지리적 필터

SELECT *,
       similarity(company_name, 'boulangerie') AS sim
FROM georefer.establishment
WHERE company_name % 'boulangerie'
  AND department_code = '75'
  AND is_active = true
ORDER BY sim DESC
LIMIT 25;

Result: 파리의 모든 베이커리를 약 45 ms에, 1,680만 행을 넘어서는 데이터에서도.

API 레이어

Spring Boot 서비스는 REST를 통해 검색 기능을 제공합니다.

# Search by SIREN
curl 'https://georefer.io/geographical_repository/v1/companies?siren=552120222' \
  -H 'X-Georefer-API-Key: YOUR_API_KEY'

# Search by name + department
curl 'https://georefer.io/geographical_repository/v1/companies/search?name=michelin&department_code=63' \
  -H 'X-Georefer-API-Key: YOUR_API_KEY'

샘플 JSON 응답

{
  "success": true,
  "data": [
    {
      "siren": "855200507",
      "siret": "85520050700046",
      "company_name": "MANUFACTURE FRANCAISE DES PNEUMATIQUES MICHELIN",
      "naf_code": "22.11Z",
      "employee_range": "5000+",
      "postal_code": "63000",
      "city": "CLERMONT-FERRAND",
      "is_headquarters": true
    }
  ]
}

성능 요약

쿼리 유형인덱스 없음 (Before)pg_trgm 사용 (After)
이름에 대한 단순 ILIKE12,847 ms66 ms
이름 + 부서 필터~12 s (전체 스캔)~45 ms
정확한 SIREN / SIRET 조회µs (B‑tree)µs (B‑tree)

숫자는 예시이며, 실제 시간은 하드웨어 및 부하에 따라 달라질 수 있습니다.

개선

쿼리총 시간평균 지연속도 향상
이름 검색12,847 ms66 ms194×
이름 + 부서 필터13,102 ms45 ms291×
SIREN 정확히8,200 ms0.3 ms27,333×
SIRET 정확히8,150 ms0.2 ms40,750×

Lessons Learned

  • Always create indexes after bulk import – creating them before can make the import 10× slower.
  • pg_trgm GIN indexes use a lot of disk – our 16.8 M‑row trigram index is ~2.3 GB.
  • Set maintenance_work_mem high during index creationSET maintenance_work_mem = '1GB' cuts index creation time in half.
  • COPY beats INSERT every time for bulk loading – use COPY for anything over 10 K rows.

사용해 보기

GEOREFER는 간단한 REST API를 통해 16.8 M SIRENE 사업장을 제공합니다:

  • 무료 티어: 100 req/day, 신용카드 필요 없음.
  • 문서:
  • 가입:

AZMORIS Engineering – “오래 지속되는 소프트웨어”

0 조회
Back to Blog

관련 글

더 보기 »

Archify 소개: 아키텍처 아이디어에서 Spring Boot 코드까지

문제 모든 백엔드 개발자는 이 순간을 경험해 본 적이 있다: 새로운 프로젝트를 시작하면서 이미 아키텍처를 염두에 두고—아마도 간단한 REST 서비스와 몇 개의 엔드포인트만을 생각하고—시작한다. 하지만 프로젝트가 진행될수록 요구사항이 늘어나고, 복잡도가 급격히 증가한다. ### 원인 1. **초기 설계 부족** - 프로젝트 초기에 충분한 도메인 모델링과 데이터 흐름 설계를 하지 않으면, 나중에 구조를 바꾸는 데 큰 비용이 든다. 2. **기능 폭발** - 초기에는 “읽기 전용” API만 필요했지만, 곧 쓰기, 인증, 권한, 실시간 알림 등 다양한 기능이 추가된다. 3. **기술 스택 변화** - 새로운 라이브러리나 프레임워크가 등장하면서 기존 코드를 교체하거나 통합해야 하는 상황이 발생한다. ### 해결책 #### 1. 도메인 중심 설계(Domain‑Driven Design) 적용 - **Bounded Context**를 명확히 정의하고, 각 컨텍스트마다 독립적인 모델과 서비스 계층을 만든다. - **Entity**, **Value Object**, **Aggregate** 등을 활용해 비즈니스 로직을 캡슐화한다. #### 2. 레이어드 아키텍처 도입 - **Presentation Layer** (Controller / Handler) – HTTP 요청을 받아 DTO 로 변환하고, 서비스에 위임한다. - **Application Layer** – 트랜잭션 경계와 워크플로우를 정의한다. - **Domain Layer** – 핵심 비즈니스 로직과 도메인 규칙을 구현한다. - **Infrastructure Layer** – DB, 메시지 브로커, 외부 API 등 기술적 세부 사항을 담당한다. #### 3. 인터페이스와 의존성 역전(Dependency Inversion) - 상위 레이어가 하위 레이어에 의존하지 않도록 **Repository Interface**, **Service Interface** 등을 정의하고, 구현은 인프라 레이어에 둔다. - 스프링에서는 `@Autowired` 대신 **Constructor Injection**을 사용하고, 테스트 시에는 **Mock** 구현체를 주입한다. #### 4. 모듈화와 마이크로서비스 전략 - 시스템이 일정 규모를 넘어가면 **Domain‑Driven Microservices** 혹은 **Modular Monolith** 로 전환한다. - 각 모듈(또는 서비스)은 독립적인 데이터베이스 스키마와 배포 파이프라인을 갖는다. #### 5. 자동화된 테스트와 CI/CD 파이프라인 - **Unit Test**, **Integration Test**, **Contract Test**(예: Spring Cloud Contract) 를 레이어별로 작성한다. - GitHub Actions, GitLab CI 등으로 **빌드 → 테스트 → 배포** 흐름을 자동화한다. #### 6. 문서화와 코드 규칙 - OpenAPI/Swagger 로 API 스펙을 정의하고, **API‑First** 접근법을 채택한다. - 코드 스타일은 **Checkstyle**, **Spotless**, **EditorConfig** 로 일관성을 유지한다. ### 예시 코드 (Spring Boot) ```java // Domain Layer @Entity public class Order { @Id @GeneratedValue private Long id; private LocalDateTime orderDate; @Embedded private Money totalAmount; // 비즈니스 메서드 public void addItem(Product product, int quantity) { … } } // Application Layer @Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepo; private final PaymentGateway paymentGateway; @Transactional public OrderDto placeOrder(CreateOrderCommand cmd) { Order order = new Order(); // 도메인 로직 수행 orderRepo.save(order); paymentGateway.charge(order.getTotalAmount()); return OrderMapper.toDto(order); } } // Infrastructure Layer @Repository public interface OrderRepository extends JpaRepository<Order, Long> {} // Presentation Layer @RestController @RequestMapping('/api/orders') @RequiredArgsConstructor public class OrderController { private final OrderService orderService; @PostMapping public ResponseEntity<OrderDto> create(@RequestBody @Valid CreateOrderCommand cmd) { OrderDto result = orderService.placeOrder(cmd); return ResponseEntity.status(HttpStatus.CREATED).body(result); } } ``` ### 마무리 프로젝트 초기에 **아키텍처 설계**와 **도메인 모델링**에 충분한 시간을 투자하면, 이후 기능 추가나 기술 교체가 훨씬 수월해진다. 레이어드 아키텍처와 DDD 원칙을 적용하고, 의존성 역전 및 자동화된 테스트를 기반으로 하면, 복잡도가 증가해도 유지보수 가능한 코드를 유지할 수 있다. **핵심 포인트** - 초기 설계에 투자 → 장기 비용 절감 - 레이어와 인터페이스로 책임 분리 - 테스트와 CI/CD 로 변경 위험 최소화 - 필요 시 마이크로서비스 혹은 모듈형 모노리식으로 확장