2026/04/29

오늘의 이야기

 



Jetpack Compose로 Google Map과 ARCore 연동하기: 카메라 방향 화살표 UI 만들기 🗺️ AR


미해결



 


최근 Jetpack Compose를 사용하여 안드로이드 앱을 개발하던 중 흥미로운 아이디어가 떠올랐습니다. 바로 Google Map 위에 AR(증강현실)을 오버레이하여, 내가 바라보는 방향을 화살표로 알려주는 기능이었죠. 이 포스트는 그 아이디어를 현실로 만들어가는 과정을 기록한 것입니다.




1단계: 목표 설정 및 현재 진행 상황



나의 목표: ARCore 예제를 참고해서 Google Map을 Compose로 보여주고, 그 위에 ARCore가 제시하는 정보를 활용해서 내가 바라보는 방향을 향해 화살표를 렌더링하는 UI를 구성하고 싶어. 현재는 Google Map에 나의 마지막 위치를 얻어서 표시하는 기능까지는 구현했어. 이번에 하고 싶은 것은 ARCore의 Session 정보를 활용해서 View를 하나 띄우고 카메라가 바라보는 방향으로 화살표가 가도록 만들어 보고 싶어.



목표는 명확했습니다. 이미 구현된 지도 위에 AR 뷰를 띄우고, ARCore 세션에서 얻은 카메라의 방향 값으로 3D 화살표를 실시간으로 움직이는 것이죠.




2단계: ARCore와 Jetpack Compose 통합하기


가장 먼저 부딪힌 문제는 '어떻게 Jetpack Compose 환경에서 AR 뷰를 자연스럽게 통합할 것인가?' 였습니다. 검색 결과, sceneview-android 라이브러리가 사실상의 표준이라는 것을 알게 되었습니다. 이 라이브러리는 ARCore와 3D 렌더링 엔진인 Filament를 Compose에서 쉽게 사용할 수 있도록 도와줍니다.


구현 계획



  1. 의존성 추가: build.gradle 파일에 sceneview-android 라이브러리를 추가합니다.

  2. 뷰 중첩: Box Composable을 사용해 GoogleMap 위에 ARScene을 오버레이합니다.

  3. 3D 모델 준비: 화면에 표시할 .glb 형식의 3D 화살표 모델을 준비하여 assets 폴더에 넣습니다.

  4. 카메라 방향 추적 및 렌더링: ARSceneonFrame 콜백을 이용해 매 프레임마다 ARCore 카메라의 위치와 방향을 얻어와 화살표 모델에 적용합니다.


구현 코드 예시


아래는 GoogleMap 위에 AR 화살표를 오버레이하는 전체 코드입니다.



import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.Marker
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberMarkerState
import io.github.sceneview.ar.ARScene
import io.github.sceneview.ar.node.ArModelNode
import io.github.sceneview.ar.rememberARCameraNode
import io.github.sceneview.rememberEngine
import io.github.sceneview.rememberModelLoader

@Composable
fun ArMapScreen() {
val userLocation = LatLng(36.3504, 127.3845) // 대전시청 예시
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(userLocation, 15f)
}

Box(modifier = Modifier.fillMaxSize()) {
// 1. Google Map (배경)
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState
) {
Marker(state = rememberMarkerState(position = userLocation), title = "My Location")
}

// 2. AR Scene (오버레이)
ArDirectionOverlay()
}
}

@Composable
fun ArDirectionOverlay() {
val engine = rememberEngine()
val modelLoader = rememberModelLoader(engine)
val cameraNode = rememberARCameraNode(engine)

val arrowNode = ArModelNode(engine).apply {
loadModelGlbAsync(
glbFileLocation = "models/arrow.glb", // assets 폴더 내 경로
onLoaded = { scale(0.3f) }
)
}

ARScene(
modifier = Modifier.fillMaxSize(),
nodes = listOf(cameraNode, arrowNode),
planeRenderer = false, // 바닥 감지 평면 끄기
onFrame = {
// 카메라 앞 1미터에 화살표 위치시키기
arrowNode.position = cameraNode.pose.transformPoint(floatArrayOf(0f, 0f, -1f))
// 화살표 방향을 카메라 방향과 일치시키기
arrowNode.rotation = cameraNode.rotation
}
)
}



3단계: 의존성 문제 해결하기


코드를 작성하고 의존성을 추가하는 과정에서 문제가 발생했습니다. 처음에는 com.google.ar:core만 추가하면 되는 줄 알았지만, sceneview-android 라이브러리가 필수적이라는 것을 깨달았습니다.



질문: 나는 이미 com.google.ar:core를 추가했는데, 따로 추가할 필요는 없는 건가?



이에 대한 답변은 명확했습니다. com.google.ar:core는 AR 기능의 핵심 '엔진'이고, sceneview-android는 이 엔진을 장착하여 Jetpack Compose에서 바로 운전할 수 있게 만든 '완성된 자동차'와 같다는 것입니다. sceneview-android를 사용하면 복잡한 렌더링과 UI 통합을 매우 쉽게 처리할 수 있습니다.


하지만 sceneview-android를 추가하자마자 더 큰 문제에 부딪혔습니다.


빌드 오류: Duplicate class ... found in modules ...


빌드 시 수많은 'Duplicate class' 오류가 발생했습니다. 오류 로그는 com.google.ar.sceneform.rendering.Color 같은 클래스가 서로 다른 두 라이브러리에서 중복으로 발견되었다고 알려주고 있었습니다.



Duplicate class com.google.ar.sceneform.rendering.Color found in modules rendering-1.17.1.aar (com.google.ar.sceneform:rendering:1.17.1) and sceneview-2.3.0.aar (io.github.sceneview:sceneview:2.3.0)
... (수많은 중복 클래스 오류)

원인 및 해결


원인은 명확했습니다. 제 프로젝트에 구글의 **오래된 Sceneform 라이브러리**와 새로운 SceneView 라이브러리가 동시에 포함되어 있었기 때문입니다. SceneView는 Sceneform의 업그레이드 버전이므로, 두 라이브러리 안에는 이름이 똑같은 클래스들이 가득했습니다.


해결 방법은 간단했습니다. build.gradle 파일에서 오래된 Sceneform 관련 의존성을 모두 제거하는 것이었습니다.



// build.gradle (Groovy)

dependencies {
// ...

// 아래와 같은 오래된 Sceneform 의존성들을 모두 삭제!
// implementation 'com.google.ar.sceneform.ux:sceneform-ux:1.17.1'
// implementation 'com.google.ar.sceneform:rendering:1.17.1'

// 최신 SceneView 라이브러리만 남겨둡니다.
implementation("io.github.sceneview:arsceneview:2.3.0")

// ...
}

오래된 의존성을 제거하고 Gradle을 다시 동기화하자, 빌드 오류는 마법처럼 사라졌습니다.




결론


Jetpack Compose 환경에서 ARCore를 연동하는 것은 sceneview-android 라이브러리 덕분에 생각보다 어렵지 않았습니다. 특히 의존성 충돌 문제는 초기에 겪기 쉬운 함정이지만, 라이브러리 간의 관계만 잘 이해하면 쉽게 해결할 수 있었습니다. 이제 지도 위에 내가 바라보는 방향을 알려주는 AR 화살표가 성공적으로 나타납니다!





오늘의 이야기


#billcorea #운동동아리관리앱
🏸 श्नीडल, बैडमिंटन क्लबों के लिए एक आवश्यक ऐप!
👉 मैच खेलें - स्कोर रिकॉर्ड करें और विरोधियों को खोजें 🎉
कहीं भी, अकेले, दोस्तों के साथ, या क्लब में बिल्कुल सही! 🤝
अगर आपको बैडमिंटन पसंद है तो इसे जरूर ट्राई करें

ऐप पर जाएं 👉 https://play.google.com/store/apps/details?id=com.billcorea.matchplay




오늘의 이야기

 




🔗 Google Nearby Connections API 완전 정복 가이드


오늘날 모바일 애플리케이션에서 기기 간 통신은 점점 중요해지고 있습니다. Google의 Nearby Connections API는 인터넷 연결 없이도 가까운 거리의 기기들 간에 안전하고 빠른 데이터 통신을 가능하게 하는 강력한 도구입니다.


 


앱 적용 예시


 


📱 Nearby Connections API란?


Nearby Connections API는 Google이 제공하는 크로스 플랫폼 API로, 다음과 같은 특징을 가집니다:



  • 오프라인 통신: 인터넷 연결 없이 기기 간 직접 통신

  • 다중 프로토콜 지원: Bluetooth, WiFi Direct, WiFi LAN 자동 선택

  • 높은 보안성: 모든 통신은 암호화되어 전송

  • 크로스 플랫폼: Android와 iOS 모두 지원

  • 쉬운 구현: 복잡한 네트워크 설정 없이 간단한 API 호출로 구현


