Skip to main content
Summary

화면은 장치의 얼굴이다

지금까지 만든 회로는 주로 컴퓨터의 시리얼 모니터를 통해 상태를 확인했다. 실습 중에는 충분하지만, 장치를 책상 위에 따로 놓고 쓰려면 문제가 생긴다. 컴퓨터를 켜고 시리얼 모니터를 열어야만 값을 볼 수 있기 때문이다.

화면을 붙이면 장치의 성격이 달라진다. 온도계는 온도를 바로 보여 주고, 거리 측정기는 거리를 표시하며, 조도 조절기는 현재 밝기 단계를 알려 준다. 여기에 버튼까지 더하면 사용자가 값을 바꾸거나 모드를 선택할 수 있다. 이때부터 프로젝트는 단순한 실험 회로가 아니라 작은 제품처럼 느껴진다.

이번 장에서는 화면을 크게 두 종류로 다룬다.

  • 16x2 LCD: 한 줄에 16글자씩, 2줄을 표시하는 문자 화면

  • I2C OLED: 작은 그래픽 화면으로, 글자와 간단한 도형을 표시할 수 있는 화면

둘 다 입문 실습에서는 보통 I2C 방식으로 연결한다. I2C는 여러 장치가 두 개의 신호선으로 통신하는 방식이다. 화면과 아두이노 사이에 많은 선을 연결하지 않아도 되므로 배선 실수가 줄어든다.

화면과 사용자 인터페이스 만들기AI 생성 이미지

I2C 화면의 네 가닥 선 확인하기

I2C 화면 모듈에는 보통 다음 네 핀이 있다.

핀 이름역할
VCC화면에 전원을 공급한다. 보통 5V 또는 3.3V를 사용한다.
GND전기가 돌아오는 기준점이다. 아두이노 GND와 반드시 연결한다.
SDA데이터를 주고받는 선이다.
SCL통신 박자를 맞추는 클록 선이다.

연결은 단순하지만, 확인할 점은 단순하지 않다. 먼저 화면 모듈의 전원 전압을 확인해야 한다. 어떤 모듈은 5V를 받아도 되지만, 어떤 OLED 모듈은 3.3V만 허용한다. 판매 페이지나 모듈 뒷면 표기를 보고 VCC에 연결할 전압을 정한다.

아두이노 Uno 계열에서는 보통 다음처럼 연결한다.

화면 모듈Arduino Uno
VCC5V 또는 3.3V
GNDGND
SDASDA 핀 또는 A4
SCLSCL 핀 또는 A5

Uno R3에서는 A4가 SDA, A5가 SCL 역할을 한다. 보드 위쪽에 따로 SDA, SCL이라고 적힌 핀이 있는 경우에도 같은 I2C 선에 연결되어 있다. 다른 보드를 쓴다면 반드시 보드 핀맵에서 SDA와 SCL 위치를 확인한다.

연결 후 화면이 켜지지 않으면 다음 순서로 점검한다.

  1. VCC와 GND가 바뀌지 않았는가?

  2. 3.3V 전용 모듈에 5V를 넣지 않았는가?

  3. SDA와 SCL을 서로 바꿔 꽂지 않았는가?

  4. LCD 뒷면의 가변저항으로 대비가 너무 낮게 설정되지 않았는가?

  5. I2C 주소가 코드와 맞는가?

특히 16x2 LCD는 백라이트가 켜져도 글자가 안 보일 수 있다. 이때는 고장이라고 판단하기 전에 LCD 뒷면의 작은 파란 가변저항을 드라이버로 천천히 돌려 본다. 대비가 맞지 않으면 글자가 출력되어도 빈 화면처럼 보인다.

I2C 주소 스캐너로 화면 찾기

I2C 장치는 각자 주소를 가진다. 집마다 주소가 있어야 우편물이 정확히 도착하는 것처럼, 아두이노도 주소를 알아야 특정 화면에 명령을 보낼 수 있다.

16x2 LCD I2C 모듈은 흔히 0x27 또는 0x3F를 사용한다. SSD1306 OLED는 흔히 0x3C 또는 0x3D를 사용한다. 하지만 모듈마다 다를 수 있으므로 외워서 쓰기보다 직접 확인하는 습관이 좋다.

다음 스케치를 업로드한 뒤 시리얼 모니터를 9600 baud로 연다.

#include <Wire.h>

void setup() {
  Serial.begin(9600);
  Wire.begin();

  Serial.println("I2C scan start");

  for (byte address = 1; address < 127; address++) {
    Wire.beginTransmission(address);
    byte error = Wire.endTransmission();

    if (error == 0) {
      Serial.print("Found I2C device at 0x");
      if (address < 16) Serial.print("0");
      Serial.println(address, HEX);
    }
  }

  Serial.println("I2C scan done");
}

void loop() {
}

