CHashtag

Docker + ASP.NET core 6.0 + EF Core + Mysql 본문

C#/ASP.NET

Docker + ASP.NET core 6.0 + EF Core + Mysql

HyoSeong 2022. 9. 29. 11:50
반응형

안녕하세요.

제목이 다소 혼란스러운데요,

마치,, 마라로제떡볶이마냥 .net에 관심이 많은 사람을 쉽게 지나치기 어려운 키워드들만 모아놨으니 말이죠 ㅋㅋ

 

아무튼 오늘은 Docker + ASP.NET core 6.0 + EF Core + Mysql을 이용해서 간단한 API 서버를 만들어보도록 하겠습니다.

(이 포스팅은 도커 및 asp.net 초심자를 위한 글임을 알려드립니다.)

 

이 글을 통해서 얻을 수 있는 내용은 아래와 같습니다.

  • EntityFramework Core
    • Code-First Migration
    • Repository Pattern
  • Repository Pattern을 사용하는 이유
  • Docker
  • ASP.NET Core 6
    • Route 규칙
    • Swagger 연동
    • Request & Response

 

시작하기 전 미리 설치해두어야 하는 툴들은 아래와 같습니다.

  • Visual Studio 2022
    • ASP.NET 및 웹 개발
  • Docker Desktop
  • .net 6
  • Mysql Workbench (optional)

자 그럼 시작합니다.


 

 

프로젝트 생성


ASP.NET Core Web API를 선택하여 프로젝트를 생성합니다.

(해당 프로젝트 유형이 존재하지 않는다면 Visual Studio Installer를 실행하여 "ASP.NET 및 웹 개발"을 설치해주세요.)

 

root 폴더 하위의 src폴더에 해당 프로젝트를 생성해주겠습니다.

이번 예제에서는 https를 다루지 않습니다.

 

그 후 폴더구조를 아래와 같이 변경해줍니다.

 

프로젝트 폴더를 1 Depth 상승

 

docker-compose support 추가

visual studio에서는 프로젝트를 docker에 추가하는 것을 돕기 위한 기능을 제공합니다.

(자동으로 docker-compose.yml 파일을 추가해줍니다.)

 

이렇게 추가하시면 아래와 같이 여러 파일들이 추가된 것을 확인하실 수 있습니다!

 

 

Mysql 추가

docker-compose.yml파일에 서비스를 추가해주겠습니다.

 

아래 내용을 추가해줍니다.

version: '3.4'

services:
  mysql-db:
    image: mysql:8.0
    container_name: mysql-db
    environment:
      MYSQL_DATABASE: product # 사용할 Database 명입니다.
      MYSQL_USER: user1 # 서비스에서 사용할 계정입니다.
      MYSQL_PASSWORD: password1 # 서비스에서 사용할 계정의 비밀번호입니다.
      MYSQL_RANDOM_ROOT_PASSWORD: 1 # root 비밀번호를 랜덤하게 설정합니다.
    ports:
      - 3307:3306 # 내부의 3306 port를 외부의 3307포트와 매핑(포트포워딩)합니다.
    restart: always
  product.api:
    image: ${DOCKER_REGISTRY-}productapi
    build:
      context: .
      dockerfile: Product.API/Dockerfile
    depends_on:
      - mysql-db # 서비스들의 시작과 종료 순서를 제어합니다. (mysql-db가 먼저 켜진 뒤 API 서버가 구동됩니다.)

이 상태로 docker를 실행하면 Mysql이 잘 생성되는 것을 확인하실 수 있습니다.

(docker-compose.yml 파일이 존재하는 경로에서 실행하셔야 합니다.)

cd src
docker-compose up --build

 

이때 아래와 같은 에러가 뜬다면 당황하지 마시고 docker-compose.yml 파일의 인코딩 옵션을 utf-8로 변경해주시면 됩니다. (혹은 한글 주석을 제거하셔도 됩니다.)

ERROR: .UnicodeDecodeError: 'utf-8' codec can't decode byte 0xbb in position 144: invalid start byte

 

실행하니 Docker Desktop에서 두 개의 Container가 잘 추가되고 실행 중인 것을 확인하실 수 있습니다.

(아직 asp.net core server는 정상 동작하지 않습니다.)

 

그럼 이제 추가한 계정으로 접속이 잘 되는지 Mysql Workbench로 확인해 보겠습니다.

Port와 Username을 아까 입력한 값으로 잘 넣어주고, Connection을 확인해봅니다.

접속이 잘 되고 product database도 잘 추가된 것을 확인할 수 있습니다.

Mysql과 API 서버 연결하기

이 예제에서는 Repository Pattern 및 Code-First Migration을 이용하여 Mysql과의 연결을 진행합니다.

 