💡 실생활 활용 사례
- 멀티플레이어 게임 (오프라인 대전)
- 파일 공유 앱
- 협업 도구 (화이트보드, 프레젠테이션)
- IoT 기기 제어
- 비상 상황 통신 시스템

🏗️ 기본 구조 및 개념


1. 주요 역할



  • Advertiser (광고자): 다른 기기가 발견할 수 있도록 자신을 광고하는 기기

  • Discoverer (발견자): 근처의 광고 중인 기기를 찾는 기기


🔄 동작 과정
1. Advertiser가 서비스를 광고 시작
2. Discoverer가 근처 기기를 탐색
3. 기기 발견 및 연결 요청
4. 연결 승인 후 데이터 송수신
5. 연결 종료

⚙️ 프로젝트 설정


build.gradle (Module: app) 설정


// Nearby Connections API 의존성 추가
implementation 'com.google.android.gms:play-services-nearby:19.0.0'

android {
compileSdk 34

defaultConfig {
minSdk 21 // Nearby API 최소 요구사항
targetSdk 34
}
}

AndroidManifest.xml 권한 설정


<!-- 필수 권한들 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

<!-- Android 12 이상을 위한 새로운 권한 -->
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />

⚠️ 중요한 주의사항
Android 6.0 (API 23) 이상에서는 런타임 권한 요청이 필수입니다. 특히 위치 권한은 Bluetooth와 WiFi 스캔에 필요합니다.

🚀 기본 구현 예제


1. MainActivity - 기본 설정 및 권한 처리


public class MainActivity extends AppCompatActivity {
private static final String TAG = "NearbyConnections";

// 서비스 ID - 같은 앱끼리 통신하기 위한 고유 식별자
private static final String SERVICE_ID = "com.yourapp.nearbyconnections";

// 연결 상태를 추적하기 위한 변수들
private String connectedEndpointId = "";
private boolean isAdvertising = false;
private boolean isDiscovering = false;

// UI 컴포넌트들
private Button btnStartAdvertising, btnStartDiscovery, btnSendMessage;
private TextView tvStatus, tvMessages;
private EditText etMessage;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);

// UI 컴포넌트 초기화
initializeViews();

// 권한 요청
requestNecessaryPermissions();

// 버튼 클릭 리스너 설정
setupClickListeners();
}

/**
* UI 컴포넌트들을 초기화하는 메소드
*/
private void initializeViews() {
btnStartAdvertising = findViewById(R.id.btnStartAdvertising);
btnStartDiscovery = findViewById(R.id.btnStartDiscovery);
btnSendMessage = findViewById(R.id.btnSendMessage);
tvStatus = findViewById(R.id.tvStatus);
tvMessages = findViewById(R.id.tvMessages);
etMessage = findViewById(R.id.etMessage);

// 초기 상태 설정
updateUI();
}

/**
* 필요한 권한들을 요청하는 메소드
*/
private void requestNecessaryPermissions() {
String[] permissions = {
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
};

// Android 12 이상에서 추가 권한 확인
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
permissions = new String[] {
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.BLUETOOTH_ADVERTISE,
Manifest.permission.BLUETOOTH_CONNECT,
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.NEARBY_WIFI_DEVICES
};
}

// 권한이 없는 경우 요청
ActivityCompat.requestPermissions(this, permissions, 1001);
}

2. 광고 시작 (Advertiser 역할)


/**
* 다른 기기들이 이 기기를 발견할 수 있도록 광고를 시작하는 메소드
*/
private void startAdvertising() {
// 광고 옵션 설정
AdvertisingOptions advertisingOptions = new AdvertisingOptions.Builder()
.setStrategy(Strategy.P2P_CLUSTER) // 1:N 연결 전략
.build();

// 연결된 기기의 이름 (다른 기기에서 보여질 이름)
String localEndpointName = Build.MODEL; // 기기 모델명 사용

Nearby.getConnectionsClient(this)
.startAdvertising(
localEndpointName, // 광고할 기기 이름
SERVICE_ID, // 서비스 ID
connectionLifecycleCallback, // 연결 생명주기 콜백
advertisingOptions // 광고 옵션
)
.addOnSuccessListener(new OnSuccessListener<Void>() {
@Override
public void onSuccess(Void unused) {
// 광고 시작 성공
isAdvertising = true;
updateStatus("광고 시작됨 - 다른 기기에서 발견 가능");
updateUI();
Log.d(TAG, "광고 시작 성공");
}
})
.addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(@NonNull Exception e) {
// 광고 시작 실패
updateStatus("광고 시작 실패: " + e.getMessage());
Log.e(TAG, "광고 시작 실패", e);
}
});
}

3. 기기 탐색 시작 (Discoverer 역할)


/**
* 근처의 광고 중인 기기들을 탐색하는 메소드
*/
private void startDiscovery() {
// 탐색 옵션 설정
DiscoveryOptions discoveryOptions = new DiscoveryOptions.Builder()
.setStrategy(Strategy.P2P_CLUSTER) // 광고와 동일한 전략 사용
.build();

Nearby.getConnectionsClient(this)
.startDiscovery(
SERVICE_ID, // 찾을 서비스 ID
endpointDiscoveryCallback, // 기기 발견 콜백
discoveryOptions // 탐색 옵션
)
.addOnSuccessListener(new OnSuccessListener<Void>() {
@Override
public void onSuccess(Void unused) {
// 탐색 시작 성공
isDiscovering = true;
updateStatus("탐색 시작됨 - 근처 기기 검색 중...");
updateUI();
Log.d(TAG, "탐색 시작 성공");
}
})
.addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(@NonNull Exception e) {
// 탐색 시작 실패
updateStatus("탐색 시작 실패: " + e.getMessage());
Log.e(TAG, "탐색 시작 실패", e);
}
});
}

4. 기기 발견 콜백


/**
* 기기 발견 시 호출되는 콜백
*/
private final EndpointDiscoveryCallback endpointDiscoveryCallback =
new EndpointDiscoveryCallback() {
@Override
public void onEndpointFound(@NonNull String endpointId,
@NonNull DiscoveredEndpointInfo info) {
// 새로운 기기 발견!
Log.d(TAG, "기기 발견: " + info.getEndpointName());
updateStatus("기기 발견: " + info.getEndpointName() + " - 연결 시도 중...");

// 자동으로 연결 요청 보내기
requestConnection(endpointId, info.getEndpointName());
}

@Override
public void onEndpointLost(@NonNull String endpointId) {
// 기기 연결 끊어짐
Log.d(TAG, "기기 연결 끊어짐: " + endpointId);
updateStatus("기기 연결이 끊어졌습니다.");
}
};

/**
* 발견된 기기에 연결을 요청하는 메소드
*/
private void requestConnection(String endpointId, String endpointName) {
Nearby.getConnectionsClient(this)
.requestConnection(
Build.MODEL, // 내 기기 이름
endpointId, // 연결할 기기 ID
connectionLifecycleCallback // 연결 생명주기 콜백
)
.addOnSuccessListener(new OnSuccessListener<Void>() {
@Override
public void onSuccess(Void unused) {
// 연결 요청 전송 성공
Log.d(TAG, "연결 요청 전송됨: " + endpointName);
updateStatus("연결 요청 전송됨: " + endpointName);
}
})
.addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(@NonNull Exception e) {
// 연결 요청 실패
Log.e(TAG, "연결 요청 실패", e);
updateStatus("연결 요청 실패: " + e.getMessage());
}
});
}

5. 연결 생명주기 관리


/**
* 연결의 생명주기를 관리하는 콜백
*/
private final ConnectionLifecycleCallback connectionLifecycleCallback =
new ConnectionLifecycleCallback() {
@Override
public void onConnectionInitiated(@NonNull String endpointId,
@NonNull ConnectionInfo connectionInfo) {
// 연결이 시작됨 - 사용자에게 승인/거절 선택권 제공
Log.d(TAG, "연결 요청 받음: " + connectionInfo.getEndpointName());

// 자동으로 연결 승인 (실제 앱에서는 사용자 확인 받는 것이 좋음)
Nearby.getConnectionsClient(MainActivity.this)
.acceptConnection(endpointId, payloadCallback);

updateStatus("연결 승인됨: " + connectionInfo.getEndpointName());
}

@Override
public void onConnectionResult(@NonNull String endpointId,
@NonNull ConnectionResolution result) {
switch (result.getStatus().getStatusCode()) {
case ConnectionsStatusCodes.STATUS_OK:
// 연결 성공!
Log.d(TAG, "연결 성공: " + endpointId);
connectedEndpointId = endpointId;
updateStatus("연결 완료! 메시지를 보낼 수 있습니다.");

// 탐색과 광고 중지 (1:1 연결이므로)
stopDiscovery();
stopAdvertising();

updateUI();
break;

case ConnectionsStatusCodes.STATUS_CONNECTION_REJECTED:
// 연결 거절됨
Log.d(TAG, "연결 거절됨: " + endpointId);
updateStatus("연결이 거절되었습니다.");
break;

default:
// 기타 연결 실패
Log.d(TAG, "연결 실패: " + endpointId);
updateStatus("연결에 실패했습니다.");
break;
}
}

@Override
public void onDisconnected(@NonNull String endpointId) {
// 연결 끊어짐
Log.d(TAG, "연결 끊어짐: " + endpointId);
connectedEndpointId = "";
updateStatus("연결이 끊어졌습니다.");
updateUI();
}
};

