2026/05/06

오늘의 이야기

 


 



WebSquare Studio에서 FusionChart 버블 차트 구현기


chart예시


 


오늘은 WebSquare Studio에서 FusionChart의 Bubble Chart를 구현하면서 겪은 시행착오와 해결 방법을 정리해보려 합니다. 데이터 바인딩, 텍스트 표시, 색상 처리, 다운로드 기능까지 다양한 이슈를 경험했는데요, 그 과정을 공유합니다.




1. dataList vs ref: 데이터 바인딩 방식


WebSquare에서는 FusionChart에 데이터를 바인딩할 때 dataList 속성이나 ref="data:dcBubble"와 같은 ref 속성을 사용할 수 있습니다.



  • dataList: DataCollection을 직접 연결

  • ref: WebSquare의 데이터 바인딩 규칙을 따름


하지만 FusionChart는 x, y, z, label, displayValue 구조를 요구하므로, 단순히 ref만 연결해서는 원하는 형태로 출력되지 않았습니다. JavaScript로 데이터를 가공해주는 과정이 필요했습니다.




2. showValues와 displayValue의 관계


버블 안에 텍스트를 표시하려면 showValuesdisplayValue의 관계를 이해해야 합니다.



  • showValues="1"이면 기본적으로 x 값이 표시됩니다.

  • 원하는 텍스트(예: 코드)를 표시하려면 displayValue를 사용해야 합니다.

  • 이때 showValues"0"으로 꺼야 displayValue가 적용됩니다.


{
"chart": {
"showValues": "0"
},
"dataset": [{
"data": [
{ "x": "20", "y": "30", "z": "15", "displayValue": "A" }
]
}]
}



3. series/columns 선언의 중요성


WebSquare Studio에서는 FusionChart를 사용할 때 <series><columns> 선언이 필수입니다. 이 선언이 없으면 차트가 렌더링되지 않거나 버블이 표시되지 않습니다.


<series>
<columns>
<column id="x"/>
<column id="y"/>
<column id="z"/>
<column id="label"/>
<column id="displayValue"/>
</columns>
</series>

데이터 구조와 이 선언이 일치해야 차트가 정상적으로 작동합니다.




4. 색상 처리: 버블과 텍스트


FusionChart에서는 다음과 같은 방식으로 색상을 제어할 수 있습니다.



  • 버블 색상: 각 데이터에 color 속성을 지정하여 개별 설정 가능

  • 텍스트 색상: chart.valueFontColor로 전체 텍스트 색상 지정


버블마다 텍스트 색상을 다르게 지정하는 기능은 기본적으로 지원되지 않습니다. 대신 툴팁에 HTML 스타일을 적용해 색상을 다르게 표현할 수 있습니다.


"chart": {
"valueFontColor": "#FF0000",
"plotToolText": "<div style='color:#00AAFF'><b>$displayValue</b></div>"
}



5. exportEnabled로 이미지 다운로드 기능 추가


차트를 이미지나 PDF로 저장하려면 exportEnabled 속성을 사용하면 됩니다.


"chart": {
"exportEnabled": "1",
"exportFileName": "bubble_chart"
}

이렇게 하면 차트 우측 상단에 Export 메뉴가 생기고, PNG, JPG, SVG, PDF로 저장할 수 있습니다. 또한 JavaScript API를 통해 직접 다운로드를 트리거할 수도 있습니다.


chartInstance.exportChart({ exportFormat: 'png' });



마무리하며


이번 경험을 통해 WebSquare Studio와 FusionChart의 연동 방식, 데이터 구조, 시각화 옵션에 대해 깊이 이해할 수 있었습니다. 특히 displayValueshowValues의 관계, series/columns 선언의 중요성은 꼭 기억해야 할 포인트입니다.


앞으로도 다양한 차트 유형을 테스트하며 더 나은 시각화를 구현해보고자 합니다.






오늘의 이야기

⌚ Android Wear & Phone 연동 디버깅 | 고도 수집, 상태 동기화, Hilt 순환 참조 정리


워치앱



 


개요 (Intro)


오늘은 Wear OS 앱과 폰 앱 사이에서 다음 세 가지를 중점적으로 작업했다. - Wear의 TimeText 스타일 수정 (텍스트 색상 변경) - 폰 앱 설정 화면에서 시작/중지 액션을 보냈을 때, 워치 메인 화면의 상태 표시 및 고도(기압) 목록 표시 동기화 - Hilt DI 구성에서 발생한 WearDataSaver 순환 참조 오류 해결 및 SyncModule 정리


📅 날짜: 2025.12.25
🎯 목표: 폰 ↔ 워치 측정 상태/고도 데이터 동기화 및 Hilt 순환 참조 제거
🧰 기술: Kotlin, Android, Wear OS, Jetpack Compose, Hilt, Gradle



문제 정의 (Problem / Motivation)


