예제의 테이블과 데이터는 모두 설명을 위해 만든 가상의 것. 아래 실행 결과는 MariaDB 10.11과 Java 21에서 직접 실행한 값.


요약

  • 증상: 리포트에서 app 채널이 0건으로 표시된다. 그런데 표의 행을 모두 합치면 전체 합계보다 12만 건이 모자란다
  • 원인: ① DB의 대소문자 무시 collation이 app과 APP을 한 그룹으로 합치고 대표 라벨을 어느 쪽으로 고를지 보장하지 않는다, ② Java의 HashMap은 대소문자를 구분한다
  • 해결: 데이터를 고치지 않고 조회 단에서 정규화(LOWER)하고, 애플리케이션에서도 한 번 더 막는다(이중 방어). 미분류 항목은 경고 로그로 남긴다
  • 교훈: 같은 값이 DB와 Java에서 서로 다르게 해석된다. 값이 계층을 넘어갈 때마다 "여기서는 어떻게 비교하는가"를 명시해야 한다

1. 상황 설정

채널별 월간 주문 건수를 집계한 테이블을 읽어 월간 리포트를 만든다. 값은 대부분 소문자인데, 외부 제휴 피드가 넣은 2건만 대문자다.

CREATE TABLE channel_stat (
  id        INT AUTO_INCREMENT PRIMARY KEY,
  month_key CHAR(6)     NOT NULL,
  channel   VARCHAR(20) NOT NULL,   -- 기본 collation: utf8mb4_general_ci
  order_cnt INT         NOT NULL
);

INSERT INTO channel_stat (month_key, channel, order_cnt) VALUES
 ('202609', 'web',  50000),
 ('202609', 'app', 120000),
 ('202609', 'APP',      2);        -- 제휴 피드가 대문자로 적재

app과 APP은 바이트로는 서로 다른 값이다.

2. 증상: 리포트에서 app이 0건이다

리포트는 채널별 합계를 Map에 담고 이름으로 값을 찾는다.

// SELECT channel, SUM(order_cnt) FROM channel_stat WHERE month_key = ? GROUP BY channel
Map<String, Integer> byChannel = rows.stream()
        .collect(Collectors.toMap(Row::channel, Row::orders));

byChannel.getOrDefault("app", 0);   // 0 ← 12만 건이 있는데?

표의 합(50,000)이 월 전체 합계(170,002)보다 120,002건 모자란다. 건수가 없는 게 아니라 표의 어떤 행에도 매칭되지 않는 이름 아래에 있다는 단서다. DB로 내려가 확인해 보자.

2. 원인 ①: DB는 대소문자를 구분하지 않는다

SELECT channel, SUM(order_cnt) AS total FROM channel_stat GROUP BY channel;
+---------+--------+
| channel | total  |
+---------+--------+
| app     | 120002 |
| web     |  50000 |
+---------+--------+

app(12만)과 APP(2)이 한 그룹으로 합쳐져 120,002가 됐다. MariaDB의 기본 collation(utf8mb4_general_ci)은 대소문자를 구분하지 않기 때문이다. (MySQL 8의 기본 utf8mb4_0900_ai_ci도 대소문자를 구분하지 않는다.) WHERE도 마찬가지다.

SELECT channel, order_cnt FROM channel_stat WHERE channel = 'app';
+---------+-----------+
| channel | order_cnt |
+---------+-----------+
| app     |    120000 |
| APP     |         2 |
+---------+-----------+

그런데 이 결과에는 더 위험한 성질이 숨어 있다. 합쳐진 그룹의 대표 라벨을 DB가 어떻게 고르는가다. 같은 데이터를 삽입 순서만 바꿔서 넣어 보자.

CREATE TABLE channel_stat2 LIKE channel_stat;
INSERT INTO channel_stat2 (month_key, channel, order_cnt) VALUES
 ('202609', 'APP',      2),        -- 대문자 행이 먼저
 ('202609', 'web',  50000),
 ('202609', 'app', 120000);

SELECT channel, SUM(order_cnt) AS total FROM channel_stat2 GROUP BY channel;
+---------+--------+
| channel | total  |
+---------+--------+
| APP     | 120002 |       ← 라벨이 'APP' 으로 바뀌었다
| web     |  50000 |
+---------+--------+

숫자(120,002)는 같지만 라벨이 app에서 APP으로 바뀌었다. 어떤 라벨이 나올지는 삽입 순서, 실행 계획 같은 조건에 따라 달라질 수 있고, 코드가 거기에 의존해서는 안 된다.

3. 원인 ②: Java의 HashMap은 대소문자를 구분한다

이제 DB가 APP이라는 라벨로 120,002를 돌려줬다고 하자.

List<Row> rows = List.of(new Row("APP", 120_002), new Row("web", 50_000));

Map<String, Integer> raw = rows.stream().collect(Collectors.toMap(Row::channel, Row::orders));
System.out.println(raw.getOrDefault("app", 0));   // 0
System.out.println(raw.getOrDefault("APP", 0));   // 120002
0
120002

"app"과 "APP"은 String.equals 기준으로 다른 키다. 리포트는 "app"을 찾고, DB는 "APP"을 줬으니 매칭에 실패하고 기본값 0이 표시된다.

4. 세 레이어의 암묵적 가정


그림 2. 각 레이어는 자기 기준으로는 정상 동작한다. 가정이 서로 달랐을 뿐이다.

  1. 입력: 외부 피드가 대문자를 적재했다. 저장 시점에 정규화가 없었다.
  2. DB: collation 규칙에 따라 두 값을 같다고 보고 합산했다. 대표 라벨은 보장되지 않는다.
  3. 애플리케이션: equals 규칙에 따라 두 값을 다르다고 보고 매칭에 실패했다.

어느 레이어도 버그가 아니다. 레이어마다 "같다"의 정의가 다른데, 아무도 그 사실을 명시하지 않았다는 것이 문제의 본질이다. 그래서 단 2건의 데이터가 12만 건짜리 표시 오류로 증폭된다.

5. 해결 선택지 비교

방법 언제 적합한가 주의할 점
A. 조회 정규화 (LOWER(col) + Java toLowerCase) 값이 의미상 대소문자와 무관하고, 데이터를 건드리고 싶지 않을 때 컬럼에 함수를 씌우면 일반적으로 인덱스를 쓰지 못한다
B. BINARY 그룹핑 대소문자가 의미 있는 구분자일 때 (예: app과 APP이 서로 다른 출처) ONLY_FULL_GROUP_BY에서 MAX() 필요 (아래)
C. 저장 시점 정규화 (INSERT 전 변환 + 기존 데이터 백필) 근본 해결을 원하고, 입력 경로를 우리가 통제할 수 있을 때 기존 데이터 백필, 원천 데이터의 소유권, 배포 범위가 부담이다
D. 컬럼 collation을 *_bin으로 변경 해당 컬럼의 모든 비교가 대소문자를 구분해야 할 때 정렬·유니크·조인 등 모든 비교에 영향을 주므로 영향 분석이 필수다
E. 입력 제약 (CHECK, 코드 테이블) 잘못된 값이 아예 못 들어오게 막고 싶을 때 CHECK 작성에 함정이 있다 (아래)

A. 조회 정규화

SELECT LOWER(channel) AS channel, SUM(order_cnt) AS total
FROM channel_stat GROUP BY LOWER(channel);
+---------+--------+
| channel | total  |
+---------+--------+
| app     | 120002 |
| web     |  50000 |
+---------+--------+

라벨이 항상 소문자로 고정되니 Java에서도 "app"으로 찾을 수 있다.

B. BINARY 그룹핑과 ONLY_FULL_GROUP_BY

대소문자를 구분해서 그룹핑하려면 GROUP BY BINARY channel을 쓸 수 있다. 그런데 ONLY_FULL_GROUP_BY가 켜져 있으면 이렇게 쓸 수 없다. (MySQL 5.7+는 기본으로 켜져 있고, MariaDB는 기본으로 꺼져 있다. 아래는 켠 상태에서 실행했다.)

SELECT channel, SUM(order_cnt) FROM channel_stat GROUP BY BINARY channel;
-- ERROR 1055 (42000): 'demo_collation.channel_stat.channel' isn't in GROUP BY

그룹 키는 BINARY channel인데 SELECT에는 channel이 있어서 서로 다른 식으로 취급되기 때문이다. MAX()로 감싸 "그룹 안에서 값은 모두 같다"고 알려주면 된다.

SELECT MAX(channel) AS channel, SUM(order_cnt) AS total
FROM channel_stat GROUP BY BINARY channel;
+---------+--------+
| channel | total  |
+---------+--------+
| APP     |      2 |
| app     | 120000 |
| web     |  50000 |
+---------+--------+

이제 app과 APP이 분리됐다. 이 방식이 맞는지는 두 값이 정말 다른 의미인지에 달려 있다. 같은 채널의 표기 차이일 뿐이라면 A가 맞다.

D. COLLATE를 쓸 때는 컬럼의 문자셋을 확인한다

COLLATE는 컬럼의 문자셋과 짝이 맞아야 한다. 컬럼이 utf8mb3인데 흔히 보이는 utf8mb4_bin을 쓰면 이렇게 실패한다.

-- channel 컬럼이 CHARACTER SET utf8mb3 인 경우
SELECT channel, COUNT(*) FROM channel_stat3 GROUP BY channel COLLATE utf8mb4_bin;
-- ERROR 1253 (42000): COLLATION 'utf8mb4_bin' is not valid for CHARACTER SET 'utf8mb3'

컬럼의 문자셋에 의존하지 않으려면 BINARY를 쓰는 편이 안전하다.

E. 소문자만 허용하는 CHECK의 함정

"소문자만 들어오게 막자"며 이렇게 쓰기 쉽다.

CREATE TABLE chk1 (channel VARCHAR(20) NOT NULL, CHECK (channel = LOWER(channel)));
INSERT INTO chk1 VALUES ('app');
INSERT INTO chk1 VALUES ('APP');      -- 통과해 버린다!

기본 collation에서는 'APP' = LOWER('APP')도 참이라서 제약이 아무것도 막지 못한다. 바이트로 비교해야 한다.

CREATE TABLE chk2 (channel VARCHAR(20) NOT NULL, CHECK (BINARY channel = LOWER(channel)));
INSERT INTO chk2 VALUES ('app');
INSERT INTO chk2 VALUES ('APP');
-- ERROR 4025 (23000): CONSTRAINT `CONSTRAINT_1` failed for `demo_collation`.`chk2`

7. 방어 코드: 한쪽이 틀어져도 리포트가 죽지 않게

DB 쿼리를 LOWER로 고쳤더라도, 누군가 쿼리를 되돌리거나 다른 경로에서 대소문자가 섞인 데이터가 들어올 수 있다. 애플리케이션에도 한 겹 더 방어를 둔다.

// 키를 소문자로 정규화하고, 같은 키가 충돌하면 합산한다
Map<String, Integer> safe = rows.stream().collect(Collectors.toMap(
        r -> r.channel().toLowerCase(Locale.ROOT),
        Row::orders,
        Integer::sum));

System.out.println(safe.getOrDefault("app", 0));   // 120002

병합 함수(Integer::sum)가 없으면 키가 충돌하는 순간 예외가 나서 리포트 발송 자체가 죽는다.

List<Row> split = List.of(new Row("app", 120_000), new Row("APP", 2));
split.stream().collect(Collectors.toMap(r -> r.channel().toLowerCase(Locale.ROOT), Row::orders));
// IllegalStateException: Duplicate key app (attempted merging values 120000 and 2)

표에 없는 값은 조용히 버리지 말고 경고를 남긴다. 그리고 표시한 합이 전체 합과 같은지 검증한다.

Set<String> known = Set.of("web", "app");
int shown = 0;                       // total: 전체 합계 (별도 쿼리로 조회)
for (var e : safe.entrySet()) {
    if (known.contains(e.getKey())) {
        shown += e.getValue();
    } else {
        log.warn("excluded unclassified channel: key={}, count={}", e.getKey(), e.getValue());
    }
}
if (shown != total) {
    log.warn("report mismatch: shown={}, total={}", shown, total);   // 2편의 증상이 바로 드러나는 지점
}

회귀 테스트도 하나 둔다. 대소문자가 다른 입력이 같은 행으로 합쳐지는지만 확인해도 같은 버그의 재발을 막는다.

@Test
void mixedCaseKeys_areMergedIntoOneRow() {
    var rows = List.of(new Row("app", 120_000), new Row("APP", 2));
    assertEquals(120_002, normalize(rows).get("app"));
}

'Backend > DB | SQL' 카테고리의 다른 글

자릿수가 다른 문자열 비교의 함정  (0) 2026.10.12

1. 자기 참조 테이블의 순환 참조를 애플리케이션이나 쿼리에서 검증해야 하는 이유

  • 자기 참조하는 테이블 정의
    CREATE TABLE categories (
      id        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
      name      VARCHAR(100) NOT NULL,
      parent_id BIGINT REFERENCES categories(id)   -- 자기 참조 FK
    );
