목록으로

Programming Notes

VS Code 기반 .NET Dev Container: 실제로 작동하는 초보자용 가이드

Dev Container의 본질

높은 수준에서 보면, Dev Container는 VS Code 내부에서 Docker 컨테이너를 개발 환경으로 사용할 수 있게 해주는 도구입니다.

하지만 핵심 아이디어는 단순히 "개발용 Docker"가 아닙니다.

진짜 핵심은 이것입니다. 환경의 복잡성을 노트북에서 제거하고, 버전 관리되는 설정 파일로 옮기는 것입니다.

Dev Container를 사용하면:

  • 노트북은 단순한 VS Code 클라이언트가 됩니다.
  • 도구, SDK, 런타임 및 종속성은 컨테이너 내부에 존재합니다.
  • 내 컴퓨터가 아닌, 프로젝트 자체가 자체 개발 환경을 정의합니다.

이것은 다음과 같은 이점을 의미합니다:

  • 다른 프로젝트로 전환해도 아무것도 망가뜨리지 않습니다.
  • 환경을 안전하게 삭제하고 다시 생성할 수 있습니다.
  • 신입 개발자도 문서화되지 않은 지식 없이 동일한 설정을 가질 수 있습니다.

.NET 프로젝트에서 Dev Container가 유용한 이유

.NET 개발은 처음에는 단순해 보이지만, 실제로는 그렇지 않은 경우가 많습니다.

일반적인 고충 사항들:

  • 개발자마다 서로 다른 .NET SDK 버전 사용
  • 한 프로젝트는 .NET 6을, 다른 프로젝트는 .NET 8을 필요로 함
  • 네이티브 종속성이 한 컴퓨터에서는 작동하지만 다른 컴퓨터에서는 작동하지 않음
  • CI(지속적 통합)는 리눅스에서 실행되지만 개발자는 윈도우를 사용함

Dev Container는 다음과 같이 이 문제를 해결합니다:

  • 개발에 사용되는 SDK 버전과 OS를 고정합니다.
  • 모든 것을 리눅스 컨테이너에서 실행합니다 (CI/운영 환경과 유사).
  • 개발자 머신을 깨끗하고 안정적으로 유지합니다.
  • 온보딩이 거의 즉각적으로 이루어집니다: 클론(clone) → 컨테이너에서 다시 열기(reopen in container) → 실행.

.devcontainer 폴더가 레포지토리에 커밋되면, 환경은 위키 페이지가 아닌 코드베이스의 일부가 됩니다.

VS Code에서 Dev Container가 작동하는 방식

Dev Container를 사용하기 위해 깊은 Docker 지식이 필요하지는 않습니다.

이해를 돕는 모델은 다음과 같습니다:

  1. 레포지토리에 .devcontainer 폴더가 있습니다.
  2. 그 안에 있는 devcontainer.json이 개발 환경을 설명합니다.
  3. VS Code가 해당 파일을 읽고 컨테이너를 시작합니다.
  4. VS Code가 컨테이너에 연결되고 그 안에서 확장 기능을 실행합니다.

소스 코드는 내 컴퓨터에 있지만:

  • 터미널은 컨테이너 내부에서 실행됩니다.
  • 디버거는 컨테이너 내부에서 실행됩니다.
  • SDK는 컨테이너 내부에 존재합니다.

무언가 고장 나면 노트북이 아니라 컨테이너를 재빌드하면 됩니다.

Dev Container가 좋은 선택인 경우 (그리고 아닌 경우)

Dev Container는 다음과 같은 경우에 적합합니다:

  • 서로 다른 요구 사항을 가진 여러 프로젝트를 작업할 때
  • 팀원 간의 환경 일관성 문제로 어려움을 겪을 때
  • CI 및 컨테이너화된 배포 환경과 리눅스 환경의 일치(parity)를 원할 때
  • 임시적인 로컬 설정보다 재현 가능성을 중요하게 생각할 때