이번에 정리한 문제들은 크게 네 가지였다. 1. Wear TimeText 색상 변경 - Wear OS의 TimeText() 컴포저블에서 시간 텍스트 색을 바꾸고 싶었다. - 문서를 보면 timeTextStyle을 통해 스타일을 주입할 수 있으나, 기본 샘플에서는 색상 변경이 적용되지 않고 있었다. 2. 폰 설정 화면의 측정 시작/중지 상태가 워치 메인 화면에 반영되지 않음 - 폰 앱 설정 화면에서: - "위치측정시작", "워치측정시작" 버튼 클릭 시 - 워치 메인 화면의 StatusSection"측정시작" / "측정중지" 같은 상태가 실시간(or 가깝게) 반영되길 원했다. - 하지만 현재 구현에서는, 폰 쪽에서 상태를 바꿔도 워치 UI에 반영이 되지 않거나, 반영 타이밍이 이상했다. 3. 고도(기압) 데이터 recentAltitudes 리스트가 비어 있음 - 설정 화면에서 "워치측정시작/중지"를 누르면 고도(기압) 데이터가 수신되고 있다고 log 에서는 보였지만, - UI 쪽에서 참조하는 recentAltitudessize가 계속 0으로 나왔다. - 즉, 실제로는 데이터 업데이트가 되고 있는데, UI에 연결되는 리스트에 값이 반영되지 않는 문제. 4. 빌드 시 Hilt 순환 참조 오류 발생 Gradle 빌드시 아래와 같은 에러가 발생했다.



error: [Dagger/DependencyCycle] Found a dependency cycle:
com.billcoreatech.health501.sync.WearDataSaver is injected at
[SingletonC] SyncModule.provideWearDataSaver(…, saver)
com.billcoreatech.health501.sync.WearDataSaver is injected at
[SingletonC] SyncModule.provideWearDataSaver(…, saver)
...

The cycle is requested via:
WearDataSaver is injected at
StepCounterApplication.wearDataSaver
StepCounterApplication is injected at
StepCounterApplication_GeneratedInjector.injectStepCounterApplication

- WearDataSaver 를 제공하는 Hilt 모듈에서 Application 과의 순환 의존이 생긴 상태였다. - DI 구성이 꼬여 있어서 Hilt 컴파일 단계에서 막힌 상황. 간단한 예시로, Hilt 모듈이 순환 참조를 만들 때의 전형적인 패턴은 다음과 같다.



// 예시 코드 (문제 상황 예시)

@Module
@InstallIn(SingletonComponent::class)
object SampleModule {

@Provides
@Singleton
fun provideSomething(app: MyApplication): Something {
// MyApplication 이 다시 Something 을 주입받고 있다면
// Dagger 입장에서는 순환 참조가 생김
return app.something
}
}



해결 과정 (How I Solved It)


각 문제를 단계별로 정리했다.


1. Wear TimeText 색상 변경: timeTextStyle 사용


Wear OS Compose에서 TimeText의 텍스트 색상은 timeTextStyle 파라미터를 통해 바꿀 수 있다. 핵심 아이디어는: - TimeTexttimeTextStyle = TimeTextDefaults.timeTextStyle(color = ...) 처럼 전달하거나 - 또는 TextStyle을 직접 만들어서 넘겨주는 것. 예시 코드는 아래와 같이 작성할 수 있다.



@Composable
fun SampleTimeText() {
// 검정색 텍스트 스타일 정의
val blackTimeTextStyle = TimeTextDefaults.timeTextStyle(
color = Color.Black // 여기서 텍스트 색상을 지정
)

TimeText(
timeTextStyle = blackTimeTextStyle
)
}

위와 같이 TimeTextDefaults.timeTextStyle()에 color 를 명시적으로 지정하면, 기본 테마 색 대신 우리가 원하는 색(예: 검정색)으로 시간 텍스트가 표시된다.


2. 폰 설정 화면의 측정 시작/중지 → 워치 StatusSection 동기화


폰 앱에서 버튼을 누르면 워치로 명령을 보내고, 워치 메인 화면의 상태 UI 가 이를 반영하게 만드는 흐름은 대략 다음과 같이 잡았다. 1. 폰 앱 설정 화면에서 ViewModel 통해 startWatchMeasurement(), stopWatchMeasurement() 같은 함수를 호출한다. 2. 이 함수는 WearDataSyncManager (또는 유사한 sync/transport 클래스)를 사용해서, 워치로 "시작" 또는 "중지" 메시지를 보낸다. 3. 워치 측에서는 해당 메시지를 수신하는 리시버/서비스에서 MutableStateFlow 혹은 MutableLiveData에 상태를 업데이트한다. 4. 워치 메인 화면의 StatusSection 컴포저블은 이 상태 Flow 를 collect 해서 텍스트를 바꾼다. 예시 구조는 대략 이런 식이다.



// (워치 쪽) 상태를 노출하는 ViewModel 예시

class WatchMainViewModel @Inject constructor(
private val statusRepository: StatusRepository
) : ViewModel() {

// 현재 측정 상태를 나타내는 Flow (예: IDLE, RUNNING, STOPPED 등)
val measurementStatus: StateFlow<MeasurementStatus> =
statusRepository.measurementStatus.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = MeasurementStatus.Idle
)
}

@Composable
fun StatusSection(viewModel: WatchMainViewModel = hiltViewModel()) {
val status by viewModel.measurementStatus.collectAsStateWithLifecycle()

val statusText = when (status) {
MeasurementStatus.Idle -> "대기 중"
MeasurementStatus.Started -> "측정시작"
MeasurementStatus.Stopped -> "측정중지"
}

Text(text = statusText)
}