의류(1) → 상의(2) → 티셔츠(3)
  • 여기서 의류의 부모를 티셔츠로 바꾸면 의류 → 티셔츠 → 상의 → 의류로 닫힌 고리가 생깁니다. 이 상태가 저장되면 루트를 찾아 올라가는 모든 조회가 무한 루프에 빠집니다.
  • DB 제약으로 막을 수 있는지도 확인했습니다. CHECK (id <> parent_id)를 걸면 직접 자기 참조는 막히지만, 간접 순환은 그대로 통과합니다. 그래서 간접 순환은 애플리케이션이나 쿼리에서 검증해야 합니다.
CREATE TABLE c2 (
    id BIGINT PRIMARY KEY,
    parent_id BIGINT REFERENCES c2(id),
    CONSTRAINT chk_no_self_parent CHECK (id <> parent_id)
);
INSERT INTO c2 VALUES (1, NULL), (2, 1);

UPDATE c2 SET parent_id = 2 WHERE id = 2;  -- ERROR: violates check constraint
UPDATE c2 SET parent_id = 2 WHERE id = 1;  -- 성공. 1 → 2 → 1 순환이 저장됨
  • 이 글에서 다루는 케이스는 다섯 가지입니다.
    요청 순환 여부
    의류를 의류 아래로 (자기 자신) O
    의류를 상의 아래로 (자식) O
    의류를 티셔츠 아래로 (손자) O
    티셔츠를 무관한 카테고리 아래로 X
    티셔츠를 의류 아래로 (조상 쪽으로 이동) X

2. 구현: 조상을 타고 올라가며 검증

코드

  • 새 부모에서 출발해 parent를 따라 루트까지 올라가면서, 수정 대상 카테고리를 만나는지 확인합니다. 만나면 대상이 새 부모의 조상이라는 뜻이므로 그 아래로 옮기면 순환이 됩니다.
  • 검증과 변경을 엔티티의 도메인 메서드에 두었습니다. 외부에서 부모를 임의로 바꿀 수 없게 하고, 검증과 변경이 항상 같이 실행되도록 하기 위해서입니다.
public void update(String name, String description, Category newParent) {
    validateParent(newParent);
    this.name = name;
    this.description = description;
    changeParent(newParent);
}

private void validateParent(Category newParent) {
    Category current = newParent;
    while (current != null) {                         // 루트(parent == null)까지 올라감
        if (current.getId().equals(this.id)) {        // 조상 중에 내가 있으면 순환
            throw new DomainException(DomainExceptionCode.CATEGORY_CIRCULAR_REFERENCE);
        }
        current = current.getParent();
    }
}

private void changeParent(Category newParent) {
    if (this.parent != null) this.parent.children.remove(this);  // 양방향 연관관계 정리
    this.parent = newParent;
    if (newParent != null) newParent.children.add(this);
}
@Transactional
public CategoryUpdateResponse updateCategory(Long categoryId, CategoryUpdateRequest dto) {
    //... 중략
    category.update(dto.getName(), dto.getDescription(), parent);  // 순환 검증 포함
    // ... 중략
}

몇 가지 설계 포인트

  • 자기 자신 지정도 같은 루프에서 잡힙니다. current가 처음에 newParent이므로, newParent == this이면 첫 반복에서 걸립니다. 직접 순환과 간접 순환을 따로 검사할 필요가 없습니다.
  • 비교는 인스턴스(==)가 아니라 id로 합니다. LAZY 연관은 프록시일 수 있어서 같은 행이라도 인스턴스가 다를 수 있습니다.
  • parentId = null은 "루트로 이동"입니다. PUT은 전체 교체이므로 null을 "변경 없음"으로 해석하지 않고, 검증 루프는 newParent == null이면 바로 종료됩니다.

앞의 다섯 케이스를 이 로직에 대입해 보면, 자기 자신·자식·손자는 올라가는 도중에 this를 만나 예외가 되고, 무관한 이동과 조상 쪽 이동은 루트까지 올라가도 만나지 않아 통과합니다. (같은 다섯 케이스를 4장에서 SQL로 실제 실행해 검증합니다.)

3. 한계 ①: 깊이만큼 DB 왕복이 생긴다

parent는 @ManyToOne(fetch = LAZY)입니다. LAZY 프록시는 접근하는 순간 초기화되므로, current.getParent()로 한 단계 올라갈 때마다 SELECT가 한 번씩 나가는 구조입니다. 프로젝트에 켜 둔 org.hibernate.SQL: DEBUG 로그에서 조상 단계마다 categories 조회가 찍히는지 확인할 수 있습니다.

깊이가 얼마나 영향을 주는지 보려고, 같은 구조(한 단계 올라갈 때마다 DB 왕복 1회)와 재귀 CTE 한 번 호출을 PostgreSQL에서 비교했습니다. Hibernate의 오버헤드는 뺀 DB 왕복 구조만의 비교입니다.

실험 조건: PostgreSQL 16, 무관한 행 10만 개와 깊이 D의 체인, 단일 연결, 로컬 소켓. 체인의 맨 아래 노드에서 올라가며 "무관한 카테고리가 조상에 있는지"를 확인합니다(순환이 아닌 경우라 끝까지 올라갑니다). 반복 200회(깊이 1000은 30회)의 중앙값입니다.

깊이 단계별 조회 (쿼리 수) 단계별 조회 (중앙값) 재귀 CTE (쿼리 1회, 중앙값)
5 5회 0.52 ms 0.13 ms
20 20회 2.10 ms 0.17 ms
100 100회 10.37 ms 0.37 ms
1000 1000회 98.57 ms 1.85 ms

단계별 조회는 깊이에 정비례하고, 이 환경에서는 왕복 한 번에 약 0.1ms가 듭니다. DB가 같은 호스트의 소켓이라 왕복 비용이 매우 작은 조건이므로, DB가 네트워크 너머에 있으면 왕복 횟수만큼 격차가 더 벌어질 것입니다.

다만 이 프로젝트의 카테고리는 예시 데이터 기준 대/중/소 3단계이고, 수정은 어드민에서만 가끔 일어납니다. 이 규모에서는 이 비용이 문제가 되지 않습니다. 깊이 제한이 없거나 깊은 트리를 다뤄야 하는 요구사항에서 문제가 됩니다.

4. 개선: 재귀 CTE로 한 번에 확인

쿼리

조상을 따라 올라가는 일을 DB에 맡기면 왕복이 한 번으로 줄어듭니다.

WITH RECURSIVE ancestors(id, parent_id) AS (
    SELECT id, parent_id FROM categories WHERE id = :newParentId    -- 새 부모에서 출발
    UNION
    SELECT c.id, c.parent_id
    FROM categories c
    JOIN ancestors a ON c.id = a.parent_id                          -- 한 단계 위로
)
SELECT EXISTS (SELECT 1 FROM ancestors WHERE id = :targetId)        -- 대상이 조상(또는 본인)에 있는가

UNION ALL이 아니라 UNION을 쓴 데는 이유가 있습니다. 이미 순환이 저장된 데이터가 있을 때 두 방식이 어떻게 다른지 확인했습니다(a ↔ b를 서로의 부모로 강제로 만든 뒤 실행).

방식 결과
UNION 중복 행을 제거하므로 순환 데이터에서도 종료 (결과 false)
UNION ALL 무한 재귀 → statement_timeout(1.5초)으로 QueryCanceled

검증 쿼리가 오염된 데이터 때문에 서버를 붙잡는 상황을 피할 수 있습니다.

정확성 검증

앞의 다섯 케이스를 같은 데이터에 실행해서 모두 기대 결과와 일치하는 것을 확인했습니다 (R → A → B → C와 무관한 X).

요청 CTE 결과 기대
A를 A 아래로 순환 순환
A를 B 아래로 순환 순환
A를 C 아래로 순환 순환
C를 X 아래로 순환 아님 순환 아님
B를 R 아래로 순환 아님 순환 아님

실행 계획

깊이 100인 체인에서 EXPLAIN (ANALYZE, BUFFERS) 결과의 핵심 부분입니다.

Recursive Union (actual rows=100 loops=1)
  ->  Index Scan using categories_pkey on categories (actual rows=1 loops=1)
  ->  Nested Loop (actual rows=1 loops=100)
        ->  WorkTable Scan on ancestors a (actual rows=1 loops=100)
        ->  Index Scan using categories_pkey on categories c (actual rows=1 loops=100)
Execution Time: 0.255 ms

10만 행 테이블인데도 풀 스캔 없이 PK 인덱스 탐색을 깊이만큼(loops=100) 반복할 뿐입니다. 재귀를 DB 안에서 수행하므로 왕복 비용이 사라지고, 남는 것은 인덱스 탐색 비용이며 이는 앞의 표에서 깊이 1000에서도 1.85ms였습니다.

JPA 적용 예시

public interface CategoryRepository extends JpaRepository<Category, Long> {

    @Query(nativeQuery = true, value = """
            WITH RECURSIVE ancestors(id, parent_id) AS (
                SELECT id, parent_id FROM categories WHERE id = :startId
                UNION
                SELECT c.id, c.parent_id
                FROM categories c
                JOIN ancestors a ON c.id = a.parent_id
            )
            SELECT EXISTS (SELECT 1 FROM ancestors WHERE id = :candidateId)
            """)
    boolean isSelfOrAncestor(@Param("startId") Long startId, @Param("candidateId") Long candidateId);
}
// 서비스: 검증은 쿼리에 맡기고, 엔티티는 값 변경만 담당
if (parent != null && categoryRepository.isSelfOrAncestor(parent.getId(), categoryId)) {
    throw new DomainException(DomainExceptionCode.CATEGORY_CIRCULAR_REFERENCE);
}
category.update(dto.getName(), dto.getDescription(), parent);   // validateParent 호출은 제거

트레이드오프

  • 네이티브 쿼리라 DB 종속입니다. WITH RECURSIVE 자체는 표준 SQL이지만, 표준 JPQL에는 CTE 문법이 없어 네이티브 쿼리로 작성해야 합니다.
  • 쿼리는 영속성 컨텍스트가 아니라 DB를 봅니다. 같은 트랜잭션에서 앞서 부모를 바꾸고 아직 flush하지 않았다면 그 변경은 CTE에 보이지 않습니다. 엔티티를 직접 타고 올라가는 방식은 메모리 상태를 보기 때문에 이 문제가 없습니다. 한 트랜잭션에서 여러 카테고리를 연속으로 옮기는 배치성 기능이라면 검증 전에 flush()가 필요합니다.
  • 도메인 규칙이 엔티티에서 서비스(쿼리)로 이동합니다. 불변식을 엔티티가 책임진다는 장점은 줄어듭니다.

5. 한계 ②: 동시에 수정하면 검증이 뚫린다

깊이와 별개로, 지금까지의 검증에는 근본적인 구멍이 하나 더 있습니다. 검증은 요청 시점의 DB 상태를 보고 통과 여부를 판단하는데, 그 사이에 다른 트랜잭션이 상태를 바꿀 수 있다는 점입니다.

시나리오

A와 B가 둘 다 루트일 때, 두 관리자가 동시에 서로를 부모로 지정합니다.

순서 트랜잭션 1 트랜잭션 2
1 A를 B 아래로: 검증 (B의 조상에 A 없음, 통과)
2 B를 A 아래로: 검증 (A의 조상에 B 없음, 통과)
3 UPDATE A SET parent_id = B
4 UPDATE B SET parent_id = A
5 COMMIT COMMIT

각 트랜잭션은 자기가 검증하는 시점에 상대의 변경이 커밋되지 않았으므로 문제를 보지 못하고, 갱신하는 행도 A와 B로 서로 달라서 행 락 충돌도 없습니다. 결과는 A.parent = B, B.parent = A 순환입니다.

PostgreSQL 16에서 READ COMMITTED(기본 격리 수준)로 위 순서를 그대로 실행하면 두 COMMIT이 모두 성공하고 [(1, 'A', 2), (2, 'B', 1)]이 저장됩니다.

대응 방식 비교

같은 시나리오를 방식별로 3회씩 반복 실행했습니다. 어느 쪽 트랜잭션이 성공하는지는 실행마다 달랐지만, 순환이 생기는지 여부는 매번 같았습니다.

방식 결과 비고
보호 없음 두 트랜잭션 모두 성공, 순환 발생
버전 컬럼(@Version 방식, UPDATE ... WHERE version = ?) 두 트랜잭션 모두 성공, 순환 발생 두 트랜잭션이 서로 다른 행을 수정하므로 버전 충돌이 일어나지 않음
어드바이저리 락으로 직렬화 한쪽은 순환으로 거부되고 한쪽만 성공, 순환 없음 두 번째 트랜잭션이 락을 기다렸다가 첫 번째의 커밋 결과를 보고 검증
SERIALIZABLE 격리 수준 한쪽은 SerializationFailure, 한쪽만 성공, 순환 없음 실패한 쪽은 애플리케이션이 재시도해야 함

@Version을 걸면 동시 수정이 해결될 것으로 기대하기 쉽지만, 낙관적 락은 같은 행을 동시에 고치는 충돌만 잡습니다. 이 문제는 서로 다른 행을 고치는 두 변경이 합쳐져서 불변식을 깨뜨리는 유형이라 막지 못했습니다.

