ASP.NET Core Web API로 Employee-Photo 관리 시스템 구축하기

  • 15 minutes to read

소개

이 글은 ASP.NET Core Web API를 사용해 직원(Employee) 및 관련 사진(Photo) 데이터를 관리하는 시스템을 구축하는 과정을 설명합니다. 프로젝트 생성부터 CRUD 메서드 작성, 데이터베이스 설정, Swagger 통합, 그리고 CORS 설정까지 전반적인 개발 과정을 다룹니다. 모든 소스 코드를 포함하며, 각 단계의 구현 내용을 상세히 설명합니다.

목차

  1. 프로젝트 생성 및 기본 설정
  2. 모델 및 데이터베이스 설정
  3. ViewModel 및 Mapster 설정
  4. Web API 기능 구현
  5. Swagger 및 HTTP 요청 테스트
  6. 소스 코드 전체 보기

1. 프로젝트 생성 및 기본 설정

1-1 ASP.NET Core Web API 프로젝트 생성

.NET CLI를 사용해 Web API 프로젝트를 생성합니다.

dotnet new webapi -o VisualAcademy.ApiService
cd VisualAcademy.ApiService

1-2 OpenAPI 및 Swagger 통합

Program.cs에서 Swagger와 OpenAPI를 설정합니다. 이를 통해 API 메타데이터를 자동으로 생성하고 UI를 통해 테스트할 수 있습니다.

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.UseSwaggerUI(options => 
        options.SwaggerEndpoint("/openapi/v1.json", "Employee API"));
}

2. 모델 및 데이터베이스 설정

2-1 Employee 및 Photo 모델 추가

Employee 모델

public class Employee
{
    public long Id { get; set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;

    public ICollection<Photo> Photos { get; set; } = new List<Photo>();
}

Photo 모델

public class Photo
{
    public long Id { get; set; }
    public string FileName { get; set; } = string.Empty;

    public long? EmployeeId { get; set; }
    public Employee? Employee { get; set; }
}

2-2 EmployeePhotoDbContext 설정

EmployeePhoto를 관리하는 DbContext를 추가합니다.

public class EmployeePhotoDbContext : DbContext
{
    public DbSet<Employee> Employees { get; set; }
    public DbSet<Photo> Photos { get; set; }

