Frontend
전시물 앞에 서면, 앱은 어떻게 알까요?
김기한·
안녕하세요. 전시 콘텐츠와 관람 앱을 만드는 개발팀입니다.
저희는 웹 개발과 함께 다양한 개발을 하고 있습니다 !!
저희가 서비스하는 앱 중에 국립과학관 가이드 입을 운영 중인다. 국립과천과학관에 관람 앱으로는 오디오 가이드를 듣고, 추천 관람 동선을 받고, 인기 전시 TOP10을 둘러볼 수 있어요. 이 앱에는 작은 기능이 하나 숨어 있어요. 전시물 앞에 서 있으면 앱이 "지금 보고 계신 전시"를 알아서 찾아 주는 기능이에요.
간단하게 생각할지 모르지만, 생각보다 많이 기능이 있답니다 .
야외라면 GPS로 해결되겠지만, 전시관은 실내라 GPS 신호가 거의 들어오지 않아요. 들어오더라도 전시물 하나하나를 구분할 만큼 정확하지 않아요. 그래서 전시관 곳곳에 작은 블루투스 비콘을 두고, 앱이 그 신호를 듣게 했어요.
"관람객이 아무것도 누르지 않아도, 앱이 내 앞의 전시물을 알아본다."
이 글은 이 한 문장이 실제 코드로 어떻게 구현돼 있는지 따라가 본 기록이에요. 생각보다 규칙은 단순했어요.
1. 가장 센 신호가, 내 앞의 전시물이에요
비콘은 자기 이름을 계속 방송하는 작은 블루투스 기기예요. 앱은 주변 신호를 잠깐 모은 뒤 신호 세기(RSSI)로 줄을 세워요. RSSI는 dBm 단위의 음수라서 0에 가까울수록 신호가 세고, 대체로 더 가까이 있다는 뜻이에요.
여기서 재밌는 건 비콘 이름이에요. 전시관 안내도를 보면 전시물마다 A1, B15, E4 같은 번호가 붙어 있는데, 비콘 이름도 같은 규칙(구역 알파벳 A~E + 번호)을 따라요. 그래서 이름 규칙에 맞는 신호만 남기면, 근처 사람들의 이어폰이나 스마트워치 신호는 자연스럽게 걸러져요.

