Recent Posts

All Posts →

부동소수점 오차와 돈 계산, BigDecimal

IEEE 754 부동소수점 표현이 0.1 같은 십진 소수를 정확히 담지 못하는 이유와, 그 오차가 실제 장애로 이어진 NEIS 성적 처리 사례를 살펴봅니다. 자바에서 돈 계산을 할 때의 대안인 BigDecimal, 정수형 최소 단위 계산, Java Money를 비교합니다.

double 이나 float 자료형은 IEEE 754 부동소수점 표준에 따라 값을 2진수로 저장합니다. 이 방식으로는 십진수 0.1을 정확하게 표현할 수 없습니다. 0.1은 2진수로는 무한히 반복되는 소수가 되기 때문에, 유한한 비트 안에 담으려면 가장 가까운 값으로 반올림해야 합니다. 이 글에서는 그 오차가 코드에서 어떻게 드러나는지, 실제 장애로 이어진 사례는 무엇이었는지, 자바에서는 어떤 대안이 있는지 정리합니다.

1. 코드로 확인하는 부동소수점 오차

jshell에서 간단히 확인해볼 수 있습니다.

jshell> System.out.println(0.1 + 0.2);
0.30000000000000004

jshell> System.out.println(0.1 + 0.2 == 0.3);
false

jshell> System.out.println(1.03 - 0.42);
0.6100000000000001

0.1을 열 번 더해도 1.0이 되지 않습니다.

jshell> double sum = 0;
jshell> for (int i = 0; i < 10; i++) { sum += 0.1; }
jshell> System.out.println(sum);
0.9999999999999999

double 리터럴 0.1에 실제로 저장된 값은 BigDecimal 생성자로 확인할 수 있습니다.

jshell> System.out.println(new BigDecimal(0.1));
0.1000000000000000055511151231257827021181583404541015625

0.1이라고 적었지만 저장된 값은 0.1보다 아주 조금 큰 다른 수입니다. 이 표현 방식에 대한 자세한 설명은 IEEE 754 위키백과 문서를 참고할 수 있습니다.

2. 돈 계산에 float/double을 쓰면 안 되는 이유

과학 계산처럼 근사값으로 충분한 영역에서는 부동소수점 오차가 문제되지 않습니다. 그러나 돈 계산은 다릅니다. 금액은 마지막 한 자리까지 정확해야 하고, 오차가 누적되면 장부가 맞지 않습니다. 위 예제처럼 1.03달러에서 0.42달러를 빼는 단순한 계산도 0.6100000000000001달러가 되어, 반올림 처리 없이는 화면에 그대로 노출되거나 비교 연산이 어긋납니다. Never Use Float and Double for Monetary Calculations를 비롯해 많은 글이 같은 이유로 금액 계산에 부동소수점 자료형을 쓰지 말라고 권고합니다.

3. 실제 장애 사례: 2011년 NEIS 성적 처리 오류

부동소수점 계산 오차는 실제로 큰 장애로 이어진 적이 있습니다. 2011년 7월 차세대 교육행정정보시스템(NEIS·나이스)에서 성적 처리 오류가 발생해, 전국 2,300여개 고교에서 1만 7천여명의 학기말 성적이 바뀌었습니다. 대입 수시모집을 앞둔 시점이어서 파장이 컸습니다.

경향신문 기사에 따르면 고교생 성적 오류는 동점자를 판별하고 석차를 매기는 과정에서 컴퓨터의 계산 오차를 보정하지 않아 발생했습니다. 연합뉴스 기사에 실린 교육과학기술부의 점검 결과를 보면, 동점자 처리의 '오류 보정 코드’를 일부 프로그램에 적용하지 않았고, 새로 도입한 DB의 특성상 나타나는 연산 오류를 예측하지 못한 것이 원인으로 지목됐습니다. 성적처럼 소수점 계산이 들어가는 값을 다룰 때 부동소수점 오차를 고려하지 않으면, 동점 여부 판정 같은 비교 연산이 어긋날 수 있음을 보여주는 사례입니다.

4. 자바에서의 대안

4.1. BigDecimal

자바에서 십진 소수를 정확하게 다루는 기본 수단은 java.math.BigDecimal 입니다.

jshell> new BigDecimal("0.1").add(new BigDecimal("0.2"))
$1 ==> 0.3

jshell> new BigDecimal("1.03").subtract(new BigDecimal("0.42"))
$2 ==> 0.61

