우여곡절, 천신만고 끝에 간신히 GitHub Copilot을 활성화하는 데 성공했다!

Visual Studio에서 GitHub 계정 연결은 이미 되어 있었고, GitHub에서 Copilot 관련 설정을 아무리 만져봐도 안되더니 어느날 갑자기 활성화가 됐다! ㄷㄷㄷ

 

그럼 이제 활용을 해 봐야지. 어디에 써먹을 수 있을까?

(참고로, GitHub Copilot 채팅창은 좌(우)측 메뉴바 전체 크기로 반영한 다음 자동 숨기기 설정해 두는 것이 제일 편리했다.)



1) 최초 아이디어 구상

내가 주로 작업하고 있는 회사 솔루션의 관리자 통계 조회 쿼리가 은근히 많이 복잡하고...

그래서 통계 페이지 및 그래프 표시가 느리고 무겁다.

 

관리자 통계 페이지(StatsController) getOrgStatView 및 getResStatView 함수 내에서

누가 조회해도 거의 같은 내용일텐데 DB로부터 통계 데이터 조회를 매번 새로 할 이유가 있나?

 

// 통계 데이터

var stats = _statsService.StatsByOrg(commonParam);

 

이거 말이다.

 

메모리 캐시를 사용해서 자주 사용되는 통계는 캐시에 저장해 두었다가 사용하는 것이 좋지 않을까?

 

[GitHub Copilot 채팅1 - 질문]

"stats를 매번 조회하지 않고 commonParam값에 따라 1시간 유효시간을 가진 메모리 캐시 방식으로 저장해 두었다가 재사용하는 코드로 수정해줘"

 

[GitHub Copilot 응답]

아래와 같이 MemoryCache를 사용하여 commonParam 값에 따라 1시간 동안 통계 데이터를 캐싱하도록 구현할 수 있습니다.
.NET 6에서는 Microsoft.Extensions.Caching.Memory 네임스페이스의 MemoryCache를 직접 사용할 수 있습니다.

1. using 구문 추가

using Microsoft.Extensions.Caching.Memory;

2. StatsController에 MemoryCache 필드 추가 및 생성자 수정

private readonly IMemoryCache _memoryCache;

public StatsController(
    IMenuService menuService, ICodeService codeService, IBldgService bldgService, 
    IFloorService floorService, IAreaService areaService, IDeskService deskService,
    IRoomService roomService, IStatsService statsService, ICompanyService comService,
    IAclService aclService, IUserService userService, IPlaceService placeService,
    IMemoryCache memoryCache) // DI로 주입
{
    _menuService = menuService;
    _codeService = codeService;
    _deskService = deskService;
    _bldgService = bldgService;
    _areaService = areaService;
    _floorService = floorService;
    _roomService = roomService;
    _statsService = statsService;
    _comService = comService;
    _aclService = aclService;
    _userService = userService;
    _placeService = placeService;
    _memoryCache = memoryCache;

    if (adminArea.StartsWith("/")) adminArea = adminArea.Substring(1);
}

3. getOrgStatView 내 stats 조회 부분을 캐시로 변경

// 통계 데이터
string cacheKey = $"StatsByOrg_{GetCommonParamCacheKey(commonParam)}";
if (!_memoryCache.TryGetValue(cacheKey, out IEnumerable<StatsEx> stats))
{
    stats = _statsService.StatsByOrg(commonParam);
    var cacheEntryOptions = new MemoryCacheEntryOptions()
        .SetAbsoluteExpiration(TimeSpan.FromHours(1));
    _memoryCache.Set(cacheKey, stats, cacheEntryOptions);
}

4. CommonParam을 캐시 키로 변환하는 메서드 추가

private string GetCommonParamCacheKey(CommonParam param)
{
    // 주요 검색 파라미터만 조합 (필요에 따라 추가/수정)
    var search = param.SearchInfo;
    return $"{param.LangCd}_{search.Type}_{search.V1}_{search.V2}_{search.ComId}_{search.DeptId}_{search.BldgId}_{search.FloorId}_{search.CodeId}_{search.DeskId}_{search.StartDt:yyyyMMdd}_{search.EndDt:yyyyMMdd}";
}

변경 요약

