홍창민정보·인공지능 교사

몸·온도·습도 · 난이도 어려움

MAX30102로 손가락 맥파를 읽어 심박을 세고 산소포화도가 근사값일 수밖에 없는 이유 알기

MAX30102는 빨간빛과 적외선을 손가락에 비춰 혈류에 따라 바뀌는 빛을 재는 I²C 센서예요(주소 0x57). n-elia 라이브러리로 맥파를 읽어 심박을 세는 코드, 산소포화도 계산의 바탕인 R 값, 보정 없는 SpO2가 근사인 이유, 안전한 탐구를 정리했어요.

글 홍창민 · 갱신 2026-10-31 · 읽는 데 약 9분 · 가상 실행 확인 (실물 미확인)

이 센서로 무엇을 잴 수 있나

MAX30102는 빨간 LED, 적외선 LED, 빛을 받는 포토다이오드, 신호를 숫자로 바꾸는 회로를 한 칩에 담은 센서예요. 손가락 끝을 올리면 LED 빛이 피부 속으로 들어갔다가 일부가 되돌아오고, 칩은 되돌아온 빛의 세기를 초당 수십 번 기록해요. 이렇게 얻은 파형을 광용적맥파(PPG)라고 불러요.

이 파형으로 잴 수 있는 것은 세 가지예요. 첫째, 심장이 한 번 뛸 때마다 파형이 한 번 오르내리니 심박수(bpm)를 셀 수 있어요. 둘째, 파형이 얼마나 크게 출렁이는지를 나타내는 관류 지수(PI)를 구할 수 있어요. 셋째, 빨강과 적외선의 출렁임을 견준 R 값을 구할 수 있어요. 산소포화도(SpO2)는 이 R 값을 바탕으로 계산하는데, 그 계산이 왜 근사인지가 이 글의 중요한 내용이에요.

교실에서는 센서 심박과 손으로 센 맥박의 차이, 누르는 힘이나 LED 세기에 따라 파형이 얼마나 뚜렷한지 같은 것을 재요. 건강 상태를 판정하는 데는 쓰지 않아요.

원리

혈액은 빛을 흡수해요. 심장이 피를 내보낼 때마다 손가락 끝 동맥의 혈액량이 조금 늘었다 줄고, 그만큼 되돌아오는 빛도 조금 줄었다 늘어요. 맥심(Maxim, 지금 Analog Devices)의 응용 노트 AN6845는 이 출렁이는 부분을 AC, 그 아래 깔린 큰 부분(정맥과 모세혈관의 피, 뼈, 물이 흡수하는 몫)을 DC라 부르고, AC를 DC로 나눈 값을 관류 지수(PI)라고 설명해요.

산소와 결합한 헤모글로빈과 그렇지 않은 헤모글로빈은 빨강과 적외선을 흡수하는 정도가 달라요. 그래서 맥박 산소 측정에는 흔히 660nm 빨강과 880nm 적외선을 쓰고, 두 빛의 AC/DC 비율을 다시 나눈 R = (빨강 AC ÷ 빨강 DC) ÷ (적외선 AC ÷ 적외선 DC)를 구해요. 같은 노트에 따르면 이론식만으로는 SpO2가 정확하지 않아서, 제3자 보정 시험실에서 참가자의 산소포화도를 조절하며 모은 자료에 2차(또는 1차) 곡선을 맞춰 SpO2 = a × R² + b × R + c의 a, b, c를 정해요. 노트가 소개한 미국 식품의약국(FDA) 기준은 70~100% 범위에서 200개 이상의 자료점, 다양한 피부색의 참가자, 반사형 오차(RMSE) 3.5% 미만이에요. 같은 센서라도 앞을 덮는 광학 창이 달라지면 곡선이 달라진다고도 적혀 있어요.

교실의 모듈은 이런 보정을 거치지 않았어요. 노트에 실린 기본 계수도 MAX30101과 맥심의 알고리즘에 맞춘 값이에요. 인터넷 예제의 R→SpO2 식을 넣으면 그럴듯한 %가 나오지만, 그 숫자가 내 모듈과 내 손가락에서 맞는다는 근거는 없어요. 그래서 이 글은 R 값까지만 계산하고, 같은 사람·같은 자리에서 견주는 데만 써요.