적용 예시: 어드바이저리 락

계층 구조 변경 전체를 한 줄로 세우는 방법입니다. 트랜잭션이 끝나면 락이 자동으로 풀립니다.

public interface CategoryRepository extends JpaRepository<Category, Long> {

    @Query(nativeQuery = true, value = "SELECT 1 FROM (SELECT pg_advisory_xact_lock(:key)) t")
    int lockCategoryTree(@Param("key") long key);
}
@Transactional
public CategoryUpdateResponse updateCategory(Long categoryId, CategoryUpdateRequest dto) {
    categoryRepository.lockCategoryTree(CATEGORY_TREE_LOCK_KEY);   // 첫 쿼리로 락 획득
    // ... 이후 기존 로직 (검증 → 수정)
}

pg_advisory_xact_lock은 반환 타입이 void라서, 결과를 받기 쉽도록 서브쿼리로 감싸 정수 1을 반환하게 했습니다.

선택 기준

  • 카테고리 구조 변경은 어드민이 드물게 하는 작업이라면 어드바이저리 락 직렬화가 가장 단순합니다. 모든 계층 변경이 한 줄로 서므로 서브트리 단위로 락을 고민할 필요가 없습니다.
  • 변경이 잦고 지연이 문제라면 SERIALIZABLE과 재시도 로직 조합을 고려할 수 있지만, 재시도 처리와 실패율 관리가 필요합니다.
  • 이 프로젝트의 현재 구현에는 동시성 방어가 없습니다. 어드민 사용자가 소수인 환경이라 동시에 서로를 부모로 지정할 가능성이 낮다고 보고 이번 범위에는 넣지 않았으며, 운영 환경이라면 위의 락을 적용하겠습니다.

6. 다른 계층 모델링과의 비교

지금까지는 parent_id 하나만 가진 인접 리스트 모델에서 검증하는 방법을 다뤘습니다. 모델 자체를 바꾸는 선택지도 있어서 비교해 두었습니다. 이 표는 일반적인 설계 패턴과 공식 문서를 기준으로 정리한 것이고, 이 프로젝트에서 직접 구현하거나 측정하지는 않았습니다.

모델 구조 순환 검증 조상/자손 조회 부모 이동 비용 참조 무결성
인접 리스트 (현재) parent_id 컬럼 앱 로직 또는 재귀 CTE 재귀가 필요 한 행 수정 FK로 보장
Closure Table 모든 조상-자손 쌍을 별도 테이블에 저장 새 부모의 조상 집합에 자신이 있는지 단순 조회 조인 한 번 서브트리의 경로 행을 삭제 후 재삽입 FK로 보장
Materialized Path /1/4/9/ 같은 경로 문자열 저장 새 부모 경로에 자신의 id가 포함되는지 확인 경로 접두사 검색 서브트리 전체의 경로 갱신 DB가 보장하지 못함
ltree (PostgreSQL 확장) 경로 전용 타입과 인덱스, 연산자 제공 경로 포함 연산자로 확인 연산자와 인덱스로 빠름 서브트리 전체의 경로 갱신 DB가 보장하지 못함
  • 읽기(조상/자손 조회)가 압도적으로 많고 이동이 드물면 Closure Table이나 경로 기반 모델이 유리하지만, 이동 시 서브트리를 갱신해야 하고 저장 공간이나 정합성 관리 부담이 늘어납니다.
  • ltree는 확장 설치가 필요하고 JPA에서 쓰려면 커스텀 타입 매핑이 필요합니다.
  • 쓰기가 단순하고 FK 무결성이 필요하면 인접 리스트가 유리하고, 부족한 읽기 성능은 재귀 CTE로 보완하는 경우가 많습니다.

7. 정리

항목 현재 구현 한계 보완 방향
순환 검증 조상을 타고 올라가는 도메인 메서드 깊이에 비례한 DB 왕복 재귀 CTE (1회 호출)
직접 자기 참조 애플리케이션 검증 CHECK (id <> parent_id) 추가 가능
간접 순환 애플리케이션 검증 DB 제약으로는 표현 불가 애플리케이션 검증 필수
동시 수정 방어 없음 서로 다른 행을 고치는 두 요청이 검증을 함께 통과 어드바이저리 락 직렬화 또는 SERIALIZABLE

현재 프로젝트는 대/중/소 3단계이고 수정이 드문 어드민 기능이라 조상 탐색 방식으로 충분합니다. 다만 아래 조건 중 하나라도 해당하면 방식을 바꾸는 것이 맞다고 판단합니다.

  • 카테고리 깊이에 제한이 없거나 깊은 트리를 지원해야 한다 → 재귀 CTE로 전환
  • 한 트랜잭션에서 여러 노드를 연속해서 이동한다 → 엔티티 탐색은 메모리 상태를, CTE는 DB 상태를 보므로 검증 시점의 flush 여부를 함께 설계
  • 여러 사용자가 동시에 카테고리 구조를 편집한다 → 락 직렬화나 SERIALIZABLE 적용
  • 조상/자손 조회가 매우 빈번하다 → Closure Table 등 모델 변경 검토

이번에 확인한 가장 중요한 점은 @Version처럼 익숙한 안전장치가 모든 동시성 문제를 막아주지는 않는다는 것입니다. "같은 행을 동시에 수정하는 문제"와 "서로 다른 행의 변경이 합쳐져서 불변식이 깨지는 문제"는 다른 종류이고, 후자는 검증이 읽는 범위까지 보호해야 해결됩니다.

부록: 재현 방법

스키마와 데이터 (깊이 100 체인, PostgreSQL)

CREATE TABLE categories (
    id        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name      VARCHAR(100) NOT NULL,
    parent_id BIGINT REFERENCES categories(id)
);

INSERT INTO categories(name) SELECT 'f' || g FROM generate_series(1, 100000) g;  -- 무관한 행 10만 개

DO $$
DECLARE prev BIGINT := NULL;
BEGIN
  FOR i IN 1..100 LOOP
    INSERT INTO categories(name, parent_id) VALUES ('n' || i, prev) RETURNING id INTO prev;
  END LOOP;
END $$;

INSERT INTO categories(name) VALUES ('target');

재귀 CTE 실행

SELECT max(id) AS tail_id FROM categories WHERE name LIKE 'n%' \gset
SELECT id AS target_id FROM categories WHERE name = 'target' \gset

EXPLAIN (ANALYZE, BUFFERS)
WITH RECURSIVE ancestors(id, parent_id) AS (
    SELECT id, parent_id FROM categories WHERE id = :tail_id
    UNION
    SELECT c.id, c.parent_id FROM categories c JOIN ancestors a ON c.id = a.parent_id
)
SELECT EXISTS (SELECT 1 FROM ancestors WHERE id = :target_id);

동시 수정 재현 (psql 세션 2개)

A, B가 모두 루트인 상태에서, 두 세션에서 아래를 순서대로 실행합니다. 각 세션에서 먼저 \set a <A의 id>, \set b <B의 id>로 변수를 지정합니다.

-- 세션 1                                   -- 세션 2
BEGIN;                                      BEGIN;
-- A를 B 아래로: B의 조상에 A가 있는가?
WITH RECURSIVE anc(id, parent_id) AS (
  SELECT id, parent_id FROM categories WHERE id = :b
  UNION
  SELECT c.id, c.parent_id FROM categories c JOIN anc a ON c.id = a.parent_id)
SELECT EXISTS (SELECT 1 FROM anc WHERE id = :a);   -- false
                                            -- B를 A 아래로: A의 조상에 B가 있는가?
                                            WITH RECURSIVE anc(id, parent_id) AS (
                                              SELECT id, parent_id FROM categories WHERE id = :a
                                              UNION
                                              SELECT c.id, c.parent_id FROM categories c JOIN anc a ON c.id = a.parent_id)
                                            SELECT EXISTS (SELECT 1 FROM anc WHERE id = :b);   -- false
UPDATE categories SET parent_id = :b WHERE id = :a;
                                            UPDATE categories SET parent_id = :a WHERE id = :b;
COMMIT;                                     COMMIT;

SELECT id, name, parent_id FROM categories WHERE id IN (:a, :b);   -- 서로를 부모로 가리킴

격리 수준을 BEGIN ISOLATION LEVEL SERIALIZABLE;로 바꾸면 두 번째 COMMIT에서 could not serialize access due to read/write dependencies among transactions 오류가 나는 것을 확인할 수 있습니다.

문자열 비교의 함정 시리즈 · 1편

월 합계가 일 합계보다 작다. 쿼리는 멀쩡해 보이는데, 말일 하루치가 통째로 사라진다. 원인은 BETWEEN 한 줄에 숨어 있는 자릿수 불일치다. 이 글은 가상의 예제로 문제를 재현하고, 고치는 방법과 그 방법을 고르는 기준, 재발을 막는 검증 방법까지 정리한다.

예제의 테이블과 데이터는 모두 설명을 위해 만든 가상의 것이다. 아래 실행 결과는 MariaDB 10.11에서 직접 실행한 값이다.


요약

  • 증상: 10분 단위 테이블에서 구한 월 합계(300)가 일 단위 요약의 합계(600)보다 작다
  • 원인: yyyyMMddHHmmss 14자리 문자열 컬럼을 yyyyMMdd 8자리 값으로 BETWEEN 비교하면, 앞 8자리가 같을 때 더 긴 문자열이 더 커서 말일 전체가 범위 밖이 된다
  • 해결: 상한을 "말일"이 아니라 "다음 달 1일(미포함)"로 두는 반열린 구간(>= from AND < to)을 쓴다
  • 검증: 서로 다른 계층(10분 → 일 → 월)의 합계를 대조하는 쿼리를 두면 같은 종류의 누락을 바로 잡을 수 있다

1. 상황 설정

이벤트를 10분 단위로 집계한 테이블과, 이를 일 단위로 요약한 테이블이 있다고 하자. 날짜는 문자열 컬럼에 yyyyMMddHHmmss 형식으로 저장한다.

CREATE TABLE event_10min (
  slot CHAR(14) NOT NULL COMMENT 'yyyyMMddHHmmss',
  cnt  INT      NOT NULL,
  PRIMARY KEY (slot)
);

-- 4월 29일 마지막 슬롯, 4월 30일 슬롯 3개, 5월 1일 첫 슬롯 등 (건수는 모두 100)
INSERT INTO event_10min VALUES
 ('20260401000000', 100), ('20260415120000', 100), ('20260429235000', 100),
 ('20260430000000', 100), ('20260430120000', 100), ('20260430235000', 100),
 ('20260501000000', 100);

-- 일 단위 요약 (day 는 8자리)
CREATE TABLE event_daily (day CHAR(8) NOT NULL, cnt INT NOT NULL, PRIMARY KEY (day));
INSERT INTO event_daily
SELECT LEFT(slot, 8), SUM(cnt) FROM event_10min GROUP BY LEFT(slot, 8);

4월 한 달의 합계를 구하는 쿼리를 이렇게 썼다.

2. 증상: 월 합계가 일 합계보다 작다

-- 10분 단위 테이블에서 4월 합계
SELECT SUM(cnt) AS april_total FROM event_10min
WHERE slot BETWEEN '20260401' AND '20260430';
+-------------+
| april_total |
+-------------+
|         300 |
+-------------+
-- 일 단위 요약에서 4월 합계
SELECT SUM(cnt) AS daily_sum FROM event_daily
WHERE day BETWEEN '20260401' AND '20260430';
+-----------+
| daily_sum |
+-----------+
|       600 |
+-----------+

같은 4월인데 300과 600이다. 두 숫자 가운데 어느 쪽이 맞는지는, 일 단위 요약의 일별 값을 직접 합쳐 보면 확인할 수 있다(4월에 100건짜리 슬롯이 6개 있으니 600이 맞다).

여기서 중요한 단서가 하나 있다.

일 단위 요약은 맞고, 10분 단위 테이블에서 구한 값만 틀리다. 그리고 모자란 양(300)은 정확히 하루치(4/30의 슬롯 3개)다.

3. 원인: 자릿수가 다른 문자열의 비교

문자열 비교는 앞에서부터 한 글자씩 본다. 앞부분이 같으면 더 긴 문자열이 더 크다.

SELECT '20260430000000' <= '20260430' AS a,
       '20260429235000' <= '20260430' AS b;
+---+---+
| a | b |
+---+---+
| 0 | 1 |
+---+---+
  • '20260429235000' <= '20260430' → 4/29 행은 상한보다 작으니 포함
  • '20260430000000' <= '20260430' → 4/30 00:00:00조차 앞 8자리가 같고 더 길어서 상한보다 크다 → 범위 밖

BETWEEN이 "상한을 포함한다"는 사실은 두 값의 형식이 같을 때만 의미가 있다. 상한이 8자리 '20260430'이면 4/30의 14자리 값은 하나도 들어오지 못한다.


그림 1. 상한값이 8자리라서 4/30의 14자리 값이 전부 범위 밖으로 밀려난다.

어느 날이 빠졌는지는 다음 쿼리로 바로 찾을 수 있다.