• IMemoryCache를 DI로 받아서 필드로 저장

• getOrgStatView에서 캐시를 먼저 조회, 없으면 조회 후 1시간 캐싱

• CommonParam의 주요 값을 조합해 캐시 키 생성

이렇게 하면 동일한 파라미터로 1시간 이내에 요청이 들어올 경우 DB/서비스를 거치지 않고 메모리에서 바로 데이터를 반환합니다.
추가로, StatsByRes 등 다른 통계 조회에도 동일한 패턴을 적용할 수 있습니다.
IMemoryCache는 Startup.cs 또는 Program.cs에서 서비스로 등록되어 있어야 합니다.
궁금한 점이 있으면 말씀해 주세요!



2) 추가 아이디어

IMemoryCache를 기존에 이미 사용하고 있었으면 몰라도, 이런 임시/테스트 코드를 위해

Startup.cs를 건드려서 DI(Dependancy Injection) 설정까지 추가해 가며 사용하기에는

'돼지목에 진주' 또는 '개발에 편자' 수준으로 일이 커질 수도 있을 것 같다. 어떤 부작용이 생길지...?

 

그냥 간단하게, 로컬 변수로 정의해서 사용하는 방식이 좋지 않을까?

 

[GitHub Copilot 채팅2 - 질문]

"MemoryCache를 사용하는 대신 로컬 static Dictionary를 사용하는 코드로 다시 수정해줘"

 

[GitHub Copilot 응답]

아래와 같이 MemoryCache 대신 static Dictionary를 사용하여 1시간 유효시간을 가진 캐시를 구현할 수 있습니다.
스레드 안전을 위해 ConcurrentDictionary를 사용하고, 만료 시간도 함께 저장합니다.

1. using 구문 추가

using System.Collections.Concurrent;

2. StatsController에 static 캐시 필드 추가

private static readonly ConcurrentDictionary<string, (IEnumerable<StatsEx> Data, DateTime ExpireAt)> _statsOrgCache
    = new();

3. 캐시 키 생성 메서드 추가

private string GetCommonParamCacheKey(CommonParam param)
{
    var search = param.SearchInfo;
    return $"{param.LangCd}_{search.Type}_{search.V1}_{search.V2}_{search.ComId}_{search.DeptId}_{search.BldgId}_{search.FloorId}_{search.CodeId}_{search.DeskId}_{search.StartDt:yyyyMMdd}_{search.EndDt:yyyyMMdd}";
}

4. getOrgStatView 내 stats 조회 부분을 캐시로 변경

// 통계 데이터
string cacheKey = GetCommonParamCacheKey(commonParam);
IEnumerable<StatsEx> stats;
if (_statsOrgCache.TryGetValue(cacheKey, out var cacheEntry) && cacheEntry.ExpireAt > DateTime.Now)
{
    stats = cacheEntry.Data;
}
else
{
    stats = _statsService.StatsByOrg(commonParam);
    _statsOrgCache[cacheKey] = (stats, DateTime.Now.AddHours(1));
}

변경 요약

• ConcurrentDictionary로 캐시 구현 (Key: 파라미터 조합, Value: (데이터, 만료시각))

• 1시간이 지난 캐시는 자동으로 갱신

• 스레드 안전하게 동작

이렇게 하면 동일한 파라미터로 1시간 이내에 요청이 들어올 경우 DB/서비스를 거치지 않고 메모리에서 바로 데이터를 반환합니다.
다른 통계 조회에도 동일한 패턴을 적용할 수 있습니다.
궁금한 점이 있으면 말씀해 주세요!



3) 추가 아이디어

좋긴 한데... 굳이 사용법도 잘 모르는 ConcurrentDictionary를 사용할 이유가 있을까?

기존에 다른 코드에서 캐시 메모리 구현할 때 사용한 방식대로 lock 구문이면 충분한데...

 

GitHub Copilot이 프로젝트 분석 및 기존 코드 스타일을 참조하는 능력은 아직 좀 떨어지는 것 같다.

전체 솔루션을 다 입력시켜서 미리 분석하도록 해야 하나? 그런 기능은 없는 것 같은데...

(그런 기능을 제공하는 AI가 있긴 하려나?)

 

[GitHub Copilot 채팅3 - 질문]