한 가지 주의할 점은 생성자에 double 을 넘기면 안 된다는 것입니다. 앞에서 봤듯이 new BigDecimal(0.1) 은 0.1이 아니라 double 에 저장된 근사값을 그대로 옮겨 담습니다. 문자열을 받는 생성자나 BigDecimal.valueOf() 를 사용해야 합니다.

4.2. 정수형 최소 단위 계산

금액을 원(₩)이나 센트 같은 최소 단위의 정수로 다루는 방법도 널리 쓰입니다. 우아한형제들 기술 블로그의 Spock으로 테스트코드를 짜보자에 나오는 예제가 이 방식을 보여줍니다. 반올림 처리에는 BigDecimal 을 쓰지만, 메소드의 입력과 리턴 타입은 long 입니다.

public static long calculate(long amount, float rate, RoundingMode roundingMode) {
    return BigDecimal.valueOf(amount * rate * 0.01)
            .setScale(0, roundingMode).longValue();
}

금액 자체는 정수로 유지하고, 소수점이 생기는 비율 계산 직후에 반올림 모드를 명시한 BigDecimal 로 다시 정수로 돌아오는 구조입니다. 다만 amount * rate * 0.01 곱셈 자체는 부동소수점 연산으로 수행되므로, 이 오차가 반올림 경계에 걸릴 수 있는 정밀도라면 곱셈까지 BigDecimal 로 옮기는 편이 안전합니다.

4.3. Java Money (JSR 354)

금액과 통화를 함께 다루는 표준 API로 Java Money도 있습니다. MonetaryAmount 인터페이스로 금액과 통화 단위를 묶어서 표현하고, 참조 구현인 Moneta를 사용합니다. 사용 예제는 Baeldung의 Java Money and the Currency API를 참고할 수 있습니다.

5. BigDecimal이 언제나 정답일까

BigDecimal 에도 비용이 있습니다. Chronicle Software의 Peter Lawrey는 If BigDecimal is the answer, it must have been a strange question에서 BigDecimal 의 단점을 이렇게 꼽습니다.

  • 문법이 자연스럽지 않다. 연산자 대신 메소드 호출을 이어붙여야 해서 수식의 가독성이 떨어진다.

  • double 보다 메모리를 많이 사용한다.

  • 객체를 계속 생성하므로 가비지가 많이 만들어진다.

  • 대부분의 연산에서 훨씬 느리다.

Lawrey의 결론은 "`double` 의 반올림을 다루는 법을 모르거나 프로젝트 표준이 BigDecimal 을 강제한다면 BigDecimal 을 쓰되, 선택할 수 있다면 BigDecimal 이 당연히 옳다고 가정하지는 말라"는 것입니다. 지연 시간에 민감한 금융 시스템 중에는 BigDecimal 없이 만들어진 시스템도 많이 돌아가고 있다는 점도 근거로 듭니다.

저는 기본값은 여전히 BigDecimal 이라고 생각합니다. 부동소수점 오차와 반올림을 정확히 통제할 자신이 있는 팀이라면 성능을 위해 double 이나 정수형 계산을 선택할 수는 있습니다. 그러나 조직에는 항상 새로운 사람이 들어오기 때문에 모든 사람이 이 글에서 다룬 부동 소수점 연산의 오차의 특성을 잘 이해하고 있다고 전제하기는 쉽지 않습니다. 따라서 팀과 프로젝트의 표준으로 BigDecimal 사용을 전수하는 것이 더 안전하다고 생각합니다.

BTrace로 MySQL JDBC 드라이버의 내부 동작 측정하기

실행 중인 JVM에 붙어서 MySQL Connector/J 내부 메소드의 호출 횟수와 평균 실행 시간을 집계하는 BTrace 스크립트 예제를 설명합니다.

예전에 쓰던 BTrace 스크립트를 모아둔 저장소를 이 블로그 저장소의 examples/btrace-scripts로 옮겼습니다. 저장소에 README가 없어서 어떤 스크립트인지 이 글로 설명을 남깁니다. 이 스크립트는 실행 중인 애플리케이션에 붙어서 MySQL JDBC 드라이버(Connector/J) 내부 메소드의 호출 횟수와 평균 실행 시간을 집계합니다.

BTrace 소개