screens/hooks/useStrongestBeaconQuery.js
await BleManager.scan([], 6, true); // 6초 동안 주변 BLE 신호 수집
setTimeout(async () => {
const peripherals = await BleManager.getDiscoveredPeripherals([]);
const sortedByRssi = peripherals
.filter((p) => {
const name = p?.name || p?.advertising?.localName || "";
const cleanName = name.replace(/\(\d+\)/g, ""); // "A9(2)" → "A9"
return /^[A-E]\d+$/.test(cleanName); // 구역 A~E + 번호만 통과
})
.sort((a, b) => b.rssi - a.rssi); // 신호가 센 순서로
if (sortedByRssi.length === 0) return;
const strongest = sortedByRssi[0];
const beaconName = (strongest.name || strongest.advertising?.localName)
.replace(/\(\d+\)/g, "");
// 1등 비콘 이름으로 가까운 전시 정보를 서버에 묻는다
const result = await trigger(beaconName).unwrap();
setData(result);
}, 8000);
screens/hooks/useStrongestBeaconQuery.js
스캔은 6초인데 결과는 8초 뒤에 읽어요. 스캔이 확실히 끝난 뒤에 목록을 읽도록 2초 여유를 둔 거예요. 그리고 앱은 비콘에 연결하지 않아요. 방송을 듣기만 하니까 비콘 수십 개가 있어도, 관람객 수백 명이 동시에 들어도 서로 방해하지 않아요.
2. 지도 위에 '내 위치' 점 찍기
전시 상세 화면은 1등만 알면 충분하지만, 전시관 지도 화면은 사정이 달라요. 관람객은 전시물 바로 앞이 아니라 전시물과 전시물 사이를 걷고 있을 때가 더 많거든요. 그래서 지도 화면은 상황을 두 가지로 나눠요.
충분히 가까울 때: 1등 신호가 기준보다 세면 그 전시물 위치에 바로 점을 찍어요.
애매할 때: 상위 두 비콘 사이 어딘가에 점을 찍되, 신호가 센 쪽으로 더 가깝게 찍어요.
// screens/MuseumMapScreen.js
const top = filteredBeacons[0];
const isCeiling = isCeilingBeacon(top.exhibit.id);
// 충분히 가까우면 그 전시물 위치에 바로 점을 찍는다
if (!isCeiling && top.rssi > -65) {
const nearest = findNearestExhibit(top.exhibit);
setCurrentPosition({ x: nearest.x, y: nearest.y });
return;
}
if (isCeiling && top.rssi > -70) { // 천장 비콘은 기준을 조금 느슨하게
const nearest = findNearestExhibit(top.exhibit);
setCurrentPosition({ x: nearest.x, y: nearest.y });
return;
}
// 애매하면 상위 두 비콘 사이에 찍는다
const [s1, s2] = filteredBeacons;
const w1 = Math.abs(s1.rssi); // 절댓값이 작을수록 가깝다
const w2 = Math.abs(s2.rssi);
// 가중치를 엇갈려 곱해서, 신호가 센 쪽으로 점이 끌려가게 한다
const x = (s1.exhibit.x * w2 + s2.exhibit.x * w1) / (w1 + w2);
const y = (s1.exhibit.y * w2 + s2.exhibit.y * w1) / (w1 + w2);
setCurrentPosition({ x, y });마지막 두 줄이 이 화면의 핵심이에요. 가중치를 엇갈려 곱하고 있죠. RSSI 절댓값은 멀수록 커지니까, 그대로 곱하면 오히려 먼 비콘 쪽으로 점이 끌려가요. 그래서 상대방의 값을 곱해요.
예를 들어 A 비콘이 -60, B 비콘이 -80이면 점은 A와 B 사이에서 A 쪽으로 약 57% 지점(80 ÷ 140)에 찍혀요. 신호 세기가 거리와 정확히 비례하지는 않지만, "센 쪽에 더 가깝다"는 감각을 지도 위에 옮기기에는 충분했어요.
천장에 달린 비콘은 기준을 -65가 아니라 -70으로 잡아요. 손에 든 휴대폰과 거리가 더 떨어져 있어서, 같은 자리에서도 바닥 근처 비콘보다 신호가 약하게 잡히기 때문이에요. 또 1등 비콘과 지도상 위치가 크게 떨어진 비콘은 계산 전에 미리 빼요. 엉뚱한 구역의 신호가 섞여 점이 튀는 걸 막기 위해서예요.
3. 비콘이 쉬지 않고 외치는 23바이트
그런데 비콘은 정확히 무엇을 방송하고 있을까요? 애플이 정한 iBeacon 형식에서 비콘의 정체는 23바이트짜리 광고 데이터에 담겨 있어요. 블루투스 광고에는 "어느 회사 형식인지"를 알려 주는 제조사 코드가 붙는데, iBeacon은 애플의 코드인 004C를 써요.
// screens/hooks/useBeaconScanner.js (발췌·단순화)
const parseIBeaconData = (device) => {
// 004C = Apple 제조사 코드. iBeacon 광고 데이터가 여기에 담긴다
const data = device.advertising?.manufacturerData?.["004c"];
if (!data?.bytes || data.bytes.length < 23) return null;
const bytes = data.bytes;
const hex = (arr) => arr.map((b) => b.toString(16).padStart(2, "0")).join("");
// [2..17] 16바이트를 8-4-4-4-12 자리로 끊으면 우리가 아는 UUID 모양이 된다
const uuid = [
bytes.slice(2, 6),
bytes.slice(6, 8),
bytes.slice(8, 10),
bytes.slice(10, 12),
bytes.slice(12, 18),
].map(hex).join("-");
// 2바이트를 숫자 하나로: 앞 바이트를 8비트 밀고 뒤 바이트를 합친다
const major = (bytes[18] << 8) | bytes[19];
const minor = (bytes[20] << 8) | bytes[21];
return { uuid, major, minor };
};UUID는 "어느 기관·서비스의 비콘인지", major와 minor는 "그 안에서 몇 번째 비콘인지"를 나타내요. major와 minor는 각각 2바이트라서 << 8로 앞 바이트를 한 칸(8비트) 밀어 올린 뒤 뒤 바이트와 합쳐요. 0x00, 0x09가 9가 되는 식이에요.
이 코드는 2장의 지도 화면이 쓰는 스캐너에 들어 있어서, 잡힌 신호마다 이름과 함께 UUID·major·minor가 붙어요. 이름은 사람이 붙인 라벨이고, 바이트는 비콘이 실제로 방송하는 값이라 둘을 함께 보면 신호를 더 확실하게 구분할 수 있어요.
4. 이름 없이 관람 흐름 읽기
비콘은 관람객을 돕는 데서 끝나지 않아요. 어느 전시 앞에 사람이 많이 머무는지, 어떤 영상과 해설 패널이 많이 열리는지 알면 전시 운영에도 도움이 돼요. 다만 관람객이 누구인지는 알 필요가 없어요.
그래서 앱은 처음 실행될 때 무작위 ID를 하나 만들어 기기 안에 저장하고, 그 ID로만 기록을 남겨요.
// screens/utils/uuidStorage.js
export const getStoredUuid = async () => {
const existing = await AsyncStorage.getItem("USER_UUID");
if (existing) return existing; // 이미 있으면 그대로 쓴다
const newUuid = uuid.v4(); // 처음 실행이면 무작위 ID를 만든다
await AsyncStorage.setItem("USER_UUID", newUuid);
return newUuid;
};
// 전시 상세 화면 (발췌) — 어느 비콘 앞에 있었는지, 무엇을 눌렀는지만 보낸다
const userUuid = await getStoredUuid();
await postBeaconDensity({ userUid: userUuid, beaconName });
postContentClick({ exhibitId, contentType: "YOUTUBE", contentSlot: 1, userUuid });비콘과 콘텐츠 기록에 실려 가는 건 무작위 ID, 비콘 이름, 누른 콘텐츠 종류뿐이에요. 앱은 첫 화면에서 연령대·성별 같은 간단한 설문만 받고, 이름이나 전화번호는 묻지 않아요. 그래도 같은 ID의 기록을 모으면 "B 구역을 지난 관람객이 C 구역의 어느 전시에서 오래 머물렀는지" 같은 흐름은 읽을 수 있어요.
콘텐츠 기록은 화면을 막지 않도록 응답을 기다리지 않고 보내요. 기록이 실패해도 관람객은 아무것도 느끼지 못해야 하니까요.
정리
코드를 다시 읽어 보니, "전시물을 알아보는 앱"은 복잡한 측위 기술이 아니라 단순한 규칙 몇 개로 움직이고 있었어요.
신호가 가장 센 비콘이 내 앞의 전시물이다.
비콘 이름을 전시 번호와 같게 지으면, 이름이 곧 필터가 된다.
애매할 땐 두 비콘 사이에, 센 쪽으로 더 가깝게 점을 찍는다.
관람 흐름은 이름 대신 무작위 ID로 읽는다.
블루투스 신호는 사람 몸이나 벽에 따라 출렁이기 때문에 이 규칙이 늘 완벽하지는 않아요. 그래도 관람객이 아무것도 누르지 않아도 앱이 먼저 말을 걸게 만드는 데는 충분했어요. 다음에 과천과학관에 가신다면 앱을 켜고 전시물 앞에 잠깐 서 보세요.
읽기 도구
약 13분 읽기
이 글이 도움이 되었나요?
Work with GIWorks
프로젝트에 GIWorks의 경험이 필요하신가요?
전시·콘텐츠·엔지니어링 프로젝트의 아이디어와 고민을 들려주세요. 필요한 기술과 실행 방법을 함께 찾겠습니다.
문의하기