폰에서 "워치측정시작" 버튼을 눌렀을 때는, 워치 쪽 StatusRepositoryMeasurementStatus.Started가 반영되도록 message/data layer 를 통해 값을 전송해 주면 된다. 핵심은 "폰 액션" → "워치 ViewModel 상태" → "StatusSection UI"로 이어지는 단방향 데이터 흐름을 명확히 만든 것이다.


3. recentAltitudes 리스트 0 사이즈 문제 & UI 표시


관찰한 현상은 다음과 같았다. - 로그 상으로는 "Altitude updated" 같은 메시지가 잘 찍히고 있었음. - 하지만 UI 쪽에서 바라보는 recentAltitudessize는 계속 0이었다. 이 경우 주로 의심해야 할 포인트는 다음 세 가지다. 1. 데이터를 추가하는 리스트 인스턴스와, UI에서 관찰하는 리스트 인스턴스가 다른가? 2. immutable 리스트를 갱신한 뒤 새로 할당하지 않고 같은 레퍼런스를 쓰고 있는가? 3. Flow/LiveData 를 관찰하는 위치와 스레드가 올바른가? 일반적인 패턴으로, 최근 고도 10개만 관리하고 UI에 보여주려면 다음과 같이 구현할 수 있다.



// 고도 데이터 모델

data class AltitudeEntry(
val timestamp: Long, // 수신 시각 (millis)
val altitude: Float // 고도(또는 기압 값)
)

class AltitudeRepository @Inject constructor() {

// 최근 고도 리스트를 StateFlow 로 노출
private val _recentAltitudes = MutableStateFlow<List<AltitudeEntry>>(emptyList())
val recentAltitudes: StateFlow<List<AltitudeEntry>> = _recentAltitudes

/**
* 새 고도 데이터를 추가하면서, 최근 10개만 유지한다.
*/
fun addAltitude(altitude: Float) {
val newEntry = AltitudeEntry(
timestamp = System.currentTimeMillis(),
altitude = altitude
)

// 기존 리스트를 복사해서 새 리스트 생성
val updated = (_recentAltitudes.value + newEntry)
.takeLast(10) // 최근 10개만 유지

_recentAltitudes.value = updated
}
}

위처럼 기존 리스트에 요소를 추가한 새 리스트를 만들고, 이를 다시 Flow 에 넣어주는 방식이면, Composable 에서 collectAsState() 시 변화가 잘 감지된다. 그리고 UI 에서는 다음과 같이 그려줄 수 있다.



@Composable
fun AltitudeHistoryCard(
altitudeRepository: AltitudeRepository = hiltViewModel<YourViewModel>().altitudeRepository
) {
val recentAltitudes by altitudeRepository.recentAltitudes
.collectAsStateWithLifecycle()

// 리스트가 비어 있으면 아무것도 그리지 않도록 요구사항을 반영
if (recentAltitudes.isEmpty()) {
// 요구사항: 값이 없으면 UI에 표시하지 않음
return
}

Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Column(modifier = Modifier.padding(8.dp)) {
Text(text = "최근 고도(기압) 10개", style = MaterialTheme.typography.titleMedium)

Spacer(modifier = Modifier.height(8.dp))

// 시간순(옛날 → 최근)으로 정렬해서 표시
val sorted = recentAltitudes.sortedBy { it.timestamp }

sorted.forEach { entry ->
val timeText = remember(entry.timestamp) {
// 간단한 시:분 포맷 예시
SimpleDateFormat("HH:mm:ss", Locale.getDefault())
.format(Date(entry.timestamp))
}

Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = timeText)
Text(text = String.format(Locale.getDefault(), "%.1f m", entry.altitude))
}
}
}
}
}

위 구조가 유지되는지 점검하면서, recentAltitudes size 가 0 인 이유를 다음 순서로 검증했다. 1. addAltitude() 가 실제로 호출되는지 로그로 확인. 2. addAltitude 안에서 _recentAltitudes.value 가 변경되는지 (디버거 또는 로그). 3. UI에서 collectAsState()로 보고 있는 recentAltitudes가 동일 인스턴스인지 (같은 Repository / 같은 ViewModel 인지 확인). 최종적으로, 데이터를 업데이트하는 쪽과 UI에서 구독하는 쪽을 같은 Repository/Flow 인스턴스로 맞추고, 리스트를 불변 리스트로 재할당하도록 수정해서 recentAltitudes size가 0이 아닌 값으로 정상적으로 올라오는 것을 확인했다.


4. Hilt 순환 참조 해결: SyncModule 정리


Hilt에서 보고해 준 순환 참조 문제는 대략 다음 구조였다. - StepCounterApplicationWearDataSaver 를 주입받고 있음 - WearDataSaver 를 제공하는 Hilt 모듈이 다시 Application 을 참조하거나, 그 반대의 형태로 이어지면서 순환 이 문제를 해결하기 위해 DI 구성을 단순화했다. 현재 SyncModule.kt은 다음처럼 정리되어 있다.