BTrace는 실행 중인 JVM에 붙어서(attach) 지정한 메소드에 계측 코드를 끼워 넣는 동적 트레이싱 도구입니다. 애플리케이션 소스를 수정하거나 서버를 재시작하지 않아도 됩니다. 추적할 지점과 수집할 값은 Java 문법으로 작성한 스크립트에 애노테이션으로 선언합니다.

이 블로그에서는 예전에 Btrace로 DBCP의 connection정보를 모니터링 하기에서 소개한 적이 있습니다. 이번 예제는 같은 도구로 Connection pool 대신 JDBC 드라이버 내부를 들여다봅니다.

무엇을 측정하는가

ConnectionMonitor.java는 MySQL Connector/J 5.1의 내부 메소드 4개를 추적합니다.

추적 대상 의미

ConnectionImpl.prepareStatement()

PreparedStatement 생성. 드라이버의 cachePrepStmts 옵션의 효과를 확인할 수 있습니다.

ConnectionImpl.setAutoCommit()

autoCommit 변경. 옵션에 따라 DB 서버와의 통신이 발생하는 지점입니다.

PreparedStatement.executeInternal()

PreparedStatement 실행이 공통적으로 거치는 내부 메소드입니다.

ConnectionImpl.execSQL()

드라이버가 실제로 SQL을 서버에 전송하는 내부 메소드입니다.

각 메소드의 호출 횟수와 평균 실행 시간을 모으면, 드라이버 튜닝 옵션(cachePrepStmts, useLocalSessionState 등)을 바꿨을 때 내부 호출이 실제로 줄어드는지 수치로 확인할 수 있습니다. 애플리케이션 코드 계층에서는 보이지 않는 드라이버 내부의 동작이라서 BTrace 같은 도구가 유용합니다.

스크립트 구조

핵심 부분만 옮기면 아래와 같습니다.

ConnectionMonitor.java
@BTrace
public class ConnectionMonitor {
	private static Aggregation prepareDuration = Aggregations.newAggregation(AggregationFunction.AVERAGE);
	private static Aggregation prepareCount = Aggregations.newAggregation(AggregationFunction.COUNT);

	@OnMethod(clazz = "com.mysql.jdbc.ConnectionImpl", method = "prepareStatement", location = @Location(Kind.RETURN))
	public static void statementPrepare(@Duration long duration) {
		Aggregations.addToAggregation(prepareDuration, duration / 1000);
		Aggregations.addToAggregation(prepareCount, 1);
	}

	@OnEvent
	public static void summary() {
		println("## com.mysql.jdbc.ConnectionImp.prepareStatement()");
		Aggregations.printAggregation("- call count : ", prepareCount);
		Aggregations.printAggregation("- average duration(microseconds) :", prepareDuration);
		Sys.exit(0);
	}
}

사용한 BTrace 기능은 세 가지입니다.

  • @OnMethod + @Location(Kind.RETURN) : 지정한 클래스·메소드가 리턴하는 시점에 핸들러를 실행합니다. 메소드 실행 시간을 담는 @Duration 파라미터는 Kind.RETURN 위치에서만 쓸 수 있습니다. 값의 단위는 나노초라서 1000으로 나눠 마이크로초로 기록했습니다.

  • Aggregation : 호출마다 값을 출력하면 부하도 크고 읽기도 어려우므로, COUNT`와 `AVERAGE 집계 함수로 요약값만 유지합니다.

  • @OnEvent : BTrace 클라이언트에서 이벤트를 보내면 실행되는 핸들러입니다. 여기서 집계 결과를 출력하고 `Sys.exit(0)`으로 세션을 끝냅니다.

저장소에는 `PrintQuery.java`도 있는데, 실행 쿼리 출력용으로 만들려다 만 것인지 `ConnectionMonitor.java`와 내용이 같은 사본입니다.

이전 BTrace 글에서 소개했던 DBCP 모니터링 스크립트들(DbcpMonitor.java, DbcpMonitorSimple.java, DbcpActiveConnectionMonitor.java)도 gist에서 이 저장소로 옮겨 함께 두었습니다. CUBRID 드라이버와 Spring 서블릿을 추적하던 CubridConnectionMonitor.java, `DaoMonitor.java`도 같은 gist에서 옮긴 스크립트입니다.

실행 방법

BTrace 클라이언트로 대상 JVM의 pid를 지정해서 실행합니다.

btrace <pid> ConnectionMonitor.java

