서론
안드로이드에서 NFC 기능을 활용하면 태그를 스마트폰에 접촉하는 것만으로 간단한 텍스트를 읽거나, 원하는 데이터를 태그에 저장할 수 있습니다.
예를 들어 URL 링크 저장, 간단한 안내 문구 기록, 명함 정보 전달, 자동 실행용 데이터 저장 등 다양한 방식으로 응용할 수 있습니다.
기존 안드로이드 NFC 예제는 자바와 XML 레이아웃 기반으로 작성된 자료가 많지만, 최근에는 Kotlin과 Jetpack Compose를 사용하는 개발 환경이 점점 더 일반적이 되고 있습니다.
그래서 이번 글에서는 복잡한 기능을 한 번에 다루기보다, Jetpack Compose 기준으로 가장 단순한 NFC 읽기/쓰기 앱을 직접 만들어보는 흐름으로 정리해보겠습니다.
이번 예제의 목표는 다음과 같습니다.
- NFC 태그를 스마트폰에 대면 텍스트를 읽기
- 입력창에 입력한 문구를 NFC 태그에 텍스트로 쓰기
- Jetpack Compose 화면에서 읽기 모드 / 쓰기 모드 전환
- NDEF 텍스트 레코드 기준으로 가장 기본적인 동작 구현
즉, 이번 글은 실무에서 복잡한 NFC 구조를 바로 다루기 전에,
“Jetpack Compose에서 NFC가 어떤 흐름으로 동작하는지” 를 가장 쉽게 이해하는 데 목적이 있습니다.
본론
이번 예제에서 만들 앱의 구조
이번 샘플 앱은 매우 단순하게 구성했습니다.
- 화면에는 텍스트 입력창 하나를 둡니다.
- 사용자는 읽기 모드 또는 쓰기 모드를 선택할 수 있습니다.
- 읽기 모드에서는 NFC 태그를 대면 기존에 저장된 텍스트를 읽어 화면에 표시합니다.
- 쓰기 모드에서는 입력창에 작성한 텍스트를 NFC 태그에 기록합니다.
여기서 핵심은 Jetpack Compose는 UI를 담당하고,
실제 NFC 처리는 Activity의 lifecycle과 NFC intent 처리를 통해 이루어진다는 점입니다.
즉, 화면은 Compose로 만들지만 NFC 감지는 여전히 안드로이드의 기본 NFC 처리 흐름을 따라갑니다.

안드로이드 스튜디오에서 Empty Activity를 선택합니다.