"ConcurrentDictionary를 사용하지 말고 lock 구문으로 직접 쓰레드 잠금 관리하도록 수정해줘"

 

[GitHub Copilot 응답]

아래와 같이 ConcurrentDictionary 대신 static Dictionary와 lock 구문을 사용하여 스레드 안전하게 1시간 만료 캐시를 구현할 수 있습니다.

1. static Dictionary와 lock 오브젝트 추가

private static readonly Dictionary<string, (IEnumerable<StatsEx> Data, DateTime ExpireAt)> _statsOrgCache
    = new Dictionary<string, (IEnumerable<StatsEx>, DateTime)>();
private static readonly object _statsOrgCacheLock = new object();

2. 캐시 키 생성 메서드 추가

private string GetCommonParamCacheKey(CommonParam param)
{
    var search = param.SearchInfo;
    return $"{param.LangCd}_{search.Type}_{search.V1}_{search.V2}_{search.ComId}_{search.DeptId}_{search.BldgId}_{search.FloorId}_{search.CodeId}_{search.DeskId}_{search.StartDt:yyyyMMdd}_{search.EndDt:yyyyMMdd}";
}

3. getOrgStatView 내 stats 조회 부분을 캐시로 변경

string cacheKey = GetCommonParamCacheKey(commonParam);
IEnumerable<StatsEx> stats;
bool cacheHit = false;

lock (_statsOrgCacheLock)
{
    if (_statsOrgCache.TryGetValue(cacheKey, out var cacheEntry) && cacheEntry.ExpireAt > DateTime.Now)
    {
        stats = cacheEntry.Data;
        cacheHit = true;
    }
}

if (!cacheHit)
{
    stats = _statsService.StatsByOrg(commonParam);
    lock (_statsOrgCacheLock)
    {
        _statsOrgCache[cacheKey] = (stats, DateTime.Now.AddHours(1));
    }
}

변경 요약

• Dictionary와 lock을 사용해 스레드 안전하게 캐시 관리

• 1시간 만료 정책 적용

• 동일 파라미터 요청 시 1시간 이내에는 DB/서비스를 거치지 않고 캐시 사용

이 방식은 .NET 6 환경에서 안전하게 동작하며, 캐시 만료도 직접 관리합니다.
다른 통계 조회에도 동일 패턴을 적용할 수 있습니다.
궁금한 점이 있으면 말씀해 주세요!



4) 실제 수정 작업

페이지 단위인 StatController보다는 내부 서비스 단위인 StatService를 수정하는 것이 나을 것 같다.

 

통계는 날짜 단위로 저장/조회되기 때문에 캐시 시간은 1시간보다는 날짜 1일 단위가 적당할 듯.

 

부서 ID, 사용자 ID가 입력된 경우에는 캐시보다는 새로 조회하는 것이 좋겠고,

역시 층 ID, 영역 ID, 리소스(회의실/좌석) ID가 입력된 경우에도 캐시 사용 없이 직접 조회하는 것이 좋을 듯.

 

...

 

이건 그냥 직접 수정하는 게 빠르겠다.

 

[StatService.cs 수정]

1. static Dictionary 추가

private class StatCache
{
    public string date;
    public IEnumerable<StatsEx> stats;
}

private static Dictionary<string, StatCache> _cacheStat = new Dictionary<string, StatCache>();

2. 데이터 조회 함수 StatsByOrg 변경

기존

public IEnumerable<StatsEx> StatsByOrg(CommonParam param)
{
    return RetrieveList(_repository.StatsByOrg, param);
}

변경