측정을 끝내고 싶을 때 클라이언트에서 Ctrl+C를 누르면 이벤트 전송 여부를 묻는 메뉴가 나옵니다. 이벤트를 보내면 @OnEvent 핸들러가 실행되어 아래처럼 요약이 출력됩니다.

## com.mysql.jdbc.ConnectionImp.prepareStatement()
- call count :  128
- average duration(microseconds) : 42
## com.mysql.jdbc.ConnectionImpl.setAutoCommit()
- call count :  64
- average duration(microseconds) : 120
...

pom.xml에 선언한 btrace-client 의존성은 빌드용이 아니라 IDE에서 BTrace API의 자동완성과 컴파일 검증을 받기 위한 것입니다. 실제 실행은 위처럼 BTrace 클라이언트가 스크립트 파일을 직접 받아서 처리합니다.

지금 다시 쓴다면 (JDK 25 기준)

이 스크립트는 BTrace 1.2와 Connector/J 5.1, JDK 6~8 시절에 작성했습니다. 최신 LTS인 JDK 25 기준으로는 상황이 꽤 다릅니다.

BTrace를 계속 쓴다면

  • BTrace는 java.net(kenai) 시절을 지나 현재 GitHub의 btraceio/btrace에서 관리됩니다. 다만 마지막 릴리스가 2023년 11월의 v2.2.6으로 활동이 뜸합니다.

  • 2.x 버전부터 패키지가 `com.sun.btrace`에서 `org.openjdk.btrace`로 바뀌어 이 스크립트를 그대로 쓸 수는 없습니다.

  • Connector/J도 6.0부터 패키지가 `com.mysql.jdbc`에서 `com.mysql.cj`로 바뀌어 추적 대상 클래스명을 바꿔야 합니다.

  • JDK 21부터는 JEP 451에 따라 실행 중인 JVM에 에이전트를 동적으로 붙일 때 경고가 출력됩니다. JDK 25에서도 아직 허용되지만, 경고 없이 쓰려면 대상 JVM을 -XX:+EnableDynamicAgentLoading 옵션으로 시작해야 합니다. BTrace처럼 attach에 의존하는 도구 전반에 해당하는 변화입니다.

JDK 25라면 JFR만으로 가능

이 스크립트가 하던 "특정 메소드의 호출 횟수와 평균 실행 시간 집계"는 JDK 25부터 외부 도구 없이 JFR(Java Flight Recorder)로 할 수 있습니다. JEP 520: JFR Method Timing & Tracingjdk.MethodTiming, jdk.MethodTrace 이벤트를 추가했습니다.

java '-XX:StartFlightRecording:jdk.MethodTiming#filter=com.mysql.cj.jdbc.ConnectionImpl::prepareStatement,filename=timing.jfr' -jar app.jar

jfr view method-timing timing.jfr

필터는 클래스::메소드 형식 외에 클래스 단위, 애노테이션 단위(@com.example.Debug)로도 지정할 수 있고, jcmd로 실행 중인 JVM에 같은 필터로 기록을 시작할 수도 있습니다. 결과는 메소드별 호출 횟수와 평균 시간 표로 출력되어, `ConnectionMonitor.java`의 요약 출력과 같은 정보를 줍니다.

다만 JEP 520은 실행 시간만 기록할 뿐 메소드의 파라미터, 리턴값, 필드값은 보여주지 않습니다. 이전 글의 DBCP 스크립트처럼 객체 내부 값을 들여다보는 용도라면 여전히 BTrace나 Arthas(watch, monitor, trace 명령) 같은 계측 도구가 필요합니다.

그 밖의 대안

  • 특정 메소드가 아닌 전체적인 프로파일링이라면 JFR 상시 기록이나 async-profiler가 우선 선택지입니다.

  • SQL 실행 관찰이 목적이라면 datasource-proxyp6spy처럼 JDBC 계층을 감싸는 라이브러리도 있습니다.

Dell XPS 13 웹캠을 살린 커널 모듈 한 줄 패치

Dell XPS 13 9350(Lunar Lake)에서 웹캠이 동작하지 않던 원인을 한 달간 추적해서 Intel CVS 드라이버의 한 줄 버그를 찾아 DKMS로 패치하고, 업스트림에 반영되기까지의 과정을 정리합니다.

