안녕하세요! 오늘은 마이크로컨트롤러(MCU) 프로젝트의 꽃, 디스플레이 업그레이드 이야기를 해보려 합니다. 최근 알리나 국내 오픈마켓에서 “2.0인치 OLED”라는 제목으로 팔리는 제품들, 아마 많이 보셨을 겁니다. 저도 이번에 스마트 부화기 프로젝트를 위해 하나 업어왔는데요. 결론부터 말씀드리면, 이건 OLED가 아니라 ST7789V 드라이버를 사용하는 TFT LCD입니다!
하지만 실망하긴 이릅니다. 오히려 이 녀석, 제대로 다뤄보니 OLED보다 훨씬 매력적인 구석이 많거든요.

1. “OLED라고 써있는데 왜 LCD인가요?”
판매자들의 낚시성 제목에 속으셨나요? 괜찮습니다. 구조를 보면 명확합니다.
- OLED: 소자 스스로 빛을 내어 백라이트가 없고 종이처럼 얇음. (번인 위험 있음)
- TFT LCD: 뒤에서 LED가 빛을 쏴주는 백라이트 방식. (번인 걱정 없음!)
24시간 내내 온도와 습도를 띄워야 하는 ‘스마트 부화기’ 같은 장치에는 오히려 화면이 타버릴 걱정 없는 TFT LCD가 신의 한 수입니다.
2. 하드웨어 SPI: 고속도로를 타야 제맛!
이 디스플레이의 핵심은 속도입니다. 보통 핀이 모자라다고 아무 핀에나 연결(Software SPI)하면 화면이 뚝뚝 끊기는 걸 보게 됩니다. 하지만 ESP32-C3의 **하드웨어 전용 핀(IOMUX)**을 사용하면 이야기가 달라집니다.
- 권장 핀 배치 (ESP32-C3 SuperMini 기준):
- SCK: GPIO 4 (전용 고속 통로)
- MOSI: GPIO 6 (전용 고속 통로)
- CS/DC/RES: GPIO 5, 7, 10 (비교적 자유로움)
이렇게 연결하고 SPI 속도를 60MHz로 설정하면, MicroPython 환경에서도 초당 60프레임급의 부드러운 애니메이션을 감상할 수 있습니다.
3. “깜빡임(Flicker)과의 전쟁”에서 승리하는 법
처음 코드를 짜면 숫자가 바뀔 때마다 화면이 깜빡거려 눈이 아플 수 있습니다. 이건 ‘지우고(Black) -> 새로 쓰기(Text)’ 과정에서 검은색이 우리 눈에 잔상으로 남기 때문인데요.
해결책은 ‘배경색 동시 출력’입니다. tft.text(font, "12:30", x, y, WHITE, BLACK) 처럼 글자색과 배경색을 한 번에 쏘면, 기존 글자를 지울 필요 없이 덮어씌워지기 때문에 깜빡임이 0%가 됩니다. 마치 고급 스마트워치 같은 부드러움을 느낄 수 있죠.
4. 실전! 벽돌깨기 게임으로 증명하는 퍼포먼스
단순히 수치만 띄우기엔 이 LCD의 성능이 아깝습니다. 하드웨어 SPI의 극한을 테스트하기 위해 벽돌깨기 게임을 돌려보았습니다.
- 잔상 제거 팁: 공이 벽에 부딪힐 때 미세하게 남는 빨간 점들은 좌표 계산의 오차 때문입니다. 공이 벽을 뚫고 나가지 않도록 좌표를 강제로 고정(
ball_x = WIDTH - size)해주면 아주 깨끗한 화면을 유지할 수 있습니다.
5. 마무리하며: 2편 예고
이번 1편에서는 2인치 TFT LCD의 정체를 밝히고, 하드웨어 SPI를 통한 성능 최적화까지 알아보았습니다.
“작은 OLED의 답답함에서 벗어나, 240×320 고해상도의 시원시원한 화면을 보니 이제야 프로젝트 할 맛이 나네요!”
이어지는 2편에서는 이 화면 아래에 ToF(거리 측정) 센서 3개를 연동하여 실시간 데이터를 멋지게 시각화하는 과정을 공유해 드리겠습니다. 핀이 부족할 것 같다고요? I2C 버스의 마법을 기대해 주세요!
※ 부록: 성능을 결정짓는 SPI 전용 핀 가이드
ESP32-C3 SuperMini는 핀 수가 적지만, 내부적으로 **IOMUX(Input/Output Multiplexer)**라는 고속 도로가 특정 핀에 깔려 있습니다. 이 ‘전용석’을 지키느냐 아니냐에 따라 LCD의 주사율이 천차만별로 달라집니다.
1. SPI 전용 핀 테이블 (성능 최우선)
| LCD 핀 이름 | ESP32-C3 핀 (GPIO) | 역할 | 변경 가능 여부 | 이유 |
| SCL (SCK) | GPIO 4 | 시리얼 클럭 | 변경 불가 (고수) | 60MHz급 고속 신호를 낼 수 있는 하드웨어 전용 라인 |
| SDA (MOSI) | GPIO 6 | 데이터 송신 | 변경 불가 (고수) | 화면의 픽셀 데이터를 쏘는 핵심 통로 |
| RES (RST) | GPIO 2 | 리셋 신호 | 자유롭게 변경 가능 | 화면을 껐다 켤 때만 쓰므로 속도와 상관없음 |
| DC (RS) | GPIO 3 | 데이터/명령 선택 | 자유롭게 변경 가능 | 제어 신호용이라 일반 GPIO 아무 곳이나 OK |
| CS | GPIO 1 | 칩 선택 | 자유롭게 변경 가능 | 통신 시작 시 한 번만 신호를 주면 됨 |
2. 왜 4번과 6번을 사수해야 하나요?
ESP32-C3의 내부 구조상, GPIO 4와 6은 CPU를 거치지 않고 메모리에서 디스플레이로 직접 데이터를 쏘는 IOMUX 경로를 탑니다.
만약 핀이 부족하다고 0번이나 1번으로 SPI 핀을 옮기게 되면, 데이터가 내부 교환기(GPIO Matrix)를 한 번 더 거치게 되어 신호 지연이 발생합니다. 결과적으로 60MHz로 설정해도 실제로는 그 절반의 속도도 내기 힘들고, 화면에 미세한 노이즈나 끊김이 발생할 수 있습니다.
🔍 심화 학습: 왜 I2C는 자유롭고, SPI는 까다로운가?
배선을 하다 보면 “I2C는 0, 1번으로 옮겨도 괜찮은데, 왜 SPI는 꼭 전용 핀을 쓰라고 할까?”라는 궁금증이 생깁니다. 이는 ESP32-C3 내부의 데이터 배달 경로 차이 때문입니다.
1. I2C: “천천히 가도 줄만 잘 서면 되는 우편물”
I2C 통신(센서용)은 보통 400kHz(0.4MHz) 속도로 움직입니다.
- 내부 구조: ESP32-C3 내부에는 ‘GPIO 매트릭스’라는 거대한 교환기가 있습니다.
- 성능 유지 비결: I2C는 워낙 느린 신호라 이 교환기를 거쳐서 어떤 핀으로 나가더라도 데이터가 뭉개지거나 지연될 걱정이 없습니다. CPU 부하도 하드웨어 컨트롤러가 알아서 처리하므로 8, 9번 기본 핀이 아닌 0, 1번으로 옮겨도 하드웨어 가속 성능이 100% 유지됩니다.
2. SPI: “초고속으로 달려야 하는 전용 고속도로”
반면, 우리가 LCD에 사용하는 SPI는 **60MHz(60,000kHz)**라는 어마어마한 속도로 데이터를 쏩니다. I2C보다 무려 150배나 빠릅니다.
- IOMUX(고속도로): 60MHz급 초고주파 신호는 내부 교환기(GPIO Matrix)를 거치면 신호가 감쇄되거나 노이즈가 생겨 통신이 끊깁니다. 그래서 CPU와 핀이 직접 연결된 IOMUX라는 ‘직통 고속도로’를 타야만 합니다.
- 전용석의 마법: GPIO 4(SCK)와 6(MOSI)이 바로 그 고속도로 진입로입니다. 이 자리를 벗어나 다른 핀으로 SPI를 강제 할당(Software SPI 등)하면, 내부 교환기를 거치느라 속도가 20MHz 이하로 급감하거나 CPU가 직접 핀을 제어해야 해서 전체 시스템이 느려지게 됩니다.
💡 요약하자면?
| 통신 방식 | 주요 용도 | 속도 체감 | 핀 전략 |
| I2C | 거리/온도 센서 | 거북이 (느림) | 아무 핀이나 OK! 하드웨어 성능 저하 없음. |
| SPI | 고해상도 LCD | 슈퍼카 (매우 빠름) | 전용 핀(4, 6) 필수! 옮기면 화면이 뚝뚝 끊김. |