패키지 이름과 저장할 위치를 설정합니다.
먼저 알아둘 점
NFC 기능을 사용하려면 몇 가지 전제가 필요합니다.
첫째, 테스트 기기는 반드시 NFC 기능이 있는 실제 스마트폰이어야 합니다.
에뮬레이터에서는 NFC 테스트가 사실상 어렵기 때문에 실기기 테스트가 필요합니다.
둘째, 태그는 NDEF를 지원하는 태그를 사용하는 것이 가장 편합니다.
이번 예제도 NDEF 기반 텍스트 읽기/쓰기를 기준으로 작성했습니다.
셋째, 앱에서 NFC를 사용하려면 AndroidManifest에 다음과 같은 설정이 필요합니다.
- NFC 권한 선언
- NFC 하드웨어 사용 여부 선언
- Activity의 launchMode 설정
- 필요 시 NDEF_DISCOVERED intent filter 등록
이 부분은 앱이 태그를 감지하고, 앱이 실행 중일 때 새로운 태그를 안정적으로 처리하기 위한 기본 설정입니다.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.NFC" />
<uses-feature
android:name="android.hardware.nfc"
android:required="true" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.NFCTESTAPP">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/Theme.NFCTESTAPP"
>
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
NFC 처리 흐름 이해하기
이번 예제의 전체 동작 흐름은 아래와 같습니다.
- 앱 실행
- 사용자가 읽기 모드 또는 쓰기 모드 선택
- Activity가 foreground 상태에서 NFC 태그 감지 대기
- 태그가 감지되면
onNewIntent()또는 초기intent처리 - 현재 모드에 따라 읽기 또는 쓰기 실행
- 결과를 Compose 상태에 반영하여 화면 갱신
이 구조를 이해하면, 이후 URL 저장, 웹 링크 열기, 명함 정보 쓰기, 앱 실행용 데이터 기록 같은 기능도 같은 방식으로 확장할 수 있습니다.
Compose 화면 구성
이번 앱의 화면은 Jetpack Compose로 아주 단순하게 만들었습니다.
주요 UI 요소는 다음과 같습니다.
- 현재 상태를 보여주는 텍스트
- 쓰기할 내용을 입력하는
OutlinedTextField - 읽기 모드 버튼
- 쓰기 모드 버튼
- 읽은 값을 표시하는 텍스트
Compose를 사용하는 장점은 상태 변화에 따라 화면이 자연스럽게 다시 그려진다는 점입니다.
예를 들어 태그를 읽은 뒤 readText 값이 바뀌면, 별도의 View 갱신 코드 없이 화면이 바로 변경됩니다.
이번 예제에서는 NfcUiState 데이터 클래스를 통해 다음 상태를 관리했습니다.
- 현재 모드
- 입력 텍스트
- 읽은 텍스트
- 현재 상태 메시지
이렇게 상태를 한곳에 모아두면, 나중에 기능이 늘어나도 관리가 훨씬 쉬워집니다.
package co.test.nfctestapp
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun NfcScreen(
state: NfcUiState,
onTextChange: (String) -> Unit,
onModeRead: () -> Unit,
onModeWrite: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(text = "NFC 읽기/쓰기 샘플")
Text(text = "현재 상태: ${state.status}")
OutlinedTextField(
value = state.inputText,
onValueChange = onTextChange,
modifier = Modifier.fillMaxWidth(),
label = { Text("쓰기할 텍스트") }
)
Button(
onClick = onModeRead,
modifier = Modifier.fillMaxWidth()
) {
Text("읽기 모드")
}
Button(
onClick = onModeWrite,
modifier = Modifier.fillMaxWidth()
) {
Text("쓰기 모드")
}
Text(text = "읽은 값: ${state.readText}")
}
}

compose 화면
Foreground Dispatch 설정
NFC 앱을 만들 때 중요한 부분 중 하나가 Foreground Dispatch입니다.
이 기능은 앱이 화면에 떠 있는 동안, NFC 태그가 감지되었을 때 현재 앱이 우선적으로 태그 이벤트를 받을 수 있도록 해줍니다.
이번 예제에서는 onResume() 에서 enableForegroundDispatch() 를 호출하고,onPause() 에서 disableForegroundDispatch() 를 호출하는 방식으로 처리했습니다.

이 구조를 사용하면 사용자가 앱을 보고 있는 동안 태그를 가까이 댔을 때 바로 현재 앱에서 감지할 수 있습니다.
여기서 PendingIntent를 함께 설정해 주는 이유는, 태그가 감지되었을 때 현재 Activity로 이벤트를 전달하기 위해서입니다.
또한 launchMode="singleTop" 설정을 함께 사용하면 이미 실행 중인 Activity를 재사용하면서 onNewIntent() 로 새로운 태그 이벤트를 받을 수 있어 흐름이 깔끔해집니다.

읽기 처리 구현
태그를 읽는 과정은 비교적 단순합니다.
앱이 태그를 감지하면 Tag 객체를 얻고,
그 태그가 Ndef를 지원하는지 먼저 확인합니다.

지원한다면 다음 순서로 처리합니다.
Ndef.get(tag)호출connect()로 연결ndefMessage추출- 첫 번째 레코드의 payload 분석
- 텍스트 추출 후 화면에 반영

이번 예제에서는 가장 단순한 NDEF Text Record를 읽는 구조로 만들었습니다.
즉, payload 안에서 언어 코드 길이를 읽고, 그 뒤에 있는 실제 텍스트를 UTF-8 문자열로 변환하는 방식입니다.