    public EmployeePhotoDbContext(DbContextOptions<EmployeePhotoDbContext> options)
        : base(options) { }
}

3. ViewModel 및 Mapster 설정

3-1 ViewModel 생성

EmployeeViewModel

public record EmployeeViewModel(long Id, string FirstName, string LastName);

PhotoViewModel

public record PhotoViewModel(long Id, string FileName, long? EmployeeId);

3-2 Mapster 설치 및 설정

Mapster는 개체 간 매핑을 간소화합니다. NuGet 패키지를 설치합니다.

dotnet add package Mapster

Mapster를 사용해 ViewModel과 모델 간 변환을 설정합니다.

4. Web API 기능 구현

4-1 GET: 모든 직원 조회

[HttpGet]
public async Task<IActionResult> Get()
{
    var employees = await _context.Employees
        .ProjectToType<EmployeeViewModel>()
        .ToListAsync();
    return Ok(employees);
}

4-2 GET: 특정 직원 조회

[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
{
    var employee = await _context.Employees
        .Include(e => e.Photos)
        .FirstOrDefaultAsync(e => e.Id == id);

    if (employee == null)
        return NotFound();

    var response = employee.Adapt<EmployeeViewModel>();
    return Ok(response);
}

4-3 POST: 새로운 직원 추가

[HttpPost]
public async Task<IActionResult> Post([FromBody] EmployeeViewModel value)
{
    var employee = value.Adapt<Employee>();
    _context.Employees.Add(employee);
    await _context.SaveChangesAsync();

    var response = employee.Adapt<EmployeeViewModel>();
    return CreatedAtAction(nameof(Get), new { id = response.Id }, response);
}

4-4 PUT: 직원 정보 수정

[HttpPut("{id}")]
public async Task<IActionResult> Put(long id, [FromBody] EmployeeViewModel model)
{
    var employee = await _context.Employees.FindAsync(id);

    if (employee == null)
        return NotFound();

    employee.FirstName = model.FirstName;
    employee.LastName = model.LastName;

    _context.Entry(employee).State = EntityState.Modified;
    await _context.SaveChangesAsync();

    return NoContent();
}

4-5 DELETE: 직원 및 관련 데이터 삭제

[HttpDelete("{id}")]
public async Task<IActionResult> Delete(long id)
{
    var employee = await _context.Employees
        .Include(e => e.Photos)
        .FirstOrDefaultAsync(e => e.Id == id);

    if (employee == null)
        return NotFound();

    _context.Photos.RemoveRange(employee.Photos);
    _context.Employees.Remove(employee);
    await _context.SaveChangesAsync();

    return NoContent();
}

5. Swagger 및 HTTP 요청 테스트

5-1 Swagger 테스트

Swagger UI를 통해 API 요청을 테스트할 수 있습니다. launchSettings.json에서 Swagger를 기본 시작 페이지로 설정합니다.

5-2 HTTP 요청 샘플

아래는 EmployeeControllerTest.http 파일을 사용한 HTTP 요청 예제입니다.

GET {{HostAddress}}/api/employee
GET {{HostAddress}}/api/employee/1

POST {{HostAddress}}/api/employee
Content-Type: application/json
{
  "FirstName": "John",
  "LastName": "Doe"
}

PUT {{HostAddress}}/api/employee/3
Content-Type: application/json
{
  "Id": 3,
  "FirstName": "Alice Updated",
  "LastName": "Johnson Updated"
}

DELETE {{HostAddress}}/api/employee/3

6. 소스 코드 전체 보기

위 소스 코드는 각 파일별로 제공되었으며, 전체 프로젝트는 GitHub 리포지토리를 통해 확인할 수 있습니다.

이 프로젝트는 ASP.NET Core Web API를 사용해 RESTful 서비스를 구축하는 실전 예제입니다. Mapster를 활용한 개체 매핑과 Swagger UI를 통한 테스트를 통해 개발자 경험을 크게 개선할 수 있습니다.

전체 소스 목록

VisualAcademy.ApiService\VisualAcademy.ApiService.csproj

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\VisualAcademy.ServiceDefaults\VisualAcademy.ServiceDefaults.csproj" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Mapster" Version="7.4.0" />
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.0" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
    <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="7.1.0" />
  </ItemGroup>

</Project>

VisualAcademy.ApiService\Models\Employee.cs

namespace VisualAcademy.ApiService.Models;

public class Employee
{
    public long Id { get; set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;

    // Photos와의 관계 설정(일대다)
    public ICollection<Photo> Photos { get; set; } = new List<Photo>();
}

VisualAcademy.ApiService\Models\Photo.cs

namespace VisualAcademy.ApiService.Models;

public class Photo
{
    public long Id { get; set; }
    public string FileName { get; set; } = string.Empty;

    // Employee와의 관계 설정(다대일)
    public long? EmployeeId { get; set; }
    public Employee? Employee { get; set; }
}

VisualAcademy.ApiService\Models\EmployeePhotoDbContext.cs

using Microsoft.EntityFrameworkCore;

namespace VisualAcademy.ApiService.Models
{
    public class EmployeePhotoDbContext : DbContext
    {
        public EmployeePhotoDbContext() : base()
        {
            
        }

        public EmployeePhotoDbContext(DbContextOptions<EmployeePhotoDbContext> options)
            : base(options) 
        {
            
        }

        public DbSet<Employee> Employees { get; set; }
        public DbSet<Photo> Photos { get; set; }
    }
}

VisualAcademy.ApiService\ViewModels\EmployeeViewModel.cs

namespace VisualAcademy.ApiService.ViewModels;

public record EmployeeViewModel(long Id, string FirstName, string LastName);

VisualAcademy.ApiService\ViewModels\PhotoViewModel.cs

namespace VisualAcademy.ApiService.ViewModels;

public record PhotoViewModel(long Id, string FileName, long? EmployeeId);

VisualAcademy.ApiService\appsettings.json

{
    "ConnectionStrings": {
        "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=EmployeePhoto;Trusted_Connection=True;"
    },
    "Logging": {
        "LogLevel": {
            "Default": "Information",
            "Microsoft.AspNetCore": "Warning"
        }
    },
    "AllowedHosts": "*"
}

VisualAcademy.ApiService\Program.cs

using VisualAcademy.ApiService.Models;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Add service defaults & Aspire client integrations.
builder.AddServiceDefaults();

// Add services to the container.
builder.Services.AddProblemDetails();

builder.Services.AddControllers();

// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi();

builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowAllOrigins", policy =>
    {
        policy.AllowAnyOrigin()
              .AllowAnyMethod()
              .AllowAnyHeader();
    });
});

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

builder.Services.AddDbContext<EmployeePhotoDbContext>(options => 
    options.UseSqlServer(connectionString));

var app = builder.Build();

// Configure the HTTP request pipeline.
app.UseExceptionHandler();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.UseSwaggerUI(options => 
        options.SwaggerEndpoint("/openapi/v1.json", "weather api"));
}