6. 데이터 송수신


/**
* 메시지를 전송하는 메소드
*/
private void sendMessage(String message) {
if (connectedEndpointId.isEmpty()) {
updateStatus("연결된 기기가 없습니다.");
return;
}

// 텍스트 메시지를 바이트 배열로 변환
Payload bytesPayload = Payload.fromBytes(message.getBytes());

// 메시지 전송
Nearby.getConnectionsClient(this)
.sendPayload(connectedEndpointId, bytesPayload)
.addOnSuccessListener(new OnSuccessListener<Void>() {
@Override
public void onSuccess(Void unused) {
// 전송 성공
Log.d(TAG, "메시지 전송 성공: " + message);
appendMessage("나: " + message);
etMessage.setText(""); // 입력 필드 초기화
}
})
.addOnFailureListener(new OnFailureListener() {
@Override
public void onFailure(@NonNull Exception e) {
// 전송 실패
Log.e(TAG, "메시지 전송 실패", e);
updateStatus("메시지 전송 실패: " + e.getMessage());
}
});
}

/**
* 데이터 수신을 처리하는 콜백
*/
private final PayloadCallback payloadCallback = new PayloadCallback() {
@Override
public void onPayloadReceived(@NonNull String endpointId, @NonNull Payload payload) {
// 데이터 수신됨
if (payload.getType() == Payload.Type.BYTES) {
// 바이트 데이터인 경우 (텍스트 메시지)
String receivedMessage = new String(payload.asBytes());
Log.d(TAG, "메시지 수신: " + receivedMessage);

// UI 업데이트는 메인 스레드에서 실행
runOnUiThread(() -> {
appendMessage("상대방: " + receivedMessage);
});
}
}

@Override
public void onPayloadTransferUpdate(@NonNull String endpointId,
@NonNull PayloadTransferUpdate update) {
// 전송 상태 업데이트 (파일 전송 시 진행률 표시 등에 사용)
if (update.getStatus() == PayloadTransferUpdate.Status.SUCCESS) {
Log.d(TAG, "페이로드 전송 완료");
} else if (update.getStatus() == PayloadTransferUpdate.Status.FAILURE) {
Log.e(TAG, "페이로드 전송 실패");
}
}
};

7. UI 업데이트 및 정리 메소드


/**
* UI 상태를 업데이트하는 메소드
*/
private void updateUI() {
runOnUiThread(() -> {
// 연결 상태에 따라 버튼 활성화/비활성화
btnSendMessage.setEnabled(!connectedEndpointId.isEmpty());
btnStartAdvertising.setEnabled(!isAdvertising);
btnStartDiscovery.setEnabled(!isDiscovering);
});
}

/**
* 상태 메시지를 업데이트하는 메소드
*/
private void updateStatus(String message) {
runOnUiThread(() -> {
tvStatus.setText(message);
});
}

/**
* 메시지를 채팅창에 추가하는 메소드
*/
private void appendMessage(String message) {
runOnUiThread(() -> {
tvMessages.append(message + "\n");
});
}

/**
* 탐색을 중지하는 메소드
*/
private void stopDiscovery() {
if (isDiscovering) {
Nearby.getConnectionsClient(this).stopDiscovery();
isDiscovering = false;
Log.d(TAG, "탐색 중지됨");
}
}

/**
* 광고를 중지하는 메소드
*/
private void stopAdvertising() {
if (isAdvertising) {
Nearby.getConnectionsClient(this).stopAdvertising();
isAdvertising = false;
Log.d(TAG, "광고 중지됨");
}
}

@Override
protected void onDestroy() {
super.onDestroy();

// 앱 종료 시 모든 연결 해제 및 서비스 중지
Nearby.getConnectionsClient(this).disconnectFromAllEndpoints();
stopDiscovery();
stopAdvertising();
}

📋 레이아웃 파일 (activity_main.xml)


<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp">

<!-- 상태 표시 -->
<TextView
android:id="@+id/tvStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="준비됨"
android:textSize="16sp"
android:padding="8dp"
android:background="#E3F2FD"
android:textColor="#1976D2" />

<!-- 제어 버튼들 -->
<Button
android:id="@+id/btnStartAdvertising"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="광고 시작 (다른 기기에서 발견 가능)"
android:layout_marginTop="16dp" />

<Button
android:id="@+id/btnStartDiscovery"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="기기 탐색 시작"
android:layout_marginTop="8dp" />

<!-- 메시지 입력 -->
<EditText
android:id="@+id/etMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="보낼 메시지를 입력하세요"
android:layout_marginTop="16dp" />

<Button
android:id="@+id/btnSendMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="메시지 전송"
android:enabled="false"
android:layout_marginTop="8dp" />

<!-- 메시지 표시 -->
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="16dp">

<TextView
android:id="@+id/tvMessages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="메시지가 여기에 표시됩니다.\n"
android:textSize="14sp"
android:padding="8dp"
android:background="#F5F5F5" />
</ScrollView>

</LinearLayout>

🎯 고급 기능 및 팁


1. 파일 전송


/**
* 파일을 전송하는 메소드
*/
private void sendFile(Uri fileUri) {
try {
// 파일에서 Payload 생성
Payload filePayload = Payload.fromFile(fileUri);

// 파일 정보를 먼저 전송 (파일명, 크기 등)
String fileInfo = "FILE:" + getFileName(fileUri) + ":" + getFileSize(fileUri);
Payload infoPayload = Payload.fromBytes(fileInfo.getBytes());

// 정보 먼저 전송
Nearby.getConnectionsClient(this)
.sendPayload(connectedEndpointId, infoPayload);

// 그 다음 파일 전송
Nearby.getConnectionsClient(this)
.sendPayload(connectedEndpointId, filePayload);

} catch (Exception e) {
Log.e(TAG, "파일 전송 실패", e);
}
}

2. 연결 전략 선택


📊 연결 전략 비교
P2P_CLUSTER: 1:N 연결, 많은 기기와 동시 연결
P2P_STAR: 1:1 연결, 안정적이고 빠른 속도
P2P_POINT_TO_POINT: 1:1 전용, 최고 성능

3. 에러 처리 및 디버깅


/**
* 상세한 에러 처리를 포함한 연결 메소드
*/
private void connectWithErrorHandling(String endpointId) {
Nearby.getConnectionsClient(this)
.requestConnection(Build.MODEL, endpointId, connectionLifecycleCallback)
.addOnFailureListener(exception -> {
// 구체적인 에러 타입별 처리
if (exception instanceof ApiException) {
ApiException apiException = (ApiException) exception;
switch (apiException.getStatusCode()) {
case ConnectionsStatusCodes.STATUS_ENDPOINT_UNKNOWN:
updateStatus("기기를 찾을 수 없습니다.");
break;
case ConnectionsStatusCodes.STATUS_NETWORK_NOT_CONNECTED:
updateStatus("네트워크에 연결되지 않았습니다.");
break;
case ConnectionsStatusCodes.STATUS_BLUETOOTH_ERROR:
updateStatus("블루투스 오류가 발생했습니다.");
break;
default:
updateStatus("연결 오류: " + apiException.getStatusCode());
break;
}
}
});
}

4. 보안 강화


/**
* 연결 시 보안 인증을 추가하는 예제
*/
private void secureConnectionHandling(String endpointId, ConnectionInfo info) {
// 연결 토큰 검증 (실제 앱에서는 더 복잡한 인증 로직 구현)
String authToken = info.getAuthenticationDigits();

// 사용자에게 인증 토큰 확인 요청
new AlertDialog.Builder(this)
.setTitle("연결 확인")
.setMessage("다음 기기와 연결하시겠습니까?\n" +
"기기명: " + info.getEndpointName() + "\n" +
"인증 코드: " + authToken)
.setPositiveButton("승인", (dialog, which) -> {
// 연결 승인
Nearby.getConnectionsClient(this)
.acceptConnection(endpointId, payloadCallback);
})
.setNegativeButton("거절", (dialog, which) -> {
// 연결 거절
Nearby.getConnectionsClient(this)
.rejectConnection(endpointId);
})
.show();
}

🚀 성능 최적화 팁


1. 배터리 최적화