package com.billcoreatech.health501.di

import android.content.Context
import com.billcoreatech.health501.sync.WearDataSyncManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object SyncModule {
@Provides
@Singleton
fun provideWearDataSyncManager(@ApplicationContext context: Context): WearDataSyncManager =
WearDataSyncManager(context)
}

핵심 포인트는: - Application 자체를 의존성으로 주입받지 않고, @ApplicationContext 로 제공되는 Context 만 사용하도록 변경했다는 점. - WearDataSaver 처럼 Application 과 서로 물고 물리던 타입을 모듈에서 제거하거나, 의존성을 단방향이 되도록 재구성했다. 이렇게 구성하면 Hilt 입장에서 "Application ↔ WearDataSaver" 사이의 순환 참조를 끊을 수 있어 컴파일 에러가 사라진다. 또한 WearDataSyncManager 는 Context 만 필요하므로, SingletonComponent 범위에 안전하게 둘 수 있다.




결과 (Result)


이번 수정으로 다음과 같은 결과를 얻었다.


✅ Wear TimeText 에서 timeTextStyle을 이용해 텍스트 색상을 원하는 색(검정색)으로 적용
✅ 폰 설정 화면의 측정 시작/중지 액션이 워치 메인 화면 StatusSection에 제대로 반영
✅ 고도(기압) 데이터가 recentAltitudes 리스트로 정상 수집되고, 최근 10개만 카드 UI로 표시
✅ Hilt DI의 순환 참조(WearDataSaver 관련) 오류 제거 및 SyncModule 정리

빌드 로그에서도 더 이상 [Dagger/DependencyCycle] 에러가 발생하지 않으며, 앱이 정상적으로 빌드 및 실행되는 것을 확인했다.




느낀 점 / 회고 (Reflection)


- Wear OS UI 는 일반 Compose 와 매우 비슷하지만, TimeText 같이 플랫폼 특화 컴포저블은 스타일 지정 방법을 한번 더 문서로 확인하는 게 좋다는 걸 느꼈다. - recentAltitudes 문제처럼 "로그는 찍히는데 UI 리스트는 비어 있는" 상황은, 대체로 상태 흐름(Flow/LiveData) 설계와 불변 리스트 재할당 문제로 귀결되는 경우가 많았다. - Hilt/Dagger 의 순환 참조 에러 메시지는 처음 보면 복잡하지만, "어떤 타입이 어떤 경로로 다시 자기 자신에게 돌아오는지"를 천천히 따라가다 보면 구조적인 문제를 바로잡는 계기가 된다. - 이번 정리를 통해, 폰 ↔ 워치 간 상태 및 데이터 동기화를 조금 더 명확한 단방향 흐름으로 정리할 수 있었다는 점이 가장 큰 수확이었다.




참고자료 (References)






오늘의 이야기

이 앱은 가끔식 가는 절(불교사찰)에서 108배를 해야 하는 데, 하니씩 세다 보면 간혹 혼란(?)이 오는 경우를 위해 구상하게 되었습니다. 


 


동작감지기



wear 가 시작 되면 오른쪽 그림과 같이 그날 걸음수를 화면에 표시 합니다.  폰에서 측정 시작을 하게 되면 왼쪽 그림과 같이 3,2,1 카운트를 하고 가운데 그림과 같이 동작을 측정합니다.  


 


설정화면



폰에서 설정은 사용자키 선택, 1일 목표 걸음수 선택하여 "모든 설정 저장 및 wear 전송" 을 클릭 하여 wear 와 동기를 처리 합니다. 


이때 워치 에서는 상대 높이을 계산하여 현재 워치 높이를 초기화 합니다. 측정시작을 했을 떄, 동작을 감지 하기 위한 위치가 초기화 됩니다. 


 


"측정시작" 하면 워치에서 측정이 시작 되고, "측정 종료"를 선택 하면 측정이 종료 됩니다.  또한 측정 시작전에 선택한 최대 회수에 도달하는 경우 측정은 자동으로 종료 되고, 기록으로 이동 합니다. 


 


메인화면



 


앱의 메인 화면에서는 일일 걸음수가 표시 됩니다.  이 걸음수는 헬스커넥터에서 측정된 걸음수를 동기화 하여 표시 하므로 걸음수 표시에 지연이 발생 할 수 있습니다. 


 


동작감지 리스트



워치를 통해 수신된 동작 감지 리스트는 동작 이력에서 표시 됩니다.  표시되는 내용은 종료시간, 감지된 횟수, 소요된 시간이 표시 됩니다. (심박수는 감지된 경우에만, 최대bpm, 최소bpm 으로 구분 되어 표시 됩니다.)


 


 


** 이 앱은 아래 링크에서 설치해 보세요.


동작감지기(108배, wear os 지원 버전) - Google Play 앱



 


동작감지기(108배, wear os 지원 버전) - Google Play 앱


동작을 감지 하는 앱 입니다. wear 버전도 포함 됩니다. 108배 하다가 잊으셨나요? 이제 그런 일은 없습니다.


play.google.com




 


예시 영상은 아래 링크에서 보실 수 있습니다.


https://youtube.com/shorts/029ZVgwfrmM?feature=share





 





