본문 바로가기
조그만 기술로 세상을 이롭게/국도어때

국도어때 앱 개발 방법 - 국도 CCTV 보기

by eplus 2026. 5. 22.

.NET MAUI로 국도 CCTV·교통정보·돌발정보 앱 만들기

이번 글에서는 국도어때 앱을 개발하는 방법을 정리해 보겠습니다.

국도어때는 사용자의 현재 위치 또는 입력한 위치를 기준으로 주변 CCTV를 조회하고, CCTV 영상과 지도 위치를 확인할 수 있는 교통정보 앱입니다. 또한 국도 기준 돌발정보, 교통정보 지도, 창원UTIS 연결, 최근 조회 위치 저장, CCTV 즐겨찾기 기능까지 포함할 수 있습니다.

개발 기술은 다음과 같습니다.

.NET MAUI
C#
Android
ITS OpenAPI
VWorld 지도
WebView
GPS 위치 조회
Geocoding
Syncfusion DataGrid
Preferences 저장
 

1. 앱 개발 목표

국도어때 앱의 핵심 목표는 다음과 같습니다.

현재 위치 기준 가까운 CCTV 조회
입력 위치 기준 CCTV 조회
CCTV 영상 보기
지도에서 CCTV 위치 확인
국도 기준 돌발정보 조회
교통정보 지도 표시
창원UTIS 연결
최근 조회 위치 저장
CCTV 즐겨찾기 등록/삭제
 

고속도로어때 앱이 고속도로 CCTV 중심이라면, 국도어때는 국도·일반도로·지역 교통정보를 중심으로 구성합니다.


2. 전체 화면 구성

앱은 하단 탭 구조로 구성하는 것이 좋습니다.

홈
교통정보
돌발정보
창원UTIS
 

홈 화면

홈 화면은 가장 중요한 화면입니다.

기능은 다음과 같습니다.

현재 위치 조회
입력 위치 조회
주변 CCTV 목록 표시
CCTV 영상 보기
CCTV 지도 보기
최근 조회 위치 표시
즐겨찾기 CCTV 표시
 

교통정보 화면

교통정보 화면은 지도 중심입니다.

현재 위치 기준 지도 표시
주변 CCTV 표시
도로 소통 정보 표시
CCTV 마커 클릭 시 영상 보기
 

돌발정보 화면

돌발정보는 국도/ITS 기준으로 조회합니다.

사고
공사
통제
정체
낙하물
기타 도로 장애
 

화면은 다음과 같이 구성할 수 있습니다.

목록
지도
CCTV
상세정보
 

창원UTIS 화면

창원 지역 사용자를 위해 창원교통정보센터를 WebView 또는 외부 브라우저로 연결합니다.

https://utis.changwon.go.kr/video/cctv
 

3. 프로젝트 생성

Visual Studio에서 .NET MAUI 프로젝트를 생성합니다.

프로젝트 형식: .NET MAUI App
대상 플랫폼: Android
언어: C#
 

예상 프로젝트 구조는 다음과 같습니다.

eCCTV
 ┣ Models
 ┃ ┣ CctvInfo.cs
 ┃ ┣ IncidentInfo.cs
 ┃ ┣ SavedLocation.cs
 ┃ ┗ FavoriteCctv.cs
 ┣ Services
 ┃ ┣ CctvService.cs
 ┃ ┣ IncidentService.cs
 ┃ ┗ AppStorageService.cs
 ┣ MainPage.xaml
 ┣ MainPage.xaml.cs
 ┣ TrafficInfoPage.xaml
 ┣ TrafficInfoPage.xaml.cs
 ┣ IncidentPage.xaml
 ┣ IncidentPage.xaml.cs
 ┣ UtisPage.xaml
 ┣ UtisPage.xaml.cs
 ┣ AppShell.xaml
 ┗ MauiProgram.cs
 

4. Android 권한 설정

위치와 인터넷 사용이 필요하므로 AndroidManifest.xml에 권한을 추가합니다.

 
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
  <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />

  <application
      android:allowBackup="true"
      android:supportsRtl="true"
      android:usesCleartextTraffic="true"
      android:hardwareAccelerated="true" />