SELECT LEFT(slot, 8) AS day, SUM(cnt) AS cnt
FROM event_10min
WHERE LEFT(slot, 8) BETWEEN '20260401' AND '20260430'     -- 날짜만 잘라서 비교
  AND NOT (slot BETWEEN '20260401' AND '20260430')        -- 원래 조건에서는 빠지는 행
GROUP BY LEFT(slot, 8);
+----------+------+
| day      | cnt  |
+----------+------+
| 20260430 |  300 |
+----------+------+

4. 해결: 같은 자릿수의 반열린 구간

상한을 "말일"이 아니라 "다음 달 1일의 시작(미포함)"으로 바꾼다.

SELECT SUM(cnt) AS april_total FROM event_10min
WHERE slot >= '20260401000000' AND slot < '20260501000000';
+-------------+
| april_total |
+-------------+
|         600 |
+-------------+

참고로 8자리 값으로 반열린 구간을 써도 접두어 비교 덕분에 600이 나온다(slot >= '20260401' AND slot < '20260501'). 하지만 형식을 맞춰 쓰는 편이 읽는 사람도, 나중에 형식이 바뀔 때도 안전하다.

애플리케이션에서는 경계를 이렇게 계산한다.

static final DateTimeFormatter DAY = DateTimeFormatter.BASIC_ISO_DATE;   // yyyyMMdd

record Range(String from, String toExclusive) {}

static Range monthRange(YearMonth month) {
    String from = month.atDay(1).format(DAY) + "000000";                       // 20260401000000
    String to   = month.plusMonths(1).atDay(1).format(DAY) + "000000";         // 20260501000000
    return new Range(from, to);
}

경계가 까다로운 연말과 윤년 2월도 같은 코드로 처리된다.

2026-04  →  from=20260401000000, toExclusive=20260501000000
2026-12  →  from=20261201000000, toExclusive=20270101000000
2028-02  →  from=20280201000000, toExclusive=20280301000000

왜 하필 반열린 구간인가

  • 포함/미포함이 명확하다. "말일 23:59:59를 포함할까, 초 이하는?" 같은 고민이 사라진다.
  • 구간이 겹치지도 비지도 않는다. 4월의 상한(미포함)이 5월의 하한(포함)과 정확히 같아서, 월을 이어 붙여도 누락·중복이 생기지 않는다.
  • 상한 계산이 쉽다. 말일 대신 plusMonths(1).atDay(1)만 쓰면 되니 28/29/30/31일을 따질 필요가 없다.

5. 다른 해결 선택지와 비교

방법 장점 단점 / 주의
같은 자릿수 + 반열린 구간 (권장) 스키마 변경 없이 바로 적용, 인덱스 활용 가능 문자열 형식에 계속 의존한다
LEFT(slot, 8) BETWEEN ... 쿼리가 직관적이다 컬럼에 함수를 씌우면 일반적으로 인덱스를 쓰지 못한다
BETWEEN '...000000' AND '...235959' 형식은 맞는다 초 이하(밀리초) 정밀도로 바뀌면 다시 누락되고, 상한이 하드코딩된다
컬럼 타입을 DATETIME으로 변경 날짜 비교가 타입 수준에서 안전하다 데이터 이관과 모든 호출부의 변경이 필요하다

운영 중인 시스템에서 당장 고쳐야 한다면 첫 번째 방법이 변경 범위가 가장 작다. 새로 설계한다면 처음부터 날짜 타입을 쓰는 게 가장 좋다.

6. 재발을 막는 검증

이런 버그는 쿼리가 에러 없이 정상 동작해서 눈에 띄지 않는다. 사람이 이상하다고 말하기 전에 잡으려면 서로 다른 계층의 합계를 대조하는 쿼리가 있어야 한다.

-- 일 단위 요약 vs 10분 단위 원본 (월 기준)
SELECT d.day_sum, m.min_sum, d.day_sum - m.min_sum AS diff
FROM (SELECT SUM(cnt) AS day_sum FROM event_daily
       WHERE day >= '20260401' AND day < '20260501') d,
     (SELECT SUM(cnt) AS min_sum FROM event_10min
       WHERE slot >= '20260401000000' AND slot < '20260501000000') m;
-- diff 가 0 이 아니면 어느 계층에서 새고 있다는 신호

경계 계산 함수는 단위 테스트로 고정해 둔다. 말일, 연말, 윤년이 테스트 케이스의 핵심이다.

@Test
void monthRange_handlesYearEndAndLeapYear() {
    assertEquals(new Range("20261201000000", "20270101000000"), monthRange(YearMonth.of(2026, 12)));
    assertEquals(new Range("20280201000000", "20280301000000"), monthRange(YearMonth.of(2028, 2)));
}

마무리

BETWEEN은 읽기 쉬워서 안심하게 되지만, 비교되는 두 값의 형식이 다르면 "범위"라는 직관이 깨진다. 문자열은 비교되는 순간에 의미가 정해진다. 다음 편에서는 같은 맥락으로, 대소문자를 구분하지 않는 DB와 구분하는 Java 사이에서 숫자가 사라지는 문제를 다룬다.

'Backend > DB | SQL' 카테고리의 다른 글

문자열 비교의 함정 시리즈 2편  (0) 2026.10.20

통계 정합성 시리즈 1/3 · 1편 월 통계 말일 누락 (이 글) · 2편 대소문자 2건이 만든 277만 건 표시 오류 · 3편 리포트 분류 기준 통합

고객사가 "월 통계와 일 통계의 숫자가 다르다"고 문의해 왔다. 원인은 SQL의 BETWEEN 한 줄이었다. 하지만 이 글은 그 한 줄을 찾은 이야기보다, 한 줄을 고치는 데서 멈추지 않은 이유와 이미 정산이 끝난 과거 숫자를 일부러 고치지 않은 이유를 정리한 기록이다.

이 글의 코드·테이블·컬럼·API 이름은 개념 설명을 위해 단순화·일반화한 예시이며, 실제 코드가 아니다.


요약

  • 증상: 월 통계와 일 통계(정산 근거)의 성공 건수가 다르다. 특정 고객사의 4월 기준 2,384건 차이
  • 원인: 14자리 날짜 컬럼을 8자리 문자열로 BETWEEN 비교해서 말일 하루치가 통째로 집계에서 누락
  • 해결: 집계 기준 계층을 일원화하고, 반열린 구간으로 경계를 바꾸고, 배치의 트랜잭션·스케줄을 정리하고, 재집계 API를 만들었다
  • 판단: 이미 정산이 끝난 4~6월 데이터는 일부러 고치지 않았다. 데이터의 절대적 정확성보다 확정된 회계 기록과의 일관성을 택했다
항목 내용
기간 2026.05 ~ 2026.08
역할 원인 분석 · 수정 · 검증 (백엔드), 정산 부서와 소급 범위 협의
기술 Java 11, Spring, MyBatis, MariaDB, ShedLock
키워드 데이터 정합성, 배치, 날짜 경계, 반열린 구간, 트랜잭션, 재집계

0. 먼저, 숫자가 흘러가는 길

담당 서비스의 이용 통계는 이력 원본에서 출발해 세 단계 테이블을 거치고, 두 곳으로 나간다.

이력 원본 ──► STAT_10MIN (10분 단위) ──► STAT_DAILY (일 단위) ──► STAT_MONTHLY (월 단위)
                                              │                        │
                                      정산 시스템의 근거 데이터     관리 화면의 월별 통계
                                                                    월간 리포트 메일

같은 기간의 숫자가 서로 다른 두 경로로 나가기 때문에, 어느 한쪽이 어긋나면 반드시 누군가는 그 차이를 발견하게 된다. 이 사건은 그렇게 발견됐다.

1. 증상

고객사가 관리 화면의 월별 통계(STAT_MONTHLY)에서 본 검증 성공 건수와, 정산에 쓰이는 STAT_DAILY의 건수가 다르다고 문의했다. 운영팀이 두 테이블을 직접 비교해 실제로 다르다는 것을 확인했고, 개발 쪽에 개선이 요청됐다.

  • 2026년 4월 집계분부터 두 값이 어긋나기 시작했다
  • 특정 고객사의 4월 성공 건수 차이는 2,384건이었다
  • 우선순위가 높은 다른 작업에 밀려, 대응 개발은 6월 말에야 착수할 수 있었다

2. 원인을 좁힌 결정적 단서

어긋난 쪽은 STAT_MONTHLY였다. STAT_DAILY는 정산이 이미 기준으로 쓰고 있었고 문제가 없었다. 그런데 이상한 점이 하나 있었다.

이미 끝난 달(4월 등)은 어긋나는데, 아직 말일이 지나지 않은 6월은 일치했다.

차이가 나는 달과 나지 않는 달의 유일한 차이는 "말일이 지났는가"였다. 이 관찰 하나로 가설이 "말일 데이터가 월 집계에서 빠진다"로 좁혀졌다. 검증은 일 단위 합계와 월 단위 값을 고객사별로 대조하는 쿼리로 했다 (단순화한 예시).

SELECT d.CUSTOMER_ID, d.day_success, m.month_success,
       d.day_success - m.month_success AS success_diff
FROM (SELECT CUSTOMER_ID, SUM(SUCCESS_CNT) AS day_success
        FROM STAT_DAILY
       WHERE FROM_DATE BETWEEN '20260401' AND '20260430'
       GROUP BY CUSTOMER_ID) d
JOIN (SELECT CUSTOMER_ID, SUM(SUCCESS_CNT) AS month_success
        FROM STAT_MONTHLY
       WHERE FROM_DATE = '202604'
       GROUP BY CUSTOMER_ID) m USING (CUSTOMER_ID);
-- 대상 고객사 행에서 success_diff = 2384

3. 근본 원인 — 자릿수가 다른 문자열의 BETWEEN

STAT_MONTHLY를 만드는 집계 쿼리는 10분 단위 원본인 STAT_10MIN을 읽으면서 이런 조건을 쓰고 있었다.

FROM STAT_10MIN
WHERE FROM_DATE BETWEEN '20260401' AND '20260430'

문제는 두 값의 자릿수다. STAT_10MIN.FROM_DATE는 yyyyMMddHHmmss 14자리인데 파라미터는 yyyyMMdd 8자리로 들어간다. 문자열 비교에서는 앞 8자리가 같으면 자릿수가 긴 쪽이 더 크다.

'20260430000000' <= '20260430'   →  거짓 (범위 밖)

말일 00:00:00 이후의 모든 행이 상한값보다 커지면서 말일 하루치가 통째로 빠졌다. BETWEEN이 상한을 포함한다는 사실은 자릿수가 다르면 아무 의미가 없었다.


그림 1. 4월 집계에서 4/30 전체가 상한값 비교에서 탈락한다.

STAT_DAILY는 일 단위(8자리)라 같은 문제가 생기지 않았고, 그래서 정산은 정상이었다. 불일치가 월 통계에서만 나타난 이유다.

4. 원인을 보다가 함께 발견한 것들

한 줄짜리 버그를 찾으러 들어갔지만, 주변 코드를 읽다 보니 같은 배치에 문제가 더 있었다.

  • 일 배치와 월 배치가 같은 시각에 실행되어 같은 테이블을 두고 경합
  • 월 집계가 STAT_DAILY가 아닌 STAT_10MIN을 참조 — 집계 기준 계층이 이원화
  • 트랜잭션을 연 뒤 early return 경로에서 commit()/rollback()이 호출되지 않음
  • 마지막 갱신 시각이 null인 초기 상태에서 NPE 가능
  • 배치 소요 시간 로그가 없어 스케줄을 조정할 근거가 없음

5. 해결

구분 조치
집계 기준 월 집계의 참조 테이블을 STAT_10MIN → STAT_DAILY로 변경. 10MIN → DAILY → MONTHLY 단일 계층 확립
날짜 경계 BETWEEN → 반열린 구간(>= from AND < to). 애플리케이션의 상한 계산도 함께 바꿔 경계 정의를 일치
스케줄 분리 월 배치를 일 배치와 겹치지 않는 시각으로 이동, 종료 시점·소요 시간 로그 추가
트랜잭션 안정화 모든 early return 경로에 commit() 추가, null 분기 처리, 통계 타입을 enum으로 정리
재집계 API 기간을 지정해 delete → insert 하는 수동 재집계 API 신규 개발

핵심 변경 (단순화한 예시)

<!-- 참조 테이블 변경(STAT_10MIN → STAT_DAILY) + BETWEEN 대신 반열린 구간 -->
<insert id="insertMonthlyStat">
    INSERT INTO STAT_MONTHLY (...)
    SELECT #{fromDate}, {구분 키 컬럼들}, SERVICE_TYPE
         , SUM(REQ_CNT), SUM(SUCCESS_CNT), SUM(INCOMPLETE_CNT), ...
    FROM STAT_DAILY
    WHERE FROM_DATE >= #{statFromDate} AND FROM_DATE &lt; #{statToDate}
    GROUP BY {구분 키 컬럼들}, SERVICE_TYPE
</insert>
// 상한은 "말일"이 아니라 "다음 달 1일(미포함)"로 정의한다
// 변경 전: month.atEndOfMonth()
LocalDate statToDate = month.plusMonths(1).atDay(1);