Dell XPS 13 9350(Lunar Lake)에 Ubuntu 24.04를 설치한 이후로 웹캠이 동작하지 않았습니다. Ubuntu 24.04 설치Ubuntu에서의 OEM 커널 설치 글에서 언급했던 그 문제입니다. 2026년 3월 6일부터 4월 10일까지 약 한 달간 원인을 추적한 끝에, Intel CVS 드라이버의 한 줄 버그가 원인임을 찾아 DKMS 패치로 해결했습니다. 이후 업스트림에 PR을 보냈고 2026년 5월에 머지되었습니다. 이 글에서는 그 과정에서 알게 된 Lunar Lake의 카메라 아키텍처, 잘못 짚었던 가설, 진짜 원인을 찾은 방법, 그리고 버그가 만들어진 업스트림 커밋 이력을 정리합니다.

1. Lunar Lake의 카메라 아키텍처

원인을 이해하려면 먼저 이 플랫폼의 카메라 구조를 알아야 합니다.

1.1. 전통적인 노트북 카메라 구조

일반적인 노트북 카메라는 단순한 경로를 따릅니다.

센서(OV02C10) ──MIPI CSI-2──→ ISP(IPU) ──→ V4L2 ──→ 앱
       ↑
  INT3472 (전원 관리: GPIO로 dvdd/avdd 제어)

INT3472 컨트롤러가 GPIO를 통해 센서에 전원을 공급하고, 센서는 MIPI CSI-2 레인으로 ISP(Image Signal Processor)에 직접 연결됩니다. 드라이버 스택이 성숙해서 대부분의 Linux 배포판에서 잘 동작합니다.

1.2. Lunar Lake의 Connected Camera(CVS) 모드

Intel Core Ultra 200V(Lunar Lake)는 Computer Vision Sensing(CVS)이라는 새로운 카메라 서브시스템을 도입했습니다. AI 비전 처리를 위한 저전력 전용 컨트롤러가 추가된 구조입니다.

센서(OV02C10) ──MIPI CSI-2──→ IPU7 ISP ──→ Camera HAL ──→ 앱
       ↑                          ↑
  CVS 컨트롤러(INTC10DE)          │
       ↑                     소유권 이전
  USBIO 브리지(INTC10B5)    (GPIO 핸드셰이크)
       ↑
    USB I2C

CVS 모드에서는 세 가지가 달라집니다.

  1. 전원 경로가 다릅니다. 센서는 INT3472가 아닌 CVS 컨트롤러 경로로 전원을 받습니다.

  2. 소유권 협상이 필요합니다. CVS 컨트롤러가 먼저 센서를 초기화하고, I2C 프로토콜 협상을 마친 뒤, GPIO 핸드셰이크로 IPU7에 센서 소유권을 넘깁니다.

  3. 프로토콜 버전 협상이 있습니다. CVS 컨트롤러와 센서 펌웨어 간에 매직 넘버(0xCAFEB0BA)로 프로토콜 버전(1.0 vs 2.0+)을 식별합니다.

1.3. 이 머신의 구성

ACPI 테이블을 직접 읽어서 확인한 이 머신의 카메라 구성은 다음과 같습니다.

ACPI 속성 의미

LCHS

1

Connected Camera 모드 활성

L1CL

0xFF

OVTI02C1은 INT3472에 의존하지 않음

LNK1._DEP

{CVSS, VIC1}

CVS 컨트롤러 + USB I2C 브리지에 의존

이 설정에서 OVTI02C1(OV02C10 센서)은 전원과 초기화를 전적으로 CVS 경로에 의존합니다. CVS probe가 실패하면 센서에 전원이 공급되지 않고, 그 뒤의 모든 단계가 멈춥니다.

2. 증상과 실패 체인

웹캠이 동작하지 않는 상태에서 /dev/video0 은 v4l2loopback 가상 디바이스일 뿐, 실제 카메라 디바이스는 없었습니다. dmesg에는 다음의 에러가 남았습니다.

Intel CVS driver i2c-INTC10DE:00: magic number in dev response not supported
Intel CVS driver i2c-INTC10DE:00: cvs_find_magic_num_support:Device protocol is 1.0
Intel CVS driver i2c-INTC10DE:00: cvs_common_probe:set_host_identifier cmd failed
Intel CVS driver i2c-INTC10DE:00: probe with driver Intel CVS driver failed with error -5
ov02c10 i2c-OVTI02C1:00: failed to find sensor: -6