준비물

  • 라즈베리파이 피코와 USB 케이블
  • MAX30102 모듈(VIN·GND·SDA·SCL·INT 핀), 암-수 점퍼선
  • 손가락과 센서를 가릴 검은 천이나 종이(주변 빛 차단)
  • 탐구용: 초시계, 주방 저울(누르는 힘 확인용)

배선

MAX30102로 손가락 맥파를 읽어 심박을 세고 산소포화도가 근사값일 수밖에 없는 이유 알기: 브레드보드에 피코와 센서를 꽂은 배선 그림
시나브로 PICO 브레드보드 작업대와 같은 계산으로 그린 배선이에요. 위 + 줄은 3.3V, 아래 + 줄은 5V예요.
브레드보드 따라 꽂기 순서 (9단계)
  1. 피코를 브레드보드 가운데 홈에 걸쳐 꽂아요. USB 단자가 왼쪽을 보게 하고, 1번 핀이 f2, 40번 핀이 e2에 들어가요. 다리가 홈 양쪽에 모두 들어갔는지 옆에서 봐요.
  2. 주황 선: 피코 36번 핀(3V3(OUT)) 옆 칸 a6 → 위쪽 + 줄(3.3V). 이제 이 줄 전체가 3.3V예요.
  3. 검은 선: 피코 38번 핀(GND) 옆 칸 a4 → 위쪽 − 줄(GND). 이제 이 줄 전체가 GND예요.
  4. 심박·산소포화도 센서 MAX30102 꽂기: 다리 VIN · GND · SDA · SCL를 위쪽 a24 · a25 · a26 · a27에 차례로 꽂아요. 다리가 브레드보드에 바로 안 들어가면 암-수 점퍼선으로 같은 칸에 이어요.
  5. 주황 선(3.3V 전원): 심박·산소포화도 센서 VIN 줄 b24 → 위쪽 + 줄(3.3V)
  6. 검은 선(GND): 심박·산소포화도 센서 GND 줄 b25 → 위쪽 − 줄(GND)
  7. 파란 선(신호): 심박·산소포화도 센서 SDA 줄 b26 → 피코 6번 핀(GP4) 옆 칸 j7
  8. 파란 선(신호): 심박·산소포화도 센서 SCL 줄 b27 → 피코 7번 핀(GP5) 옆 칸 j8
  9. 마지막 확인: ① 위쪽 + 줄(3.3V)과 아래쪽 + 줄(5V)이 서로 이어지지 않았나요? ② 모든 부품의 GND가 − 줄에 이어졌나요? ③ 5V 신호 다리는 저항을 거쳐 피코에 이어졌나요? ④ 긴 브레드보드는 + − 줄이 가운데에서 끊긴 것도 있어요(빨간·파란 선이 끊겨 있으면 짧은 선으로 이어요). ⑤ 그림과 한 칸씩 비교한 뒤 USB를 꽂아요.
모듈 핀 피코 핀 비고
VIN 3V3 (36번) 모듈 전원
GND GND (38번) 공통 접지
SDA GP4 (6번) I²C 데이터
SCL GP5 (7번) I²C 시계
INT 연결 안 함 이 라이브러리는 쓰지 않아요

주소는 0x57이고 바꿀 수 없어요. DS3231 시계 모듈에 붙은 기억 칩도 0x57을 쓰니 같은 버스에 함께 두지 않아요(I²C 장치 여러 개 연결하기). 모듈마다 전원 회로가 조금씩 달라서, 스캔에 0x57이 보이지 않으면 배선을 확인한 뒤 판매처 회로도를 봐요.

코드

from machine import Pin, SoftI2C
import time
from max30102 import MAX30102    # n-elia/MAX30102-MicroPython-driver (MIT)

i2c = SoftI2C(sda=Pin(4), scl=Pin(5), freq=400000)
print("I2C:", [hex(a) for a in i2c.scan()])   # 0x57이 보여야 해요
sensor = MAX30102(i2c=i2c)
sensor.setup_sensor()            # 빨강+적외선, 400Hz를 8개씩 평균 = 초당 50개

avg = 0                          # 적외선 값을 천천히 따라가는 기준선
was_above = False
last_beat = 0
gaps = []                        # 최근 박동 간격(ms)
reds = []
irs = []
bpm = 0
pi = 0
r = 0
print("손가락 끝을 센서에 가볍게 올리고 움직이지 마세요")