// 일 배치와 겹치지 않도록 실행 시각 분리 (시각·락 시간은 예시 값)
@Scheduled(cron = "0 30 1 * * *")
@SchedulerLock(name = "monthlyStatJob", lockAtMostFor = "PT10M")
protected void monthlyStatJob() {
    long start = System.currentTimeMillis();
    statService.runMonthly();
    log.info("monthly stat job end. elapsed={}ms", System.currentTimeMillis() - start);
}

// 트랜잭션 종료가 누락되던 경로 보완
if (firstTx == null) {
    log.debug("no data to aggregate");
    transactionManager.commit(transactionStatus);   // 추가
    return;
}

재집계 API에서 가장 신경 쓴 설계는 배치의 락·상태 테이블을 건드리지 않는 것이었다. 기존에는 과거 데이터를 다시 계산하려면 운영자가 상태 테이블의 마지막 갱신 시각을 직접 UPDATE해야 했다. 이 방식은 정기 배치의 진행 상태를 오염시키고 되돌리기도 어렵다. 새 API는 파라미터를 검증하고, 지정한 기간의 데이터만 delete → insert 하며, 실패하면 rollback 후 예외를 전파한다.

6. 기술적 의사결정

① @Transactional을 쓰지 않고 수동 트랜잭션을 유지했다.
가장 깔끔해 보이는 선택지였지만 적용하지 않았다.

  • 기존 코드가 catch에서 예외를 삼키고 있어서, 프록시 기반 AOP는 이를 정상 종료로 보고 commit한다. 어노테이션만 붙이면 오히려 잘못된 데이터가 커밋될 수 있었다.
  • finally에서 예외 여부와 무관하게 상태를 갱신하는데, 같은 트랜잭션으로 묶이면 이 갱신까지 롤백된다. 분리하려면 구조를 크게 바꿔야 했다.

운영 배치의 리스크에 비해 변경 범위가 과하다고 판단해, 누락된 종료 경로만 명시적으로 보완하는 최소 침습 수정을 택했다.

② 월 집계의 기준을 STAT_10MIN이 아닌 STAT_DAILY로 정했다.
"가장 상세한 원본에서 매번 재계산한다"는 원래 설계 의도도 이해는 됐다. 하지만 정산이 이미 STAT_DAILY를 신뢰의 기준으로 쓰고 있었다. 집계 기준을 하나로 통일하는 것이 정밀도보다 우선이라고 판단했다. 서로 다른 소스에서 계산된 두 숫자는 언제든 다시 어긋날 수 있기 때문이다.

③ 과거 데이터를 소급 재계산하지 않기로 했다.
기술적으로는 4~6월 전체를 다시 집계할 수 있었다. 그러나 그 기간은 정산이 이미 끝난 구간이었다. 정산 부서와 협의해 범위를 나눴다.

  • 4~6월: 기존(버그 포함) 데이터를 유지 — 정산 정합성 우선
  • 7월 이후: 패치 버전 기준으로 통계·정산 진행

데이터의 절대적 정확성보다 이미 확정된 회계 기록과의 일관성을 택한 트레이드오프다. 대신 기준일을 명확히 공유해서 이후 데이터는 정확하도록 했다.

④ 발견했지만 바로 고치지 않은 것 — INCOMPLETE_CNT.
집계 쿼리를 분석하다가 INCOMPLETE_CNT가 이름과 달리 결과 코드 조건 없이 전체 요청 건수처럼 집계되고 있음을 발견했다. 그런데 이 값은 월간 사용현황 메일에 그대로 노출되고 있었다. 코드를 먼저 고치면 대외 보고 숫자가 바뀐다. 업무 정의 확인을 선행 과제로 분리하고 별도 커밋으로 미뤘다. (이 컬럼은 몇 달 뒤 월간 리포트 이슈에서 다시 등장한다.)

⑤ 커밋을 성격별로 나눴다. 문제가 생겼을 때 되돌릴 범위를 좁히기 위해서다.

커밋 1: 일별/월별 집계 기준 및 날짜 경계 수정
커밋 2: 트랜잭션 / null / 파싱 안정성 수정
커밋 3: 10분 통계 중복 insert 방지
커밋 4: INCOMPLETE_CNT 업무 정의 반영

7. 테스트 코드가 없는 환경에서 어떻게 검증했나

프로젝트에 테스트 코드가 없었다. 그래서 SQL 결과를 직접 대조하는 방식으로 검증을 설계했다.

  1. _TEMP 테이블에서 기존 쿼리 vs 신규 쿼리의 결과를 만든다
  2. 10MIN → DAILY → MONTHLY 3계층을 각각 합산해 세 값이 일치하는지 본다
  3. 로컬 → 개발 서버 호출 → 스테이징 배포 후 담당자 검증 → 운영 반영 순으로 단계적으로 넓힌다
  4. 운영 반영 뒤 같은 대조를 다시 수행하고 증적을 문서로 남긴다

8. 결과

  • 특정 고객사 4월 기준 STAT_DAILY ↔ STAT_MONTHLY 성공 건수 차이 2,384건 → 0건
  • 날짜 경계를 반열린 구간으로 통일해 자릿수 불일치로 누락되는 구조 자체를 제거
  • 트랜잭션 미종료 경로와 NPE 지점 제거, 배치 스케줄 충돌 해소, 소요 시간 로그로 이후 스케줄 조정의 근거 확보
  • 재집계 API와 인수인계 가이드를 제공해 상태 테이블을 직접 수정하던 보정 작업을 API 호출 한 번으로 대체

9. 이 사건에서 얻은 것

  • 문자열은 비교되는 순간 의미가 정해진다. 같은 "날짜"라도 자릿수가 다르면 BETWEEN은 다른 뜻이 된다. 경계는 포함/미포함이 명확한 반열린 구간으로 정의한다.
  • 숫자를 만드는 경로는 하나여야 한다. 서로 다른 소스에서 계산된 두 숫자는 언제든 다시 어긋난다.
  • 고칠 수 있는 것과 고쳐도 되는 것은 다르다. 과거 정산 데이터, 대외 보고 숫자처럼 기술적으로는 고칠 수 있지만 조직이 감당할 수 없는 수정은 범위에서 분리했다.

그런데 이 사건이 정리된 뒤, 이번에는 월간 리포트에서 약 277만 건이 0으로 표시되는 더 이상한 문제가 접수됐다. 원인은 단 2건의 대소문자 다른 데이터였다. 다음 글에서 이어진다.

iOS에서만 CAPTCHA 숫자가 항상 고정 숫자로 고정되고, 새로고침도 먹통이 되는 버그를 만났다. 그런데 정작 서버에도 프론트에도 실패했다는 로그가 단 한 줄도 없었다. 이 글은 "어디서 왜 실패하는지 아무도 모르는" 상태를, 버그를 고치기 전에 관측 가능한 상태로 먼저 바꾸고, 그렇게 확보한 로그로 실제 근본 원인까지 규명한 과정을 정리한 기록이다.


TL;DR

  • 증상: 담당 서비스의 인증 팝업에서 CAPTCHA 보안문자가 항상 고정 숫자로만 노출되고, 새로고침 버튼이 동작하지 않는 것처럼 보임
  • 함정: 사용자가 화면에 보이는 고정 숫자를 입력하면 "인증번호 불일치"가 뜬다 → 그런데 어디서도 실패 로그가 안 남는다
  • 판단: 원인 후보가 5개 이상이고 재현도 안 되는 상황에서 추측으로 코드부터 고치지 않았다. 대신 실패 지점 8곳에 구조화 로그를 심어 "관측 가능한 문제"로 먼저 전환했다 (Phase 1)
  • 결말: 확보한 운영 로그를 근거로, "iOS 단말 문제"로 여겨지던 이 이슈가 사실은 프론트 HTML에 하드코딩된 정적 더미 이미지가 요청 실패 시 그대로 노출되는 구조적 결함이라는 것을 규명했다 (Phase 2). 1년 가까이 원인 미상으로 남아있던 이슈였다.

1. 문제 발견

담당 서비스의 인증 팝업(웹뷰 기반 인증 페이지)에서 CAPTCHA 입력 단계 불만이 CS로 들어오기 시작했다. 증상을 재정리하면 이랬다.

  • iOS 환경에서 진행 시 CAPTCHA 보안문자 이미지가 항상 고정 숫자로만 보인다
  • 새로고침 버튼을 눌러도 다른 숫자로 바뀌지 않는다 (동작하지 않는 것처럼 보임)
  • 화면에 보이는 고정 숫자를 그대로 입력하면 "인증번호가 일치하지 않습니다" 메시지가 뜨고 인증이 끝나지 않는다
  • 심지어 기기를 재부팅하거나 앱을 재설치해도 동일한 증상이 반복된다는 CS도 있었다

숫자가 "고정"되어 보인다는 것부터가 이상했다. CAPTCHA는 매 요청마다 서버가 새로 발급해서 Redis에 저장하는 구조였기 때문에, 정상적으로 동작한다면 같은 값이 반복해서 뜰 이유가 없었다.

원인 후보를 팀 논의에서 여러 개 올렸다.

  • 프론트 캐싱 문제 (이미지가 캐시되어 안 바뀌는 것 아닌가)
  • 랜덤 시드 문제 (서버 쪽 난수 생성이 고장난 것 아닌가)
  • 세션/쿠키 문제 (iOS WebView의 쿠키 정책 때문 아닌가)
  • 레이스 컨디션 (요청이 겹쳐서 이전 값이 남는 것 아닌가)
  • 네트워크/WebView 자체 문제 (iOS 인앱 브라우저 특유의 제약 아닌가)

이 시점에서 나온 후보만 5개. 그런데 이걸 하나씩 검증할 방법이 없었다.

2. 재현 시도가 막힌 이유

가장 먼저 한 일은 당연히 재현이었다. 그런데 스테이징에서도, 로컬에서도 이 증상이 재현되지 않았다. iOS 특정 버전 + 특정 인앱 브라우저(네이버 앱 등) 조합에서만 산발적으로 발생하는 것으로 추정됐는데, 사내에서 그 조합의 실 기기를 확보하기도 어려웠고, 확보해도 실패가 매번 재현되는 게 아니었다.

더 큰 문제는 따로 있었다. 서버에도 프론트에도 이 흐름에 대한 실패 로그가 단 한 줄도 없었다. 서비스운영파트에 문의해도 "그냥 새로고침이 안 된다"는 사용자 진술 이상의 정보가 없었다. 즉:

  • 네트워크 / 서버 응답 / Blob 변환 / 이미지 반영 중 어느 단계에서 실패했는지 알 수 없었다
  • 왜 iOS에서만 발생하는지 알 수 없었다
  • 애초에 요청이 서버까지 도달은 했는지조차 알 수 없었다

재현도 안 되고, 로그도 없고, 원인 후보는 5개. 여기서 흔히 하는 실수가 "그럴듯한 후보 하나를 골라서 고치고 배포해보는 것"이다. 나는 그렇게 하지 않기로 했다.

3. 원인 후보 배제 과정

먼저 CAPTCHA 이미지가 어떻게 화면에 뜨는지부터 코드를 역추적했다. 구조는 이랬다.

  1. 인증창 HTML은 <img> 태그의 초기값으로 디자인용 플레이스홀더 이미지를 넣어둔다
  2. 페이지 로드 시 /gui/popup/captcha/image 로 요청을 보내고, 성공하면 응답으로 받은 이미지로 <img> 의 src를 교체한다

여기까지 보고 나서 의심 후보 중 "캐싱", "레이스 컨디션" 두 개는 코드상 자연스럽게 배제됐다 — 애초에 캐시 제어 헤더나 동시 요청을 막는 로직 자체가 없어서, 캐싱/레이스 컨디션이 원인이라면 오히려 더 다양한 값이 보였어야 했다.

그리고 진짜 문제를 찾았다. 이미지 교체 로직 자체에 실패를 감지하는 코드가 없었다.

// 문제 코드: process.js
requestImage: function (url, data, element) {
    var request = new XMLHttpRequest();
    request.open("POST", url, true);
    request.responseType = "blob";

    request.onload = function () {          // HTTP 상태 코드 미검사
        var reader = new FileReader();
        reader.onloadend = function () {     // onerror 미정의
            element.src = reader.result;
        }
        reader.readAsDataURL(this.response);
    }
    // onerror / onabort / ontimeout 전부 없음
    request.send(JSON.stringify(data))
}

이 함수에는 다음이 전부 빠져 있었다.

  • HTTP 상태 코드 확인 (onload는 4xx/5xx가 와도 그냥 실행된다)
  • onerror, onabort, ontimeout 핸들러
  • 응답 MIME 타입·크기 검증
  • 실패 시 UI 표시나 재시도 로직
  • 중복 요청/응답 순서 제어

즉, iOS에서 XHR이 실패하거나 중단되면 아무 표시도 없이 초기 플레이스홀더 이미지가 그대로 남는 구조였다. 새로고침 버튼도 같은 요청을 다시 보내고 똑같이 실패를 무시하니 "버튼이 안 먹는다"처럼 보인 것도 자연스럽게 설명됐다. 그리고 서버는 실제 정답을 Redis에 별도로 저장해두는 구조였으니, 사용자가 화면의 고정 숫자를 입력하면 당연히 "불일치"가 날 수밖에 없었다. 버그가 아니라 버그의 증상만 보고 있었던 것.