우선 아래 Package들을 다운로드해줍니다. (혹시 몰라 버전도 함께 적어둡니다.)

 

Install-Package Microsoft.EntityFrameworkCore -Version 6.0.9
Install-Package Pomelo.EntityFrameworkCore.MySql 6.0.2
Install-Package Microsoft.EntityFrameworkCore.Tools 6.0.9

 

Connection String 추가

# appsettings.json
{
  ...
  "ConnectionStrings": {
    "mysqlConnectionString": "server=mysql-db,3307;user=user1;password=password1;database=product;"
  }
}

 

Model 생성하기

// Models\Product.cs

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace Product.API.Models
{
    [Table("product")]
    public class Product
    {
        [Column("name"), Required, StringLength(50)]
        public string Name { get; set; }

        [Column("code"), Key]
        public Guid Code { get; set; }

        [Column("price"), Required]
        public double Price { get; set; }

        [Column("summary"), StringLength(200)]
        public  string Summary { get; set; }
    }
}

 

Context 추가

// Infrastructure\ProductContext.cs

using Microsoft.EntityFrameworkCore;

namespace Product.API.Infrastructure
{
    public class ProductContext : DbContext
    {
        public ProductContext(DbContextOptions<ProductContext> options) : base(options)
        {
        }

        public DbSet<Models.Product> products { get; set; }
    }
}

 

Repository 추가

repository는 우선 기본 기능만 구현해 두겠습니다.

상세한 구현은 Controller구현시에 함께 구현하도록 하겠습니다.

// Infrastructure\Repositories\ProductRepository.cs

using Product.API.Infrastructure;

namespace CLO_PHONEBOOK.Infrastructure.Repository
{
    public class ProductRepository : IDisposable
    {
        private readonly ProductContext _context;

        public ProductRepository(ProductContext context)
        {
            _context = context ?? throw new ArgumentNullException(nameof(context));
        }

        public void Save()
        {
            _context.SaveChanges();
        }

        private bool disposed = false;

        protected virtual void Dispose(bool disposing)
        {
            if (!this.disposed)
            {
                if (disposing)
                {
                    _context.Dispose();
                }
            }
            this.disposed = true;
        }

        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }
    }
}

 

DbContext 등록하기

이제 만들어둔 Context 및 Repository를 ASP.NET Core에 추가해주도록 하겠습니다. (Dependency injection)

ASP.NET Core 6.0에서는 Startup.cs를 사용하지 않으니 Program.cs에 바로 추가해주시면 됩니다.

// Program.cs

using Microsoft.EntityFrameworkCore;
using Product.API.Infrastructure;

var builder = WebApplication.CreateBuilder(args);

var serverVersion = new MySqlServerVersion(new Version(8, 0));

builder.Services.AddDbContext<ProductContext>(options => options.UseMySql(builder.Configuration.GetConnectionString("mysqlConnectionString"), serverVersion));

builder.Services.AddScoped<ProductRepository>();

...

 

Code-First Migration 제작하기

Package Manager Console에서 아래 명령어를 실행해줍니다.

Add-Migration -p Product.API -o Infrastructure\Migrations InitialMigration

그럼 Infrastructure\Migrations 폴더 아래에 파일 2개가 생기는 것을 확인할 수 있습니다.

Migration Code는 Up, Down으로 이루어져있는데, 이 내용들을 가지고 데이터베이스 마이그레이션을 진행하는 것입니다.

(자세한 내용은 다루지 않겠습니다.)

 

Code Migration진행하기

이제 Migration 코드도 제작했고, Mysql에 Migration을 실행하기만 하면 됩니다!

이 게시글에서는 웹 서버 시작 지점에서(Program.cs) Migration을 진행하는 방법에 대해 알아보겠습니다.

마이그레이션 자동 적용은 DB 데이터 손실을 초래할 수 있습니다.
특히 다중 웹서버 환경에서 Migration을 진행하실 때에는 여러 서버가 동시에 Migration을 진행하지 않도록 각별히 주의하셔야 합니다.

 

// Program.cs

...

var builder = WebApplication.CreateBuilder(args);

...

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var services = scope.ServiceProvider;

    var context = services.GetRequiredService<ProductContext>();

    // docker depends_on옵션이 절대적으로 우선실행을 보장하지 않는다.
    // 따라서 mysql이 구동되지 않았을 때 먼저 서버가 구동되는 상황이 발생하여 Retry하도록 함
    var needMigration = Retry.Do(() => context.Database.GetPendingMigrations().Any(), TimeSpan.FromSeconds(5));
    if (needMigration)
    {
        context.Database.Migrate();
    }
}

여기서 중요한 부분은 바로 Retry입니다.

분명 docker-compose 파일에서 depends_on을 지정하여 부팅 순서를 명시적으로 지정해 줬었는데요,