</manifest>
 

usesCleartextTraffic="true"는 일부 CCTV 영상 또는 공공기관 서비스가 HTTP 기반 리소스를 사용할 수 있기 때문에 필요할 수 있습니다.


5. CCTV 데이터 모델 만들기

CCTV 정보를 담을 모델을 만듭니다.

 
namespace eITS.Models;

public class CctvInfo
{
    public string Name { get; set; } = "";
    public string Url { get; set; } = "";
    public double Lat { get; set; }
    public double Lng { get; set; }
    public double DistanceKm { get; set; }

    public string DistanceText => $"{DistanceKm:0.0}km";
}
 

주요 항목은 CCTV명, 영상 URL, 위도, 경도, 거리입니다.


6. ITS OpenAPI로 CCTV 조회하기

CCTV 조회는 서비스 클래스로 분리합니다.

 
using System.Net;
using System.Text.Json;
using eITS.Models;

namespace eITS.Services;

public class CctvService
{
    private readonly HttpClient _http;

    public CctvService()
    {
        var handler = new HttpClientHandler
        {
            AutomaticDecompression = DecompressionMethods.All
        };

        _http = new HttpClient(handler)
        {
            Timeout = TimeSpan.FromSeconds(20)
        };

        _http.DefaultRequestHeaders.UserAgent.ParseAdd("ecctv/1.0");
        _http.DefaultRequestHeaders.Accept.ParseAdd("application/json");
    }

    public async Task<List<CctvInfo>> GetNearestCctvsAsync(
        double lat,
        double lng,
        int take = 20)
    {
        string url =
            "https://openapi.its.go.kr:9443/cctvInfo?" +
            "apiKey=발급받은_API_KEY" +
            "&type=its" +
            "&cctvType=2" +
            $"&minX={lng - 1}" +
            $"&maxX={lng + 1}" +
            $"&minY={lat - 1}" +
            $"&maxY={lat + 1}" +
            "&getType=json";

        string json = await _http.GetStringAsync(url);

        using JsonDocument doc = JsonDocument.Parse(json);

        if (!doc.RootElement.TryGetProperty("response", out JsonElement response))
            return new();

        if (!response.TryGetProperty("data", out JsonElement data))
            return new();

        if (data.ValueKind != JsonValueKind.Array)
            return new();

        List<CctvInfo> list = new();

        foreach (JsonElement item in data.EnumerateArray())
        {
            string name = GetString(item, "cctvname", "이름없음");
            string cctvUrl = GetString(item, "cctvurl", "");
            double y = GetDouble(item, "coordy");
            double x = GetDouble(item, "coordx");

            if (string.IsNullOrWhiteSpace(cctvUrl))
                continue;

            double distance = GetDistanceKm(lat, lng, y, x);

            list.Add(new CctvInfo
            {
                Name = name,
                Url = cctvUrl,
                Lat = y,
                Lng = x,
                DistanceKm = distance
            });
        }

        return list
            .OrderBy(x => x.DistanceKm)
            .Take(take)
            .ToList();
    }

    private static string GetString(JsonElement item, string name, string defaultValue)
    {
        if (!item.TryGetProperty(name, out JsonElement el))
            return defaultValue;

        return el.ValueKind == JsonValueKind.String
            ? el.GetString() ?? defaultValue
            : el.ToString();
    }

    private static double GetDouble(JsonElement item, string name)
    {
        if (!item.TryGetProperty(name, out JsonElement el))
            return 0;

        if (el.ValueKind == JsonValueKind.Number)
            return el.GetDouble();

        if (el.ValueKind == JsonValueKind.String &&
            double.TryParse(el.GetString(), out double value))
            return value;

        return 0;
    }