다음과 같은 경우에는 이상적이지 않을 수 있습니다:

  • 매우 작은 일회성 스크립트를 작업할 때
  • 윈도우 전용 도구에 크게 의존할 때
  • 환경 내에서 Docker를 전혀 사용할 수 없을 때

대부분의 전문적인 .NET 팀에게는 이점이 비용보다 훨씬 큽니다.

Windows의 Docker: 초기에 내려야 할 결정

윈도우에서 Dev Container를 시작할 때 가장 먼저 결정해야 할 것은 Docker가 머신에서 실행되는 방식입니다. Docker Desktop과 WSL 내의 Docker Engine 모두 Dev Container와 잘 작동하지만, 목적이 약간 다릅니다.

Docker Desktop 사용

Docker Desktop은 Dev Container를 시작하는 가장 쉽고 초보자 친화적인 방법입니다.

장점

  • 최소한의 설정으로 매우 빠른 설치
  • 컨테이너, 이미지, 로그를 위한 그래픽 대시보드 제공
  • VS Code 및 WSL2와 매끄럽게 통합
  • 학습 단계에서 트러블슈팅이 쉬움

단점

  • 백그라운드에서 더 많은 시스템 리소스 사용
  • 활발하게 개발하지 않을 때도 추가 서비스 실행
  • 일부 기업 환경에서 사용이 제한되거나 라이선스 정책이 다를 수 있음

Docker Desktop을 사용하는 경우

  • Docker나 Dev Container가 처음인 경우
  • 가장 간단하고 빠른 설정을 원하는 경우
  • 세밀한 제어보다 사용 편의성을 중시하는 경우
  • Docker Desktop 사용이 허용된 개인 프로젝트나 환경에서 작업하는 경우

대부분의 초보 개발자에게는 Docker Desktop을 권장합니다.

WSL 내부의 Docker Engine 사용

이 방식은 Docker Desktop 없이 WSL2에서 실행되는 리눅스 배포판(예: Ubuntu)에 Docker Engine을 직접 설치하는 방식입니다.

장점

  • Docker Desktop에 비해 낮은 리소스 사용량
  • 리눅스 네이티브 동작 (CI 및 운영 환경과 더 유사)
  • Docker Desktop에 대한 의존성 없음
  • 기업 또는 제한된 환경에서 선호되는 경우가 많음

단점

  • 수동 설치 및 설정 필요
  • 기본적인 리눅스 및 WSL 지식 필요
  • 그래픽 UI 없음 (모든 것이 CLI 기반)

WSL 내 Docker Engine을 사용하는 경우

  • Docker Desktop 사용이 금지되거나 제한된 경우
  • 더 가볍고 리눅스 중심의 워크플로우를 원하는 경우
  • 이미 대부분의 작업을 WSL 내부에서 수행하는 경우
  • Docker 설정에 대해 더 세밀한 제어를 원하는 경우

이 방식은 Docker와 WSL에 익숙해진 후에 권장합니다.

주의: Docker Desktop과 WSL 내부의 Docker Engine을 혼용하지 마세요. 하나를 선택하고 그것만 사용해야 합니다.

두 가지를 동시에 실행하면 Docker context가 충돌하여 설정이 올바르더라도 Dev Container가 예측할 수 없는 방식으로 실패하는 경우가 많습니다.

성능을 획기적으로 높여주는 팁

WSL에서 리눅스 컨테이너를 사용하는 경우, 코드를 WSL 파일 시스템 내부에 저장하세요.

  • 권장: /home/<user>/projects/your-repo
  • 피해야 할 곳: /mnt/c/Users/<user>/your-repo

리눅스 컨테이너가 윈도우 파일에 접근하면 속도가 느려지고 파일 감시(file-watching) 문제가 발생합니다. 레포지토리를 WSL 내부로 옮기면 Dev Container가 거의 네이티브 환경처럼 느껴질 것입니다.

처음 설정하기: 가장 간단한 시작 방법