여기까지 오면 "그럼 이 코드만 고치면 되는 거 아니야?" 라는 생각이 들 수 있다. 실제로 나도 그랬다. 하지만 한 가지가 걸렸다.

왜 iOS에서만 XHR이 실패하는지는 여전히 알 수 없었다. 코드를 고쳐도, 그게 "진짜 원인"을 없앤 건지 "증상 하나"만 가린 건지 검증할 방법이 없었다.

4. Phase 1 — "고치기" 전에 "보이게 하기"

이게 이 이슈에서 가장 중요한 판단이었다고 생각한다. 원인 후보가 여러 개고 재현도 안 되는 상태에서 코드를 추측성으로 고치면, 고쳐졌는지조차 확인할 수 없다. 그래서 운영팀에는 이렇게 명확히 전달했다.

현재 소스 코드 상 해당 과정에서 에러 내용과 원인을 기록하는 로직이 없어, 과거 사례의 구체적인 실패 원인과 iOS에서만 발생한 이유는 현시점에서 확인하기 어렵습니다. 따라서 현재 할 수 있는 것은 구체적인 에러 내용과 원인을 알 수 있는 로직을 추가하여 배포하는 것입니다.

그래서 대응을 2단계(Phase)로 나눴다.

Phase 목표 상태
Phase 1 실패 지점별 진단 로그 확보 (관측 가능성 확보) 완료
Phase 2 확보한 로그 분석 → 원인 확정 → 대응 개발 원인 확정 완료, 코드 수정은 CS 재인입 시 진행

CAPTCHA 처리 흐름을 다시 그려보면 실패가 날 수 있는 지점이 서버 4곳, 프론트 4곳, 총 8곳이었다.


그림 1. iOS WebView → auth-api-server → Redis 로 이어지는 요청 흐름에서 실패가 발생할 수 있는 8개 지점. Phase 1은 이 8곳 각각에 구조화 로그를 심는 작업이었다.

4-1. 백엔드 — 서버 쪽 4개 실패 지점

공통 접두어 [CAPTCHA] 와 phase= 키를 쓰는 구조화 로그 포맷을 정했다.

phase=request-failed       # 요청 처리 실패 (예외 종류, 응답 커밋 여부, 소요 시간)
phase=image-write-failed   # 응답 스트림 이미지 쓰기 실패 (Broken pipe 등)
phase=redis-save-failed    # 생성한 정답의 Redis 저장 실패
phase=validation-failed    # 입력값 검증 실패 (Redis 조회 성공 여부 포함)

까다로웠던 건 image-write-failed 쪽이었다. 응답 스트림에 이미지를 쓰다가 나는 IOException (클라이언트가 연결을 끊는 등)은 일반적인 컨트롤러 레벨 try-catch로는 잡히지 않는다. 그래서 ServletOutputStream을 위임(delegate) 패턴으로 감싸서, 실제 write() 호출 시점의 예외를 가로채는 래퍼(CaptchaResponseTracker)를 만들었다.

// 응답 스트림에 실제로 쓰는 시점의 실패를 잡는 래퍼
@Override
public void write(byte[] value, int offset, int length) throws IOException {
    try {
        delegate.write(value, offset, length);
    } catch (IOException exception) {
        tracker.recordWriteFailure(exception);
        throw exception;
    }
}

4-2. 프론트 — 클라이언트 쪽 4개 실패 지점

requestImage()에 상태 코드 검사와 실패 핸들러를 추가하고, 실패 유형을 4가지 에러 코드로 분류했다.

CAPTCHA_IMAGE_HTTP_FAILED       # 404, 500, 502, 503 등 HTTP 응답 오류
CAPTCHA_IMAGE_READ_FAILED       # 응답은 받았으나 FileReader가 Blob 읽기 실패
CAPTCHA_IMAGE_REQUEST_FAILED    # 서버 연결 불가, DNS/CORS/TLS/네트워크 단절
CAPTCHA_IMAGE_REQUEST_ABORTED   # 페이지 이동, 인증창 종료, abort() 호출

4-3. 클라이언트 로그를 서버로 끌어오기

브라우저에서 난 실패는 당연히 서버 로그에 안 남는다. 그래서 클라이언트 오류를 서버 로그로 전달하는 엔드포인트를 하나 신설했다.

@PostMapping("/popup/log/front-error")
public String logFrontError(@RequestBody String request) {
    FrontErrorLogRequest req = GsonUtil.GSON.fromJson(request, FrontErrorLogRequest.class);
    log.warn("[CAPTCHA_CLIENT] {}", MaskingUtil.doMasking(GsonUtil.GSON.toJson(req)));
    return "{\"resultCode\":\"2000\",\"resultMsg\":\"success\"}";
}

전송하는 값은 requestId, captchaId, HTTP status, readyState, User-Agent 정도로 한정했다. CAPTCHA 정답이나 사용자가 입력한 값은 절대 로그에 남기지 않았고, 서버에 기록할 때도 MaskingUtil로 마스킹을 거치게 했다. 담당 서비스 특성상 로그 자체가 또 다른 위험이 될 수 있어서다.

4-4. 서버 로그와 클라이언트 로그를 하나로 연결하기 (Correlation)

서버가 요청마다 발급하는 requestId(UUID)와, Redis 키로 쓰이는 captchaId를 클라이언트 로그에도 그대로 실어 보내게 했다. 이렇게 하면 "브라우저에서 시작된 실패"를 서버 로그 한 곳에서 requestId 하나로 추적할 수 있다.

실제로 스테이징 배포 후 운영 로그에서 잡힌 실패 사례는 이런 모습이었다 (아래 IP는 예시로 대체함).

# 이미지 요청 자체가 실패한 케이스 검색
grep -i "CAPTCHA" /app/wildfly/wildfly/domain/servers/auth-was0*/log/server.log* \
  | grep "phase=image-request-failed"

# 결과 예시
[CAPTCHA_CLIENT] clientIp=203.0.113.10 payload={
  "errorCode":"CAPTCHA_IMAGE_REQUEST_FAILED",
  "pageUrl":"https://cert.example-auth.com/auth_popup.html",
  "userAgent":"Mozilla/5.0 (iPhone; CPU iPhone OS 26_6 like Mac OS X) ... NAVER(inapp; search; ...)",
  "message":"[CAPTCHA] phase=image-request-failed requestId=38da7f64-... requestUri=/gui/popup/captcha/image status=0 readyState=4",
  "timestamp":"2026-08-17T06:06:08.093Z"
}

status=0, readyState=4 조합은 브라우저가 서버에 요청을 아예 보내지 못했거나 응답을 못 받았을 때 나오는 전형적인 패턴이다. User-Agent에 찍힌 NAVER(inapp; ...) 문자열까지 더해서, "iOS 인앱 WebView 환경에서 네트워크 요청 자체가 실패한다" 는 가설이 처음으로 데이터로 뒷받침되는 순간이었다.

5. 재현이 안 되는 버그, 어떻게 테스트했나

Phase 1 개발 자체를 검증하는 것도 문제였다. 8개 실패 지점을 "일부러" 발생시켜서 로그가 제대로 찍히는지 봐야 했는데, 운영 코드는 건드리지 않는다는 제약이 있었다. 실패 유형별로 재현 방법을 다르게 설계했다.

실패 유형 재현 방법
request-failed Postman으로 형식이 깨진 토큰(not-json) 전송
validation-failed 인증 요청을 cURL로 복사해 재전송 (CAPTCHA는 1회 검증 후 Redis에서 삭제되는 점을 이용)
image-http-failed Requestly 확장으로 500 응답 Mock (DevTools의 요청 차단은 status=0만 만들어서 부적합)
image-request-failed Chrome DevTools의 Network request blocking 사용
image-read-failed DevTools Snippets에서 FileReader를 일시적으로 가로채 NotReadableError 강제 발생
image-request-aborted 숨긴 iframe에서 네이티브 XMLHttpRequest를 확보해 send() 직후 abort() 호출
redis-save-failed 스테이징·운영에서 재현 불가 → 단위 테스트 코드로 검증

image-request-aborted 케이스가 특히 까다로웠다. 페이지에 이미 프록시로 감싸둔 XMLHttpRequest가 있어서, 그걸 그대로 쓰면 로그 전송용 요청까지 같이 오염됐다. 그래서 숨긴 iframe의 contentWindow에서 원본(프록시 안 걸린) XMLHttpRequest를 꺼내 쓰는 방식으로 우회했다.

6. Phase 2 — 근본 원인 확정

Phase 1에서 로그를 심어두고 몇 주간 운영 로그를 쌓은 뒤, 이 데이터를 근거로 Phase 2(원인 분석)를 진행했다.

확정된 근본 원인

프론트 저장소 코드를 다시 훑다가 결정적인 단서를 찾았다. 인증 팝업 HTML의 CAPTCHA <img> 태그 초기 src가 정적 파일(더미 이미지)로 하드코딩되어 있었고, 그 파일을 직접 열어보니 표기값이 화면에서 보던 고정 숫자와 정확히 일치했다.

즉, 사용자가 보던 그 숫자는 서버가 생성한 CAPTCHA 정답이 아니라, 퍼블리싱 단계에서 넣어둔 더미 이미지였다. 이 더미 이미지는 페이지가 로드되는 순간부터 모든 사용자에게 노출되고, 정상적인 경우엔 XHR 응답으로 곧바로 교체되어 아무도 보지 못한다. 문제는 그 요청이 끝까지 도달하지 못하면 더미 이미지가 그대로 화면에 남는다는 것이었다.

새로고침·재부팅·재설치로 해결되지 않는 이유

로그와 코드를 같이 보니 CS에서 반복되던 세 가지 진술이 전부 코드로 설명됐다.

  • "새로고침을 눌러도 그대로예요" — 새로고침 버튼은 페이지를 다시 불러오는 게 아니라, 동일한 이미지 요청 함수를 한 번 더 호출할 뿐이었다. 요청이 실패하는 조건 자체가 없어지지 않으면 결과도 똑같았다.
  • "버튼을 눌러도 화면이 안 바뀌어요" — requestImage()의 모든 실패 경로(onerror, onabort, reader 실패)는 로그만 남기고 element.src를 전혀 건드리지 않는다. 사용자 입장에선 버튼이 반응이 없는 것처럼 보일 수밖에 없었다.
  • "재부팅·재설치해도 똑같아요" — 더미 이미지는 단말기 캐시가 아니라 서버가 내려주는 HTML 안에 정적 리소스로 포함되어 있었다. 기기를 초기화해도 페이지 자체가 항상 그 이미지로 시작하니 증상이 반복될 수밖에 없었다.

Phase 1에서 확보해둔 validation-failed (redisFound=true) 로그의 의미도 이 시점에 완전히 확정됐다. Redis에는 진짜 정답이 정상적으로 저장돼 있고, 사용자는 화면의 더미 값을 입력하니 값만 불일치하는 것이었다.

여기에 더해, requestImage()의 HTTP 오류 처리 코드에 return문이 빠져 있어서 503/504 같은 오류 응답을 받고도 그 오류 응답 본문을 그대로 이미지로 설정하려 시도하는 추가 결함도 발견했다.

운영 로그로 본 실제 분포

3주간(2026-09-01 ~ 09-21) 누적된 클라이언트 실패 로그를 집계해봤다.

구분 건수
전체 오류 54
iOS (iPhone/iPad) 34 (63%)
비iOS 20 (37%)
image-request-failed 36
image-http-failed 15
image-request-aborted 2
image-read-failed 1

가장 흥미로웠던 지점은 비iOS 비중이 37%나 된다는 것이었다. 그동안 "iOS에서만 발생하는 문제"로 인식돼 왔는데, 실제로는 iOS 인앱 WebView(네이버 인앱, 카카오톡 인앱 브라우저 등)가 화면 전환이나 백그라운드 진입 시 진행 중인 XHR을 더 자주 끊어버리는 환경적 특성 때문에 노출 빈도가 높았던 것뿐이었다. 결함 자체는 플랫폼을 가리지 않는 구조적 문제였다.

또 하나 눈에 띈 건 clientIp가 단 두 개의 값으로만 집계된다는 점이었다. 확인해보니 이건 사용자의 실제 IP가 아니라 WAS 앞단 프록시/L4 장비의 IP가 찍히고 있었던 것이었다. 사용자 식별에는 쓸 수 없는 값이라, 이건 X-Forwarded-For 헤더 미반영이라는 별도 개선 과제로 분리했다.

Phase 1 설계 단계에서는 "이론상 있을 수 있는 실패"로만 분류해뒀던 image-read-failed도 실제로 1건 관측됐다. 설계할 때는 확신이 없던 실패 경로가 데이터로 증명되는 순간이었다.

대응 방향 — 최소 변경 원칙

원인이 명확해진 만큼, 변경 범위가 가장 작은 안부터 정리했다.