예를 들어 시리얼 모니터에 다음처럼 나오면 화면 주소는 0x27이다.

Found I2C device at 0x27

아무 주소도 나오지 않으면 코드를 고치기 전에 배선을 먼저 본다. I2C 주소 스캐너는 화면을 예쁘게 출력하기 위한 코드가 아니라, 아두이노와 화면이 실제로 통신하고 있는지 확인하는 진단 도구다.

16x2 LCD에 첫 글자 출력하기

16x2 LCD는 글자 표시가 목적일 때 쓰기 좋다. 온도, 습도, 거리, 메뉴 이름처럼 짧은 정보를 보여 주기에 충분하다.

Arduino IDE의 Library Manager에서 LiquidCrystal_I2C 라이브러리를 설치한다. 같은 이름의 라이브러리가 여러 개 있을 수 있지만, 입문 단계에서는 예제 코드가 함께 제공되는 널리 쓰이는 라이브러리를 선택하면 된다. 이 책의 예제는 lcd.init()을 사용하는 형태로 설명한다. 설치한 라이브러리의 예제가 lcd.begin()을 쓴다면, 그 라이브러리에서는 초기화 함수 이름이 조금 다르다고 이해하면 된다.

먼저 가장 작은 코드로 화면이 살아 있는지 확인한다.

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

LiquidCrystal_I2C lcd(0x27, 16, 2);

void setup() {
  lcd.init();
  lcd.backlight();
  lcd.setCursor(0, 0);
  lcd.print("Hello");
}

void loop() {
}

주소 스캐너에서 0x3F가 나왔다면 첫 줄을 다음처럼 바꾼다.

LiquidCrystal_I2C lcd(0x3F, 16, 2);

lcd.setCursor(0, 0)은 첫 번째 칸, 첫 번째 줄로 커서를 옮긴다는 뜻이다. LCD 좌표는 0부터 시작한다. 따라서 첫 줄의 맨 왼쪽은 (0, 0), 둘째 줄의 맨 왼쪽은 (0, 1)이다.

글자가 깨지거나 이상한 위치에 찍히면 다음을 확인한다.

  • LCD 크기를 16, 2로 맞췄는가?

  • 주소가 실제 주소와 같은가?

  • lcd.init()과 lcd.backlight()를 setup()에서 실행했는가?

  • 너무 긴 문자열을 한 줄에 출력하지 않았는가?

16x2 LCD는 한 줄에 16칸뿐이다. 긴 문장을 출력하기보다 Temp: 23.5C처럼 짧고 일정한 형식으로 표시하는 것이 좋다.

OLED에 첫 글자 출력하기

OLED는 LCD보다 작고 선명한 경우가 많다. 또한 글자 크기를 바꾸거나 간단한 선과 사각형을 그릴 수 있다. 대신 그래픽 화면이므로 보통 Adafruit SSD1306과 Adafruit GFX 같은 라이브러리를 함께 사용한다.

Library Manager에서 다음 라이브러리를 설치한다.

  • Adafruit SSD1306

  • Adafruit GFX Library

많이 쓰는 0.96인치 OLED는 해상도가 128x64인 경우가 많다. 주소는 보통 0x3C다. 주소 스캐너 결과가 다르면 코드의 주소를 바꾼다.

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

Adafruit_SSD1306 display(128, 64, &Wire, -1);

void setup() {
  display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  display.clearDisplay();

  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(0, 0);
  display.println("Hello OLED");
  display.display();
}

void loop() {
}

OLED에서는 display.display()가 중요하다. 글자를 쓰는 명령은 화면에 바로 나타나는 것이 아니라, 내부 버퍼에 먼저 그려진다. 마지막에 display.display()를 호출해야 실제 화면으로 전송된다. 글자를 바꿀 때는 보통 clearDisplay()로 지우고, 새 내용을 그린 뒤, 다시 display()를 호출한다.

화면이 나오지 않으면 LCD와 마찬가지로 전원, GND, SDA, SCL, 주소를 확인한다. OLED는 모듈에 따라 3.3V 전용일 수 있으므로 전원 전압 확인을 특히 조심한다. 해상도가 128x32인 OLED를 쓰는 경우에는 코드의 높이 값도 64가 아니라 32에 맞춰야 한다.

센서 값을 읽기 쉽게 표시하기

화면에 값을 띄우는 일은 단순히 숫자를 출력하는 것이 아니다. 사람이 읽기 쉬운 형식으로 정리하는 일이다.

나쁜 예를 먼저 보자.

537

이 숫자만 보면 무엇을 뜻하는지 알기 어렵다. 조도인지, 거리인지, 온도인지, 가변저항 값인지 구분할 수 없다.

조금 나은 표시는 다음과 같다.

Light: 537

더 좋은 표시는 단위나 범위를 함께 보여 준다.