오늘의 이야기



#스치니1000프로젝트 #재미 #행운기원 #Compose #Firebase

🎯 야 너 토요일마다 로또 확인하냐?
나도 맨날 “혹시나~” 하면서 봤거든 ㅋㅋ

근데 이제는 그냥 안 해
AI한테 맡겼어 🤖✨

그것도 구글 Gemini로다가!

그래서 앱 하나 만들었지
👉 “로또 예상번호 by Gemini” 🎱

AI가 분석해서 번호 딱! 뽑아줌
그냥 보고 참고만 하면 됨

재미로 해도 좋고…
혹시 모르는 거잖아? 😏


https://play.google.com/store/apps/details?id=com.billcorea.gptlotto1127




오늘의 이야기


#스하리1000명프로젝트

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

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

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





오늘의 이야기

🦾 Android | 하단 바 + Navigation Compose + 보안 키 주입(ResValue) 적용기


하단바 만들기 예제



개요 (Intro)



  • 오늘의 목표 / 배경: 앱에 하단 바를 도입하고 Compose Navigation으로 홈/설정/광고 탭을 구성. 외부 API 키를 local.properties에서 안전하게 읽어 리소스로 주입.

  • 어떤 문제를 해결하려 했는지: Gradle Kotlin DSL에서 local.properties 로딩 시 IDE 경고/오류, 네비게이션 구성 시 일부 import 누락 및 경고.

  • 사용한 기술 스택: Kotlin, Jetpack Compose, Navigation-Compose, Hilt, Gradle Kotlin DSL


📅 날짜: 2025.11.23
🎯 목표: 바텀 네비게이션 3탭(홈/설정/광고) + 키 주입(resValue) + Gradle DSL 정리
🧰 기술: Kotlin, Compose, Navigation, Hilt, Gradle Kotlin DSL

문제 정의 (Problem / Motivation)



  • local.properties 값을 build.gradle.kts에서 java.util.Properties로 직접 로딩 시 IDE가 Unresolved reference: util, load 등 오탐 경고를 표시.

  • Compose 네비게이션 구현 중, Modifier.padding(innerPadding) 관련 import 누락으로 Unresolved reference 'padding' 오류.

  • 하단 바와 NavHost를 연결하는 구조에서 패키지 경로, 아이콘 import, 경고(예: hiltViewModel deprecate 알림) 확인 필요.


// (이슈 예시) build.gradle.kts에서 직접 Properties 로딩 시 IDE 경고 발생 가능
val localProps = java.util.Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) file.inputStream().use { load(it) } // load 참조 경고/오류 표기 사례
}
val cupangAccessKey = localProps.getProperty("cupangAccessKey") ?: ""

해결 과정 (How I Solved It)



  • local.properties 로딩은 Gradle의 providers.gradleProperty API로 대체하여 IDE/Gradle 모두에서 안정적으로 동작하도록 변경.

  • 앱 모듈의 defaultConfig에서 resValue로 문자열 리소스로 주입.

  • 하단 바 + NavHost 구조를 분리 파일로 구성하고, 필요한 Compose import를 명시하여 컴파일 오류 제거.


// app/build.gradle.kts (발췌) — Gradle providers API 사용
val cupangAccessKey: String = providers.gradleProperty("cupangAccessKey").orNull ?: ""
val cupangSecretKey: String = providers.gradleProperty("cupangSecretKey").orNull ?: ""

android {
defaultConfig {
resValue("string", "cupang_access_key", cupangAccessKey)
resValue("string", "cupang_secret_key", cupangSecretKey)
}
}

// 하단 바 + 네비게이션 (발췌)
sealed class AppScreen(val route: String, val label: String, val icon: ImageVector) {
data object Home: AppScreen("home", "홈", Icons.Filled.Home)
data object Settings: AppScreen("settings", "설정", Icons.Filled.Settings)
data object Ads: AppScreen("ads", "광고", Icons.Filled.Campaign)
}

@Composable
fun AppNavHost(navController: NavHostController, viewModel: HealthConnectViewModel = hiltViewModel()) {
NavHost(navController = navController, startDestination = AppScreen.Home.route) {
composable(AppScreen.Home.route) { MainScreen(viewModel) }
composable(AppScreen.Settings.route) { SettingsScreen() }
composable(AppScreen.Ads.route) { AdsScreen() }
}
}

@Composable
fun AppScaffoldWithBottomBar() {
val navController = rememberNavController()
Scaffold(bottomBar = { BottomBar(navController) }) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) { AppNavHost(navController) }
}
}

결과 (Result)



  • 하단 바 3탭(홈/설정/광고) + NavHost 라우팅이 정상 작동.

  • local.propertiescupangAccessKey/cupangSecretKeyR.string으로 노출, 화면/Compose에서 getString으로 참조 가능.

  • Gradle Kotlin DSL의 IDE 경고를 줄이고, 스크립트 단순화로 유지보수성 개선.


✅ 바텀 네비게이션 안정화 및 화면 전환 확인
🔐 키 주입(resValue) 동작 확인, 보안 노출 리스크는 추후 서버 프록시로 추가 보완 예정
🧹 Gradle DSL 경고 제거로 개발 경험 개선