app.UseCors("AllowAllOrigins");

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapDefaultEndpoints();

app.MapControllers();

app.Run();

VisualAcademy.ApiService\Controllers\EmployeeController.cs

using VisualAcademy.ApiService.Models;
using VisualAcademy.ApiService.ViewModels;
using Mapster;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

// For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860

namespace VisualAcademy.ApiService.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class EmployeeController : ControllerBase
    {
        private readonly EmployeePhotoDbContext _context;

        public EmployeeController(EmployeePhotoDbContext context)
        {
            _context = context;
        }

        #region GetAll
        // GET: api/Employee
        // 모든 직원 목록을 조회
        [HttpGet]
        [ProducesResponseType(typeof(IEnumerable<EmployeeViewModel>), StatusCodes.Status200OK)] // 성공 시 반환 타입과 HTTP 상태 코드를 명시
        public async Task<IActionResult> Get()
        {
            // 직원 데이터를 EmployeeViewModel로 변환하여 반환
            var employees = await _context.Employees
                //.Select(e => new EmployeeViewModel(e.Id, e.FirstName, e.LastName))
                .ProjectToType<EmployeeViewModel>()
                .ToListAsync();

            return Ok(employees);
        }
        #endregion

        #region GetById
        // GET api/<EmployeeController>/5
        // 특정 ID의 직원 정보를 조회
        [HttpGet("{id}")]
        [ProducesResponseType(typeof(EmployeeViewModel), StatusCodes.Status200OK)] // 200: 성공적으로 데이터를 반환
        [ProducesResponseType(StatusCodes.Status404NotFound)] // 404: 요청한 직원이 없을 경우
        public async Task<IActionResult> Get(int id)
        {
            // 직원 정보 및 관련된 사진 데이터를 포함하여 조회
            var employee = await _context.Employees
                .Include(e => e.Photos)
                .FirstOrDefaultAsync(e => e.Id == id);

            if (employee == null)
            {
                return NotFound(); // 직원이 없으면 404 반환 
            }

            //var response = new EmployeeViewModel(employee.Id, employee.FirstName, employee.LastName);
            var response = employee.Adapt<EmployeeViewModel>();

            return Ok(response); // 직원 데이터를 반환
        }
        #endregion

        #region POST
        // POST api/<EmployeeController>
        // 새로운 직원을 생성
        [HttpPost]
        [ProducesResponseType(typeof(EmployeeViewModel), StatusCodes.Status201Created)] // 201: 생성된 리소스를 반환
        [ProducesResponseType(StatusCodes.Status400BadRequest)] // 400: 잘못된 요청일 경우
        public async Task<IActionResult> Post([FromBody] EmployeeViewModel value)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState); // 유효성 검사 실패 시 400 반환
            }

            // 요청 데이터를 Employee 엔터티로 변환하여 저장
            //var employee = new Employee
            //{
            //    FirstName = value.FirstName,
            //    LastName = value.LastName,
            //};
            var employee = value.Adapt<Employee>();

            _context.Employees.Add(employee);
            await _context.SaveChangesAsync();

            //var response = new EmployeeViewModel(employee.Id, employee.FirstName, employee.LastName);
            var response = employee.Adapt<EmployeeViewModel>();

            return CreatedAtAction(nameof(Get), new { id = response.Id }, response); // 생성된 직원 정보 반환
        }
        #endregion

        #region PUT
        // PUT api/<EmployeeController>/5
        // 기존 직원 정보를 업데이트
        [HttpPut("{id}")]
        [ProducesResponseType(StatusCodes.Status204NoContent)] // 204: 성공적으로 업데이트하고 콘텐츠 없음
        [ProducesResponseType(StatusCodes.Status404NotFound)] // 404: 요청한 직원이 없을 경우
        [ProducesResponseType(StatusCodes.Status400BadRequest)] // 400: 잘못된 요청일 경우
        public async Task<IActionResult> Put(long id, [FromBody] EmployeeViewModel model)
        {
            if (!ModelState.IsValid)
                return BadRequest(ModelState); // 유효성 검사 실패 시 400 반환

            var employee = await _context.Employees.FindAsync(id);

            if (employee == null)
                return NotFound(); // 직원이 없으면 404 반환

            employee.FirstName = model.FirstName;
            employee.LastName = model.LastName;

            _context.Entry(employee).State = EntityState.Modified;

            try
            {
                await _context.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!EmployeeExists(id))
                    return NotFound(); // 업데이트 중 직원이 삭제되었을 경우 404 반환

                throw;
            }

            return NoContent(); // 성공 시 204 반환
        } 

        private bool EmployeeExists(long id)
        {
            return _context.Employees.Any(e => e.Id == id);
        }
        #endregion

        #region DELETE
        // DELETE api/<EmployeeController>/5
        // 특정 직원 데이터를 삭제
        [HttpDelete("{id}")]
        [ProducesResponseType(StatusCodes.Status204NoContent)] // 204: 성공적으로 삭제하고 콘텐츠 없음
        [ProducesResponseType(StatusCodes.Status404NotFound)] // 404: 요청한 직원이 없을 경우
        public async Task<IActionResult> Delete(long id)
        {
            // 직원 정보와 관련된 사진 데이터를 포함하여 조회
            var employee = await _context.Employees
                .Include(e => e.Photos)
                .FirstOrDefaultAsync(e => e.Id == id);

            if (employee == null)
                return NotFound(); // 직원이 없으면 404 반환

            // 직원 데이터와 관련된 사진 데이터를 먼저 삭제
            _context.Photos.RemoveRange(employee.Photos);

            // 직원 데이터 삭제
            _context.Employees.Remove(employee);
            await _context.SaveChangesAsync();

            return NoContent(); // 성공 시 204 반환
        } 
        #endregion
    }
}