그러나 그건 부팅 요청 순서일 뿐 각각의 서비스들이 부팅이 완전히 완료된 후에 다음 서비스가 시작되는 것은 아니기 때문입니다.

 

Retry.cs

더보기
namespace Product.API.Utils
{
    public static class Retry
    {
        public static void Do(
            Action action,
            TimeSpan retryInterval,
            int maxAttemptCount = 3)
        {
            Do<object>(() =>
            {
                action();
                return null;
            }, retryInterval, maxAttemptCount);
        }

        public static T Do<T>(
            Func<T> action,
            TimeSpan retryInterval,
            int maxAttemptCount = 3)
        {
            var exceptions = new List<Exception>();

            for (int attempted = 0; attempted < maxAttemptCount; attempted++)
            {
                try
                {
                    if (attempted > 0)
                    {
                        Thread.Sleep(retryInterval);
                    }
                    return action();
                }
                catch (Exception ex)
                {
                    exceptions.Add(ex);
                }
            }
            throw new AggregateException(exceptions);
        }
    }
}

 

Controller 구현하기

거의 다 왔습니다.

이제 API Server에서 가장 중요한 API를 제작해보겠습니다.

 

우선 뼈대만 만들어둔 Repository에 필요한 기능들을 먼저 추가해주겠습니다.

// Infrastructure\Repositories\ProductRepository.cs

using Product.API.Infrastructure;

namespace CLO_PHONEBOOK.Infrastructure.Repository
{
    public class ProductRepository : IDisposable
    {
        ...
        
        public IEnumerable<Product.API.Models.Product> GetProducts()
        {
            return _context.products.ToList();
        }

        public void InsertEmployee(Product.API.Models.Product product, bool save = false)
        {
            _context.products.Add(product);

            if (save)
            {
                Save();
            }
        }
        
        ...
    }
}
// Controllers\ProductController.cs

using CLO_PHONEBOOK.Infrastructure.Repository;
using Microsoft.AspNetCore.Mvc;
using System.Net;

namespace Product.API.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class ProductController : ControllerBase
    {
        private readonly ProductRepository _productRepository;

        public ProductController(ProductRepository productRepository)
        {
            _productRepository = productRepository;
        }

        [HttpGet]
        [ProducesResponseType(typeof(IEnumerable<Models.Product>), (int)HttpStatusCode.OK)]
        [ProducesResponseType(typeof(void), (int)HttpStatusCode.NoContent)]
        public ActionResult<IEnumerable<Models.Product>> Get()
        {
            return Ok(_productRepository.GetProducts());
        }
        
        [HttpPost]
        [ProducesResponseType((int)HttpStatusCode.Created)]
        [ProducesResponseType((int)HttpStatusCode.BadRequest)]
        [Consumes("application/json")]
        public async Task<ActionResult<Guid>> Add([FromBody] Models.Product product)
        {
            try
            {
                if(product.Code == Guid.Empty)
                {
                    product.Code = Guid.NewGuid();
                }

                _productRepository.InsertEmployee(product, true);

                return CreatedAtAction(null, product.Code);
            }
            catch (Exception exc)
            {
                return BadRequest(exc.Message);
            }
        }
    }
}

네 이렇게 모든 구현이 끝났습니다.

 

프로젝트를 실행해보시면 두 API가 작동하는 것을 확인하실 수 있습니다!!

 

GlobalUsing 적용

정말 마지막으로, 지금까지 각종 cs파일들에 추가된 using들을 global using으로 바꿔주도록 하겠습니다.

(자세한 내용은 아래 링크를 참고해주세요.)

https://github.com/Hyo-Seong/docker-with-aspdotnet6-tutorial/commit/49de4b3b6c81c08be2bbc5f0a43038d144eca4f1

 

Add GlobalUsings.cs · Hyo-Seong/docker-with-aspdotnet6-tutorial@49de4b3

Show file tree Showing 9 changed files with 16 additions and 30 deletions.

github.com

 


네 이렇게 각종 핫한 기술들을 간단히 체험해보았습니다.

 

진행한 프로젝트의 전체 코드는 아래 Github에서 확인 가능하니 참고 바랍니다.

https://github.com/Hyo-Seong/docker-with-aspdotnet6-tutorial 

 

GitHub - Hyo-Seong/docker-with-aspdotnet6-tutorial: Docker를 이용하여 asp.net 6 개발환경 구성하기 (with. Entity Fra

Docker를 이용하여 asp.net 6 개발환경 구성하기 (with. Entity Framework Core) - GitHub - Hyo-Seong/docker-with-aspdotnet6-tutorial: Docker를 이용하여 asp.net 6 개발환경 구성하기 (with. Entity Framework Core)

github.com

 

오늘도 긴 글 읽어주셔서 감사합니다.

도움이 되었길 바랍니다.

반응형