읽기 기능은 단순해 보이지만, 실제로는 아래 예외 상황도 생각해야 합니다.
- NDEF를 지원하지 않는 태그
- 태그가 비어 있는 경우
- 잘못된 payload 형식
- 연결 중 예외 발생
이번 샘플에서는 이런 경우 상태 메시지를 화면에 보여주도록 구성해, 테스트 중 바로 확인할 수 있게 했습니다.

NFC 태그 읽기 성공
쓰기 처리 구현
쓰기 기능은 읽기보다 한 단계 더 주의할 점이 많습니다.
기본 흐름은 다음과 같습니다.
- 먼저 입력창에 텍스트가 있는지 확인
- NDEF Text Record 생성
NdefMessage생성- 태그가
Ndef를 지원하는지 확인 - 쓰기 가능 여부 확인
- 태그의 최대 용량 확인
writeNdefMessage()실행
이번 예제에서는 사용자가 입력한 문구를 NDEF 텍스트 레코드로 만들어 태그에 기록하도록 했습니다.
또한, 태그가 아직 NDEF 포맷이 되어 있지 않은 경우를 대비해 NdefFormatable 도 함께 확인했습니다.
이 경우 포맷 후 메시지를 기록하는 방식으로 처리할 수 있습니다.
쓰기 기능에서 꼭 확인해야 하는 부분은 아래와 같습니다.
- 태그가 쓰기 가능한지
- 태그 용량이 충분한지
- NDEF 포맷이 가능한 태그인지
- 사용자가 비어 있는 텍스트를 입력하지 않았는지
이런 체크를 먼저 해두면 실제 테스트 중 실패 원인을 더 쉽게 파악할 수 있습니다.


Text Record 생성 방식
이번 예제에서 가장 중요한 부분 중 하나는 Text Record를 직접 생성하는 코드입니다.
NDEF 텍스트 레코드는 단순히 문자열만 넣는 것이 아니라, 다음 정보가 함께 들어갑니다.
- 언어 코드 길이
- 언어 코드
- 실제 텍스트 데이터
예제에서는 언어 코드를 ko로 두고, 입력한 문자열을 UTF-8 바이트 배열로 변환한 뒤 payload를 구성했습니다.
이 방식은 가장 기본적인 NDEF 텍스트 저장 방식이기 때문에,
나중에 URL 레코드, MIME 타입 레코드, 외부 타입 레코드 등으로 확장할 때도 구조를 이해하는 데 도움이 됩니다.

실제 테스트 방법
앱을 실행한 뒤 NFC가 켜져 있는 실제 스마트폰으로 테스트합니다.
먼저 읽기 모드에서 이미 텍스트가 들어 있는 태그를 대보면, 화면의 상태 메시지와 읽은 값 영역이 바뀌는 것을 확인할 수 있습니다.
그 다음 쓰기 모드로 전환한 뒤 입력창에 원하는 문구를 적고 태그를 대면,
앱이 해당 태그에 텍스트를 기록합니다.
마지막으로 다시 읽기 모드로 바꿔 같은 태그를 대면, 방금 기록한 내용이 제대로 읽히는지 확인할 수 있습니다.
테스트할 때는 다음 부분을 특히 체크하면 좋습니다.
- 쓰기 완료 후 다시 읽었을 때 같은 내용이 나오는지
- 태그에 따라 쓰기 불가 메시지가 나오는지
- 빈 입력값일 때 안내 메시지가 나오는지
- NDEF를 지원하지 않는 태그에서는 적절한 오류가 표시되는지

쓰기 모드 진입

텍스트 입력후 NFC 태그 쓰기