#폰트 테스트
from machine import Pin, SPI
import st7789py as st7789
import time
import random
# 폰트 모듈 불러오기 (파일이 ESP32 내부에 있어야 함)
import vga1_8x8 as font8
import vga2_8x16 as font16
import vga2_16x32 as font32
# 1. 하드웨어 SPI 설정 (최적화 핀)
# SCK: 4, MOSI: 6, RST: 2, CS: 1, DC: 3
spi = SPI(1, baudrate=60000000, sck=Pin(4), mosi=Pin(6))
tft = st7789.ST7789(
spi, 240, 320,
reset=Pin(2, Pin.OUT),
cs=Pin(1, Pin.OUT),
dc=Pin(3, Pin.OUT),
rotation=0
)
def font_demo():
tft.fill(st7789.BLACK)
# --- 타이틀 (가로줄) ---
tft.text(font16, "FONT SHOWCASE", 10, 10, st7789.WHITE)
tft.hline(10, 30, 220, st7789.BLUE)
# 1. vga1_8x8 (소형)
tft.text(font8, "This is vga1_8x8 font", 10, 50, st7789.GREEN)
tft.text(font8, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", 10, 65, st7789.GREEN)
# 2. vga2_8x16 (중형)
tft.text(font16, "vga2_8x16 Standard", 10, 90, st7789.YELLOW)
tft.text(font16, "0123456789", 10, 110, st7789.YELLOW)
# 3. vga2_16x32 (대형) - 부화기 온도/거리 표시용
tft.text(font32, "2인치 LCD", 10, 150, st7789.MAGENTA)
tft.text(font32, "HIGH-RES", 10, 190, st7789.CYAN)
# 4. 애니메이션 텍스트 (성능 확인용)
for i in range(10):
color = random.getrandbits(16)
tft.text(font32, "FAST SPI!", 40, 250, color)
time.sleep(0.1)
print("폰트 데모 시작!")
while True:
font_demo()
time.sleep(2)
# 화면 전환 연출
# mtft.fill(st7789.WHITE)
time.sleep(0.1)
tft.fill(st7789.BLACK)
# 시계
from machine import Pin, SPI, RTC
import st7789py as st7789
import time
import vga2_16x32 as font
# 1. 하드웨어 SPI 및 LCD 설정 (권장 핀)
# SCK: 4, MOSI: 6, RST: 2, CS: 1, DC: 3
spi = SPI(1, baudrate=60000000, sck=Pin(4), mosi=Pin(6))
tft = st7789.ST7789(
spi, 240, 320,
reset=Pin(2, Pin.OUT),
cs=Pin(1, Pin.OUT),
dc=Pin(3, Pin.OUT),
rotation=0
)
# 2. RTC 시간 설정 (연, 월, 일, 요일, 시, 분, 초, 0)
rtc = RTC()
def main():
# 배경 초기화 (최초 1회만)
tft.fill(st7789.BLACK)
# 테두리 디자인
tft.rect(10, 10, 220, 300, st7789.BLUE)
tft.rect(13, 13, 214, 294, st7789.WHITE)
last_sec = -1
last_min = -1
print("시계 작동 중...")
while True:
t = rtc.datetime()
year, month, day, _, hour, minute, second, _ = t
# 초가 바뀔 때만 업데이트
if second != last_sec:
# [1] 날짜 업데이트 (매 초마다 그리지 않고 날짜가 바뀔 때만 그려도 되지만 일단 포함)
date_str = "{:04d}-{:02d}-{:02d}".format(year, month, day)
# 배경색을 인자로 직접 전달 (깜빡임 방지 핵심)
tft.text(font, date_str, 40, 50, st7789.GREEN, st7789.BLACK)
# [2] 시간(시:분) 업데이트
time_hm = "{:02d}:{:02d}".format(hour, minute)
tft.text(font, time_hm, 80, 120, st7789.YELLOW, st7789.BLACK)
# [3] 초(Second) 업데이트
time_s = "{:02d}s".format(second)
tft.text(font, time_s, 100, 160, st7789.CYAN, st7789.BLACK)
# [4] 하단 게이지 바 애니메이션 (깜빡임 최소화 로직)
# 바가 늘어날 때 전체를 지우지 않고 새 부분만 덧칠함
bar_w = int((second / 59) * 200)
if second == 0: # 0초가 되면 바 전체를 한 번 지움
tft.fill_rect(20, 250, 200, 15, st7789.BLACK)
tft.fill_rect(20, 250, bar_w, 15, st7789.MAGENTA)
last_sec = second
time.sleep(0.05) # CPU 부하 감소 및 부드러운 루프
# 실행
try:
main()
except KeyboardInterrupt:
print("중단됨")
#벽돌깨기 데모용
from machine import Pin, SPI
import st7789py as st7789
import time
import random
import sys
import uselect
import vga2_16x32 as font # 폰트 파일 임포트 필수!
# 1. 하드웨어 SPI 설정
# SCK: 4, MOSI: 6, RST: 2, CS: 1, DC: 3
spi = SPI(1, baudrate=60000000, sck=Pin(4), mosi=Pin(6))
tft = st7789.ST7789(
spi, 240, 320,
reset=Pin(2, Pin.OUT),
cs=Pin(1, Pin.OUT),
dc=Pin(3, Pin.OUT),
rotation=0
)
# 게임 변수
WIDTH, HEIGHT = 240, 320
PADDLE_W, PADDLE_H = 80, 12
BALL_SIZE = 8
dx, dy = 6, -6 # 속도감 있게 수정
def start_game():
tft.fill(st7789.BLACK)
paddle_x = (WIDTH - PADDLE_W) // 2
ball_x, ball_y = WIDTH // 2, HEIGHT // 2
# 벽돌 생성
bricks = []
for r in range(4):
for c in range(3):
bx = c * 75 + 10
by = r * 25 + 50
color = random.getrandbits(16)
bricks.append([bx, by, color])
tft.fill_rect(bx, by, 70, 20, color)
while True:
# --- 자동 패들 모드 (입력 대신 공을 따라감) ---
old_px = paddle_x
paddle_x = ball_x - (PADDLE_W // 2)
paddle_x = max(0, min(WIDTH - PADDLE_W, paddle_x))
# 패들 잔상 제거 및 그리기
if old_px != paddle_x:
tft.fill_rect(old_px, HEIGHT-30, PADDLE_W, PADDLE_H, st7789.BLACK)
tft.fill_rect(paddle_x, HEIGHT-30, PADDLE_W, PADDLE_H, st7789.WHITE)
# --- 공 이동 및 잔상 제거 (수정됨) ---
# 현재 위치를 정수형으로 저장
ix, iy = int(ball_x), int(ball_y)
# 이전 공 위치를 공보다 살짝 크게 지우기 (잔상 방지)
tft.fill_rect(ix - 1, iy - 1, BALL_SIZE + 2, BALL_SIZE + 2, st7789.BLACK)
ball_x += dx
ball_y += dy
# 오른쪽 벽 충돌 보정 (잔상의 주범!)
if ball_x >= WIDTH - BALL_SIZE:
ball_x = WIDTH - BALL_SIZE # 좌표 강제 고정
globals()['dx'] *= -1
# 왼쪽 벽 충돌 보정
if ball_x <= 0:
ball_x = 0
globals()['dx'] *= -1
# 천장 충돌 보정
if ball_y <= 0:
ball_y = 0
globals()['dy'] *= -1
# 패들 충돌
if ball_y >= HEIGHT - 30 - BALL_SIZE and paddle_x <= ball_x <= paddle_x + PADDLE_W:
globals()['dy'] *= -1
ball_y = HEIGHT - 30 - BALL_SIZE
# 벽돌 충돌 (폰트 에러 구간 아님)
for b in bricks[:]:
if b[0] <= ball_x <= b[0] + 70 and b[1] <= ball_y <= b[1] + 20:
tft.fill_rect(b[0], b[1], 70, 20, st7789.BLACK)
bricks.remove(b)
globals()['dy'] *= -1
break
# 공 그리기
tft.fill_rect(int(ball_x), int(ball_y), BALL_SIZE, BALL_SIZE, st7789.RED)
# --- 에러 해결 지점: 폰트 객체를 반드시 전달해야 함 ---
if ball_y > HEIGHT:
tft.text(font, "GAME OVER", 40, 140, st7789.RED, st7789.BLACK)
time.sleep(2)
return # 게임 리셋
if not bricks:
tft.text(font, "YOU WIN!", 60, 140, st7789.YELLOW, st7789.BLACK)
time.sleep(2)
return
time.sleep(0.01)
# 무한 재시작
while True:
start_game()



micro2iot에 답글 남기기 응답 취소