/**
* 배터리 효율적인 설정
*/
private void optimizedAdvertising() {
AdvertisingOptions options = new AdvertisingOptions.Builder()
.setStrategy(Strategy.P2P_CLUSTER)
// 저전력 모드 사용
.setLowPower(true)
.build();

// 일정 시간 후 자동으로 광고 중지
Handler handler = new Handler();
handler.postDelayed(() -> {
stopAdvertising();
updateStatus("배터리 절약을 위해 광고가 중지되었습니다.");
}, 60000); // 1분 후 중지
}

2. 연결 품질 모니터링


/**
* 연결 품질을 모니터링하는 클래스
*/
public class ConnectionQualityMonitor {
private long lastMessageTime = 0;
private int failedMessages = 0;
private static final long TIMEOUT_MS = 5000; // 5초 타임아웃

public void onMessageSent() {
lastMessageTime = System.currentTimeMillis();
}

public void onMessageFailed() {
failedMessages++;
if (failedMessages > 3) {
// 연결 품질이 나쁘다고 판단, 재연결 시도
reconnectWithBetterStrategy();
}
}

private void reconnectWithBetterStrategy() {
// 더 안정적인 전략으로 재연결
// 예: P2P_STAR 전략 사용
}
}

🔧 문제해결 가이드


⚠️ 자주 발생하는 문제들

1. 기기를 발견하지 못하는 경우:
- 위치 권한이 허용되었는지 확인
- 블루투스와 WiFi가 켜져있는지 확인
- 같은 SERVICE_ID를 사용하는지 확인

2. 연결이 자주 끊어지는 경우:
- 기기 간 거리가 너무 멀지 않은지 확인
- 다른 무선 신호 간섭이 없는지 확인
- 적절한 연결 전략을 선택했는지 확인

3. 메시지가 전송되지 않는 경우:
- 연결 상태를 확인
- 페이로드 크기 제한 확인 (최대 32KB)
- 네트워크 상태 확인

디버깅을 위한 로그 설정


/**
* 상세한 로깅을 위한 헬퍼 클래스
*/
public class NearbyLogger {
private static final String TAG = "NearbyConnections";

public static void logConnectionState(String endpointId, String state) {
Log.d(TAG, String.format("연결 상태 변경 - ID: %s, 상태: %s, 시간: %s",
endpointId, state, new Date()));
}

public static void logPayloadInfo(Payload payload) {
Log.d(TAG, String.format("페이로드 정보 - ID: %d, 타입: %s, 크기: %d",
payload.getId(),
payload.getType().name(),
payload.asBytes() != null ? payload.asBytes().length : 0));
}
}

🎨 UI/UX 개선 아이디어


연결 상태 시각화


/**
* 연결 상태를 시각적으로 표시하는 메소드
*/
private void updateConnectionStatus(boolean isConnected) {
runOnUiThread(() -> {
if (isConnected) {
// 연결됨 - 녹색 표시
tvStatus.setBackgroundColor(ContextCompat.getColor(this, R.color.green_light));
tvStatus.setTextColor(ContextCompat.getColor(this, R.color.green_dark));
tvStatus.setText("🟢 연결됨 - 메시지 전송 가능");
} else {
// 연결 안됨 - 회색 표시
tvStatus.setBackgroundColor(ContextCompat.getColor(this, R.color.grey_light));
tvStatus.setTextColor(ContextCompat.getColor(this, R.color.grey_dark));
tvStatus.setText("⚪ 연결 대기중");
}
});
}

/**
* 메시지에 타임스탬프 추가
*/
private void appendMessageWithTimestamp(String message) {
SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss", Locale.getDefault());
String timestamp = sdf.format(new Date());
String formattedMessage = String.format("[%s] %s", timestamp, message);

runOnUiThread(() -> {
tvMessages.append(formattedMessage + "\n");
// 스크롤을 맨 아래로 이동
scrollView.post(() -> scrollView.fullScroll(View.FOCUS_DOWN));
});
}

📱 실제 앱 적용 사례


멀티플레이어 게임 구현


/**
* 간단한 멀티플레이어 게임을 위한 데이터 구조
*/
public class GameData {
private String playerName;
private int playerScore;
private String gameAction;

// JSON으로 직렬화하여 전송
public String toJson() {
return new Gson().toJson(this);
}

public static GameData fromJson(String json) {
return new Gson().fromJson(json, GameData.class);
}
}

/**
* 게임 데이터 전송
*/
private void sendGameAction(String action, int score) {
GameData gameData = new GameData();
gameData.playerName = Build.MODEL;
gameData.playerScore = score;
gameData.gameAction = action;

sendMessage(gameData.toJson());
}

🎯 마무리 및 다음 단계


Nearby Connections API는 오프라인 환경에서도 강력한 기기 간 통신을 제공하는 훌륭한 도구입니다. 이 가이드를 통해 기본적인 구현부터 고급 기능까지 다뤄보았습니다.


핵심 포인트 요약:



  • ✅ 적절한 권한 설정이 성공의 열쇠

  • ✅ 연결 전략을 용도에 맞게 선택

  • ✅ 에러 처리를 통한 안정성 확보

  • ✅ 사용자 경험을 고려한 UI 설계

  • ✅ 배터리 최적화로 실용성 향상


추천 학습 경로:



  1. 기본 예제로 동작 원리 이해

  2. 파일 전송 기능 추가

  3. 보안 강화 및 인증 구현

  4. 실제 앱에 통합 및 최적화

  5. iOS와의 크로스 플랫폼 통신 구현


🚀 다음 프로젝트 아이디어
- 오프라인 채팅 앱
- 파일 공유 도구
- 멀티플레이어 보드게임
- 협업 화이트보드
- IoT 기기 제어 앱

Nearby Connections API의 무한한 가능성을 탐험하며 혁신적인 앱을 만들어보세요! 궁금한 점이나 더 자세한 구현 방법이 필요하다면 공식 문서를 참고하시기 바랍니다.




이 가이드가 도움이 되셨다면 ⭐를 눌러주세요!
더 많은 안드로이드 개발 팁은 블로그에서 만나보실 수 있습니다.






오늘의 이야기


#스하리1000명프로젝트

오늘 내가 만든앱 하나 알려주고 싶어, 이 앱은 알림수집기 라고 이름을 붙였는 데,
내 폰에 표시 되는 알림을 읽어서 내가 지정한 단어가 들어 있고, 지출기록을 남겨야 하는 알림이
있으면 수집하고, 카카오톡으로 친구에게 전달해 주는 기능을 구현해 줄꺼야. 📲

이번 패치에서는 하루 한번 지정한 시간에 나에게 알림(노티) 하도록 기능을 추가 했어. 🙏
한번 써보고 불편한 거 있으면 말해줘.

앱 바로가기
👉 https://play.google.com/store/apps/details?id=com.nari.notify2kakao





오늘의 이야기

 



DevExpress DxDataGrid에서 셀 편집 제어 및 포커스 설정


datagrid



 


이번 글에서는 DxDataGrid에서 행이 추가된 이후 특정 셀의 편집을 막고, 커서를 원하는 컬럼으로 이동시키는 방법에 대해 살펴봅니다.


🎯 특정 셀 편집 막기


행이 추가된 후 특정 셀을 편집 불가능하게 만들기 위해선 행에 플래그를 추가하거나 조건부 설정이 필요합니다.


onRowInserting: function(e) {
// 행 데이터 설정
e.data.isNewRow = true;
}

이후 그리드 설정에서 셀 편집을 막는 방법:


cellPrepared: function(e) {
if (e.rowType === "data" && e.data.isNewRow && e.column.dataField === "ComboColumn") {
e.cellElement.css("pointer-events", "none");
e.cellElement.css("background-color", "#f0f0f0");
}
}

🧭 커서를 두 번째 컬럼으로 이동시키기


새 행이 삽입된 후 두 번째 컬럼으로 커서를 이동시키려면 editCell 메서드를 사용합니다.


onRowInserting: function(e) {
setTimeout(function () {
const grid = $("#gridContainer").dxDataGrid("instance");
const rowIndex = grid.getVisibleRows().length - 1;
grid.editCell(rowIndex, "SecondColumn"); // 컬럼 이름은 실제 dataField로!
}, 0);
}

📌 요약



  • 편집 제한: 셀의 pointer-events를 제거하여 마우스 입력 차단

  • 포커스 이동: 새 행 추가 후 editCell로 특정 셀에 커서 설정


DxDataGrid의 커스터마이징은 정말 유연해서 다양한 시나리오에 대응할 수 있습니다. 위 예제를 바탕으로 더 발전시킨 기능도 얼마든지 구현할 수 있어요. 필요하시다면 팝업 편집 모드서버 연동 방식에 대한 내용도 추가로 알려드릴게요!





오늘의 이야기


#스하리1000명프로젝트,
แพ้เกาหลีเหรอ? แม้ว่าคุณจะพูดภาษาเกาหลีไม่ได้ แต่แอปนี้จะช่วยให้คุณเดินทางได้อย่างง่ายดาย
เพียงพูดภาษาของคุณ ระบบจะแปล ค้นหา และแสดงผลลัพธ์เป็นภาษาของคุณ
เหมาะสำหรับนักเดินทาง! รองรับมากกว่า 10 ภาษา รวมถึงภาษาอังกฤษ ญี่ปุ่น จีน เวียดนาม และอื่นๆ อีกมากมาย
ลองตอนนี้!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