느낀 점 / 회고 (Reflection)



  • Gradle 스크립트에서 표준 API를 직접 다루기보다는 Gradle 제공 API를 활용하는 편이 IDE/빌드 모두에 일관적.

  • 네비게이션은 파일 분리(화면/바/호스트)로 가독성이 크게 좋아짐.

  • 민감 키는 최종적으로 서버 서명/프록시로 이동하는 게 안전. 일단은 resValue로 편의 제공.


참고자료 (References)






오늘의 이야기


LLM 설치해 보기



 


🐍 Python | Hugging Face 모델, 왜 요약을 못할까? (Base vs. Instruct 모델, 버전 충돌 해결기)


개요 (Intro)


Hugging Face 모델 로딩 시 마주쳤던 인증 오류(401, 403)를 해결한 후, 새로운 문제에 직면했다. 모델이 요약 지시를 제대로 따르지 않았고, 이를 해결하는 과정에서 예상치 못한 라이브러리 버전 충돌까지 겪었다. 오늘의 일지는 이 두 가지 문제를 해결한 과정을 상세히 기록한다.


📅 날짜: 2025.11.09
🎯 목표: Gemma 모델이 뉴스 기사를 의미 있는 한국어로 요약하도록 만들기
🧰 기술: Python, Hugging Face (transformers, torch, torchvision)

문제 정의 (Problem / Motivation)


인증 문제를 모두 해결하고 `google/gemma-2b` 모델을 성공적으로 로드했지만, 정작 중요한 요약 기능이 제대로 동작하지 않았다. 게다가 문제를 해결하려다 새로운 오류까지 발생했다.


문제 1: 모델이 지시를 따르지 않음


분명 "요약해줘"라고 요청했지만, 모델은 엉뚱한 텍스트를 생성하거나 입력한 프롬프트를 그대로 반복할 뿐, 의미 있는 요약문을 만들어내지 못했다.


문제 2: `torchvision` 버전 충돌 오류


라이브러리 버전 문제 해결을 위해 `pip install --upgrade`를 실행한 후, 이전에는 없던 새로운 오류가 발생했다. 모델 로딩 단계에서 `RuntimeError: operator torchvision::nms does not exist` 라는 메시지와 함께 프로그램이 중단되었다.



ModuleNotFoundError: Could not import module 'GemmaForCausalLM'. Are this object's requirements defined correctly?
... (Caused by)
RuntimeError: operator torchvision::nms does not exist

해결 과정 (How I Solved It)


두 문제는 별개의 원인을 가지고 있었고, 하나씩 해결해 나갔다.



  1. Base 모델 vs. Instruction-Tuned 모델의 차이 이해:
    첫 번째 문제의 원인은 모델 선택에 있었다. 내가 사용한 `google/gemma-2b`는 **기반(Base) 모델**로, 다음에 올 단어를 예측할 뿐 지시를 따르도록 훈련되지 않았다. "요약"과 같은 특정 작업을 수행하려면, 지시를 따르도록 미세조정(Fine-tuning)된 **Instruction-Tuned 모델 (`google/gemma-2b-it`)**을 사용해야 했다.

    # 수정 전
    # model_name = "google/gemma-2b"

    # 수정 후: Instruction-Tuned 모델로 변경
    model_name = "google/gemma-2b-it"

    또한, 모델이 생성한 전체 텍스트에서 입력 프롬프트를 제외하고 순수 요약 결과만 추출하도록 코드 로직을 개선했다.

  2. 라이브러리 버전 충돌 해결:
    두 번째 문제의 원인은 `torch`와 `torchvision`의 버전 호환성이 깨졌기 때문이었다. `pip install --upgrade`가 각 라이브러리를 개별적으로 최신화하면서 발생한 문제로 추정된다. 가장 확실한 해결책은 관련 라이브러리를 완전히 삭제하고 재설치하는 것이었다.

    # 1. 기존 라이브러리 완전 삭제
    pip uninstall torch torchvision -y

    # 2. 호환되는 버전으로 재설치
    pip install torch
    pip install torchvision



결과 (Result)


위의 과정을 통해 모든 문제를 해결하고, 마침내 `gemma-2b-it` 모델이 정상적으로 다운로드되는 것을 확인했다.


✅ `torch`와 `torchvision` 버전 충돌 문제 해결!
✅ Base 모델 대신 Instruction-Tuned 모델(`gemma-2b-it`)을 사용하도록 코드 수정 완료!
⏳ 현재 새로운 `gemma-2b-it` 모델 다운로드 진행 중 (100% 완료되면 요약 기능 정상 동작 기대)

느낀 점 / 회고 (Reflection)



  • LLM을 사용할 때 **Base 모델**과 **Instruction-Tuned 모델**의 차이를 아는 것이 매우 중요하다는 것을 깨달았다. 특정 작업을 시키려면 반드시 `-it`나 `-instruct`가 붙은 모델을 써야 한다.

  • 라이브러리 버전 문제는 언제나 복병이다. `pip install --upgrade`가 만능 해결책이 아니며, 때로는 깨끗하게 삭제 후 재설치하는 것이 더 확실한 방법이다.

  • 오류 로그를 차근차근 읽는 것이 문제 해결의 가장 빠른 길이다. `torchvision::nms`라는 키워드를 통해 `torch`와 `torchvision`의 관계를 의심해 볼 수 있었다.