    private static double GetDistanceKm(double lat1, double lon1, double lat2, double lon2)
    {
        const double R = 6371.0;

        double dLat = ToRad(lat2 - lat1);
        double dLon = ToRad(lon2 - lon1);

        double a =
            Math.Sin(dLat / 2) * Math.Sin(dLat / 2) +
            Math.Cos(ToRad(lat1)) *
            Math.Cos(ToRad(lat2)) *
            Math.Sin(dLon / 2) *
            Math.Sin(dLon / 2);

        double c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a));

        return R * c;
    }

    private static double ToRad(double deg)
    {
        return deg * Math.PI / 180.0;
    }
}
 

여기서 중요한 부분은 type=its입니다.

고속도로가 아니라 국도·일반도로 중심 CCTV를 조회하려면 type=its 기준으로 구성합니다.


7. 현재 위치 조회 기능

MAUI에서는 Geolocation 기능으로 현재 위치를 가져올 수 있습니다.

 
private async Task RefreshCurrentLocationAndCctvAsync()
{
    try
    {
        PermissionStatus status =
            await Permissions.CheckStatusAsync<Permissions.LocationWhenInUse>();

        if (status != PermissionStatus.Granted)
            status = await Permissions.RequestAsync<Permissions.LocationWhenInUse>();

        if (status != PermissionStatus.Granted)
            return;

        GeolocationRequest request =
            new(GeolocationAccuracy.High, TimeSpan.FromSeconds(10));

        Location? location =
            await Geolocation.Default.GetLocationAsync(request);

        if (location == null)
            return;

        SetCurrentPosition(location.Latitude, location.Longitude);

        await LoadCctvAsync(location.Latitude, location.Longitude);
    }
    catch
    {
        // 위치 오류 처리
    }
}
 

현재 위치를 가져온 후 CCTV 조회 서비스에 위도와 경도를 전달합니다.


8. 입력 위치 조회 기능

사용자가 직접 지역명을 입력하면 Geocoding을 이용해 좌표로 변환합니다.

 
private async Task SearchByAddressAsync()
{
    string keyword = txtAddress.Text?.Trim() ?? "";

    if (string.IsNullOrWhiteSpace(keyword))
    {
        txtAddress.Focus();
        return;
    }

    IEnumerable<Location> locations =
        await Geocoding.Default.GetLocationsAsync(keyword);

    Location? first = locations?.FirstOrDefault();

    if (first == null)
        return;

    SetCurrentPosition(first.Latitude, first.Longitude);

    await LoadCctvAsync(first.Latitude, first.Longitude);
}
 

예를 들어 사용자가 다음과 같이 입력하면:

창원시청
마산역
부산 해운대
김해공항
 

해당 위치의 좌표를 찾고 그 주변 CCTV를 조회합니다.


9. CCTV 목록 화면 구성

CCTV 목록은 Syncfusion SfDataGrid를 사용하면 보기 좋게 구성할 수 있습니다.

 
<syncfusion:SfDataGrid
    x:Name="gridCctv"
    AutoGenerateColumnsMode="None"
    SelectionMode="Single"
    HeaderRowHeight="54"
    RowHeight="58"
    ColumnWidthMode="Fill"
    SelectionChanged="gridCctv_SelectionChanged">

    <syncfusion:SfDataGrid.Columns>
        <syncfusion:DataGridTextColumn
            HeaderText="CCTV"
            MappingName="Name" />

        <syncfusion:DataGridTextColumn
            HeaderText="거리"
            MappingName="DistanceText"
            Width="80" />
    </syncfusion:SfDataGrid.Columns>
</syncfusion:SfDataGrid>
 

큰 글자 설정이 된 스마트폰에서는 행 높이를 충분히 주는 것이 중요합니다.

 
gridCctv.HeaderRowHeight = 54;
gridCctv.RowHeight = 58;
 

10. CCTV 영상 보기

목록에서 CCTV를 선택하면 WebView에 영상 URL을 표시합니다.

 
private void gridCctv_SelectionChanged(
    object sender,
    DataGridSelectionChangedEventArgs e)
{
    if (e.AddedRows != null && e.AddedRows.Count > 0)
        _selected = e.AddedRows[0] as CctvInfo;

    if (_selected == null)
        return;

    if (!Uri.TryCreate(_selected.Url, UriKind.Absolute, out Uri? uri))
        return;

    webView.Source = new UrlWebViewSource
    {
        Url = uri.ToString()
    };

    ShowTab(1);
}
 