오늘의 이야기


Firebase Storage로 이미지 업로드하기 — 갤러리/카메라 선택 + 업로드 진행률 표시


적용해본 예시 이미지


 


이 글은 Jetpack Compose로 만든 안드로이드 앱에서 Firebase Storage에 이미지를 업로드하는 전 과정을 다룹니다. 사용자는 갤러리에서 이미지 선택 혹은 카메라로 촬영한 사진을 업로드할 수 있고, 업로드 진행률다운로드 URL을 바로 확인할 수 있습니다. 초보자분들도 그대로 따라 하면 동작하도록 전체 코드와 함께 단계별로 설명했습니다.



1) 사전 준비




  • Firebase 콘솔에서 프로젝트 생성 → Android 앱 등록google-services.jsonapp/ 폴더에 복사

  • Firebase 콘솔 > Storage에서 시작하기 (초기엔 테스트 규칙 가능)

  • Android Studio 최신 버전 권장, minSdk 24+ 예시



2) Gradle & 프로젝트 설정


settings.gradle


pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}

dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}

프로젝트 루트 build.gradle


buildscript {
dependencies {
// Google Services 플러그인
classpath("com.google.gms:google-services:4.4.2")
}
}

앱 모듈 app/build.gradle.kts


plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.gms.google-services")
}

android {
namespace = "com.example.firebaseupload"
compileSdk = 34

defaultConfig {
applicationId = "com.example.firebaseupload"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
}

dependencies {
// Firebase BOM: 버전 하나로 일괄 관리
implementation(platform("com.google.firebase:firebase-bom:33.1.2"))
// KTX 사용 시 -ktx 종속성 권장
implementation("com.google.firebase:firebase-storage-ktx")

// Compose 필수 의존성 (버전은 프로젝트에 맞게 조정 가능)
implementation("androidx.activity:activity-compose:1.9.0")
implementation("androidx.compose.ui:ui:1.6.8")
implementation("androidx.compose.material3:material3:1.2.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")

// 이미지 미리보기(선택)
implementation("io.coil-kt:coil-compose:2.6.0")
}

Tip: Firebase BOM은 수시로 업데이트됩니다. 새 프로젝트에선 최신 BOM을 사용하세요.

3) 권한 & FileProvider 설정


AndroidManifest.xml


<manifest ...>
<uses-permission android:name="android.permission.CAMERA" />

<application ...>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

res/xml/file_paths.xml


<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="cache" path="." />
</paths>


카메라 촬영 결과를 임시 파일에 저장하려면 FileProvider가 필요합니다. cache-path는 앱의 캐시 디렉토리를 공유 가능 경로로 노출합니다. 일부 기기에서는 카메라 권한을 런타임으로 요청해야 할 수도 있습니다.



4) 갤러리/카메라 선택 UI (Compose)


Activity Result API를 사용해 갤러리(GetContent)와 카메라(TakePicture)를 호출합니다. 카메라의 경우 미리 만든 임시 Uri를 전달해야 합니다.


/** ImagePickerWithCamera.kt */
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.FileProvider
import java.io.File

@Composable
fun ImagePickerWithCamera(onImageSelected: (Uri) -> Unit) {
val context = LocalContext.current

// 카메라 촬영을 위한 임시 파일 & Uri
val imageFile = remember { File(context.cacheDir, "temp_${System.currentTimeMillis()}.jpg") }
val imageUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
imageFile
)

val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success -> if (success) onImageSelected(imageUri) }

val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri -> uri?.let(onImageSelected) }

Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { cameraLauncher.launch(imageUri) }) { Text("카메라 촬영") }
Button(onClick = { galleryLauncher.launch("image/*") }) { Text("갤러리 선택") }
}
}

초보자 포인트:

  • rememberLauncherForActivityResult는 외부 액티비티(갤러리/카메라)를 호출하고 결과를 콜백으로 받는 도우미입니다.

  • 카메라는 사진을 어디에 저장할지 알아야 하므로, 미리 만든 파일의 Uri를 전달합니다.



5) Firebase Storage 업로드 + 진행률


putFile로 업로드를 시작하고, addOnProgressListener로 진행률을 수신합니다. 완료되면 downloadUrl을 받아 사용자에게 보여줄 수 있습니다.


/** FirebaseUpload.kt */
import android.net.Uri
import com.google.firebase.ktx.Firebase
import com.google.firebase.storage.ktx.storage

fun uploadImageToFirebaseWithProgress(
uri: Uri,
onProgress: (Int) -> Unit,
onResult: (String?) -> Unit
) {
val storageRef = Firebase.storage.reference
val fileRef = storageRef.child("uploads/${System.currentTimeMillis()}.jpg")

val uploadTask = fileRef.putFile(uri)
uploadTask
.addOnProgressListener { snap ->
val p = (100.0 * snap.bytesTransferred / snap.totalByteCount).toInt()
onProgress(p)
}
.addOnSuccessListener {
fileRef.downloadUrl.addOnSuccessListener { url -> onResult(url.toString()) }
}
.addOnFailureListener { e ->
e.printStackTrace()
onResult(null)
}
}

초보자 포인트:

  • uploads/ 폴더 아래에 타임스탬프 기반 파일명을 사용해 중복을 피합니다.

  • 성공 시 URL은 공유 가능한 다운로드 링크입니다. (규칙에 따라 접근 제한 가능)



6) 완성 화면(Compose) 구성


버튼 한 번으로 선택/촬영 후 업로드까지 이어지고, 이미지 미리보기와 진행률, 최종 URL을 보여주는 간단한 화면입니다.


/** UploadImageScreenWithCamera.kt */
import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage

@Composable
fun UploadImageScreenWithCamera() {
var imageUri by remember { mutableStateOf<Uri?>(null) }
var uploadedUrl by remember { mutableStateOf<String?>(null) }
var progress by remember { mutableStateOf(0) }

Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ImagePickerWithCamera { uri ->
imageUri = uri
progress = 0
uploadedUrl = null
uploadImageToFirebaseWithProgress(
uri,
onProgress = { p -> progress = p },
onResult = { url -> uploadedUrl = url }
)
}

Spacer(Modifier.height(16.dp))

imageUri?.let {
Text("선택/촬영한 이미지:")
AsyncImage(
model = it,
contentDescription = null,
modifier = Modifier.size(200.dp)
)
}

if (progress in 1..99) {
Spacer(Modifier.height(8.dp))
Text("업로드 중: ${'$'}progress%")
}

uploadedUrl?.let {
Spacer(Modifier.height(16.dp))
Text("업로드 완료 URL:", color = Color(0xFF16A34A))
SelectionContainer { Text(it, color = Color(0xFF2563EB)) }
}
}
}

MainActivity 설정


/** MainActivity.kt */
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { UploadImageScreenWithCamera() }
}
}

동작 흐름: 버튼 클릭 → (갤러리/카메라) 선택 → Firebase 업로드 시작 → 진행률 표시 → URL 표시

7) Storage 보안 규칙


개발 중(테스트) 규칙 — 반드시 운영 전 교체


service firebase.storage {
match /b/{bucket}/o {
match /{allPaths=**} {
allow read, write: if true; // 테스트 모드: 누구나 접근 (위험)
}
}
}

예시) 로그인 사용자만 자신의 폴더에 업로드/읽기


service firebase.storage {
match /b/{bucket}/o {
match /user_uploads/{uid}/{allPaths=**} {
allow read, write: if request.auth != null && request.auth.uid == uid;
}
}
}

운영 시 권장: Firebase Authentication(익명/이메일/소셜)과 함께 사용자별 경로를 사용하세요.

8) 자주 만나는 오류 & 해결법



  • Manifest merger failed / FileProvider 오류android:authorities="${applicationId}.provider"가 앱 ID와 정확히 일치하는지 확인.

  • Permission Denied → Storage 규칙을 확인. 개발 중엔 테스트 규칙, 운영은 인증 기반 규칙 사용.

  • 이미지 미리보기가 안 보임 → Coil 의존성 추가/버전 확인, AsyncImage에 올바른 Uri 전달.

  • 업로드가 매우 느림 → 네트워크 상태 점검, 사진 크기 줄이기(리사이즈/압축) 고려.


9) 전체 코드 모음 (복사해서 바로 사용)



build.gradle.kts (app)


plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.gms.google-services")
}