실패의 연쇄를 따라가면 다음과 같습니다.

  1. CVS probe가 시작되고, cvs_find_magic_num_support() 함수가 이 장치의 펌웨어를 "프로토콜 1.0"으로 판정합니다(magic_num_support = false).

  2. 그런데도 드라이버는 cvs_write_i2c(SET_HOST_IDENTIFIER, NULL, 0) 명령을 무조건 호출합니다.

  3. 프로토콜 1.0 펌웨어는 이 명령을 이해하지 못해서 -EIO를 반환하고, CVS probe가 실패합니다.

  4. 센서 소유권 이전이 일어나지 않아 센서에 전원이 공급되지 않습니다.

  5. ov02c10 드라이버가 I2C로 센서 접근을 시도하지만 전원이 없어 -ENXIO로 실패하고, 웹캠을 쓸 수 없게 됩니다.

근본 원인은 한 줄입니다. 프로토콜 1.0 장치에 프로토콜 2.0+ 전용 명령(SET_HOST_IDENTIFIER)을 무조건 보내는 것입니다.

3. 잘못 짚었던 가설

처음부터 이 원인을 찾은 것은 아닙니다. dmesg에는 CVS 에러와 함께 int3472-discrete: GPIO type 0x02 unknown 이라는 에러도 있었습니다. 처음에는 이 INT3472 GPIO 에러를 원인으로 보고, discrete.c 에 'OVTI02C1 + 0x02 → DOVDD' 매핑을 추가하는 DKMS 패치를 작성했습니다. 빌드와 설치까지 했으나 재부팅 후에도 증상이 그대로였습니다.

이후 ACPI NVS 메모리를 직접 읽고 나서야 두 가지를 알게 되었습니다.

  • GPIO type 0x02 unknown 에러는 이 노트북에 함께 달려 있는 다른 센서(HIMX1092)에 대한 것이었고, OVTI02C1과는 무관했습니다.

  • LCHS=1 (Connected Camera 모드)에서 OVTI02C1은 INT3472가 아닌 Intel CVS 경로로 전원을 받습니다.

에러 메시지가 여러 개 보일 때, 눈에 먼저 띄는 에러가 내 문제의 원인이라는 보장이 없다는 교훈을 얻었습니다. INT3472 패치는 폐기하고 CVS 드라이버 분석으로 방향을 바꿨습니다.

그 외에도 시도했다가 막힌 경로들이 있었습니다.

시도 결과

커널 6.11/6.14로 다운그레이드

동일한 원인으로 동일한 증상

media-ctl + v4l2-ctl --stream-mmap 직접 캡처

STREAMON 실패 (IPU7은 PSys 경유가 필요)

libcamera (cam --list)

IPU7 pipeline handler가 아직 없음

4. 버그가 만들어진 과정

Intel이 공개한 intel/vision-drivers 저장소의 커밋 이력을 추적하면 버그가 만들어진 과정이 보입니다.

날짜 커밋 내용 SET_HOST_IDENTIFIER 상태

2024-10-22

93244d7

SET_HOST_IDENTIFIER 명령 최초 추가

if (!i2c_shared) 가드 (부분적 보호)

2024-11-26

23f3733

magic_num_support 필드 도입, 매직 넘버 검증 추가

변경 없음

2025-03-12

69d20a1

코드 정리 중 i2c_shared 가드 제거

무조건 호출로 변경

2025-06-04

14d0181

"Fix I2C read for protocol 1.0 firmware"

cvs_get_device_cap 만 가드, SET_HOST_IDENTIFIER 누락

버그는 두 단계에 걸쳐 만들어졌습니다.

첫 번째 단계는 69d20a1 (2025-03-12)입니다. "Remove LJCA WA | Code Cleanup"이라는 커밋에서 코드 정리를 하면서 i2c_shared 조건 분기를 제거했습니다.

-  if (!icvs->i2c_shared) {
-      ret = cvs_write_i2c(SET_HOST_IDENTIFIER, NULL, 0);
-      ...
-  }
+  ret = cvs_write_i2c(SET_HOST_IDENTIFIER, NULL, 0);

원래 i2c_shared는 완벽한 가드는 아니었지만, 일부 장치에서 SET_HOST_IDENTIFIER 호출을 건너뛸 수 있게 해줬습니다. 이 제거로 SET_HOST_IDENTIFIER가 모든 장치에서 무조건 실행되게 바뀌었습니다.