참고자료 (References)






오늘의 이야기


#스하리1000명프로젝트,
Bị lạc ở Hàn Quốc? Ngay cả khi bạn không nói được tiếng Hàn, ứng dụng này vẫn giúp bạn đi lại dễ dàng.
Chỉ cần nói ngôn ngữ của bạn—nó sẽ dịch, tìm kiếm và hiển thị kết quả bằng ngôn ngữ của bạn.
Tuyệt vời cho du khách! Hỗ trợ hơn 10 ngôn ngữ bao gồm tiếng Anh, tiếng Nhật, tiếng Trung, tiếng Việt, v.v.
Hãy thử nó ngay bây giờ!
https://play.google.com/store/apps/details?id=com.billcoreatech.opdgang1127




오늘의 이야기

🐘 Oracle | GREATEST 함수와 PIVOT으로 데이터 다루기


개발자가 database 을 구현하고 있는 중.



개요 (Intro)


오늘은 Oracle DB에서 여러 컬럼 값 중 최댓값을 구하는 방법과 월별 데이터를 한 행으로 변환하는 방법을 실습했다. 특히 GREATEST 함수의 동작 방식과 PIVOT 구문을 활용한 데이터 구조 변환을 다뤘다. 사용한 기술 스택: Oracle SQL, SQL Developer


📅 날짜: 2025.12.19
🎯 목표: 컬럼 값 비교와 월별 데이터 Pivot 처리
🧰 기술: Oracle SQL, GREATEST, PIVOT, CASE

문제 정의 (Problem / Motivation)


특정 행(row)의 3개 컬럼 값 중 최댓값을 구해야 하는 상황이 있었다. 단순 숫자뿐 아니라 문자(R, O, Y, G) 값이 들어 있는 경우 어떤 결과가 나오는지 확인 필요. 또한 1월부터 12월까지 월별 데이터가 여러 행으로 저장된 테이블을 한 행으로 변환해 보고 싶었다.


 -- 예시: 특정 행의 3개 컬럼 중 최댓값 구하기 SELECT GREATEST(col1, col2, col3) AS max_value FROM your_table WHERE id = 123; 

해결 과정 (How I Solved It)


GREATEST 함수는 숫자뿐 아니라 문자도 비교 가능하다는 점을 확인했다. 문자 비교 시 ASCII 코드 기준으로 가장 큰 값이 반환됨. (예: R=82, O=79, Y=89, G=71 → 최댓값은 Y) 의미적 순서(예: R > O > Y > G)를 반영하려면 CASE 또는 DECODE로 매핑 후 비교해야 한다.


 -- 색상 우선순위 매핑 후 최댓값 구하기 SELECT CASE GREATEST( DECODE(col1, 'R',4, 'O',3, 'Y',2, 'G',1), DECODE(col2, 'R',4, 'O',3, 'Y',2, 'G',1), DECODE(col3, 'R',4, 'O',3, 'Y',2, 'G',1) ) WHEN 4 THEN 'R' WHEN 3 THEN 'O' WHEN 2 THEN 'Y' WHEN 1 THEN 'G' END AS max_color FROM your_table WHERE id = 123; 

월별 데이터를 한 행으로 변환하기 위해 PIVOT을 활용했다.


 -- PIVOT을 이용한 월별 데이터 변환 SELECT * FROM sales_data PIVOT ( SUM(amount) FOR month IN (1 AS JAN, 2 AS FEB, 3 AS MAR, 4 AS APR, 5 AS MAY, 6 AS JUN, 7 AS JUL, 8 AS AUG, 9 AS SEP, 10 AS OCT, 11 AS NOV, 12 AS DEC) ); 

결과 (Result)


GREATEST로 숫자와 문자를 비교할 수 있음을 확인했고, 문자 비교 시 ASCII 기준이라는 점을 배웠다. 색상 순서를 반영하려면 매핑이 필요하다는 교훈을 얻었다. PIVOT을 통해 월별 데이터를 한 행으로 변환하는 데 성공했다.


✅ 컬럼 값 비교 로직 정리 완료✅ 월별 데이터 Pivot 변환 성공📊 데이터 구조 이해도 향상

느낀 점 / 회고 (Reflection)


단순히 함수만 쓰는 것이 아니라, 데이터의 의미를 반영하려면 추가 로직이 필요하다는 점을 깨달았다. PIVOT은 데이터 분석 시 매우 유용하며, 앞으로 보고서 작성이나 BI 툴 연동 시 자주 활용할 수 있을 것 같다. SQL은 단순 조회를 넘어서 데이터 구조를 재구성하는 강력한 도구라는 점을 다시 느꼈다.


참고자료 (References)


Oracle SQL Functions - GREATEST Oracle PIVOT Clause 공식 문서





2026/05/05

오늘의 이야기

 



🕹️ Android | Wear Compose UI 레이아웃 정리와 중앙정렬, 문자열 리소스화


앱 구성중



 