Light: 52%

이번에는 A0에 연결한 가변저항 값을 0~100%로 바꾸어 LCD에 표시해 보자. 조도센서를 연결했다면 같은 구조로 밝기 정도를 표시할 수 있다.

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

LiquidCrystal_I2C lcd(0x27, 16, 2);

const int sensorPin = A0;

void setup() {
  lcd.init();
  lcd.backlight();
}

void loop() {
  int raw = analogRead(sensorPin);
  int percent = map(raw, 0, 1023, 0, 100);

  lcd.setCursor(0, 0);
  lcd.print("Level Monitor  ");

  lcd.setCursor(0, 1);
  lcd.print("Value: ");
  lcd.print(percent);
  lcd.print("%   ");

  delay(300);
}

줄 끝의 공백은 일부러 넣었다. LCD는 이전에 찍힌 글자를 자동으로 모두 지워 주지 않는다. 예를 들어 100%가 표시된 뒤 9%를 출력하면 화면에 900%처럼 남을 수 있다. 그래서 짧아진 글자 뒤쪽을 공백으로 덮어 주어야 한다.

화면 갱신 주기도 중요하다. 센서값은 계속 변하지만, 화면을 너무 자주 지우고 다시 그리면 깜빡임이 생긴다. 온도와 습도처럼 천천히 변하는 값은 1~2초에 한 번이면 충분하다. 가변저항처럼 사용자가 돌리는 값도 200~500ms 정도면 읽기에 자연스럽다.

소수점도 필요한 만큼만 보여 준다. 온도를 23.4672C처럼 표시하면 정밀해 보이지만 실제 센서 정확도보다 과한 정보일 수 있다. 보통은 23.5C처럼 소수점 한 자리면 충분하다.

float temp = 23.4672;
lcd.print(temp, 1);  // 23.5처럼 소수점 한 자리 출력

좋은 화면 표시는 세 가지를 갖춘다.

  1. 이름: 무엇을 나타내는 값인지 알려 준다.

  2. 단위: C, %, cm처럼 해석 기준을 붙인다.

  3. 갱신 주기: 너무 느리지도, 너무 빠르지도 않게 바꾼다.

버튼으로 메뉴 만들기

화면이 값을 보여 주는 역할이라면, 버튼은 사용자의 선택을 장치에 전달하는 역할이다. 버튼 한 개로도 모드를 바꿀 수 있지만, 메뉴를 만들 때는 보통 두세 개가 편하다.

여기서는 버튼 세 개를 사용한다.

버튼역할
UP항목을 위로 이동하거나 값을 증가시킨다.
DOWN항목을 아래로 이동하거나 값을 감소시킨다.
SELECT현재 항목을 선택하거나 설정 화면으로 들어간다.

버튼은 이전에 배운 것처럼 INPUT_PULLUP으로 연결한다. 한쪽 다리는 아두이노 입력 핀에, 다른 쪽 다리는 GND에 연결한다. 이 방식에서는 버튼을 누르지 않았을 때 HIGH, 눌렀을 때 LOW로 읽힌다.

아래 예제는 LCD에 메뉴 항목을 표시하고, UP/DOWN 버튼으로 항목을 바꾸는 최소 구조다.

#include <Wire.h>
#include <LiquidCrystal_I2C.h>

LiquidCrystal_I2C lcd(0x27, 16, 2);

const int upButton = 2;
const int downButton = 3;
const int selectButton = 4;

const char* menuItems[] = {"Light", "Temp", "Setting"};
const int menuCount = 3;
int menuIndex = 0;

bool lastUp = HIGH;
bool lastDown = HIGH;
bool lastSelect = HIGH;

void setup() {
  pinMode(upButton, INPUT_PULLUP);
  pinMode(downButton, INPUT_PULLUP);
  pinMode(selectButton, INPUT_PULLUP);

  lcd.init();
  lcd.backlight();
  showMenu();
}

void loop() {
  bool nowUp = digitalRead(upButton);
  bool nowDown = digitalRead(downButton);
  bool nowSelect = digitalRead(selectButton);

  if (lastUp == HIGH && nowUp == LOW) {
    menuIndex--;
    if (menuIndex < 0) menuIndex = menuCount - 1;
    showMenu();
    delay(80);
  }

  if (lastDown == HIGH && nowDown == LOW) {
    menuIndex++;
    if (menuIndex >= menuCount) menuIndex = 0;
    showMenu();
    delay(80);
  }

  if (lastSelect == HIGH && nowSelect == LOW) {
    showSelected();
    delay(80);
  }

  lastUp = nowUp;
  lastDown = nowDown;
  lastSelect = nowSelect;
}

void showMenu() {
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("Menu");

  lcd.setCursor(0, 1);
  lcd.print("> ");
  lcd.print(menuItems[menuIndex]);
}