public IEnumerable<StatsEx> StatsByOrg(CommonParam param)
{
    // 부서 ID, 사용자 ID, 층 ID, 영역 ID, 리소스 ID가 없는 경우에만 캐시 사용
    if (string.IsNullOrEmpty(param.SearchInfo.DeptId) &&
        string.IsNullOrEmpty(param.SearchInfo.Keyword) &&
        string.IsNullOrEmpty(param.SearchInfo.FloorId) &&
        string.IsNullOrEmpty(param.SearchInfo.CodeId) &&
        string.IsNullOrEmpty(param.SearchInfo.DeskId))
    {
        // 캐시 키 생성
        var key = BizUtil.ProtectSHA256(string.Format("{0}|{1}|{2}|{3}|{4}",
            param.SearchInfo.Type, // DESK | ROOM | VISIT
            param.SearchInfo.ComId,
            param.SearchInfo.BldgId,
            param.SearchInfo.StartDt?.ToString("yyyyMMdd"),
            param.SearchInfo.V1)); // D | W | M | Y

        lock (_cacheStat)
        {
            var now = DateTime.Today.ToString("yyyyMMdd");

            // 날짜가 지난 캐시는 제거
            foreach (var item in _cacheStat.Where(x => x.Value.date != now))
            {
                _cacheStat.Remove(item.Key);
            }

            // 1일 내 동일한 요청이 오면 캐시 반환
            if (_cacheStat.ContainsKey(key) && _cacheStat[key].date == now)
                return _cacheStat[key].stats;

            var obj = new StatCache { date = now, stats = RetrieveList(_repository.StatsByOrg, param) };
            if (!_cacheStat.ContainsKey(key))
                _cacheStat.Add(key, obj);
            else
                _cacheStat[key] = obj;

            return obj.stats;
        }
    }
    else
        return RetrieveList(_repository.StatsByOrg, param);
}

3. 데이터 조회 함수 StatsByRes 변경

기존

public IEnumerable<StatsEx> StatsByRes(CommonParam param)
{
    return RetrieveList(_repository.StatsByRes, param);
}

변경

public IEnumerable<StatsEx> StatsByRes(CommonParam param)
{
    // 부서 ID, 사용자 ID, 층 ID, 영역 ID, 리소스 ID가 없는 경우에만 캐시 사용
    if (string.IsNullOrEmpty(param.SearchInfo.DeptId) &&
        string.IsNullOrEmpty(param.SearchInfo.Keyword) &&
        string.IsNullOrEmpty(param.SearchInfo.FloorId) &&
        string.IsNullOrEmpty(param.SearchInfo.CodeId) &&
        string.IsNullOrEmpty(param.SearchInfo.DeskId))
    {
        // 캐시 키 생성
        var key = BizUtil.ProtectSHA256(string.Format("{0}|{1}|{2}|{3}|{4}",
            param.SearchInfo.Type, // DESK | ROOM | VISIT
            param.SearchInfo.ComId,
            param.SearchInfo.BldgId,
            param.SearchInfo.StartDt?.ToString("yyyyMMdd"),
            param.SearchInfo.V1)); // D | W | M | Y

        lock (_cacheStat)
        {
            var now = DateTime.Today.ToString("yyyyMMdd");

            // 날짜가 지난 캐시는 제거
            foreach (var item in _cacheStat.Where(x => x.Value.date != now))
            {
                _cacheStat.Remove(item.Key);
            }

            // 1일 내 동일한 요청이 오면 캐시 반환
            if (_cacheStat.ContainsKey(key) && _cacheStat[key].date == now)
                return _cacheStat[key].stats;

            var obj = new StatCache { date = now, stats = RetrieveList(_repository.StatsByRes, param) };
            if (!_cacheStat.ContainsKey(key))
                _cacheStat.Add(key, obj);
            else
                _cacheStat[key] = obj;

            return obj.stats;
        }
    }
    else
        return RetrieveList(_repository.StatsByRes, param);
}



5) 추가 아이디어

두 함수가 거의 동일한데 코드가 중복이라 유지/관리가 불편할 수 있겠다...

 

이걸 합치는 건 AI한테 맡겨볼까?

 

[GitHub Copilot 채팅4 - 질문]

"StatsByOrg와 StatsByRes는 거의 동일한 로직인데 이 두 함수를 하나로 합쳐줘"

 

[GitHub Copilot 응답]

아래와 같이 StatsByOrg와 StatsByRes를 하나의 함수로 통합할 수 있습니다.
두 함수의 차이는 내부적으로 호출하는 리포지토리 메서드(StatsByOrg vs StatsByRes)뿐이므로,
공통 로직을 하나의 private 메서드로 만들고, 기존 인터페이스는 각각의 이름으로 유지하면서 내부에서 공통 메서드를 호출하도록 하면 됩니다.