개요 (Intro)



  • 오늘의 목표 / 배경: Wear OS 앱의 메인 화면 Compose 레이아웃을 모듈화하고 가독성을 높이며, 텍스트 중앙정렬과 문자열 리소스화를 적용

  • 어떤 문제를 해결하려 했는지: 거대한 단일 컴포저블 내부 UI가 난해하고, 일부 하드코딩 텍스트/정렬 불일치가 존재

  • 사용한 기술 스택: Kotlin, Jetpack Compose for Wear, Hilt, Coroutines


📅 날짜: 2025.12.17
🎯 목표: Compose UI 분리(refactor) + 중앙정렬 + 문자열 리소스화 + 불필요 섹션 제거
🧰 기술: Kotlin, Android Studio, Compose, MVVM-ish, Hilt



문제 정의 (Problem / Motivation)



  • 메인 UI가 하나의 거대한 함수에 몰려 있어 읽기/수정이 어려움

  • 일부 텍스트가 하드코딩되어 국제화/재사용성에 제약

  • 센터 정렬이 필요한 텍스트들이 좌측정렬로 되어 UI 일관성이 떨어짐

  • Wear OS에서 의미가 약한 배터리 최적화 안내/버튼 섹션 존재


// 예시 (정리 이전): 단일 컴포저블 안에 다양한 UI가 혼재해 가독성 저하
@Composable
fun WearApp() {
// ... 상태/권한/서비스 로직
Scaffold {
ScalingLazyColumn {
// 권한, 상태, 위치, PDR, 배터리 섹션이 모두 item 블록으로 길게 나열됨
}
}
}



해결 과정 (How I Solved It)



  • UI를 작은 컴포저블들로 분리: PermissionSection, StatusSection, LocationSection, PdrSection

  • 텍스트 중앙정렬: 모든 텍스트 컴포넌트에 Modifier.fillMaxWidth() + TextAlign.Center 적용

  • 문자열 리소스화: 하드코딩된 한글("전송 임계값=")을 strings.xml로 이전

  • 불필요한 배터리 최적화 섹션 제거


// 파일: MainActivity.kt — 초보자도 이해하기 쉬운 주석 포함 예시
// 1) 가운데 정렬을 위한 import
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.ui.text.style.TextAlign

@Composable
fun StatusSection(
syncStatusText: String,
syncStatusColor: Color,
steps: Long,
lastDelta: Long,
stepThreshold: Long,
headingRad: Double,
lastPayload: StepPayload?,
altitude: Double?,
unit: String
) {
// Column의 수평 정렬은 Center로 유지하되,
// 각 Text를 화면 너비만큼 확장하고(Text가 가로폭을 차지),
// TextAlign.Center로 내용을 중앙에 배치
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = syncStatusText,
color = syncStatusColor,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth(), // 전체 너비 사용
textAlign = TextAlign.Center // 텍스트 중앙 정렬
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.steps_label) + ": " + steps + " (Δ " + lastDelta + ")",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Text(
text = stringResource(R.string.step_threshold_label) + stepThreshold,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Text(
text = String.format(
java.util.Locale.US,
stringResource(R.string.heading_label) + ": %.0f°",
Math.toDegrees(headingRad)
),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
}
}



전송 임계값=

// 파일: MainActivity.kt — 배터리 최적화 섹션 제거 예시
// 기존: BatteryOptimizationSection(...) 사용 및 관련 PositionIndicator 주변 조건부 렌더링
// 변경: 해당 섹션/조건 로직 삭제로 UI 단순화



결과 (Result)



  • UI가 섹션별 컴포저블로 분리되어 가독성 및 유지보수성 향상

  • 텍스트 중앙정렬로 일관된 시각 경험 제공

  • 문자열 리소스화로 국제화/재사용성 개선

  • 불필요한 배터리 최적화 UI 제거로 사용자 혼란 감소


✅ Compose 아이템들이 깔끔하게 모듈화됨
🎯 텍스트 중앙정렬 적용으로 UI 일관성 확보
🧩 문자열 리소스 관리로 다국어 및 유지보수 용이



느낀 점 / 회고 (Reflection)



  • Wear Compose에서도 작은 컴포저블로 나누는 것이 가장 큰 생산성 향상을 가져온다

  • 하드코딩 텍스트는 사소해 보여도 국제화/테스트/일관성 측면에서 리소스화가 중요

  • 기기 특성(Wear OS)을 고려한 UI/설정 항목은 실제 동작 가능 여부를 검증 후 제공해야 한다




참고자료 (References)






오늘의 이야기


#billcorea #운동동아리관리앱
🏸 Schneedle, aplikasi yang wajib dimiliki oleh klub bulu tangkis!
👉 Match Play – Rekam Skor & Temukan Lawan 🎉
Sempurna untuk di mana saja, sendirian, bersama teman, atau di klub! 🤝
Jika Anda suka bulu tangkis, cobalah

Buka aplikasi 👉 https://play.google.com/store/apps/details?id=com.billcorea.matchplay




오늘의 이야기

    WebSquare Studio에서 FusionChart 버블 차트 구현기 chart예시   오늘은 WebSquare Studio에서 FusionChart의 Bubble Char...