두 번째 단계는 14d0181 (2025-06-04)입니다. "Fix I2C read for protocol 1.0 firmware"라는 제목으로 프로토콜 1.0 지원을 추가한 커밋입니다. cvs_find_magic_num_support() 함수를 만들어 프로토콜 버전을 감지하고, cvs_get_device_cap() 호출은 magic_num_support 조건으로 감쌌습니다. 하지만 바로 아래의 SET_HOST_IDENTIFIER 호출은 같은 조건으로 감싸는 것을 빠뜨렸습니다.

+  ret = cvs_find_magic_num_support(icvs);
+  if (ret)
+      goto exit;
+
+  if (icvs->magic_num_support) {
+      ret = cvs_get_device_cap(&icvs->cv_fw_capability);
+      if (ret)
+          goto exit;
+  }
+
   ret = cvs_write_i2c(SET_HOST_IDENTIFIER, NULL, 0);  // ← 가드 없음

같은 함수 안에서, 같은 패턴의 가드가 필요한 두 호출 중 하나만 수정한 전형적인 누락입니다.

5. 패키지 업데이트로는 해결되지 않는 이유

Ubuntu의 linux-modules-vision-*-oem 패키지는 Intel 업스트림의 특정 시점 스냅샷을 빌드한 것입니다. 당시 설치되어 있던 6.17.0-1017.17 패키지는 업스트림 HEAD를 기반으로 했지만, 업스트림 자체에 버그가 있으므로 패키지를 아무리 업데이트해도 수정되지 않는 상황이었습니다.

패키지 모듈에 수정이 포함되었는지는 바이너리 역어셈블(objdump)로 확인했습니다. Ubuntu 패키지의 intel_cvs.ko와 패치를 적용한 DKMS 빌드의 cvs_common_probe 함수를 비교하면, SET_HOST_IDENTIFIER 호출(mov edi, 0x805) 직전에 magic_num_support 검사와 조건 점프가 있는지가 다릅니다. 이 확인 기법은 Linux 바이너리 역어셈블로 커널 모듈 패치 확인하기 글에서 따로 정리했습니다.

6. 해결 방법

6.1. 한 줄 패치

수정 자체는 한 줄의 if 문 추가입니다. 기존 cvs_get_device_cap() 가드와 완전히 동일한 패턴입니다.