android {
namespace = "com.example.firebaseupload"
compileSdk = 34

defaultConfig {
applicationId = "com.example.firebaseupload"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
}

dependencies {
implementation(platform("com.google.firebase:firebase-bom:33.1.2"))
implementation("com.google.firebase:firebase-storage-ktx")

implementation("androidx.activity:activity-compose:1.9.0")
implementation("androidx.compose.ui:ui:1.6.8")
implementation("androidx.compose.material3:material3:1.2.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4")

implementation("io.coil-kt:coil-compose:2.6.0")
}

AndroidManifest.xml


<uses-permission android:name="android.permission.CAMERA" />

<application ...>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>

res/xml/file_paths.xml


<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="cache" path="." />
</paths>

ImagePickerWithCamera.kt


import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import java.io.File

@Composable
fun ImagePickerWithCamera(onImageSelected: (Uri) -> Unit) {
val context = LocalContext.current

val imageFile = remember { File(context.cacheDir, "temp_${System.currentTimeMillis()}.jpg") }
val imageUri = FileProvider.getUriForFile(
context,
"${context.packageName}.provider",
imageFile
)

val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success -> if (success) onImageSelected(imageUri) }

val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri -> uri?.let(onImageSelected) }

Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { cameraLauncher.launch(imageUri) }) { Text("카메라 촬영") }
Button(onClick = { galleryLauncher.launch("image/*") }) { Text("갤러리 선택") }
}
}

FirebaseUpload.kt


import android.net.Uri
import com.google.firebase.ktx.Firebase
import com.google.firebase.storage.ktx.storage

fun uploadImageToFirebaseWithProgress(
uri: Uri,
onProgress: (Int) -> Unit,
onResult: (String?) -> Unit
) {
val storageRef = Firebase.storage.reference
val fileRef = storageRef.child("uploads/${System.currentTimeMillis()}.jpg")

val uploadTask = fileRef.putFile(uri)
uploadTask
.addOnProgressListener { snap ->
val p = (100.0 * snap.bytesTransferred / snap.totalByteCount).toInt()
onProgress(p)
}
.addOnSuccessListener {
fileRef.downloadUrl.addOnSuccessListener { url -> onResult(url.toString()) }
}
.addOnFailureListener { onResult(null) }
}

UploadImageScreenWithCamera.kt


import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage

@Composable
fun UploadImageScreenWithCamera() {
var imageUri by remember { mutableStateOf<Uri?>(null) }
var uploadedUrl by remember { mutableStateOf<String?>(null) }
var progress by remember { mutableStateOf(0) }

Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ImagePickerWithCamera { uri ->
imageUri = uri
progress = 0
uploadedUrl = null
uploadImageToFirebaseWithProgress(
uri,
onProgress = { p -> progress = p },
onResult = { url -> uploadedUrl = url }
)
}

Spacer(Modifier.height(16.dp))

imageUri?.let {
Text("선택/촬영한 이미지:")
AsyncImage(model = it, contentDescription = null, modifier = Modifier.size(200.dp))
}

if (progress in 1..99) {
Spacer(Modifier.height(8.dp))
Text("업로드 중: ${'$'}progress%")
}

uploadedUrl?.let {
Spacer(Modifier.height(16.dp))
Text("업로드 완료 URL:", color = Color(0xFF16A34A))
SelectionContainer { Text(it, color = Color(0xFF2563EB)) }
}
}
}

MainActivity.kt


import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { UploadImageScreenWithCamera() }
}
}


10) 다음 단계 아이디어



  • Firebase Authentication 연동해 사용자별 업로드 제한 및 이력 관리

  • 업로드한 메타데이터(파일명, URL, 작성자, 시간)를 Firestore에 저장

  • 썸네일/압축 생성, EXIF 제거 등 이미지 전처리

  • 사용자별 폴더 구조: user_uploads/{uid}/...







2026/04/28

오늘의 이야기

<주변검색 주변찾기 앱, 동작감지기 앱 :  이하 사용자앱으로 표시 >:는) 「개인정보 보호법」 제30조에 따라 정보주체의 개인정보를 보호하고 이와 관련한 고충을 신속하고 원활하게 처리할 수 있도록 하기 위하여 다음과 같이 개인정보 처리방침을 수립·공개합니다.
○ 이 개인정보처리방침은 2022년 4월 1부터 적용됩니다.
 
제1조(개인정보의 처리 목적)

<사용자 앱>은(는) 다음의 목적을 위하여 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.

 
○ 제공되는 앱의 사용자 확인을 위해서 만 사용 됩니다.

제2조(개인정보의 처리 및 보유 기간)

① <사용자 앱>은(는) 법령에 따른 개인정보 보유·이용기간 또는 정보주체로부터 개인정보를 수집 시에 동의받은 개인정보 보유·이용기간 내에서 개인정보를 처리·보유합니다.

② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다.



  • 1.<앱 사용자 회원가입 및 관리>

  • <앱 사용자 회원가입 및 관리>와 관련한 개인정보는 수집.이용에 관한 동의일로부터<이 앱의 사용기간 동안>까지 위 이용목적을 위하여 보유.이용됩니다.

  • 보유근거 : 이 앱의 사용자 확인을 위해서 사용 됩니다.

  • 2 <앱 사용자 위치정보 : 백그라운드 위치 포함>

  • 백그라운드 위치정보 : 이 앱의 기본 기능인 도착 알림 발송등을 위해서 백그라운드 에서 위치 정보를 활용 합니다.  사용자의 위치 정보는 앱의 기능 활용을 위해서만 사용 되며, 앱 외부로 전송 하지 않습니다.

  • 3 <Health Data 의 사용>

  • 이 앱은 사용자의 폰이나, 워치를 통해 사용자의 Health Data 가 측정 됩니다. 측정된 Health Data 는 이 앱에서 사용자의 신체상태을 표시 하는 데만 사용 됩니다. 

  • 측정된 Health Data 을 앱 이외의 공간(온라인전송 포함)으로 복사 되거나, 제공 되지 않습니다.


제3조(개인정보의 제3자 제공)

① <사용자 앱>은(는) 개인정보를 제1조(개인정보의 처리 목적)에서 명시한 범위 내에서만 처리하며, 정보주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.
② < billcorea >은(는) 다음과 같이 개인정보를 제3자에게 제공하지 않습니다.
 
제4조(개인정보처리 위탁)

① <사용자 앱>은(는) 원활한 개인정보 업무처리를 위하여 다음과 같이 개인정보 처리업무를 위탁하지 않습니다.
②  위탁업무의 내용이나 수탁자가 변경될 경우에는 지체없이 본 개인정보 처리방침을 통하여 공개하도록 하겠습니다.

제5조(정보주체와 법정대리인의 권리·의무 및 그 행사방법)
① 정보주체는 우리집에 대해 언제든지 개인정보 열람·정정·삭제·처리정지 요구 등의 권리를 행사할 수 있습니다.
② 제1항에 따른 권리 행사는우리집에 대해 「개인정보 보호법」 시행령 제41조제1항에 따라 서면, 전자우편, 모사전송(FAX) 등을 통하여 하실 수 있으며 우리집은(는) 이에 대해 지체 없이 조치하겠습니다.
③ 제1항에 따른 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자 등 대리인을 통하여 하실 수 있습니다.이 경우 “개인정보 처리 방법에 관한 고시(제2020-7호)” 별지 제11호 서식에 따른 위임장을 제출하셔야 합니다.
④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 의하여 정보주체의 권리가 제한 될 수 있습니다.
⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에서 그 개인정보가 수집 대상으로 명시되어 있는 경우에는 그 삭제를 요구할 수 없습니다.
⑥ billcorea 은(는) 정보주체 권리에 따른 열람의 요구, 정정·삭제의 요구, 처리정지의 요구 시 열람 등 요구를 한 자가 본인이거나 정당한 대리인인지를 확인합니다.


제6조(처리하는 개인정보의 항목 작성)
①<사용자 앱>은(는) 다음의 개인정보 항목을 처리하고 있습니다.



  • 1<앱 사용자 회원가입 및 관리 >

  • 필수항목 : 식별기호(uuid token), 이메일주소,  

  • 소셜 로그인 : 이메일주소, 별명(별칭), 프로필 이미지 링크

  • 2<앱 사용자 위치 정보>

  • 앱 사용중 위치 정보 및 백그라운드 위치 정보 포함.

  • 3<Health Data>

  • 앱 사용중 사용자의 심박수, 걸음수의 정보.


 


제7조(개인정보의 파기)

① <사용자 앱> 은(는) 개인정보 보유기간의 경과, 처리목적 달성 등 개인정보가 불필요하게 되었을 때에는 지체없이 해당 개인정보를 파기합니다.

② 정보주체로부터 동의받은 개인정보 보유기간이 경과하거나 처리목적이 달성되었음에도 불구하고 다른 법령에 따라 개인정보를 계속 보존하여야 하는 경우에는, 해당 개인정보를 별도의 데이터베이스(DB)로 옮기거나 보관장소를 달리하여 보존합니다.
1. 법령 근거 : 관련법규 적용 사항 없음
2. 보존하는 개인정보 항목 : 없음