void showSelected() {
  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("Selected:");

  lcd.setCursor(0, 1);
  lcd.print(menuItems[menuIndex]);
}

이 코드는 버튼이 눌리는 순간만 감지한다. 핵심은 현재 버튼 상태와 이전 버튼 상태를 비교하는 부분이다.

if (lastDown == HIGH && nowDown == LOW) {

HIGH에서 LOW로 바뀌었다는 것은 버튼을 누르지 않은 상태에서 누른 상태로 넘어갔다는 뜻이다. 버튼을 계속 누르고 있는 동안 같은 동작이 반복되는 것을 줄일 수 있다.

예제 안의 delay(80)은 아주 간단한 디바운싱 역할을 한다. 버튼 접점이 튀면서 여러 번 눌린 것처럼 읽히는 문제를 줄이기 위한 짧은 대기다. 입문용 예제에서는 이 정도로도 차이를 느낄 수 있지만, 나중에 더 안정적인 프로그램을 만들 때는 millis()를 사용해 이 대기 시간도 없애는 편이 좋다.

화면 출력 코드를 함수로 분리하기

화면을 쓰기 시작하면 loop()가 금방 복잡해진다. 센서 읽기, 버튼 확인, 메뉴 처리, 화면 갱신 코드가 한곳에 섞이면 어디에서 문제가 생겼는지 찾기 어렵다.

좋은 기준은 간단하다.

  • loop()에는 전체 흐름만 남긴다.

  • 화면에 그리는 코드는 함수로 뺀다.

  • 센서를 읽는 코드도 필요하면 함수로 뺀다.

  • 버튼 처리는 현재 상태를 바꾸는 역할에 집중한다.

예를 들어 조도값을 읽고, 버튼으로 표시 모드를 바꾸는 구조는 다음처럼 정리할 수 있다.

void loop() {
  readButtons();
  readSensor();
  updateDisplay();
}

이렇게 쓰면 loop()만 봐도 프로그램이 무엇을 하는지 알 수 있다. 버튼을 읽고, 센서를 읽고, 화면을 갱신한다. 세부 내용은 각 함수 안으로 들어간다.

아래 코드는 전체 스케치가 아니라, 함수로 나누는 감각을 보여 주기 위한 핵심 조각이다.

int rawValue = 0;
int lightPercent = 0;
int screenMode = 0;

void readSensor() {
  rawValue = analogRead(A0);
  lightPercent = map(rawValue, 0, 1023, 0, 100);
}

void updateDisplay() {
  lcd.setCursor(0, 0);

  if (screenMode == 0) {
    lcd.print("Light Monitor  ");
    lcd.setCursor(0, 1);
    lcd.print("Light: ");
    lcd.print(lightPercent);
    lcd.print("%   ");
  } else {
    lcd.print("Raw View       ");
    lcd.setCursor(0, 1);
    lcd.print("Raw: ");
    lcd.print(rawValue);
    lcd.print("    ");
  }
}

함수로 나눈다고 해서 프로그램이 자동으로 좋아지는 것은 아니다. 함수 이름이 분명해야 한다. doThing()보다 updateDisplay()가 낫고, check()보다 readButtons()가 낫다. 이름만 보고 역할을 예상할 수 있어야 나중에 다시 읽기 쉽다.

작은 제품처럼 마무리하기

화면과 버튼을 붙일 때 가장 흔한 실수는 기능을 한 번에 많이 넣는 것이다. 온도, 습도, 조도, 거리, 알람, 설정값을 모두 한 화면에 넣으려 하면 오히려 읽기 어려워진다.

작은 장치일수록 화면은 간결해야 한다.

Temp: 23.5C
Hum : 48%

또는

> Light
  Setting

이 정도면 충분하다. 사용자가 지금 무엇을 보고 있는지, 어떤 버튼을 누르면 무엇이 바뀌는지만 알 수 있으면 된다.

마지막으로 점검 기준을 정리하자.

  • 화면이 아예 안 켜지면 전원과 GND를 먼저 확인한다.

  • 백라이트만 켜지고 글자가 없으면 주소와 LCD 대비를 확인한다.

  • OLED가 반응하지 않으면 주소, 해상도, 전원 전압을 확인한다.

  • 숫자가 읽기 어렵다면 이름, 단위, 소수점 자리수를 정리한다.

  • 메뉴가 여러 번 움직이면 버튼 디바운싱을 확인한다.

  • loop()가 길어지면 화면 출력과 버튼 처리를 함수로 분리한다.

화면은 장치가 말하는 방법이고, 버튼은 사용자가 대답하는 방법이다. 이 두 가지가 들어가면 아두이노 프로젝트는 실험 회로에서 사용 가능한 도구로 한 단계 올라간다.

책 목록

아두이노 학습: 기초 회로부터 IoT 캡스톤까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기