public IEnumerable<StatsEx> StatsByOrg(CommonParam param)
{
    return StatsByOrgOrRes(param, isOrg: true);
}

public IEnumerable<StatsEx> StatsByRes(CommonParam param)
{
    return StatsByOrgOrRes(param, isOrg: false);
}

private IEnumerable<StatsEx> StatsByOrgOrRes(CommonParam param, bool isOrg)
{
    // 부서 ID, 사용자 ID, 층 ID, 영역 ID, 리소스 ID가 없는 경우에만 캐시 사용
    if (string.IsNullOrEmpty(param.SearchInfo.DeptId) &&
        string.IsNullOrEmpty(param.SearchInfo.Keyword) &&
        string.IsNullOrEmpty(param.SearchInfo.FloorId) &&
        string.IsNullOrEmpty(param.SearchInfo.CodeId) &&
        string.IsNullOrEmpty(param.SearchInfo.DeskId))
    {
        // 캐시 키 생성
        var key = BizUtil.ProtectSHA256(string.Format("{0}|{1}|{2}|{3}|{4}",
            param.SearchInfo.Type, // DESK | ROOM | VISIT
            param.SearchInfo.ComId,
            param.SearchInfo.BldgId,
            param.SearchInfo.StartDt?.ToString("yyyyMMdd"),
            param.SearchInfo.V1)); // D | W | M | Y

        lock (_cacheStat)
        {
            var now = DateTime.Today.ToString("yyyyMMdd");

            // 날짜가 지난 캐시는 제거
            foreach (var item in _cacheStat.Where(x => x.Value.date != now).ToList())
            {
                _cacheStat.Remove(item.Key);
            }

            // 1일 내 동일한 요청이 오면 캐시 반환
            if (_cacheStat.ContainsKey(key) && _cacheStat[key].date == now)
                return _cacheStat[key].stats;

            IEnumerable<StatsEx> stats;
            if (isOrg)
                stats = RetrieveList(_repository.StatsByOrg, param);
            else
                stats = RetrieveList(_repository.StatsByRes, param);

            var obj = new StatCache { date = now, stats = stats };
            if (!_cacheStat.ContainsKey(key))
                _cacheStat.Add(key, obj);
            else
                _cacheStat[key] = obj;

            return obj.stats;
        }
    }
    else
    {
        if (isOrg)
            return RetrieveList(_repository.StatsByOrg, param);
        else
            return RetrieveList(_repository.StatsByRes, param);
    }
}

변경 요약

• StatsByOrgOrRes(CommonParam param, bool isOrg)로 공통 로직을 통합

• 기존 StatsByOrg, StatsByRes는 각각 isOrg 플래그만 다르게 넘겨서 호출

• 캐시 및 데이터 조회 로직이 완전히 동일하게 동작

이렇게 하면 코드 중복 없이 유지보수가 쉬워집니다.
추가적으로, 두 함수 중 하나만 외부에 노출하고 싶다면 인터페이스에서 하나만 남기고, 파라미터로 구분할 수도 있습니다.
궁금한 점이 있으면 말씀해 주세요!



6) 결론

GitHub Copilot은 원하는 내용을 하나하나 세밀하게 질문을 잘하면 원하는 수준의 코드를 잘 만들어 준다.

초/중급 개발자 + Stackoverflow 검색을 동시에 하고 있는 기분?

처음에 질문을 잘 하는 것이 중요하지만, 질문을 대충한 다음 조금씩 수정해 가도 그럭저럭 괜찮을 것 같다.

질문이 곧 비용이라 무료 버전으로는 한계가 크지만.

You are using Copilot for free
Responses are limited to 2,000 code completions and 50 chat messages per month.

 

단, "적용" 버튼을 눌러 직접 코드에 반영하는 기능은... 기능이 있긴 있는데...

엄청나게 느리기도 하지만, 대부분 정상 동작하지 않는다. 복사해서 붙여넣는 방식으로 사용하는 것이 정신건강에 좋을 것 같다.

(이건 Free 버전이라 그럴 수도... 유료 버전에서는 잘 동작할 수도 있겠지?)

 

 



Posted by 떼르미
,


자바스크립트를 허용해주세요!
Please Enable JavaScript![ Enable JavaScript ]