while True:
    sensor.check()
    for k in range(4):           # 라이브러리 저장 칸은 4개라 쌓인 만큼 꺼내요
        if sensor.available():
            red = sensor.pop_red_from_storage()
            ir = sensor.pop_ir_from_storage()
            if avg == 0:
                avg = ir
            avg = avg * 0.95 + ir * 0.05
            above = ir > avg
            if above and not was_above and ir > 10000:   # 기준선을 위로 넘는 순간 = 한 박
                now = time.ticks_ms()
                gap = time.ticks_diff(now, last_beat)
                if last_beat and 300 < gap < 2000:      # 첫 박은 건너뛰고 30~200bpm만 믿어요
                    gaps.append(gap)
                    gaps = gaps[-5:]
                last_beat = now
            was_above = above
            reds.append(red)
            irs.append(ir)
    if len(irs) >= 100:          # 약 2초치(50개 × 2)
        ir_dc = sum(irs) / len(irs)
        red_dc = sum(reds) / len(reds)
        ir_ac = max(irs) - min(irs)
        red_ac = max(reds) - min(reds)
        if ir_dc > 10000 and ir_ac > 0 and red_dc > 0:
            pi = ir_ac / ir_dc * 100                     # 관류 지수(%)
            r = (red_ac / red_dc) / (ir_ac / ir_dc)      # 산소포화도 계산의 바탕
        if gaps:
            s = sorted(gaps)
            bpm = 60000 / s[len(s) // 2]                 # 간격의 중앙값으로
        print(f"DATA: bpm={bpm:.0f}, pi={pi:.2f}, r={r:.3f}, ir_dc={ir_dc:.0f}")
        reds = []
        irs = []
    time.sleep_ms(10)
  • 라이브러리는 n-elia의 MicroPython 드라이버예요. 시나브로 PICO에서 실행하면 커밋을 고정한 파일 두 개(max30102/__init__.py, circular_buffer.py)를 /lib/max30102/에 자동으로 넣어요. 다른 편집기라면 저장소의 두 파일을 같은 자리에 복사해요.
  • setup_sensor() 기본값은 LED 모드 2(빨강+적외선), 초당 400번 재서 8개씩 평균, 펄스 폭 411µs예요. 그래서 초당 약 50개가 쌓여요.
  • 라이브러리는 칩에서 읽어 온 값을 4개까지만 보관해요(STORAGE_QUEUE_SIZE = 4). 반복 안에서 오래 쉬거나 많이 출력하면 값을 놓쳐요.
  • 박동은 적외선 값이 천천히 따라가는 기준선을 위로 넘는 순간으로 세고, 최근 간격 5개의 중앙값으로 bpm을 내요. 0.3초보다 짧거나 2초보다 긴 간격은 잡음으로 보고 버려요. 처음 몇 줄은 기준선이 자리 잡는 중이라 기록에서 빼요.
  • 2초마다 100개의 최대−최소를 AC, 평균을 DC로 써서 PI와 R을 계산해요. 손가락이 움직이면 최대−최소가 커져 두 값이 함께 흔들려요.

값 해석과 보정

bpm은 손으로 센 맥박과 견줘요. 다른 손 손목에서 30초 동안 센 맥박에 2를 곱해 같은 시간의 센서 값과 비교해요. 차이가 크면 손가락이 움직였거나 누르는 힘이 맞지 않는 경우가 많아요.

PI는 신호가 쓸 만한지 알려 줘요. PI가 아주 작거나 들쭉날쭉하면 그 구간의 bpm과 R은 버려요. 같은 손가락이라도 손이 차가우면 출렁임이 작아질 수 있어요.

R은 비교용으로만 써요. 위에서 본 것처럼 R을 %로 바꾸려면 보정 시험실에서 얻은 계수가 필요해요. 이 글의 R은 "오늘 내 손가락, 이 자리"에서 조건을 바꿀 때 어떻게 움직이는지 보는 숫자예요.

칩 온도는 체온이 아니에요. 라이브러리의 read_temperature()는 칩 내부 온도를 돌려줘요. README는 이 값이 산소포화도 계산의 온도 보정용이고 정확도가 ±1℃라고 적어요.

자주 하는 실수

  1. 손가락으로 세게 누르기. 모세혈관이 눌려 출렁임이 사라져요. 시나브로 PICO 안내도 이 점을 경고해요. 살짝 올려요.
  2. 밝은 창가에서 손가락 옆을 열어 두기. 주변 빛이 섞여 파형이 흔들려요. 천으로 가려요.
  3. 0x57을 쓰는 다른 장치와 같은 버스에 두기. 값이 엉망이 되거나 스캔이 이상해요.
  4. 반복 안에 time.sleep(1)을 넣기. 그사이 쌓인 값을 잃어 박동을 놓쳐요.
  5. 화면에 뜬 SpO2 %를 믿기. 보정되지 않은 숫자예요. 건강 판단에 쓰지 않아요.

이 센서로 잴 수 있는 탐구

1. 센서 심박은 손으로 센 맥박과 얼마나 같을까 - 질문: 쉬고 있을 때와 가볍게 제자리 걷기를 1분 한 직후, 센서 bpm은 손목 맥박과 몇 bpm 차이가 날까? - 바꾸는 것: 상태(앉아서 5분 쉰 뒤, 제자리 걷기 1분 직후) - 재는 것: 센서 bpm, 손으로 센 bpm(30초 × 2), 차이(bpm) - 방법: 한 사람이 센서에 손가락을 올리고 다른 사람이 반대쪽 손목 맥박을 세요. 상태마다 3번 하고, 기록에는 이름 대신 번호를 써요. 몸이 불편한 사람은 걷기 조건을 하지 않아요.

2. 누르는 힘에 따라 파형은 얼마나 뚜렷할까 - 질문: 손가락으로 누르는 힘이 커지면 PI는 어떻게 바뀔까? - 바꾸는 것: 누르는 힘(주방 저울 눈금 20g, 50g, 100g, 200g) - 재는 것: PI(%), 박동을 센 비율(%) - 방법: 피코와 브레드보드를 주방 저울에 올리고 영점을 맞춘 뒤, 저울 눈금이 정한 값에 머물도록 눌러요. 힘마다 20초씩 기록하고 3번 반복해요.

3. LED를 밝게 하면 신호가 좋아질까 - 질문: LED 세기를 0x1F, 0x3F, 0x7F로 바꾸면 ir_dc와 PI는 어떻게 달라질까? - 바꾸는 것: sensor.setup_sensor() 다음 줄에 넣는 sensor.set_active_leds_amplitude(값) - 재는 것: ir_dc(ADC 값), PI(%), bpm이 손으로 센 값과 5bpm 안으로 맞은 비율(%) - 방법: 같은 손가락, 같은 힘으로 세기마다 1분씩 재요. ir_dc가 더 오르지 않고 멈추면 ADC 범위 끝에 닿은 것이니 그 조건은 따로 표시해요.

안전

  • 이 장치는 의료기기가 아니에요. 심박, R, 산소포화도 숫자로 몸 상태를 판단하지 않아요. 걱정되면 어른께 알리고 인증받은 기기를 써요.
  • 숨을 참거나 산소를 줄여 산소포화도를 떨어뜨리는 실험은 하지 않아요.
  • 운동 조건은 가벼운 제자리 걷기까지만 하고, 어지럽거나 숨이 차면 바로 멈춰요.
  • 심박은 건강 정보예요. 다른 사람을 잴 때는 허락을 받고, 결과를 공개할 때 이름을 빼요.

더 알아보기

  • 응용 노트: Maxim Integrated(Analog Devices) Application Note 6845 "Guidelines for SpO2 Measurement Using the Maxim MAX32664 Sensor Hub"(AC·DC와 관류 지수, 660nm·880nm, R 값, a·b·c 보정 계수, FDA 기준)
  • 라이브러리: n-elia/MAX30102-MicroPython-driver(MIT). 주소 0x57, 부품 ID 0x15, setup_sensor() 기본값, 저장 칸 4개, read_temperature() 정확도 ±1℃(README)
  • 기초 글: 잴 수 있는 탐구 주제 만들기, 센서 값의 신뢰도, I²C 장치 여러 개 연결하기
글쓴이

홍창민 · 울산 학성고등학교 정보·인공지능 교사. 수업에 쓰는 도구(시나브로 PICO 웹 IDE, 학교 AI 서버 등)를 직접 만들어 운영해요. 소개 보기 · 고칠 곳 알려 주기