일부 CCTV 영상은 WebView에서 바로 재생되지 않을 수 있습니다. 이 경우 Android WebView 설정, 팝업 처리, 또는 외부 브라우저 연결 방식도 고려해야 합니다.


11. 지도에서 CCTV 표시하기

지도는 WebView 안에 HTML을 넣어 표시할 수 있습니다.

VWorld 배경지도와 Leaflet을 조합하면 MAUI에서도 지도 기반 화면을 쉽게 구성할 수 있습니다.

 
mapWebView.Source = new HtmlWebViewSource
{
    Html = BuildMapHtml()
};
 

HTML 내부에서는 CCTV 좌표를 마커로 표시합니다.

 
function addMarker(idx, lat, lng, name, distance, isSelected) {
    const marker = L.circleMarker([lat, lng], {
        radius: isSelected ? 9 : 7,
        color: isSelected ? '#DC2626' : '#2563EB',
        fillColor: isSelected ? '#EF4444' : '#3B82F6',
        fillOpacity: 0.9,
        weight: 2
    }).addTo(map);

    marker.bindPopup(
        '<div class="info"><b>' + name + '</b><br/>거리: ' + distance + '</div>'
    );

    marker.on('click', function() {
        window.location.href = 'ecctv://cctv/' + idx;
    });
}
 

MAUI 쪽에서는 Navigating 이벤트를 받아 마커 클릭을 처리합니다.

 
private void mapWebView_Navigating(object sender, WebNavigatingEventArgs e)
{
    if (string.IsNullOrWhiteSpace(e.Url))
        return;

    if (!e.Url.StartsWith("ecctv://cctv/"))
        return;

    e.Cancel = true;

    string idxText = e.Url.Replace("ecctv://cctv/", "").Trim();

    if (!int.TryParse(idxText, out int idx))
        return;

    if (idx < 0 || idx >= _items.Count)
        return;

    CctvInfo item = _items[idx];
    _selected = item;

    webView.Source = new UrlWebViewSource
    {
        Url = item.Url
    };

    ShowTab(1);
}
 

12. 큰 글자 설정 대응

실제 스마트폰에서는 사용자가 글자 크기를 크게 설정하는 경우가 많습니다.

이때 버튼, 입력창, 그리드가 잘려 보이지 않도록 해야 합니다.

버튼 자동 확대 제한

버튼은 FontAutoScalingEnabled="False"를 적용하는 것이 안정적입니다.

 
<Button
    Text="현재위치"
    FontSize="10"
    FontAutoScalingEnabled="False"
    LineBreakMode="NoWrap" />
 

입력필드는 접근성을 위해 자동 확대를 허용하고, 버튼만 제한하는 방식이 좋습니다.

검색 영역 한 줄 유지

검색 영역은 다음처럼 구성할 수 있습니다.

 
<Grid
    x:Name="searchGrid"
    RowDefinitions="Auto"
    ColumnDefinitions="*,90,90"
    ColumnSpacing="6">

    <Border Grid.Column="0">
        <Entry
            x:Name="txtAddress"
            Placeholder="위치 입력" />
    </Border>

    <Button
        Grid.Column="1"
        Text="입력위치"
        FontAutoScalingEnabled="False" />

    <Button
        Grid.Column="2"
        Text="현재위치"
        FontAutoScalingEnabled="False" />
</Grid>
 

큰 글자 환경에서 한 줄 표시를 유지하려면 버튼 문구를 짧게 하고, 버튼 폭을 충분히 확보해야 합니다.


13. 최근 조회 위치 저장

최근 조회 위치는 Preferences 방식으로 저장할 수 있습니다.

먼저 모델을 만듭니다.

 
namespace eITS.Models;

public class SavedLocation
{
    public string Name { get; set; } = "";
    public double Lat { get; set; }
    public double Lng { get; set; }
    public DateTime SavedAt { get; set; } = DateTime.Now;
}
 

저장 서비스는 다음과 같이 만듭니다.

 
using System.Text.Json;
using eITS.Models;