③ 개인정보 파기의 절차 및 방법은 다음과 같습니다.
1. 파기절차
<사용자 앱> 은(는) 파기 사유가 발생한 개인정보를 선정하고, < billcorea > 의 개인정보 보호책임자의 승인을 받아 개인정보를 파기합니다.
2. 앱의 사용자 설정에서 로그인 관련 정보 삭제 버튼을 이용하여 삭제 할 수 있습니다.

제8조(개인정보의 안전성 확보 조치)

<사용자 앱>은(는) 개인정보의 안전성 확보를 위해 다음과 같은 조치를 취하고 있습니다.

1. 내부관리계획의 수립 및 시행
개인정보의 안전한 처리를 위하여 내부관리계획을 수립하고 시행하고 있습니다.

2. 개인정보에 대한 접근 제한
개인정보를 처리하는 데이터베이스시스템에 대한 접근권한의 부여,변경,말소를 통하여 개인정보에 대한 접근통제를 위하여 필요한 조치를 하고 있으며 침입차단시스템을 이용하여 외부로부터의 무단 접근을 통제하고 있습니다.

3. 비인가자에 대한 출입 통제
개인정보를 보관하고 있는 물리적 보관 장소를 별도로 두고 이에 대해 출입통제 절차를 수립, 운영하고 있습니다.

제9조(개인정보 자동 수집 장치의 설치•운영 및 거부에 관한 사항)
<사용자 앱> 은(는) 정보주체의 이용정보를 저장하고 수시로 불러오는 ‘쿠키(cookie)’를 사용하지 않습니다.
 
제10조 (개인정보 보호책임자)
① <사용자 앱> 은(는) 개인정보 처리에 관한 업무를 총괄해서 책임지고, 개인정보 처리와 관련한 정보주체의 불만처리 및 피해구제 등을 위하여 아래와 같이 개인정보 보호책임자를 지정하고 있습니다.



  • ▶ 개인정보 보호책임자

  • 성명 :강동엽

  • 직책 : manager

  • 직급 : manager

  • 연락처 : 0504-0662-8122, help@billcorea.com


※ 개인정보 보호 담당부서로 연결됩니다.



  • ▶ 개인정보 보호 담당부서

  • 부서명 : manager

  • 담당자 : 강동엽

  • 연락처 :  0504-0662-8122, help@billcorea.com


② 정보주체께서는 우리집 의 서비스(또는 사업)을 이용하시면서 발생한 모든 개인정보 보호 관련 문의, 불만처리, 피해구제 등에 관한 사항을 개인정보 보호책임자 및 담당부서로 문의하실 수 있습니다. 우리집 은(는) 정보주체의 문의에 대해 지체 없이 답변 및 처리해드릴 것입니다.
 
제11조(개인정보 열람청구)
정보주체는 「개인정보 보호법」 제35조에 따른 개인정보의 열람 청구를 아래의 부서에 할 수 있습니다.
<사용자 앱>은(는) 정보주체의 개인정보 열람청구가 신속하게 처리되도록 노력하겠습니다.



  • ▶ 개인정보 열람청구 접수·처리 부서

  • 부서명 : manager

  • 담당자 : 강동엽

  • 연락처 :  0504-0662-8122, help@billcorea.com



제12조(권익침해 구제방법)
정보주체는 개인정보침해로 인한 구제를 받기 위하여 개인정보분쟁조정위원회, 한국인터넷진흥원 개인정보침해신고센터 등에 분쟁해결이나 상담 등을 신청할 수 있습니다. 이 밖에 기타 개인정보침해의 신고, 상담에 대하여는 아래의 기관에 문의하시기 바랍니다.

1. 개인정보분쟁조정위원회 : (국번없이) 1833-6972 (www.kopico.go.kr)
2. 개인정보침해신고센터 : (국번없이) 118 (privacy.kisa.or.kr)
3. 대검찰청 : (국번없이) 1301 (www.spo.go.kr)
4. 경찰청 : (국번없이) 182 (ecrm.cyber.go.kr)

「개인정보보호법」제35조(개인정보의 열람), 제36조(개인정보의 정정·삭제), 제37조(개인정보의 처리정지 등)의 규정에 의한 요구에 대 하여 공공기관의 장이 행한 처분 또는 부작위로 인하여 권리 또는 이익의 침해를 받은 자는 행정심판법이 정하는 바에 따라 행정심판을 청구할 수 있습니다.

※ 행정심판에 대해 자세한 사항은 중앙행정심판위원회(www.simpan.go.kr) 홈페이지를 참고하시기 바랍니다.

제13조(개인정보 처리방침 변경)

① 이 개인정보처리방침은 2025년 7월 11부터 적용됩니다.


 


부칙 <개인정보 처리 지침의 개정>


* 이 지침은 필요에 따라 갱신 되며, 사용자에게 개별 통지는 되지 않습니다.


 





오늘의 이야기

이번 YouTube 영상 "AI Breakfast 개발자 특별편 | Episode 3 - Android 앱 개발의 미래"는 구글 I/O 2023에서 공개된 AI 기술이 안드로이드 앱 개발의 **기획, 개발, 배포, 운영 등 모든 단계를 어떻게 변화시키고 있는지**에 대해 심도 깊은 논의를 진행합니다 [1]. AI가 단순한 도구를 넘어 개발자들의 워크플로우 전반에 큰 변화를 가져오고 있으며, 개발자 역할의 재정의 가능성도 언급되었습니다 [2].

**AI 기술이 안드로이드 앱 개발 각 단계에 미치는 영향:**

*   **기획 및 UI/UX 디자인 단계**:
    *   **Stitch(스티치)**: 텍스트나 이미지 입력만으로 AI가 UI 디자인과 코드를 자동으로 생성해주는 도구로 소개되었습니다 [2]. 이 도구는 개발자가 디자인을 할 수 있게 돕고, 기획자나 디자이너의 반복적인 레이아웃 작업 부담을 줄여주며, 개발자와 디자이너/기획자 간의 **효율적인 협업을 가능하게 합니다** [3]. 데모 버전 제작 시에도 디자인 퀄리티를 크게 높여주는 장점이 있습니다 [3].

*   **코딩 단계**:
    *   **AI 코딩 어시스턴트**: 안드로이드 앱 개발은 웹 개발에 비해 AI 도구 적용이 다소 늦었지만, 이제 구글의 대표적인 안드로이드 개발 툴인 **안드로이드 스튜디오에 Gemini(제미나이)가 공식적으로 통합**되면서 개발자들이 AI의 도움을 적극적으로 받을 수 있게 되었습니다 [3].
    *   **높은 AI 코드 기여도**: 현재 현업에서는 코딩 시 AI의 기여도가 **벌써 50%에 달하는 사례**가 언급되었으며, 이는 거의 동료와 같은 협업 수준으로 평가됩니다 [4]. AI는 변수명 짓기 등 단순 반복 작업을 대신하여 개발자가 **더 큰 그림과 창의적인 부분에 집중**할 수 있도록 돕습니다 [4].
    *   **안드로이드 개발의 숙제 해결**: AI는 특히 안드로이드 개발자들이 어려움을 겪는 **다양한 화면 크기 및 OS 버전 대응, 구글 플레이 정책 준수**와 같은 '영원한 숙제'들을 해결하는 데 큰 도움을 줄 것으로 기대됩니다 [4, 5].

*   **AI 모델 및 온디바이스 AI**:
    *   **Gemini(제미나이) vs. Gemma(잼마)**:
        *   **Gemini**: 구글의 대표적인 거대 AI 모델이며, API를 통해 활용할 수 있는 PaaS(Platform as a Service) 형태로 제공됩니다 [6].
        *   **Gemma**: Gemini의 내용을 경량화한 작은 모델로, 사용자가 직접 가져다가 자신에게 맞게 변경하고 학습시킬 수 있습니다 [6]. 디바이스에서도 원활하게 동작하며, 특히 **온디바이스 AI에 최적화된 폼팩터**로 주목받고 있습니다 [6].
    *   **온디바이스 AI의 장점**:
        *   **개인 정보 보호**: 민감한 사용자 정보가 디바이스 내에서만 머물러 클라우드로 나가지 않아 보안에 강합니다 [7].
        *   **오프라인 사용 가능**: 인터넷 연결 없이도 기능 구현이 가능합니다 [7].
        *   **빠른 응답 속도**: 클라우드 통신 지연 없이 빠른 응답이 가능합니다 [7].
        *   **비용 절감**: 추가 비용 없이 디바이스 내에서 처리되어 전기 요금만으로 해결됩니다 [7].
    *   **활용 사례**: 카카오T 데모에서 Gemini Nano 모델을 활용하여 사용자의 자연어 주문을 분석하고 주소 등 정보를 추출하는 기능이 소개되었으며 [7], 개인 상담사처럼 **사용자의 민감한 정보를 보호하며 파인 튜닝이 가능한 온디바이스 모델**(Gemma 1B) 활용 사례도 제시되었습니다 [7].