Dev Container를 처음 시도한다면 다음 순서를 그대로 따르세요:

  1. Visual Studio Code 설치
  2. Dev Containers 확장 기능 설치
  3. Docker Desktop (또는 WSL 내 Docker Engine) 설치
  4. WSL 파일 시스템 내에 레포지토리 클론
  5. VS Code에서 폴더 열기
  6. "Dev Containers: Reopen in Container" 명령 실행

그게 전부입니다. 나머지는 VS Code가 처리합니다.

첫 번째 .NET Dev Container (실습 예제)

기술 스택

  • .NET 8 Web API
  • PostgreSQL 16
  • Entity Framework Core + Npgsql
  • VS Code Dev Containers
  • Docker Compose
프로젝트 구조
my-blog-api/
├─ .devcontainer/
│   └─ devcontainer.json
├─ docker-compose.yml
└─ src/
    └─ BlogApi/
        ├─ Program.cs
        ├─ BlogApi.csproj
        ├─ appsettings.json
        ├─ Models/
        └─ Data/

1단계: Web API 생성

mkdir my-blog-api 
cd my-blog-api 
mkdir src && cd src 
dotnet new webapi -n BlogApi 
cd BlogApi

2단계: EF Core + PostgreSQL 패키지 추가

dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore.Design

3단계: Docker Compose (API + PostgreSQL)

레포지토리 루트에 docker-compose.yml을 생성합니다:

version: "3.8"

services:
  app:
    image: mcr.microsoft.com/devcontainers/dotnet:1-8.0
    volumes:
      - .:/workspace:cached
    working_dir: /workspace
    command: sleep infinity
    ports:
      - "5000:5000"
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: devuser
      POSTGRES_PASSWORD: devpwd
      POSTGRES_DB: devdb
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

  pgadmin:
    image: dpage/pgadmin4
    environment:
      PGADMIN_DEFAULT_EMAIL: [email protected]
      PGADMIN_DEFAULT_PASSWORD: admin
    ports:
      - "5050:80"
    depends_on:
      - db

volumes:
  pgdata:

4단계: Dev Container 설정

.devcontainer/devcontainer.json을 생성합니다:

{
  "name": "dotnet-postgres-devcontainer",
  "dockerComposeFile": "../docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspace",
  "shutdownAction": "stopCompose",
  "customizations": {
    "vscode": {
      "extensions": [
        "ms-dotnettools.csdevkit",
        "ms-dotnettools.csharp",
        "ms-azuretools.vscode-docker"
      ]
    }
  },
  "postCreateCommand": "dotnet restore"
}

VS Code에서 폴더를 열고 다음을 실행합니다: Dev Containers: Reopen in Container

5단계: 연결 문자열 (컨테이너 간 통신)

appsettings.json을 업데이트합니다:

{
  "ConnectionStrings": {
    "DefaultConnection": "Host=db;Port=5432;Database=devdb;Username=devuser;Password=devpwd"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

Host=db가 작동하는 이유는 Docker Compose가 서비스 간 내부 DNS를 제공하기 때문입니다.

6단계: EF Core 모델 및 DbContext

Post 엔티티 – Models/Post.cs

namespace BlogApi.Models;

public class Post
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}

DbContext – Data/BlogDbContext.cs

using BlogApi.Models;
using Microsoft.EntityFrameworkCore;

namespace BlogApi.Data;

public class BlogDbContext : DbContext
{
    public BlogDbContext(DbContextOptions<BlogDbContext> options) : base(options) { }

    public DbSet<Post> Posts => Set<Post>();
}

7단계: Program.cs (최소 CRUD)

Program.cs를 다음 내용으로 교체합니다:

using BlogApi.Data;
using BlogApi.Models;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<BlogDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// 시작 시 마이그레이션 적용 (개발 환경 편의용)
using (var scope = app.Services.CreateScope())
{
    var db = scope.ServiceProvider.GetRequiredService<BlogDbContext>();
    db.Database.Migrate();
}

app.UseSwagger();
app.UseSwaggerUI();

app.MapGet("/posts", async (BlogDbContext db) =>
    await db.Posts.OrderByDescending(p => p.CreatedUtc).ToListAsync());

app.MapPost("/posts", async (Post post, BlogDbContext db) =>
{
    db.Posts.Add(post);
    await db.SaveChangesAsync();
    return Results.Created($"/posts/{post.Id}", post);
});

app.MapPut("/posts/{id:int}", async (int id, Post input, BlogDbContext db) =>
{
    var post = await db.Posts.FindAsync(id);
    if (post is null) return Results.NotFound();

    post.Title = input.Title;
    post.Content = input.Content;
    await db.SaveChangesAsync();

    return Results.Ok(post);
});

app.MapDelete("/posts/{id:int}", async (int id, BlogDbContext db) =>
{
    var post = await db.Posts.FindAsync(id);
    if (post is null) return Results.NotFound();

    db.Posts.Remove(post);
    await db.SaveChangesAsync();
    return Results.NoContent();
});

app.Run("http://0.0.0.0:5000");

8단계: 마이그레이션 실행 (Dev Container 내부에서)

cd src/BlogApi
dotnet tool install --global dotnet-ef
export PATH="$PATH:/home/vscode/.dotnet/tools"

dotnet ef migrations add InitialCreate
dotnet ef database update

9단계: API 실행

dotnet run

🔗 접속:

흔한 실수와 빠른 해결법

실수 증상 해결법
Docker 모델 혼용 무작위 실패 하나의 Docker 방식만 사용
/mnt/c 아래의 코드 빌드 속도 저하 레포지토리를 WSL 파일 시스템으로 이동
Docker가 실행 중이 아님 컨테이너가 시작되지 않음 docker info 확인
Pruning(정리) 먼저 실행 문제 재발 데몬/컨텍스트 먼저 수정

직면할 수 있는 주요 과제들

  • 여러 Docker 엔진 동시 활성화: Docker Desktop과 WSL 내부의 Docker Engine이 모두 존재하여 충돌 발생.
  • 불안정한 Docker CLI 컨텍스트: Docker CLI가 간헐적으로 잘못되었거나 손상된 Docker 엔드포인트를 가리킴.
  • Docker 데몬이 실행 중인 것처럼 보이나 사용할 수 없음: 데몬이 활성 상태임에도 불구하고 Docker 명령이 API 오류와 함께 실패함.
  • WSL 내부의 systemd 의존성 문제: WSL 재시작 후 Docker Engine이 의존하는 systemd가 일관되게 활성화되지 않음.
  • 설정 중 Dev Container 실패: VS Code Dev Container가 기능 설치 및 빌드 중에 오류를 표시함.
  • 오해의 소지가 있는 Docker 오류 메시지: 실제 근본 원인을 가리는 API 또는 버전 관련 오류 메시지.
  • 캐시 정리가 효과 없음: 이미지와 컨테이너를 Pruning해도 기저의 데몬 문제가 해결되지 않음.
  • 컨테이너 관찰 가능성 혼란: PostgreSQL과 pgAdmin은 작동하지만, 컨테이너 상태, 볼륨 및 데이터 위치가 불분명함.

해결책 및 유지 관리 가능한 설정

1. 단일 Docker 모델 강제 적용. Docker Desktop 또는 WSL 전용 Docker Engine 중 하나만 사용하고 절대로 혼용하지 마세요.
docker version
docker info
  • 하나의 서버만 표시되는지 확인하십시오.
  • 네이티브 WSL Docker 사용 시 dockerDesktopLinuxEngine에 대한 참조가 없어야 합니다.
2. Docker CLI 컨텍스트 명시적 고정. Compose나 Dev Container를 실행하기 전에 항상 Docker 컨텍스트를 확인하고 설정하세요.
docker context ls
docker context show
docker context use default
  • 컨텍스트가 의도한 데몬(WSL 또는 Desktop)을 가리켜야 합니다.
3. 프로젝트 시작 전 Docker 데몬 상태 확인. Dev Container나 Compose를 실행하기 전에 Docker에 접속 가능한지 확인하세요.
docker info
docker ps
  • API / 500 / 버전 오류 없이 결과가 반환되어야 합니다. 실패할 경우 진행하지 마세요.
4. WSL에서 systemd 활성화 확인. WSL 내부의 Docker Engine은 systemd에 의존합니다.
cat /etc/wsl.conf

# 예상 결과:
[boot]
systemd=true

설정되어 있지 않다면 변경 사항을 적용하고 WSL을 재시작한 후 다시 확인하세요:

wsl --shutdown
systemctl status docker
5. WSL 재시작 후 Docker 명시적 시작. WSL 재시작 시 서비스가 자동으로 중지될 수 있습니다.
sudo systemctl start docker
sudo systemctl enable docker
6. 프로젝트에 WSL 네이티브 파일 시스템만 사용. 프로젝트를 /home/<user>/... 아래에 두세요.
  • /mnt/c/... 경로는 피하세요. 경로가 /home/으로 시작해야 합니다.
7. Dev Container를 "해결책"이 아닌 "소비자"로 취급하세요. Dev Container 외부에서 Docker 문제를 먼저 해결하세요.

Docker Compose가 먼저 작동하는지 확인합니다:

docker compose config
docker compose up -d
code .

그 후에 "Reopen in Container"를 실행하세요.

8. 첫 실행 시 Dev Container 기능을 최소화하세요.
  • 기본 이미지와 필요한 서비스로만 시작하세요.
  • 기본적인 안정성이 확보된 후 기능을 추가하세요.
9. 컨테이너 관찰 가능성을 명시적으로 확인하세요. 컨테이너 상태, 포트 매핑, 볼륨 마운트를 확인하세요.
docker ps
docker inspect <container_name>
docker logs <container_name>
# 포트 확인:
ss -lntp | grep <port>
10. 첫 번째 해결책으로 캐시 정리를 남용하지 마세요. 데몬 문제를 해결하기 위해 prune에 의존하지 마세요.

데몬이 건강해진 후에만 수행하세요:

docker system prune -f
docker volume prune -f
11. "정상 상태" 기준 체크리스트를 수립하세요. 개발 시작 전 순서를 확인하세요.
wsl --shutdown
# WSL 다시 열기
sudo systemctl start docker
docker context show
docker info
docker compose up -d
code .
# 그 다음 "Reopen in Container" 실행
12. Dev Container 실행 중 문제가 발생하면 컨테이너를 중지 및 삭제하고 다시 빌드할 수 있습니다.
docker ps
docker stop $(docker ps -q)
docker rm -f $(docker ps -aq)
docker ps

마치며

Dev Container는 로컬 개발 환경을 불안정하고 머신에 종속적인 설정에서 재현 가능하고 버전 관리되는 환경으로 전환해 줍니다.

Dev Container, Docker Compose, PostgreSQL, pgAdmin을 사용하면 전체 .NET 개발 스택이 노트북이 아닌 컨테이너 내부에서 실행됩니다. SDK, 데이터베이스, 도구들이 격리되고 일관되며 재빌드하기 쉬워집니다.

무언가 고장 나면 컴퓨터가 아니라 컨테이너를 재빌드하세요.

이 접근 방식은 온보딩 마찰을 제거하고, CI와의 리눅스 환경 일치를 개선하며, 전형적인 "내 컴퓨터에서는 되는데요" 문제를 해결합니다. Docker가 안정화되면 Dev Container는 현대적인 .NET 애플리케이션을 구축하는 가장 신뢰할 수 있는 방법 중 하나가 될 것입니다.

핵심 요약

  • Dev Container는 개발 환경을 코드처럼 취급합니다.
  • .NET, PostgreSQL, pgAdmin이 컨테이너 내에서 완전히 격리되어 실행됩니다.
  • pgAdmin은 데이터베이스 상태와 마이그레이션에 대한 명확한 가시성을 제공합니다.
  • Docker 안정성이 전제 조건입니다. Dev Container는 Docker 자체의 문제를 해결해 주지 않습니다.
  • 온보딩이 단순해집니다: 클론 → 컨테이너에서 다시 열기 → 실행.
  • 노트북이 아니라 컨테이너를 재빌드하세요.