namespace eITS.Services;

public static class AppStorageService
{
    private const string RecentLocationKey = "recent_locations";

    public static List<SavedLocation> GetRecentLocations()
    {
        string json = Preferences.Default.Get(RecentLocationKey, "");

        if (string.IsNullOrWhiteSpace(json))
            return new();

        return JsonSerializer.Deserialize<List<SavedLocation>>(json) ?? new();
    }

    public static void SaveRecentLocation(SavedLocation location)
    {
        var list = GetRecentLocations();

        list.RemoveAll(x => x.Name == location.Name);

        list.Insert(0, location);

        list = list.Take(10).ToList();

        string json = JsonSerializer.Serialize(list);

        Preferences.Default.Set(RecentLocationKey, json);
    }
}
 

위치 검색 성공 시 저장합니다.

 
AppStorageService.SaveRecentLocation(new SavedLocation
{
    Name = keyword,
    Lat = first.Latitude,
    Lng = first.Longitude,
    SavedAt = DateTime.Now
});
 

최근 위치를 클릭하면 저장된 좌표 기준으로 다시 CCTV를 조회합니다.

 
private async Task LoadBySavedLocationAsync(SavedLocation location)
{
    txtAddress.Text = location.Name;

    SetCurrentPosition(location.Lat, location.Lng);

    await LoadCctvAsync(location.Lat, location.Lng);
}
 

14. CCTV 즐겨찾기 등록

자주 보는 CCTV는 즐겨찾기로 저장할 수 있습니다.

모델은 다음과 같습니다.

 
namespace eITS.Models;

public class FavoriteCctv
{
    public string Name { get; set; } = "";
    public string Url { get; set; } = "";
    public double Lat { get; set; }
    public double Lng { get; set; }
}
 

저장 서비스에 즐겨찾기 기능을 추가합니다.

 
private const string FavoriteCctvKey = "favorite_cctvs";

public static List<FavoriteCctv> GetFavoriteCctvs()
{
    string json = Preferences.Default.Get(FavoriteCctvKey, "");

    if (string.IsNullOrWhiteSpace(json))
        return new();

    return JsonSerializer.Deserialize<List<FavoriteCctv>>(json) ?? new();
}

public static void SaveFavoriteCctv(FavoriteCctv item)
{
    var list = GetFavoriteCctvs();

    list.RemoveAll(x => x.Name == item.Name && x.Url == item.Url);

    list.Insert(0, item);

    list = list.Take(30).ToList();

    string json = JsonSerializer.Serialize(list);

    Preferences.Default.Set(FavoriteCctvKey, json);
}
 

선택한 CCTV를 즐겨찾기에 등록합니다.

 
private void OnFavoriteClicked(object sender, EventArgs e)
{
    if (_selected == null)
        return;

    AppStorageService.SaveFavoriteCctv(new FavoriteCctv
    {
        Name = _selected.Name,
        Url = _selected.Url,
        Lat = _selected.Lat,
        Lng = _selected.Lng
    });
}
 

15. CCTV 즐겨찾기 삭제

즐겨찾기 삭제 기능도 함께 구현합니다.

 
public static void DeleteFavoriteCctv(FavoriteCctv item)
{
    var list = GetFavoriteCctvs();

    list.RemoveAll(x => x.Name == item.Name && x.Url == item.Url);

    string json = JsonSerializer.Serialize(list);

    Preferences.Default.Set(FavoriteCctvKey, json);
}
 

삭제 버튼 클릭 시 확인창을 띄웁니다.

 
private async void OnFavoriteDeleteClicked(object sender, EventArgs e)
{
    if (sender is not Button button)
        return;

    if (button.BindingContext is not FavoriteCctv item)
        return;

    bool ok = await DisplayAlert(
        "즐겨찾기 삭제",
        $"{item.Name} CCTV를 삭제할까요?",
        "삭제",
        "취소");

    if (!ok)
        return;

    AppStorageService.DeleteFavoriteCctv(item);

    LoadFavoriteCctvs();
}
 