VisualAcademy.ApiService\Controllers\EmployeeControllerTest.http

@HostAddress = https://localhost:7312

### 

GET {{HostAddress}}/api/employee
Accept: application/json

###

GET {{HostAddress}}/api/employee/1
Accept: application/json

###

POST {{HostAddress}}/api/employee
Content-Type: application/json

{
  "FirstName": "John",
  "LastName": "Doe"
}

###

POST {{HostAddress}}/api/employee
Content-Type: application/json

{
  "FirstName": "Jane",
  "LastName": "Doe"
}

###

PUT {{HostAddress}}/api/employee/3
Content-Type: application/json

{
  "Id": 3,
  "FirstName": "Alice Updated",
  "LastName": "Johnson Updated"
}

###

DELETE {{HostAddress}}/api/employee/3

### 

Web API 인증

ASP.NET Core Web API에 고정된 이메일과 암호를 사용하는 Basic 인증 구현

고정된 이메일과 암호를 사용하는 Basic 인증은 간단한 보안 메커니즘을 제공합니다. 아래에서는 VisualAcademy.ApiService 프로젝트에 Basic 인증을 구현하는 방법을 단계별로 설명합니다.

1. Basic 인증 미들웨어 구현

1-1 인증 핸들러 클래스 작성

ASP.NET Core의 인증 미들웨어를 사용하려면 AuthenticationHandler를 상속받아 커스텀 핸들러를 작성합니다.

BasicAuthenticationHandler.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;

namespace VisualAcademy.ApiService.Security
{
    public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
    {
        private const string FixedEmail = "admin@visualacademy.com"; // 고정 이메일
        private const string FixedPassword = "securepassword"; // 고정 비밀번호

        public BasicAuthenticationHandler(
            IOptionsMonitor<AuthenticationSchemeOptions> options,
            ILoggerFactory logger,
            UrlEncoder encoder,
            ISystemClock clock)
            : base(options, logger, encoder, clock)
        {
        }

        protected override Task<AuthenticateResult> HandleAuthenticateAsync()
        {
            // Authorization 헤더 확인
            if (!Request.Headers.ContainsKey("Authorization"))
            {
                return Task.FromResult(AuthenticateResult.Fail("Authorization header missing."));
            }

            try
            {
                var authHeader = Request.Headers["Authorization"].ToString();
                if (!authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
                {
                    return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization header."));
                }

                var encodedCredentials = authHeader.Substring("Basic ".Length).Trim();
                var decodedBytes = Convert.FromBase64String(encodedCredentials);
                var decodedCredentials = Encoding.UTF8.GetString(decodedBytes);

                // 이메일과 암호 분리
                var credentials = decodedCredentials.Split(':');
                if (credentials.Length != 2)
                {
                    return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization header format."));
                }

                var email = credentials[0];
                var password = credentials[1];

                // 고정된 이메일과 암호 비교
                if (email != FixedEmail || password != FixedPassword)
                {
                    return Task.FromResult(AuthenticateResult.Fail("Invalid email or password."));
                }

                // 인증 성공 시 ClaimsPrincipal 생성
                var claims = new[] { new Claim(ClaimTypes.Name, email) };
                var identity = new ClaimsIdentity(claims, Scheme.Name);
                var principal = new ClaimsPrincipal(identity);
                var ticket = new AuthenticationTicket(principal, Scheme.Name);

                return Task.FromResult(AuthenticateResult.Success(ticket));
            }
            catch
            {
                return Task.FromResult(AuthenticateResult.Fail("Error occurred during authentication."));
            }
        }
    }
}

1-2 인증 스키마 등록

Program.cs에서 커스텀 인증 핸들러를 등록하고 스키마를 설정합니다.

Program.cs
using VisualAcademy.ApiService.Security;

var builder = WebApplication.CreateBuilder(args);

// 인증 스키마 추가
builder.Services.AddAuthentication("BasicAuthentication")
    .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>("BasicAuthentication", null);

var app = builder.Build();

// 인증 미들웨어 추가
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

2. 인증 요구 사항 적용

2-1 컨트롤러 또는 액션에 [Authorize] 속성 추가

Authorize 속성을 사용해 인증을 요구합니다. Basic 인증이 설정된 컨트롤러는 인증되지 않은 사용자의 요청을 거부합니다.

EmployeeController.cs
using Microsoft.AspNetCore.Authorization;

namespace VisualAcademy.ApiService.Controllers
{
    [Authorize] // Basic 인증을 요구
    [Route("api/[controller]")]
    [ApiController]
    public class EmployeeController : ControllerBase
    {
        private readonly EmployeePhotoDbContext _context;