*   **API 활용**:
    *   **Gen AI SDK**: Gemini API 사용 시 **Gen AI SDK를 반드시 사용할 것을 권장**합니다 [8]. 이는 개발자용/운영용 API 구분을 없애고 플래그 변경만으로 보안 강화를 할 수 있게 하여 코드 변경 없이 개발부터 운영까지 자연스럽게 보안을 적용할 수 있도록 합니다 [8].
    *   **Vertex AI**: API 키 방식의 한계(관리 어려움, 유출 위험)를 넘어, **강화된 보안과 특정 클라이언트/IP 제약 등 기업 환경에서의 요구사항을 충족시키기 위해 Vertex AI를 함께 활용**할 수 있습니다 [8].
    *   **데이터 보안**: API 호출 시 개인 데이터가 서버로 그대로 넘어가는 것이 아니라, 머신러닝의 첫 레이어를 거쳐 **웨이트 값만 전달되므로 개인 정보 유출에 대한 우려를 덜 수 있습니다** [9].

*   **배포 및 운영 단계**:
    *   AI는 앱 배포 시 **구글 플레이 정책 준수**에 대한 가이드를 제공하고 [10], 배포 후 발생하는 오류 보고서를 분석하여 **자동으로 코드를 수정해주는 가능성**을 제시합니다 [10]. 이는 개발자의 운영 부담을 크게 줄여줄 것으로 기대됩니다 [10].

**안드로이드 개발의 미래 및 개발자의 역할**:

*   **새로운 기회와 생산성 향상**: AI는 개발자에게 **새로운 장난감**과 같으며, 생산성 향상을 바탕으로 **더욱 높은 품질의, 더 멋진 사용자 경험을 제공하는 앱**을 만들 수 있게 합니다 [10]. XR(확장 현실)과 같은 새로운 분야에서도 개발자들이 할 일이 훨씬 늘어날 것으로 예상됩니다 [10].
*   **AI 에이전트 앱**: AI를 활용하여 앱 내에서 사용자에게 화면 설명, 쇼핑 추천 등 **다양한 기능을 제공하는 AI 에이전트 앱** 개발이 활성화될 것으로 보이며 [11], 구글에서는 에이전트 개발을 용이하게 하는 **Agent Development Kit(ADK)**를 제공합니다 [11]. 안드로이드는 다양한 앱 간 상호작용을 염두에 둔 플랫폼 설계(인텐트 개념 등)로 **에이전트 시대에 이미 준비된 플랫폼**으로 평가됩니다 [12].
*   **생성형 AI의 확장**: 동영상 생성, 이미지 생성 등 **생성형 AI API를 활용하여 기존에 상상하기 어려웠던 새로운 사용자 경험**을 제공하는 앱이 등장할 수 있습니다 [12]. 안드로이드 피규어에 옷을 입혀주는 '안드로이드파이'와 같은 쇼케이스 앱이 그 예시입니다 [12].
*   **개발자 역량 강화**:
    *   **주니어 개발자**: AI 시대에는 학생들이나 주니어 개발자들이 **최고 레벨 회사들의 기술을 바로 접하고 활용할 수 있는 기회**가 열렸습니다 [13]. 새로운 툴 학습에 민첩하고 호기심 많은 주니어들이 유리할 수 있습니다 [13].
    *   **시니어 개발자**: AI가 90%의 코드를 생성하더라도, **10%의 결함을 찾아내고 노하우를 발휘하는 것은 시니어의 역할**이므로, 주니어와 시니어의 협업이 가장 아름다운 결과를 만들 것이라 예상됩니다 [13].
    *   **무한한 가능성**: 안드로이드는 스마트폰, 태블릿, 시계뿐 아니라 키오스크 등 생활 속 곳곳에 숨어있는 **다양한 폼팩터에서 AI의 힘을 빌려 새로운 경험을 펼칠 수 있는 무한한 가능성**을 가지고 있습니다 [13]. AI는 개발자들의 '꿈을 꾸지 못하던 저 너머의 세계'를 현실로 만들 수 있게 합니다 [10, 13].


*** 영상링크
https://youtu.be/6WBgw764tNw?si=w35kyJscWMMCArsa

google io 2025





오늘의 이야기

 


Jetpack Compose + Firebase 기반 게시판 앱 개발기


게시글 만들기



 


이 글은 Jetpack Compose, Firebase Realtime Database, Hilt, Compose Destinations를 기반으로 한 게시판 앱 개발 과정을 정리한 것입니다. 게시글은 최근순으로 표시되며, 댓글 기능도 포함합니다.


1. 프로젝트 구성



  • Kotlin

  • Jetpack Compose

  • Firebase Realtime Database

  • Hilt (DI)

  • Compose Destinations (Navigation)


2. 게시글 리스트 화면


Firebase에서 게시글 데이터를 읽고, 최근 등록순으로 보여줍니다.



LazyColumn(
reverseLayout = true // 최신 글이 위로
) {
items(posts) { post ->
PostItem(post)
}
}

FloatingActionButton을 이용해 글쓰기 화면으로 이동합니다.



Scaffold(
floatingActionButton = {
FloatingActionButton(onClick = {
navigator.navigate(NewPostScreenDestination())
}) {
Icon(Icons.Default.Add, contentDescription = "새 글 작성")
}
}
) {
// Content here
}

3. 글쓰기 화면



@Destination
@Composable
fun NewPostScreen(navigator: DestinationsNavigator) {
var title by remember { mutableStateOf("") }
var content by remember { mutableStateOf("") }

Column(modifier = Modifier.padding(16.dp)) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("제목") },
colors = TextFieldDefaults.outlinedTextFieldColors(
textColor = Color.Black
)
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = content,
onValueChange = { content = it },
label = { Text("내용") }
)
Spacer(Modifier.height(16.dp))
Button(onClick = {
// Firebase에 저장
navigator.popBackStack()
}) {
Text("등록")
}
}
}

4. 게시글 수정


수정 시, postId를 인자로 전달해 해당 글을 로딩하고 수정합니다.



@Destination
@Composable
fun EditPostScreen(postId: String, navigator: DestinationsNavigator) {
val viewModel: PostViewModel = hiltViewModel()
val post = viewModel.getPost(postId)

// 수정 UI 구성
}

5. Compose Destinations 구성



@Composable
fun MainNavHost() {
val navController = rememberNavController()
DestinationsNavHost(
navController = navController,
navGraph = NavGraphs.root
)
}

Destinations에서 ViewModel 외부 주입이 필요한 경우



DestinationsNavHost(navController = navController, navGraph = NavGraphs.root) {
composable(EditPostScreenDestination) {
EditPostScreen(
postId = it.navArgs.postId,
navigator = it.destinationsNavigator,
mainViewModel = customViewModel
)
}
}

6. UI 요소 조정



  • TopBar 여백 제거: Scaffold에서 topBar 생략

  • FloatingActionButton 아래 배치: Scaffold 내에서 기본 위치에 둬도 하단 고정

  • ModalBottomSheet 테두리 색상 조정: 직접 설정은 어려움 → 커스텀 구현 필요


7. 다국어 번역 예시























용어 번역
Posts 한국어: 게시글, 중국어: 帖子, 대만어: 貼文, 일본어: 投稿, 베트남어: Bài viết, 태국어: โพสต์, 필리핀어: Mga post
도로명주소 영어: Road name address, 중국어: 道路名地址, 대만어: 路名地址, 일본어: 道路名住所, 베트남어: Địa chỉ theo tên đường, 태국어: ที่อยู่ตามชื่อถนน, 필리핀어: Address ng kalsada
Distance 중국어: 距离, 대만어: 距離, 일본어: 距離, 베트남어: Khoảng cách, 태국어: ระยะทาง, 필리핀어: Distansya

마무리


이와 같은 구조로 Jetpack Compose와 Firebase를 이용한 게시판 앱을 확장할 수 있습니다. 다음 포스트에서는 이미지 업로드, 사용자 인증, 또는 푸시 알림 추가도 소개할 수 있습니다.





오늘의 이야기


#스하리1000명프로젝트,
Parfois, il est difficile de parler avec des travailleurs étrangers, n'est-ce pas ?
J'ai créé une application simple qui aide ! Vous écrivez dans votre langue et les autres le voient dans la leur.
Il se traduit automatiquement en fonction des paramètres.
Super pratique pour des discussions faciles. Jetez-y un oeil quand vous en aurez l'occasion !
https://play.google.com/store/apps/details?id=com.billcoreatech.multichat416




오늘의 이야기

  하청 개발자와 불법파견: 법적 쟁점과 대응 전략 하청 개발자 주의 사항   1. 주제와 예시 IT 업계에서 프리랜서 또는 외주 개발자가 원청업체의 프로젝트에 투입되어 직접 업무 ...