16. 돌발정보 조회

돌발정보는 IncidentService로 분리합니다.

국도 기준으로 조회하려면 ITS 기준 API를 사용합니다.

핵심은 고속도로 중심 type=ex가 아니라 국도/ITS 중심 type=its를 사용하는 것입니다.

돌발정보 모델은 다음과 같이 구성할 수 있습니다.

 
namespace eITS.Models;

public class IncidentInfo
{
    public string Title { get; set; } = "";
    public string EventType { get; set; } = "";
    public string RoadName { get; set; } = "";
    public string Message { get; set; } = "";
    public string StartDate { get; set; } = "";
    public string EndDate { get; set; } = "";
    public string LanesBlocked { get; set; } = "";
    public double Lat { get; set; }
    public double Lng { get; set; }
}
 

돌발정보 화면에서는 다음 순서로 구성하면 됩니다.

현재 위치 조회
주변 돌발정보 조회
목록 표시
지도 표시
가까운 CCTV 자동 연결
상세정보 표시
 

17. 창원UTIS 연결

창원UTIS는 창원교통정보센터 CCTV 화면을 연결합니다.

 
private async void OnTitleTapped(object sender, TappedEventArgs e)
{
    try
    {
        await Launcher.Default.OpenAsync(
            "https://utis.changwon.go.kr/video/cctv");
    }
    catch
    {
    }
}
 

앱 내부 WebView에서 팝업이 정상적으로 열리지 않는 경우가 있습니다. 이때는 외부 브라우저 연결 방식이 가장 안정적입니다.


18. Preferences 저장 방식의 장점

최근 조회 위치와 CCTV 즐겨찾기는 DB 없이 Preferences에 저장할 수 있습니다.

장점은 다음과 같습니다.

DB 설치 불필요
구현이 간단함
앱 내부 저장 가능
최근 위치, 즐겨찾기, 설정값 저장에 적합
앱 삭제 시 함께 삭제됨
 

저장 데이터가 많지 않은 앱에서는 Preferences 방식이 매우 효율적입니다.


19. 앱 빌드와 배포

Android 앱을 배포하려면 csproj에서 앱 정보를 설정합니다.

 
<ApplicationTitle>국도어때</ApplicationTitle>
<ApplicationId>com.esgit.ecctv</ApplicationId>
<ApplicationDisplayVersion>1.0.0</ApplicationDisplayVersion>
<ApplicationVersion>1</ApplicationVersion>
 

Release 빌드는 다음 명령으로 진행할 수 있습니다.

 
dotnet publish -f net9.0-android -c Release ^
-p:AndroidPackageFormats=aab ^
-p:AndroidKeyStore=true ^
-p:AndroidSigningKeyStore=ecctv.keystore ^
-p:AndroidSigningStorePass=비밀번호 ^
-p:AndroidSigningKeyAlias=ecctv ^
-p:AndroidSigningKeyPass=비밀번호
 

Google Play Console에 올릴 경우 APK보다는 AAB 형식이 일반적입니다.


20. 마무리

국도어때 앱은 단순한 CCTV 조회 앱이 아니라, 위치 기반 교통정보 확인 앱입니다.

핵심 기능은 다음과 같습니다.

현재 위치 기반 CCTV 조회
입력 위치 기반 CCTV 조회
지도 기반 CCTV 확인
CCTV 영상 보기
국도 기준 돌발정보 조회
창원UTIS 연결
최근 조회 위치 저장
CCTV 즐겨찾기 등록/삭제
 

이 앱은 출퇴근, 장거리 운행, 국도 이동, 지역 도로 확인, 사고·공사 구간 확인에 유용합니다.

특히 고속도로 중심 정보만으로 부족한 사용자를 위해 국도와 일반도로 중심의 정보를 제공한다는 점에서 실용성이 높습니다.

앞으로 자주 가는 지역 빠른 조회, 돌발정보 알림, 지역별 CCTV 필터, 즐겨찾기 동기화 기능 등을 추가하면 더 완성도 높은 생활형 교통정보 앱으로 발전시킬 수 있습니다.

728x90
반응형