        public EmployeeController(EmployeePhotoDbContext context)
        {
            _context = context;
        }

        // 기존 메서드...
    }
}

2-2 특정 액션에만 인증 적용

컨트롤러 전체가 아닌 특정 액션에만 인증을 요구하려면 액션 메서드에 [Authorize]를 적용합니다.

[HttpGet]
[Authorize]
public async Task<IActionResult> Get()
{
    var employees = await _context.Employees.ToListAsync();
    return Ok(employees);
}

3. HTTP 요청 테스트

3-1 인증 헤더 추가

Basic 인증 요청에는 Authorization 헤더가 필요합니다. 인증 정보를 Base64로 인코딩해 전달합니다.

예제: admin@visualacademy.com:securepassword를 Base64로 인코딩
echo -n "admin@visualacademy.com:securepassword" | base64
# YWRtaW5Aa29kZWUuY29tOnNlY3VyZXBhc3N3b3Jk

3-2 HTTP 요청 예제

GET 요청 (성공)
GET {{HostAddress}}/api/employee
Authorization: Basic YWRtaW5Aa29kZWUuY29tOnNlY3VyZXBhc3N3b3Jk
GET 요청 (실패 - 잘못된 비밀번호)
GET {{HostAddress}}/api/employee
Authorization: Basic YWRtaW5Aa29kZWUuY29tOmJhZHBhc3N3b3Jk

4. 결과

4-1 성공 응답

인증이 성공하면 API가 정상적으로 데이터를 반환합니다.

[
    {
        "id": 1,
        "firstName": "John",
        "lastName": "Doe"
    },
    {
        "id": 2,
        "firstName": "Jane",
        "lastName": "Smith"
    }
]

4-2 실패 응답

인증이 실패하면 API는 401 Unauthorized 상태 코드를 반환합니다.

{
    "title": "Unauthorized",
    "status": 401,
    "detail": "Invalid email or password."
}

5. 주의사항

  1. 보안 고려:

    • 고정된 이메일과 암호는 테스트 환경에서만 사용하며, 프로덕션에서는 데이터베이스 기반 인증 또는 OAuth2 등의 보안 메커니즘을 사용하세요.
    • HTTPS를 활성화하여 네트워크 전송 중 데이터를 암호화하세요.
  2. 기본 인증 확장 가능성:

    • 고정된 이메일과 비밀번호 대신 사용자 데이터를 데이터베이스에서 검증하도록 확장할 수 있습니다.

이 구현은 간단한 Basic 인증 메커니즘을 설명하며, 보다 안전하고 확장 가능한 방식으로 발전시킬 수 있습니다. Basic 인증은 인증 메커니즘의 기초를 이해하는 데 유용하며, 다양한 시나리오에서 활용될 수 있습니다.

VisualAcademy Docs의 모든 콘텐츠, 이미지, 동영상의 저작권은 박용준에게 있습니다. 저작권법에 의해 보호를 받는 저작물이므로 무단 전재와 복제를 금합니다. 사이트의 콘텐츠를 복제하여 블로그, 웹사이트 등에 게시할 수 없습니다. 단, 링크와 SNS 공유, Youtube 동영상 공유는 허용합니다. www.VisualAcademy.com
박용준 강사의 모든 동영상 강의는 데브렉에서 독점으로 제공됩니다. www.devlec.com