-		ret = cvs_write_i2c(SET_HOST_IDENTIFIER, NULL, 0);
-		if (ret) {
-			dev_err(cvs->dev, "%s:set_host_identifier cmd failed", __func__);
-			goto exit;
+		if (icvs->magic_num_support) {
+			ret = cvs_write_i2c(SET_HOST_IDENTIFIER, NULL, 0);
+			if (ret) {
+				dev_err(cvs->dev, "%s:set_host_identifier cmd failed", __func__);
+				goto exit;
+			}
 		}

6.2. DKMS로 패키지 모듈 오버라이드

업스트림 소스에 위의 패치를 적용하고 DKMS(vision-driver/1.0.0)로 빌드해서 설치했습니다. DKMS 모듈은 /lib/modules/<커널 버전>/updates/dkms/ 경로에 설치되는데, 이 경로가 패키지 모듈 경로(ubuntu/vision/)보다 우선 로드됩니다. 패키지를 건드리지 않고 모듈만 바꿔치기할 수 있고, 커널이 업데이트되어도 DKMS가 자동으로 다시 빌드해줍니다.

install-vision-dkms.sh
#!/bin/bash
set -e

SRC="$HOME/work/vision-drivers"  # 패치 적용한 소스 위치
DEST="/usr/src/vision-driver-1.0.0"

sudo cp -r "$SRC" "$DEST"
sudo rm -rf "$DEST/.git"

sudo dkms add vision-driver/1.0.0
sudo dkms build vision-driver/1.0.0
sudo dkms install vision-driver/1.0.0

재부팅 후 실제 로드된 모듈이 DKMS 빌드인지 확인합니다.

modinfo intel_cvs | grep filename
# OK:   /lib/modules/.../updates/dkms/intel_cvs.ko.zst
# FAIL: /lib/modules/.../ubuntu/vision/intel_cvs.ko.zst (패키지 원본)

journalctl -k -b 0 --no-pager | grep -E "cvs|CVS|INTC10DE" | head -10
# "Transfer of ownership success" 확인

6.3. 권한 설정과 캡처 확인

패치만으로는 부족하고 권한 설정도 필요했습니다. 사용자를 video 그룹에 추가하고, IPU7 PSys 장치의 권한을 udev 규칙으로 지정합니다.

sudo usermod -aG video $USER

echo 'SUBSYSTEM=="intel-ipu7-psys", MODE="0660", GROUP="video"' \
  | sudo tee /etc/udev/rules.d/50-ipu7.rules

IPU7은 아직 libcamera pipeline handler가 없어서 cam --list 에 표시되지 않습니다. Intel Camera HAL(libcamhal-ipu7x)과 GStreamer의 icamerasrc 플러그인으로 캡처를 확인했습니다.

gst-launch-1.0 icamerasrc device-name=ov02c10-uf num-buffers=3 \
  ! "video/x-raw,format=NV12" ! jpegenc ! filesink location=/tmp/test.jpg

이 세 가지를 모두 적용한 뒤에 1920x1080 캡처에 성공했습니다.

7. 왜 전에는 됐다가 안 됐을까

Dell이 이 모델을 출시할 때는 당연히 카메라가 동작하는 상태로 테스트했을 것입니다. 무엇이 바뀌었을까요?

가장 유력한 추정은 Ubuntu OEM 커널의 vision-drivers 스냅샷 시점입니다. Ubuntu OEM 커널 패키지가 빌드되는 시점에 따라 포함되는 vision-drivers 버전이 달라집니다. Dell 출하 시점의 Ubuntu는 SET_HOST_IDENTIFIER가 추가되기 전이거나 i2c_shared 가드가 있던 버전의 vision-drivers를 사용했을 가능성이 높습니다. 이후 OEM 커널 업데이트로 새 스냅샷이 적용되면서, i2c_shared 가드가 제거된(69d20a1 이후) 버전이 들어온 것으로 보입니다.

센서 펌웨어 업데이트나 모듈 로드 순서 변경 같은 다른 가능성도 검토했지만, 펌웨어를 업데이트한 이력이 없고 에러의 성격도 달라서 가능성이 낮다고 판단했습니다.

8. 왜 널리 보고되지 않았을까

이 버그가 광범위하게 보고되지 않은 이유도 짐작해볼 수 있습니다.

  1. Lunar Lake 자체가 새로운 플랫폼입니다. 2024년 하반기에 출시되어 Linux 사용자 기반이 아직 작습니다.

  2. CVS 모드는 일부 구성에서만 활성화됩니다. 같은 Lunar Lake라도 LCHS=0 (전통 모드)이면 CVS 드라이버를 거치지 않습니다.

  3. 프로토콜 1.0이 구형입니다. 최신 CVS 펌웨어는 프로토콜 2.0+를 지원하므로 SET_HOST_IDENTIFIER가 성공합니다. Dell XPS 13 9350의 OV02C10은 프로토콜 1.0 펌웨어를 사용하는, 상대적으로 드문 조합입니다.

  4. Windows에서는 드라이버 스택이 다릅니다. Intel은 Windows용 드라이버를 별도로 관리하므로, Linux 전용 업스트림 저장소의 버그가 Windows에서는 나타나지 않습니다.

Intel 내부 개발팀이 주로 프로토콜 2.0+ 장치로 테스트하기 때문에, 프로토콜 1.0 장치에서만 발생하는 이 문제를 발견하지 못한 것으로 추정됩니다.

9. 업스트림 반영

로컬 DKMS 패치는 커널 업데이트 때마다 유지보수 부담이 남습니다. 근본적인 해결을 위해 2026년 4월 10일에 intel/vision-drivers#35로 수정 PR을 제출했습니다. 이후 같은 수정에 인접한 수정 두 건(cvs_init() 에러 전파, find_oem_prod_id 에서의 ACPI_HANDLE() 사용)을 묶은 #38로 정리해서 다시 올렸고, 2026년 5월 7일에 머지되었습니다.

업스트림에 머지된 수정이 Ubuntu의 vision-drivers 스냅샷 갱신을 거쳐 OEM 커널 패키지로 릴리스되면 DKMS 모듈은 제거할 수 있습니다. 패키지 모듈에 수정이 포함되었는지는 앞서 소개한 역어셈블 방법이나 strings 명령으로 확인할 수 있습니다.

10. 참고 자료


이 포스트는 Claude Code와 정상혁이 함께 작성했습니다.