즉시 적용 가능 (변경량 3줄 수준, 서버·API 변경 없음)

  • 더미 이미지 src를 제거하고, 로드 실패 시 "보안문자를 불러오지 못했습니다. 새로고침을 눌러주세요" 안내 문구를 노출
  • requestImage()의 HTTP 오류 처리부에 누락된 return 추가

이 두 가지만 반영해도 "틀린 값을 입력해서 인증이 막히는" CS는 원천 차단된다. 증상도 "인증 실패"에서 "보안문자 미표시"로 바뀌어서, 앞으로 비슷한 문제가 생겨도 원인 추적이 훨씬 쉬워진다.

추가 검토 항목

  • 화면이 포그라운드로 돌아올 때 이미지 재요청 (visibilitychange 이벤트) — 변경량 대비 효과가 가장 클 것으로 예상
  • XHR 타임아웃 설정 (504 상황에서 무한 대기 방지)
  • 새로고침 시 인증 토큰 자체를 갱신하는 방식 검토 (인증 플로우 전반에 영향이 있어 별도 검토 필요)
  • FileReader + readAsDataURL 대신 createObjectURL 또는 base64 JSON 응답으로 전환, 동기 XHR 제거, 자동 재시도 도입

병행 과제

  • 서버 측 503/504 발생 원인 확인 (게이트웨이 타임아웃, 이미지 생성 소요 시간)
  • 서버 로그에 User-Agent·clientIp(정확한 값) 추가
  • 배포 시 프론트 난독화 재빌드 필요 여부 확인

의사결정

실제 코드 수정은 CS가 다시 재인입되는 시점에 진행하기로 팀과 합의했다. 원인이 확정되고 최소 변경 대응안까지 나온 상태에서, 발생 빈도(3주간 54건, 그마저도 대부분 실제 서비스 장애로는 이어지지 않는 수준)와 회귀 위험을 함께 고려한 결정이었다. 문제를 이해하지 못해서 미루는 것과, 문제를 완전히 이해한 상태에서 우선순위를 조정하는 것은 전혀 다른 이야기라고 생각한다.

7. 기술적 의사결정에서 신경 쓴 것들

돌아보면 로그 몇 줄 추가하는 작업처럼 보이지만, 판단이 필요한 지점이 꽤 많았다.

① "고치기"보다 "보이게 하기"를 먼저 선택했다. 원인 후보가 5개 이상이고 재현이 안 되는 상태에서 추측 수정은 검증 불가능한 수정이다. 관측 가능성 확보를 선행 과제로 명확히 분리해서 팀에 설명했다.

② 로깅 API에 기존 Command/Factory 계층을 태우지 않았다. 이 프로젝트는 요청마다 Command → Factory를 거치는 구조였지만, 단순 로그 수집에 같은 계층을 씌우는 건 오버엔지니어링이라고 판단했다. 새 컨트롤러 대신 기존 AuthPopupController에 메서드 1개만 추가해서 변경 범위를 최소화했고, 같은 presentation 패키지에 둬서 기존 ApiExceptionHandler의 예외 처리를 그대로 물려받는 이점도 챙겼다.

③ 전역 window.onerror 훅은 도입하지 않았다. 전역 훅을 걸면 CAPTCHA와 무관한 오류까지 서버로 들어와서 신호 대 잡음비가 나빠진다. 명시적으로 식별한 실패 지점에서만 리포트하도록 범위를 제한했다.

④ 보안을 우선했다. 담당 서비스라 로그 자체가 위험이 될 수 있다. 진단에 필요한 최소 정보(requestId, captchaId, 상태 코드, readyState, User-Agent)만 보내고, CAPTCHA 정답·사용자 입력값은 절대 로그에 남기지 않았다.

⑤ 성공 로그는 2차 배포에서 뺐다. 처음엔 request-start, image-generated 같은 성공 경로 로그도 넣었는데, 트래픽이 큰 인증 서비스에서 전 요청을 로깅하면 로그량·성능 부담이 커진다고 판단해서 실패 로그만 남기도록 축소했다.

⑥ 로그를 "찾을 수 있게" 설계했다. 로그를 남기는 것과 운영팀이 실제로 찾을 수 있는 것은 다른 문제다. 모든 로그에 [CAPTCHA] 접두어와 phase= 키를 통일해서 grep 한 줄로 검색 가능하게 했고, 검색 키워드와 로그 경로를 별도 문서로 정리해서 전달했다.

grep -i "CAPTCHA" /app/wildfly/.../server.log* | grep "phase=validation-failed"

8. 성과

  • 근본 원인 확정: Phase 1에서 구축한 로그를 근거로, 1년 가까이 재현 불가로 미해결이던 이슈의 원인이 프론트 HTML에 하드코딩된 정적 더미 이미지임을 규명했다. "iOS 단말 문제"로 여겨지던 장애를 플랫폼 공통의 구조적 결함으로 재정의하고, 최소 변경 대응안까지 도출했다.
  • 관측 불가 영역 해소: 실패 기록이 전무했던 CAPTCHA 처리 흐름에 서버 4종 + 클라이언트 4종, 총 8개 실패 지점의 진단 로그를 확보했다. "로그 없이 추정만 반복"하던 상태를 데이터 기반으로 분석 가능한 상태로 바꿨다.
  • 실제 원인 단서 포착: 운영 로그에서 실제 실패 사례가 잡혔다.
    • image-request-failed — iOS 인앱 WebView에서 status=0, readyState=4로 네트워크 요청 자체가 실패
    • image-request-aborted — 요청 중단 사례
    • validation-failed (redisFound=true) — Redis 조회는 성공했지만 값이 불일치한 케이스
    • request-failed — 요청 처리 단계 예외
  • 재현 불가 이슈의 테스트 절차 확립: 운영 코드 무수정 원칙 하에 6가지 실패 시나리오의 재현 방법을 정립하고 문서화했다.
  • 서버-클라이언트 로그 상관관계 확보: requestId / captchaId 기반으로, 브라우저에서 시작된 실패를 서버 로그 한 곳에서 추적할 수 있게 됐다.
  • 협업: 서비스운영파트·개발파트와 2단계 대응 계획을 합의하고, 스테이징 배포 요청부터 테스트, 로그 가이드 전달까지 주도했다.

9. 회고

이 이슈를 하면서 가장 크게 느낀 건, "버그를 고치는 능력"과 "고칠 수 있는 상태로 만드는 능력"은 다른 능력이라는 점이었다. 원인 후보가 여러 개고 재현조차 안 되는 상황에서는, 아무리 실력이 좋아도 "감"으로 고치는 수밖에 없다. 그 상태에서 한 발 물러나 "지금 우리에게 정말 필요한 건 수정이 아니라 관측이다"라고 팀에 설명하고 합의를 끌어낸 과정 자체가, 코드 몇 줄 짜는 것보다 오히려 더 어려운 일이었다.

그리고 실제로 Phase 2에서 확인했듯, 관측이 먼저였기 때문에 원인을 "감"이 아니라 데이터로 확정할 수 있었다. "iOS에서만 생기는 문제"라는 처음의 전제 자체가, 운영 로그를 쌓고 나서야 "노출 빈도가 높았을 뿐인 플랫폼 공통 결함"으로 뒤집혔다. 만약 Phase 1을 건너뛰고 "iOS WebView 이슈니까 iOS 쪽만 방어 코드를 넣자"는 식으로 대응했다면, 비iOS에서도 반복되는 37%의 사례는 여전히 미해결로 남았을 것이다. 첫 가설을 의심하지 않고 그대로 밀어붙였다면 얻지 못했을 결론이었다.

또 하나는 "무엇을 로그로 남길지"를 정하는 것도 결국 설계라는 점이다. 너무 적게 남기면 여전히 못 찾고, 너무 많이 남기면 성능·보안·검색성이 다 나빠진다. 로그 설계도 API 설계나 스키마 설계처럼 트레이드오프를 저울질하는 작업이라는 걸 이번에 제대로 체감했다.

마지막으로, 원인을 다 알아낸 뒤에도 "지금 바로 고칠지, 다음 CS 재인입 때 고칠지"를 팀과 함께 판단해야 했다. 문제를 이해하는 것과 그걸 언제 반영할지 우선순위를 정하는 것은 별개의 의사결정이라는 것도 이번에 배운 부분이다.


부록: 예상 질문 정리 (인터뷰 대비용 메모)

Q. 왜 바로 코드를 고치지 않고 로그부터 넣었나요?
원인 후보가 다수였고 재현이 안 되는 상황이었다. 추측으로 수정하면 고쳐졌는지 검증할 수단이 없다. 관측 가능성 확보가 선행되어야 수정의 효과를 측정할 수 있다고 판단했고, 실제로 로그 배포 후 확보한 데이터를 근거로 원인을 완전히 확정할 수 있었다.

Q. 재현이 안 되는 오류는 어떻게 테스트했나요?
운영 코드를 건드리지 않는다는 제약 하에 실패 유형별로 재현 수단을 나눴다. 서버 예외는 잘못된 파라미터로 유도했고, HTTP 오류는 Requestly로 응답을 Mock했다(DevTools 요청 차단은 status=0만 만들어서 부적합했다). 브라우저 API 실패는 DevTools Snippets에서 FileReader/XMLHttpRequest를 일시적으로 가로채서 재현했고, 프록시 오염 문제는 iframe에서 네이티브 구현체를 꺼내 우회했다. 인프라 장애(Redis)는 재현이 불가능하다고 판단해 테스트 코드로 대체했다.

Q. 로그 설계에서 신경 쓴 점은?
상관관계(requestId + captchaId로 서버·클라이언트 로그 연결), 검색성([CAPTCHA] 접두어 + phase= 키 통일), 보안(토큰·개인정보·CAPTCHA 정답 미기록, 서버 저장 시 마스킹), 비용(성공 로그는 2차 배포에서 제거) 네 가지를 같이 고려했다.

Q. 이 이슈는 완전히 해결됐나요?
솔직히 말하면 단계적으로 진행 중이다. Phase 1(진단 체계 구축)은 완료됐고 실제로 운영 로그에서 실패 사례를 확보했다. Phase 2에서는 근본 원인(하드코딩된 정적 더미 이미지)을 완전히 확정했고, 새로고침·재부팅으로도 회복되지 않는 이유까지 코드 근거로 설명할 수 있게 됐다. 최소 변경 대응안까지 마련했지만, 실제 코드 반영은 발생 빈도와 회귀 위험을 고려해 CS가 재인입되는 시점에 진행하기로 팀 차원에서 결정했다. 로그가 없었다면 계속 "iOS 단말 이슈"로 남아있었을 문제를 구조적 결함으로 규명한 것이 Phase 1과 2를 거치며 얻은 가장 큰 성과라고 생각한다.

TL;DR — VM 2대가 동시에 재부팅된 후 WildFly 도메인 컨트롤러가 자동 기동되지 않았다. 증상은 슬레이브 서버의 반복 종료였지만, 근본 원인은 systemd unit 파일의 WantedBy를 wantedBy로 쓴 오타 한 글자였다. 원인 추적부터 복구까지 약 40분. 이 글은 그 40분의 기록과, 거기서 건진 교훈에 대한 이야기다.

배경: WildFly Domain Mode

담당하는 인증 서비스의 개발 환경은 WildFly를 도메인 모드로 운영한다. 서버는 두 대다.

  • 도메인 컨트롤러(마스터, 10.0.0.10) — 원본 WAR 배포본과 서버 그룹 설정을 보관하고, 슬레이브에 설정을 배포한다. 관리 포트는 9990.
  • 호스트 컨트롤러(슬레이브, 10.0.0.11) — 마스터로부터 설정과 WAR를 받아 실제 서비스 JVM 인스턴스를 기동한다. 실제 코드가 돌아가는 서버다.

여러 모듈의 JVM 인스턴스를 서버 그룹 단위로 묶어 관리할 수 있다는 게 도메인 모드의 장점이지만, 뒤집어 말하면 마스터가 죽으면 슬레이브는 기동조차 못 하는 구조다. 이 사실이 이번 장애의 복선이 된다.

장애: 표준창이 열리지 않는다

어느 오후, 개발망의 인증 표준창이 HTTP 503을 반환하기 시작했다. 슬레이브 서버에 들어가 보니 이상한 패턴이 보였다. WildFly 프로세스가 기동되고 약 33초 만에 종료되기를 반복하고 있었다. 재시작해도 똑같았다.

먼저 서버에 무슨 일이 있었는지부터 확인했다.

last -x reboot shutdown | head -5
reboot   system boot  3.10.0-1160.el7. Wed ... 15:16
reboot   system boot  3.10.0-1160.el7. (약 4개월 전)
reboot   system boot  3.10.0-1160.el7. (약 9개월 전)

마스터와 슬레이브 두 VM이 같은 시각에 재부팅되어 있었다. 재부팅 이력을 거슬러 올라가 보니 수개월 간격으로 같은 패턴이 반복된 흔적도 있었다. 즉, 이건 오늘 처음 생긴 문제가 아니라 재부팅될 때마다 터질 수 있는 문제가 잠복해 있다가 이번에 드러난 것이었다.

추적: 증상이 있는 곳에 원인이 있는 건 아니다

반복 종료하는 건 슬레이브니까 처음엔 슬레이브 설정을 의심했다. 그런데 로그를 열어보니 이야기가 달랐다.