입력된 텍스트, 읽기 모드에서 확인
이번 예제를 확장하는 방법
이번 샘플은 아주 단순한 텍스트 읽기/쓰기에 집중했지만, 실제로는 여기서부터 다양한 기능으로 확장할 수 있습니다.
예를 들면 다음과 같습니다.
- URL 링크 저장
- 연락처 정보 저장
- 앱 실행용 데이터 구성
- Room DB에 태그 기록 저장
- 읽은 결과를 서버로 전송
- 태그 입력 내역 리스트 구성
- Compose Navigation과 연동한 다중 화면 처리
즉, 이번 글의 핵심은 완성형 앱을 만드는 것이 아니라,
Jetpack Compose와 NFC를 연결하는 가장 기본적인 출발점을 만드는 것이라고 보시면 됩니다.
package co.test.nfctestapp
import android.app.PendingIntent
import android.content.Intent
import android.nfc.NdefMessage
import android.nfc.NdefRecord
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.nfc.tech.Ndef
import android.nfc.tech.NdefFormatable
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
class MainActivity : ComponentActivity() {
private var nfcAdapter: NfcAdapter? = null
private var pendingIntent: PendingIntent? = null
private val uiState = mutableStateOf(NfcUiState())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
pendingIntent = PendingIntent.getActivity(
this,
0,
Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
0
}
)
setContent {
MaterialTheme {
NfcScreen(
state = uiState.value,
onTextChange = { newText ->
uiState.value = uiState.value.copy(inputText = newText)
},
onModeRead = {
uiState.value = uiState.value.copy(
mode = NfcMode.READ,
status = "읽기 모드입니다. 태그를 스마트폰에 대세요."
)
},
onModeWrite = {
uiState.value = uiState.value.copy(
mode = NfcMode.WRITE,
status = "쓰기 모드입니다. 태그를 스마트폰에 대세요."
)
}
)
}
}
handleIntent(intent)
}
override fun onResume() {
super.onResume()
nfcAdapter?.enableForegroundDispatch(
this,
pendingIntent,
null,
null
)
}
override fun onPause() {
super.onPause()
nfcAdapter?.disableForegroundDispatch(this)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent?) {
if (intent == null) return
val action = intent.action
if (
action == NfcAdapter.ACTION_TAG_DISCOVERED ||
action == NfcAdapter.ACTION_NDEF_DISCOVERED ||
action == NfcAdapter.ACTION_TECH_DISCOVERED
) {
val tag: Tag? = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG)
if (tag != null) {
when (uiState.value.mode) {
NfcMode.READ -> readFromTag(tag)
NfcMode.WRITE -> writeToTag(tag, uiState.value.inputText)
}
}
}
}
private fun readFromTag(tag: Tag) {
try {
val ndef = Ndef.get(tag)
if (ndef == null) {
uiState.value = uiState.value.copy(
status = "NDEF를 지원하지 않는 태그입니다."
)
return
}
ndef.connect()
val ndefMessage = ndef.ndefMessage
val records = ndefMessage?.records.orEmpty()
val text = records.firstOrNull()?.let { parseTextRecord(it) } ?: "읽을 데이터가 없습니다."
ndef.close()
uiState.value = uiState.value.copy(
readText = text,
status = "태그 읽기 성공"
)
} catch (e: Exception) {
Log.e("NFC", "read error", e)
uiState.value = uiState.value.copy(
status = "읽기 실패: ${e.message}"
)
}
}
private fun writeToTag(tag: Tag, text: String) {
if (text.isBlank()) {
uiState.value = uiState.value.copy(status = "쓰기할 문구를 입력하세요.")
return
}
val message = NdefMessage(
arrayOf(createTextRecord(text))
)
try {
val ndef = Ndef.get(tag)
if (ndef != null) {
ndef.connect()
if (!ndef.isWritable) {
uiState.value = uiState.value.copy(status = "쓰기 불가능한 태그입니다.")
ndef.close()
return
}
val size = message.toByteArray().size
if (ndef.maxSize < size) {
uiState.value = uiState.value.copy(status = "태그 용량이 부족합니다.")
ndef.close()
return
}
ndef.writeNdefMessage(message)
ndef.close()
uiState.value = uiState.value.copy(status = "태그 쓰기 성공")
return
}
val format = NdefFormatable.get(tag)
if (format != null) {
format.connect()
format.format(message)
format.close()
uiState.value = uiState.value.copy(status = "포맷 후 태그 쓰기 성공")
return
}
uiState.value = uiState.value.copy(status = "NDEF 쓰기를 지원하지 않는 태그입니다.")
} catch (e: Exception) {
Log.e("NFC", "write error", e)
uiState.value = uiState.value.copy(status = "쓰기 실패: ${e.message}")
}
}
private fun createTextRecord(text: String): NdefRecord {
val languageCode = "ko".toByteArray(Charsets.US_ASCII)
val textBytes = text.toByteArray(Charsets.UTF_8)
val payload = ByteArray(1 + languageCode.size + textBytes.size)
payload[0] = languageCode.size.toByte()
System.arraycopy(languageCode, 0, payload, 1, languageCode.size)
System.arraycopy(textBytes, 0, payload, 1 + languageCode.size, textBytes.size)
return NdefRecord(
NdefRecord.TNF_WELL_KNOWN,
NdefRecord.RTD_TEXT,
ByteArray(0),
payload
)
}
private fun parseTextRecord(record: NdefRecord): String {
val payload = record.payload
val languageCodeLength = payload[0].toInt() and 0x3F
return String(
payload,
1 + languageCodeLength,
payload.size - 1 - languageCodeLength,
Charsets.UTF_8
)
}
}
enum class NfcMode {
READ, WRITE
}
data class NfcUiState(
val mode: NfcMode = NfcMode.READ,
val inputText: String = "",
val readText: String = "",
val status: String = "읽기 모드입니다. 태그를 스마트폰에 대세요."
)
MainActivity.kt 전문
결말
이번 글에서는 Jetpack Compose 기준으로 NFC 태그를 읽고 쓰는 가장 단순한 안드로이드 앱을 만들어보았습니다.
전체 흐름을 다시 정리하면 다음과 같습니다.
- Manifest에서 NFC 사용 선언
- Activity에서 Foreground Dispatch 설정
- Compose UI에서 읽기/쓰기 모드 전환
- 태그 감지 시 현재 모드에 따라 읽기 또는 쓰기 처리
- 결과를 상태값으로 관리하고 화면에 반영
예전에는 NFC 예제가 대부분 XML 레이아웃과 자바 중심으로 정리되어 있었지만,
이번처럼 Compose 방식으로 구현해두면 최신 안드로이드 개발 흐름에도 더 자연스럽게 연결할 수 있습니다.
특히 상태 관리와 UI 갱신이 훨씬 단순해져서,
이후 NFC 기록 내역 화면, 태그 상세 화면, 자동 실행 기능 같은 확장 작업도 한결 수월해집니다.
맺음말
NFC 기능은 처음 접하면 다소 어렵게 느껴질 수 있지만,
실제로는 “태그를 감지하고, 데이터를 읽고, 필요하면 기록한다” 는 기본 흐름을 이해하면 점점 응용 범위를 넓혀갈 수 있습니다.
이번 글에서는 가장 기초적인 Text Record 기준으로 읽기/쓰기를 구현해 보았지만,
이 정도만 제대로 동작해도 NFC 앱 개발의 핵심 구조는 이미 익힌 셈입니다.
다음 단계에서는 다음과 같은 주제도 충분히 이어서 다룰 수 있습니다.
- URL 저장용 NFC 앱 만들기
- Jetpack Compose에서 NFC 기록 리스트 화면 만들기
- Room DB와 연결한 태그 기록 관리
- 실제 서비스용 NFC 입력/읽기 앱 구조 만들기
Jetpack Compose 기반으로 NFC 앱을 직접 만들어보고 싶다면,
이번 예제를 먼저 실기기에서 충분히 테스트해 본 뒤 조금씩 기능을 확장해보는 것을 추천드립니다.