INFO  [org.jboss.as.host.controller] WFLYHC0148: Connecting to master host controller remote://10.0.0.10:9990
ERROR [org.jboss.as.host.controller] (Controller Boot Thread) WFLYHC0052: Could not connect to master. Aborting.
      Error was: java.net.ConnectException: WFLYPRT0053: Could not connect to remote://10.0.0.10:9990. The connection failed
INFO  [org.jboss.as.process] WFLYPC0009: Process terminated, exit code 99

슬레이브는 자기 잘못으로 죽는 게 아니었다. 마스터에 연결이 안 돼서 30초 동안 11회 재시도한 뒤 스스로 종료(exit code 99)하고 있었다. 아까 관찰한 "33초 만에 종료"는 재시도 30초 + 종료 처리 3초였던 것이다.

시선을 마스터로 옮겼다. 혹시 네트워크 문제인가 싶어 슬레이브에서 확인해 보았다.

timeout 3 bash -c 'echo > /dev/tcp/10.0.0.10/9990' && echo OPEN || echo CLOSED
# → CLOSED (connection refused)
ping -c 2 10.0.0.10
# → 정상 응답

ping은 되는데 포트는 refused. 즉 장비는 살아 있고 WildFly 프로세스만 안 떠 있는 상태다. 네트워크 단절 가설은 여기서 배제됐다. 재부팅 후 슬레이브는 자동 기동됐는데 왜 마스터는 안 떴을까?

근본 원인: 대문자 W 한 글자

마스터의 systemd 설정을 확인했다.

systemctl is-enabled wildfly
# → static
grep -i wantedby /etc/systemd/system/wildfly.service
# → wantedBy=multi-user.target

WantedBy가 아니라 wantedBy. 소문자 w 하나가 문제의 전부였다.

systemd는 unit 파일의 키 이름을 대소문자까지 정확히 구분한다. 그리고 모르는 키는 에러를 내는 대신 조용히 무시한다. [Install] 섹션에 유효한 WantedBy가 없으니 systemctl enable을 해도 multi-user.target 아래에 심볼릭 링크가 생기지 않고, 상태는 static으로 표시되며, 부팅 시 자동 시작 대상에서 조용히 빠진다.

무서운 건 이 오타가 평소엔 아무 증상도 만들지 않는다는 점이다. 수동으로 systemctl start를 하면 멀셟하게 잘 뚜다. 재부팅이라는 조건이 충족될 때만 발현되는 잠복 결함이었고, 그래서 수개월 간 아무도 모르고 있었다.

복구: 순서가 전부다

복구 자체는 단순하다. 다만 도메인 모드에서는 기동 순서가 중요하다. 마스터가 없는 채로 슬레이브를 먼저 올리면 33초 뒤 또 죽는다.

  1. 마스터 기동 → 관리 포트 9990이 열릴 때까지 대기 (실측 약 20~30초)
  2. 슬레이브 기동
  3. 서비스 JVM 인스턴스 기동 확인

여기서 한 가지를 덩붙였다. 슬레이브 기동이 성공했는지를 언제 판단할 수 있을까? 연결 실패 시 33초 만에 종료된다는 실측치가 있으니, 여유를 더해 "기동 후 40초가 지나도 active (running)이면 성공"이라는 판정 기준을 런북에 명시했다. "좀 기다려보고 괜찮으면 된 거에요" 같은 감으로는 다음 사람이 복구할 수 없다. 런북의 판정 기준은 숫자여야 한다.

장애 인지부터 서비스 정상화까지 약 40분이 걸렸다.

남은 숙제

  • 오타 수정 — unit 파일 수정 권한이 운영 주체와 분리되어 있어 담당 부서에 수정을 요청한 상태다. 수정 전까지는 재부팅 시 동일 장애가 재발한다. 그래서 런북이 더 중요해졌다.
  • VM 2대 동시 재부팅의 원인 — 수개월 간격으로 반복된 패턴이라 하이퍼바이저나 전원 이벤트 쪽을 의심하고 있지만 아직 확인되지 않았다.
  • 헬스체크 알림 — 이번에는 사용자가 503을 보고 나서야 인지했다. 관리 포트 헬스체크 기반 알림을 붙이면 재부팅 직후 미기동을 먼저 탐지할 수 있다.

배운 것

  1. 증상이 보이는 서버가 범인이 아닐 수 있다. 반복 종료는 슬레이브에서 일어났지만 원인은 마스터에 있었다. 분산 구조에서는 "이 서버가 의존하는 것"까지 의심 범위에 넣어야 한다.
  2. 조용히 실패하는 설정이 제일 무석다. systemd는 모르는 키를 에러 없이 무시한다. 설정이 "적용된 것 같은 상태"와 "실제 적용된 상태"는 다르다. systemctl is-enabled 같은 명령으로 적용 상태를 검증하는 습관이 필요하다.
  3. 재부팅은 가장 싸고 확실한 카오스 테스트다. 재부팅 후 서비스가 스스로 돌아오는지는 평소엔 절대 검증되지 않는 경로다. 이번 잠복 결함은 그 경로에 수개월간 숨어 있었다.

본 글의 IP·시각 등은 예시로 대체하였습니다.

문제

https://school.programmers.co.kr/learn/courses/30/lessons/42842

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

 


 

정답 코드

첫 번째 제출
def solution(brown, yellow):
    answer = []
    yellow_wh = (brown-4)/2
    i=1
    while i <=  yellow_wh//2:
        if i * (yellow_wh-i) == yellow:
            answer.append((yellow_wh-i)+2)
            answer.append(i+2)
            break
        i=i+1
    return answer
  • yellow_wh: yellow의 가로 길이+세로 길이
    1. brown = (yellow 가로 길이+세로 길이) * 2 + 4 이므로 중간 합을 기준으로 식을 변형하면 위 코드처럼 됨
  • yellow_wh//2 까지 i 를 1씩 더하면서 반복문 돌리기: yellow = i + (yellow_wh-i) 조건에 맞는 쌍을 찾기 위함
  • yellow 가로>=세로 길이 이므로 애초에 큰 숫자(yellow_wh - i)를 먼저 append 하면 정답 나옴 

 

두 번째 제출 (다른 사람 풀이 참고)
def solution(brown, yellow):
    yellow_wh = (brown-4)/2
    i=1
    while i <=  yellow_wh//2:
        if i * (yellow_wh-i) == yellow:
            return [(yellow_wh-i)+2, i+2]
        i=i+1
  • answer 배열에 append 하지 않고, 바로 배열 형태로 return 하면 코드가 더욱 짧아짐
  •  

문제

https://school.programmers.co.kr/learn/courses/30/lessons/132201

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

 


 

정답 코드

-- COALESCE() 함수 사용
SELECT PT_NAME, PT_NO, GEND_CD, AGE, COALESCE(TLNO, 'NONE') AS TLNO
FROM patient
WHERE AGE <=12 AND GEND_CD = 'W' 
ORDER BY AGE DESC, PT_NAME ASC;

-- CASE 문 사용
SELECT PT_NAME, PT_NO, GEND_CD, AGE, 
    CASE 
        WHEN TLNO IS NULL THEN 'NONE'
        ELSE TLNO
    END AS TLNO
FROM patient
WHERE AGE <=12 AND GEND_CD = 'W' 
ORDER BY AGE DESC, PT_NAME ASC;

 


 

배운 점

 

1. COALESCE() 함수

  • '합치다' 라는 의미 
  • 여러 개의 인수를 받아 첫 번째로 NULL이 아닌 인수를 반환
    = 따라서 COALESCE(TLNO, 'NONE') 는 NULL 을 'NONE' 을 반환하고, 그 외는 반환 
  • IS NULL 을 사용 & 조건문 일 때, 간단하게 표현할 수 있는 코드

 

2. CASE 문

  • 문법
CASE 
    WHEN TLNO IS NULL THEN 'NONE'
    ELSE TLNO
END AS TLNO
  • IF ~ ELSE 문을 SQL 식으로 직관적으로 표현한 것 
  • 꼭 IS NULL 이 아니더라도 다양한 상황에서 활용 가능 
  • WHERE 문이 아니라 SELECT 문에서 사용해야 함을 주의

문제

https://school.programmers.co.kr/learn/courses/30/lessons/144853

 

프로그래머스

코드 중심의 개발자 채용. 스택 기반의 포지션 매칭. 프로그래머스의 개발자 맞춤형 프로필을 등록하고, 나와 기술 궁합이 잘 맞는 기업들을 매칭 받으세요.

programmers.co.kr

 


 

정답 코드

SELECT BOOK_ID, TO_CHAR(PUBLISHED_DATE, 'YYYY-MM-DD')
FROM book
WHERE CATEGORY = '인문' AND TO_CHAR(PUBLISHED_DATE, 'YYYY') = '2021'
ORDER BY PUBLISHED_DATE ASC;

 


 

배운 점

 

1. 타입이 DATE 일 때 처리 방법

  • 타입이 DATE = CHAR로 타입을 바꾸는 척 해야 처리 가능! 
    • 출력되는 형식 변경: TO_CHAR(PUBLISHED_DATE, 'YYYY-MM-DD')
    • 값 중 일부 글자가 특정 글자에 해당하는 값만 필터링: TO_CHAR(PUBLISHED_DATE, 'YYYY') = '2021'

 

2. 정렬

  • ORDER BY 까먹지 말자. 오름차순은 ASC, 내림차순은 DESC
    • ORDER (X), ARRANGE(X), SORT(X) 

문제 및 레퍼런스

https://school.programmers.co.kr/learn/courses/30/lessons/42862

https://namhandong.tistory.com/152

https://iambeginnerdeveloper.tistory.com/107


 

정답 코드

def solution(n, lost, reserve):
    
    answer = 0  #1번
    new_reserve = set(reserve)-set(lost)  #2번
    new_lost = set(lost)-set(reserve) 
    
    for i in new_reserve:  #2번
        if i-1 in new_lost: 
            new_lost.remove(i-1)  #3번
        elif i+1 in new_lost: 
            new_lost.remove(i+1) 
            
    answer = n - len(new_lost)  #1번
    
    return answer

 


 

배운 점

0. 변수 설정

  • 문풀 전: 내가 정한 규칙에 따라 입력값을 항상 재설정 
    • 문제점: 레퍼런스를 보게 될 경우, 변수 달라 항상 고생. 또한 대문자로 변수 썼으므로 고속으로 코딩하기에 불편
  • 결론: 프로그래머스처럼 입력값 변수가 정해져 있는 경우 그냥 그거 쓰자 

 

1. 리턴 값 초기화의 중요성 

  • 문풀 전: 리턴 값을 초기화 하지 않고, 바로 answer = n - len(new_lost) 식으로 값 할당 
    • 문제점: 코드를 맞게 작성하였는데도, 심지어 정답 코드들과 초기화 부분을 제외하고 코드가 동일한데도  계속 일부 3-5개 케이스를 통과하지 못함 
    • 원인: 이전에 문풀 했을 때 할당되었던 answer이 누적될 수 있음
  • 결론: 항상 리턴 값은 초기화하고 시작하는 것을 습관화하자

 

2. 배열 or 집합 값의 직접 탐색 

  1. 직접 탐색
    • 문풀 전: 항상 for i in range(len(lost)) 식으로 탐색하고 싶은 배열의 길이의 범위를 지정하여 탐색
      • 문제점: lost[i] 식으로 한 번 더 그 값을 지정해주어야 하는 번거로움 발생. 또한 이번 문제처럼 lost 도 탐색해야 하는 경우 무조건 이중 for문 사용하여 배열 값에 접근해야함 
    • 결론: 배열 값에 바로 접근하자. for i in lost
  2. 집합 값 탐색
    • 문풀 전: 집합을 생성하고 집합을 탐색하는 것을 몰랐음
      • 문제점: 이번 문제처럼 공통 원소를 제거하는 set(lost) - set(reserve) 같은 코드를 사용할 수 없음
    • 결론: 배열, 집합 양자 왔다 갔다 하자
      • 배열을 집합으로 만들기: set(lost) ( 결과: {2, 4} )
      • 차집합 (= 공통 원소 제거): set(lost) - set(reserve)
      • 집합도 len(lost), lost.remove(i) 처럼 배열에서 사용하는 메서드 사용 가능

 

3. remove() 메서드

  • 문풀 전: 배열에서 원소 제거할 때 무조건 pop() 메서드 사용
    • 문제점: 메서드 특성 상 스택큐 문제에서 뒤의 원소부터 제거한다는 느낌이 강함 (실제 특정 값만 제거할 수 있는데도). 따라서 부담스러움
  • 결론
    • remove() 메서드를 사용하자.
    • 다만 배열 or 집합 순회하면서 remove() 메서드를 사용할 경우, 순회하는 대상이 달라져 원하는 결과가 나오지 않을 가능성 있음 
      -> lost[:] 식으로 리스트의 전체를 슬라이싱하는 기법 사용하자. 이를 통해 리스트의 모든 요소를 복사하여 새로운 리스트를 생성하고, 이를 순회하면서 각 요소에 접근 가능 

+ Recent posts