ASP.NET Core 인증 사용자 프로필 사진 관리

  • 16 minutes to read

ASP.NET Core Identity를 사용하는 프로젝트에서 사용자 프로필 사진을 관리하려면, 별도의 복잡한 구성 없이 간단한 Razor Page 추가만으로 기능을 확장할 수 있습니다.

이 문서는 Profile Photo 관리 기능을 모든 .NET 프로젝트에서 재사용 가능하게 적용하는 방법을 설명합니다.

그림: 인증 사용자 프로필 사진 관리

인증 사용자 프로필 사진 관리


📁 추가해야 할 파일

다음 두 개의 파일을 프로젝트에 추가합니다.

잠시 후 전체 소스를 확인할 수 있습니다.

Areas/Identity/Pages/Account/Manage/ProfilePhotoManager.cshtml
Areas/Identity/Pages/Account/Manage/ProfilePhotoManager.cshtml.cs

예시 경로:

C:\dev\Hawaso\src\Hawaso\Areas\Identity\Pages\Account\Manage\ProfilePhotoManager.cshtml
C:\dev\Hawaso\src\Hawaso\Areas\Identity\Pages\Account\Manage\ProfilePhotoManager.cshtml.cs

⚙️ 적용 개요

이 기능은 ASP.NET Core Identity의 기본 구조를 그대로 활용합니다.

  • 사용자 데이터는 ApplicationUser에 저장
  • 이미지 데이터는 byte[] 형태로 DB에 저장
  • Razor Pages 기반으로 UI 구성
  • UserManager / SignInManager를 통해 사용자 정보 업데이트

🧱 사전 준비

1. ApplicationUser에 속성 추가

사용자 엔티티에 프로필 사진 필드를 추가합니다.

public byte[]? ProfilePicture { get; set; }

이미 존재하는 경우 생략 가능합니다.


2. 데이터베이스 마이그레이션

필드 추가 후 마이그레이션을 적용합니다.

dotnet ef migrations add AddProfilePicture
dotnet ef database update

🧭 메뉴 연결

Identity 관리 메뉴에 페이지를 노출하려면 다음 파일을 수정합니다.

파일

Areas/Identity/Pages/Account/Manage/ManageNavPages.cs

추가

public static string ProfilePhotoManager => "ProfilePhotoManager";

public static string ProfilePhotoManagerNavClass(ViewContext viewContext)
    => PageNavClass(viewContext, ProfilePhotoManager);

파일

Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml

메뉴 추가

<li class="nav-item">
    <a class="nav-link @ManageNavPages.ProfilePhotoManagerNavClass(ViewContext)"
       asp-page="./ProfilePhotoManager">
        Profile Photo
    </a>
</li>

🔄 ActivePage 설정

페이지 상단에 ActivePage 설정이 필요합니다.

ViewData["ActivePage"] = ManageNavPages.ProfilePhotoManager;

이 설정이 없으면 메뉴가 선택 상태로 표시되지 않습니다.


📦 전체 소스 코드

다음 섹션에서 전체 소스를 확인할 수 있습니다.

Code-Behind (로직)

Areas/Identity/Pages/Account/Manage/ProfilePhotoManager.cshtml.cs
#nullable enable

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Threading.Tasks;

namespace Azunt.Web.Areas.Identity.Pages.Account.Manage
{
    public class ProfilePhotoManagerModel : PageModel
    {
        private const long MaxProfilePhotoSize = 5 * 1024 * 1024;

        private readonly UserManager<ApplicationUser> _userManager;
        private readonly SignInManager<ApplicationUser> _signInManager;

        public ProfilePhotoManagerModel(
            UserManager<ApplicationUser> userManager,
            SignInManager<ApplicationUser> signInManager)
        {
            _userManager = userManager;
            _signInManager = signInManager;
        }

        [TempData]
        public string? StatusMessage { get; set; }

        [BindProperty]
        public InputModel Input { get; set; } = new();

        public class InputModel
        {
            [Display(Name = "Profile Photo")]
            public byte[]? ProfilePhoto { get; set; }
        }

        private Task LoadAsync(ApplicationUser user)
        {
            Input = new InputModel
            {
                ProfilePhoto = user.ProfilePicture
            };

            return Task.CompletedTask;
        }

        public async Task<IActionResult> OnGetAsync()
        {
            var user = await _userManager.GetUserAsync(User);

            if (user == null)
            {
                return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
            }

            await LoadAsync(user);

            return Page();
        }

        public async Task<IActionResult> OnGetDownloadProfilePhotoAsync()
        {
            var user = await _userManager.GetUserAsync(User);

            if (user == null)
            {
                return NotFound("Unable to load current user.");
            }

            if (user.ProfilePicture == null || user.ProfilePicture.Length == 0)
            {
                return NotFound("No profile photo found.");
            }

            var imageInfo = DetectImageInfo(user.ProfilePicture);
            var safeUserName = CreateSafeFileName(user.UserName ?? "user");
            var fileName = $"{safeUserName}-profile-photo{imageInfo.Extension}";

            return File(user.ProfilePicture, imageInfo.ContentType, fileName);
        }

        public async Task<IActionResult> OnPostUploadProfilePhotoAsync(IFormFile? profilePhoto)
        {
            var user = await _userManager.GetUserAsync(User);

            if (user == null)
            {
                return NotFound("Unable to load current user.");
            }

            var validationError = ValidateProfilePhoto(profilePhoto);

            if (!string.IsNullOrEmpty(validationError))
            {
                return BadRequest(validationError);
            }

            await SaveProfilePhotoAsync(user, profilePhoto!);

            var updateResult = await _userManager.UpdateAsync(user);

            if (!updateResult.Succeeded)
            {
                return BadRequest("Unable to update profile photo.");
            }

            await _signInManager.RefreshSignInAsync(user);

            return new OkResult();
        }

        private static string? ValidateProfilePhoto(IFormFile? profilePhoto)
        {
            if (profilePhoto == null || profilePhoto.Length == 0)
            {
                return "Profile photo file is required.";
            }

            if (profilePhoto.Length > MaxProfilePhotoSize)
            {
                return "Profile photo must be 5MB or smaller.";
            }

            if (!string.IsNullOrWhiteSpace(profilePhoto.ContentType) &&
                !profilePhoto.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
            {
                return "Only image files are allowed.";
            }

            return null;
        }

        private static async Task SaveProfilePhotoAsync(ApplicationUser user, IFormFile profilePhoto)
        {
            using (var dataStream = new MemoryStream())
            {
                await profilePhoto.CopyToAsync(dataStream);
                user.ProfilePicture = dataStream.ToArray();
            }
        }

        private static ImageFileInfo DetectImageInfo(byte[] bytes)
        {
            if (bytes.Length >= 12)
            {
                if (bytes[0] == 0x89 &&
                    bytes[1] == 0x50 &&
                    bytes[2] == 0x4E &&
                    bytes[3] == 0x47)
                {
                    return new ImageFileInfo("image/png", ".png");
                }

                if (bytes[0] == 0xFF &&
                    bytes[1] == 0xD8)
                {
                    return new ImageFileInfo("image/jpeg", ".jpg");
                }

                if (bytes[0] == 0x47 &&
                    bytes[1] == 0x49 &&
                    bytes[2] == 0x46)
                {
                    return new ImageFileInfo("image/gif", ".gif");
                }

                if (bytes[0] == 0x52 &&
                    bytes[1] == 0x49 &&
                    bytes[2] == 0x46 &&
                    bytes[3] == 0x46 &&
                    bytes[8] == 0x57 &&
                    bytes[9] == 0x45 &&
                    bytes[10] == 0x42 &&
                    bytes[11] == 0x50)
                {
                    return new ImageFileInfo("image/webp", ".webp");
                }

                if ((bytes[0] == 0x49 &&
                     bytes[1] == 0x49 &&
                     bytes[2] == 0x2A &&
                     bytes[3] == 0x00) ||
                    (bytes[0] == 0x4D &&
                     bytes[1] == 0x4D &&
                     bytes[2] == 0x00 &&
                     bytes[3] == 0x2A))
                {
                    return new ImageFileInfo("image/tiff", ".tif");
                }
            }

            return new ImageFileInfo("application/octet-stream", ".bin");
        }

        private static string CreateSafeFileName(string value)
        {
            if (string.IsNullOrWhiteSpace(value))
            {
                return "user";
            }

            foreach (var invalidChar in Path.GetInvalidFileNameChars())
            {
                value = value.Replace(invalidChar, '-');
            }

            return value.Replace('@', '-').Replace('.', '-').Trim('-');
        }

        private sealed class ImageFileInfo
        {
            public ImageFileInfo(string contentType, string extension)
            {
                ContentType = contentType;
                Extension = extension;
            }

            public string ContentType { get; }

            public string Extension { get; }
        }
    }
}

Razor Page (UI)

Areas/Identity/Pages/Account/Manage/ProfilePhotoManager.cshtml
@page
@model Azunt.Web.Areas.Identity.Pages.Account.Manage.ProfilePhotoManagerModel
@{
    ViewData["Title"] = "Profile Photo";
    ViewData["ActivePage"] = ManageNavPages.ProfilePhotoManager;
    var uploadProfilePhotoUrl = Url.Page(null, "UploadProfilePhoto");
    var downloadProfilePhotoUrl = Url.Page(null, "DownloadProfilePhoto");
}

<div class="container py-4">
    <div class="mb-4">
        <h4 class="mb-1 fw-semibold">@ViewData["Title"]</h4>
        <div class="text-muted small">
            Upload, drag and drop, capture, crop, download, and save a profile photo used for your account identity across the system.
        </div>
    </div>

    <partial name="_StatusMessage" model="Model.StatusMessage" />

    <form id="profile-photo-form" method="post" enctype="multipart/form-data">
        @Html.AntiForgeryToken()

        <div asp-validation-summary="All" class="text-danger mb-3"></div>

        <div class="card shadow-sm border-0">
            <div class="card-header bg-light border-0">
                <div class="fw-semibold">Profile Photo</div>
                <div class="text-muted small">
                    Recommended: clear front-facing image, JPG or PNG, up to 5MB.
                </div>
            </div>

            <div class="card-body">
                <div class="row align-items-start g-4">
                    <div class="col-lg-6 text-center">
                        <label asp-for="Input.ProfilePhoto" class="form-label fw-semibold d-block mb-3"></label>

                        <div class="profile-photo-wrapper mx-auto">
                            @if (Model.Input.ProfilePhoto != null && Model.Input.ProfilePhoto.Length > 0)
                            {
                                <img id="profilePhoto"
                                     class="profile-photo-preview"
                                     src="data:image/*;base64,@Convert.ToBase64String(Model.Input.ProfilePhoto)"
                                     alt="Profile Photo" />

                                <div id="profilePhotoPlaceholder"
                                     class="profile-photo-placeholder d-none">
                                    👤
                                </div>
                            }
                            else
                            {
                                <img id="profilePhoto"
                                     class="profile-photo-preview d-none"
                                     src=""
                                     alt="Profile Photo" />

                                <div id="profilePhotoPlaceholder" class="profile-photo-placeholder">
                                    👤
                                </div>
                            }

                            <div class="profile-photo-actions"
                                 onmousedown="event.preventDefault(); event.stopPropagation();"
                                 onclick="event.stopPropagation();">
                                <button type="button"
                                        id="profilePhotoUploadButton"
                                        class="btn btn-primary rounded-circle profile-photo-action-btn"
                                        title="Upload Photo"
                                        aria-label="Upload Photo"
                                        onclick="openProfilePhotoFileUpload(event)">
                                    📁
                                </button>

                                <button type="button"
                                        id="profilePhotoCameraButton"
                                        class="btn btn-primary rounded-circle profile-photo-action-btn"
                                        title="Take Photo"
                                        aria-label="Take Photo"
                                        onclick="openProfilePhotoWebcamCapture(event)">
                                    📷
                                </button>

                                @if (Model.Input.ProfilePhoto != null && Model.Input.ProfilePhoto.Length > 0)
                                {
                                    <button type="button"
                                            id="profilePhotoDownloadButton"
                                            class="btn btn-success rounded-circle profile-photo-action-btn"
                                            title="Download Photo"
                                            aria-label="Download Photo"
                                            onclick="downloadProfilePhoto(event)">
                                        ⬇️
                                    </button>
                                }
                            </div>
                        </div>

                        <input type="file"
                               id="profilePhotoFileInput"
                               name="profilePhoto"
                               accept=".png,.jpg,.jpeg,.gif,.tif,.tiff,.webp"
                               class="d-none"
                               onchange="uploadProfilePhotoFromFile(this)" />

                        <span asp-validation-for="Input.ProfilePhoto" class="text-danger d-block mt-2"></span>
                        <div id="profilePhotoMessage" class="text-muted mt-2 small"></div>
                    </div>

                    <div class="col-lg-6">
                        <div id="profilePhotoDropZone"
                             class="profile-photo-drop-zone mb-3"
                             onclick="openProfilePhotoFileUpload(event)"
                             ondragenter="handleProfilePhotoDragEnter(event)"
                             ondragover="handleProfilePhotoDragOver(event)"
                             ondragleave="handleProfilePhotoDragLeave(event)"
                             ondrop="handleProfilePhotoDrop(event)">
                            <div class="profile-photo-drop-icon">⬆️</div>
                            <div class="fw-semibold">Drag and drop an image here</div>
                            <div class="text-muted small">
                                Or click this area to select a file from your device.
                            </div>
                        </div>

                        <div class="enterprise-guide-box">
                            <div class="fw-semibold mb-2">Guidelines</div>
                            <ul class="small text-muted mb-3 ps-3">
                                <li>Use a clear image that helps administrators identify your account.</li>
                                <li>Supported formats include JPG, PNG, GIF, TIF, TIFF, and WEBP.</li>
                                <li>The maximum allowed file size is 5MB.</li>
                                <li>You may upload, drag and drop, or capture a photo using your camera.</li>
                                <li>Before saving, you can crop the image into a profile-friendly square.</li>
                                <li>If a photo is already uploaded, use the download button to save a copy.</li>
                            </ul>

                            <div class="small text-muted border rounded bg-light p-3">
                                This profile photo may be displayed in account management, user lists, internal workflows,
                                and other authenticated areas of the system.
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </form>
</div>

<div id="profilePhotoWebcamModal" class="modal-backdrop-custom d-none">
    <div class="modal-dialog-custom">
        <div class="modal-content shadow">
            <div class="modal-header">
                <div>
                    <h5 class="modal-title mb-0">Take Photo</h5>
                    <div class="text-muted small">Capture a photo, then crop it before uploading.</div>
                </div>
                <button type="button"
                        class="btn-close"
                        aria-label="Close"
                        onclick="closeProfilePhotoWebcamCapture()">
                </button>
            </div>

            <div class="modal-body text-center">
                <video id="profilePhotoWebcamVideo"
                       class="profile-photo-webcam-video"
                       autoplay
                       playsinline>
                </video>

                <canvas id="profilePhotoWebcamCanvas"
                        width="480"
                        height="360"
                        class="d-none">
                </canvas>

                <img id="profilePhotoWebcamPreview"
                     class="profile-photo-webcam-preview d-none"
                     alt="Captured Profile Photo" />

                <div id="profilePhotoWebcamMessage" class="text-muted mt-2 small"></div>
            </div>

            <div class="modal-footer">
                <button type="button"
                        class="btn btn-outline-secondary"
                        onclick="closeProfilePhotoWebcamCapture()">
                    Cancel
                </button>

                <button type="button"
                        id="profilePhotoCaptureButton"
                        class="btn btn-primary"
                        onclick="captureProfilePhotoWebcamPhoto()">
                    Capture
                </button>

                <button type="button"
                        id="profilePhotoWebcamUploadButton"
                        class="btn btn-success"
                        onclick="uploadProfilePhotoWebcamPhoto()">
                    Crop & Upload
                </button>
            </div>
        </div>
    </div>
</div>

<div id="profilePhotoCropModal" class="modal-backdrop-custom d-none">
    <div class="modal-dialog-custom crop-dialog-custom">
        <div class="modal-content shadow">
            <div class="modal-header">
                <div>
                    <h5 class="modal-title mb-0">Crop Profile Photo</h5>
                    <div class="text-muted small">
                        Adjust the image before uploading. The saved image will be cropped as a square.
                    </div>
                </div>
                <button type="button"
                        class="btn-close"
                        aria-label="Close"
                        onclick="closeProfilePhotoCropModal()">
                </button>
            </div>

            <div class="modal-body">
                <div class="crop-stage mx-auto">
                    <canvas id="profilePhotoCropCanvas"
                            width="512"
                            height="512"
                            class="profile-photo-crop-canvas">
                    </canvas>
                </div>

                <div class="mt-3">
                    <label for="profilePhotoCropZoom" class="form-label small fw-semibold mb-1">
                        Zoom
                    </label>
                    <input type="range"
                           id="profilePhotoCropZoom"
                           class="form-range"
                           min="1"
                           max="3"
                           step="0.05"
                           value="1"
                           oninput="renderProfilePhotoCropPreview()" />
                </div>

                <div class="row g-3">
                    <div class="col-md-6">
                        <label for="profilePhotoCropOffsetX" class="form-label small fw-semibold mb-1">
                            Horizontal Position
                        </label>
                        <input type="range"
                               id="profilePhotoCropOffsetX"
                               class="form-range"
                               min="-100"
                               max="100"
                               step="1"
                               value="0"
                               oninput="renderProfilePhotoCropPreview()" />
                    </div>

                    <div class="col-md-6">
                        <label for="profilePhotoCropOffsetY" class="form-label small fw-semibold mb-1">
                            Vertical Position
                        </label>
                        <input type="range"
                               id="profilePhotoCropOffsetY"
                               class="form-range"
                               min="-100"
                               max="100"
                               step="1"
                               value="0"
                               oninput="renderProfilePhotoCropPreview()" />
                    </div>
                </div>

                <div id="profilePhotoCropMessage" class="text-muted mt-2 small"></div>
            </div>

            <div class="modal-footer">
                <button type="button"
                        class="btn btn-outline-secondary"
                        onclick="closeProfilePhotoCropModal()">
                    Cancel
                </button>

                <button type="button"
                        class="btn btn-outline-primary"
                        onclick="resetProfilePhotoCropControls()">
                    Reset
                </button>

                <button type="button"
                        id="profilePhotoCropUploadButton"
                        class="btn btn-success"
                        onclick="uploadCroppedProfilePhoto()">
                    Upload Cropped Photo
                </button>
            </div>
        </div>
    </div>
</div>

@section Scripts {
    <style>
        .profile-photo-wrapper {
            position: relative;
            width: 350px;
            height: 350px;
        }

        .profile-photo-preview,
        .profile-photo-placeholder {
            width: 350px;
            height: 350px;
            border-radius: 50%;
            border: 1px solid #dee2e6;
            object-fit: cover;
            box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, .08);
        }

        .profile-photo-placeholder {
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 72px;
            background-color: #f8f9fa;
            color: #6c757d;
        }

        .profile-photo-actions {
            position: absolute;
            right: 20px;
            bottom: 20px;
            display: flex;
            gap: 10px;
            z-index: 10;
        }

        .profile-photo-action-btn {
            width: 48px;
            height: 48px;
            padding: 0;
            font-size: 22px;
            line-height: 1;
            box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, .15);
        }

            .profile-photo-action-btn:disabled {
                opacity: .65;
                cursor: not-allowed;
            }

        .profile-photo-drop-zone {
            border: 2px dashed #ced4da;
            border-radius: .75rem;
            padding: 1.5rem;
            text-align: center;
            background: #f8f9fa;
            cursor: pointer;
            transition: border-color .15s ease-in-out, background-color .15s ease-in-out, box-shadow .15s ease-in-out;
        }

            .profile-photo-drop-zone:hover,
            .profile-photo-drop-zone.profile-photo-drop-zone-active {
                border-color: #0d6efd;
                background: #eef5ff;
                box-shadow: 0 0.25rem 0.75rem rgba(13, 110, 253, .12);
            }

        .profile-photo-drop-icon {
            font-size: 32px;
            line-height: 1;
            margin-bottom: .5rem;
        }

        .enterprise-guide-box {
            border: 1px solid #e9ecef;
            border-radius: .75rem;
            padding: 1.25rem;
            background: #fff;
        }

        .modal-backdrop-custom {
            position: fixed;
            inset: 0;
            z-index: 1055;
            background: rgba(0, 0, 0, .55);
            padding: 1rem;
            overflow-y: auto;
        }

        .modal-dialog-custom {
            max-width: 560px;
            margin: 4rem auto;
        }

        .crop-dialog-custom {
            max-width: 680px;
        }

        .profile-photo-webcam-video,
        .profile-photo-webcam-preview {
            width: 100%;
            max-width: 480px;
            border-radius: .5rem;
            background: #000;
        }

        .crop-stage {
            width: 320px;
            height: 320px;
            border-radius: .75rem;
            border: 1px solid #dee2e6;
            overflow: hidden;
            background: #f8f9fa;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        .profile-photo-crop-canvas {
            width: 320px;
            height: 320px;
            display: block;
            background: #f8f9fa;
        }

        @@media (max-width: 576px) {
            .profile-photo-wrapper {
                width: 260px;
                height: 260px;
            }

            .profile-photo-preview,
            .profile-photo-placeholder {
                width: 260px;
                height: 260px;
            }

            .crop-stage,
            .profile-photo-crop-canvas {
                width: 260px;
                height: 260px;
            }
        }
    </style>

    <script>
        var uploadProfilePhotoUrl = '@uploadProfilePhotoUrl';
        var downloadProfilePhotoUrl = '@downloadProfilePhotoUrl';
        var maxProfilePhotoSize = 5 * 1024 * 1024;

        var profilePhotoWebcamState = {
            stream: null,
            capturedBlob: null
        };

        var profilePhotoCropState = {
            sourceBlob: null,
            sourceFileName: "profile-photo.jpg",
            image: null,
            sourceObjectUrl: null
        };

        function getRequestVerificationToken() {
            var tokenInput = document.querySelector('input[name="__RequestVerificationToken"]');
            return tokenInput ? tokenInput.value : "";
        }

        function downloadProfilePhoto(event) {
            if (event) {
                event.preventDefault();
                event.stopPropagation();

                if (event.stopImmediatePropagation) {
                    event.stopImmediatePropagation();
                }
            }

            if (downloadProfilePhotoUrl) {
                window.location.href = downloadProfilePhotoUrl;
            }

            return false;
        }

        function setProfilePhotoMessage(message) {
            var messageElement = document.getElementById("profilePhotoMessage");
            if (messageElement) {
                messageElement.innerText = message;
            }
        }

        function setProfilePhotoWebcamMessage(message) {
            var messageElement = document.getElementById("profilePhotoWebcamMessage");
            if (messageElement) {
                messageElement.innerText = message;
            }
        }

        function setProfilePhotoCropMessage(message) {
            var messageElement = document.getElementById("profilePhotoCropMessage");
            if (messageElement) {
                messageElement.innerText = message;
            }
        }

        function setProfilePhotoButtonsDisabled(disabled) {
            [
                "profilePhotoUploadButton",
                "profilePhotoCameraButton",
                "profilePhotoDownloadButton",
                "profilePhotoWebcamUploadButton",
                "profilePhotoCaptureButton",
                "profilePhotoCropUploadButton"
            ].forEach(function (id) {
                var button = document.getElementById(id);
                if (button) {
                    button.disabled = disabled;
                }
            });
        }

        function showProfilePhotoPreview(blobOrFile) {
            var profilePhoto = document.getElementById("profilePhoto");
            var placeholder = document.getElementById("profilePhotoPlaceholder");

            if (profilePhoto) {
                profilePhoto.src = window.URL.createObjectURL(blobOrFile);
                profilePhoto.classList.remove("d-none");
            }

            if (placeholder) {
                placeholder.classList.add("d-none");
            }
        }

        function isValidProfilePhotoFile(file, messageSetter) {
            if (!file) {
                messageSetter("Profile photo file is required.");
                return false;
            }

            if (file.size > maxProfilePhotoSize) {
                messageSetter("Profile photo must be 5MB or smaller.");
                return false;
            }

            if (file.type && !file.type.startsWith("image/")) {
                messageSetter("Only image files are allowed.");
                return false;
            }

            return true;
        }

        function openProfilePhotoFileUpload(event) {
            if (event) {
                event.preventDefault();
                event.stopPropagation();

                if (event.stopImmediatePropagation) {
                    event.stopImmediatePropagation();
                }
            }

            var input = document.getElementById("profilePhotoFileInput");

            if (input) {
                input.value = "";
                input.click();
            }

            return false;
        }

        function handleProfilePhotoDragEnter(event) {
            event.preventDefault();
            event.stopPropagation();

            var dropZone = document.getElementById("profilePhotoDropZone");
            if (dropZone) {
                dropZone.classList.add("profile-photo-drop-zone-active");
            }
        }

        function handleProfilePhotoDragOver(event) {
            event.preventDefault();
            event.stopPropagation();

            var dropZone = document.getElementById("profilePhotoDropZone");
            if (dropZone) {
                dropZone.classList.add("profile-photo-drop-zone-active");
            }
        }

        function handleProfilePhotoDragLeave(event) {
            event.preventDefault();
            event.stopPropagation();

            var dropZone = document.getElementById("profilePhotoDropZone");

            if (dropZone && !dropZone.contains(event.relatedTarget)) {
                dropZone.classList.remove("profile-photo-drop-zone-active");
            }
        }

        function handleProfilePhotoDrop(event) {
            event.preventDefault();
            event.stopPropagation();

            var dropZone = document.getElementById("profilePhotoDropZone");
            if (dropZone) {
                dropZone.classList.remove("profile-photo-drop-zone-active");
            }

            if (!event.dataTransfer || !event.dataTransfer.files || event.dataTransfer.files.length === 0) {
                setProfilePhotoMessage("Please drop an image file.");
                return false;
            }

            var file = event.dataTransfer.files[0];

            if (!isValidProfilePhotoFile(file, setProfilePhotoMessage)) {
                return false;
            }

            openProfilePhotoCropModal(file, file.name || "profile-photo.jpg", setProfilePhotoMessage);
            return false;
        }

        async function uploadProfilePhotoBlob(blob, fileName, messageSetter) {
            if (!isValidProfilePhotoFile(blob, messageSetter)) {
                return false;
            }

            var formData = new FormData();
            formData.append("profilePhoto", blob, fileName);

            messageSetter("Uploading photo...");
            setProfilePhotoButtonsDisabled(true);

            try {
                var response = await fetch(uploadProfilePhotoUrl, {
                    method: "POST",
                    headers: {
                        "RequestVerificationToken": getRequestVerificationToken()
                    },
                    body: formData
                });

                if (!response.ok) {
                    var errorText = await response.text();
                    messageSetter(errorText || "Photo upload failed.");
                    return false;
                }

                messageSetter("Photo uploaded successfully.");
                return true;
            } catch (error) {
                messageSetter("Photo upload failed.");
                return false;
            } finally {
                setProfilePhotoButtonsDisabled(false);
            }
        }

        async function uploadProfilePhotoFromFile(input) {
            if (!input || !input.files || input.files.length === 0) {
                return;
            }

            var file = input.files[0];

            if (!isValidProfilePhotoFile(file, setProfilePhotoMessage)) {
                return;
            }

            openProfilePhotoCropModal(file, file.name || "profile-photo.jpg", setProfilePhotoMessage);
        }

        function openProfilePhotoCropModal(blobOrFile, fileName, messageSetter) {
            if (!isValidProfilePhotoFile(blobOrFile, messageSetter || setProfilePhotoMessage)) {
                return;
            }

            clearProfilePhotoCropState();

            profilePhotoCropState.sourceBlob = blobOrFile;
            profilePhotoCropState.sourceFileName = fileName || "profile-photo.jpg";
            profilePhotoCropState.sourceObjectUrl = URL.createObjectURL(blobOrFile);

            var image = new Image();

            image.onload = function () {
                profilePhotoCropState.image = image;
                resetProfilePhotoCropControls();

                var cropModal = document.getElementById("profilePhotoCropModal");
                if (cropModal) {
                    cropModal.classList.remove("d-none");
                }

                setProfilePhotoCropMessage("Adjust the crop area, then upload the cropped photo.");
                renderProfilePhotoCropPreview();
            };

            image.onerror = function () {
                setProfilePhotoMessage("Unable to load image for cropping.");
                clearProfilePhotoCropState();
            };

            image.src = profilePhotoCropState.sourceObjectUrl;
        }

        function closeProfilePhotoCropModal() {
            var cropModal = document.getElementById("profilePhotoCropModal");

            if (cropModal) {
                cropModal.classList.add("d-none");
            }

            setProfilePhotoCropMessage("");
            clearProfilePhotoCropState();
        }

        function clearProfilePhotoCropState() {
            if (profilePhotoCropState.sourceObjectUrl) {
                URL.revokeObjectURL(profilePhotoCropState.sourceObjectUrl);
            }

            profilePhotoCropState.sourceBlob = null;
            profilePhotoCropState.sourceFileName = "profile-photo.jpg";
            profilePhotoCropState.image = null;
            profilePhotoCropState.sourceObjectUrl = null;
        }

        function resetProfilePhotoCropControls() {
            var zoom = document.getElementById("profilePhotoCropZoom");
            var offsetX = document.getElementById("profilePhotoCropOffsetX");
            var offsetY = document.getElementById("profilePhotoCropOffsetY");

            if (zoom) {
                zoom.value = "1";
            }

            if (offsetX) {
                offsetX.value = "0";
            }

            if (offsetY) {
                offsetY.value = "0";
            }

            renderProfilePhotoCropPreview();
        }

        function renderProfilePhotoCropPreview() {
            var image = profilePhotoCropState.image;
            var canvas = document.getElementById("profilePhotoCropCanvas");

            if (!image || !canvas) {
                return;
            }

            var context = canvas.getContext("2d");
            var outputSize = canvas.width;

            var zoomInput = document.getElementById("profilePhotoCropZoom");
            var offsetXInput = document.getElementById("profilePhotoCropOffsetX");
            var offsetYInput = document.getElementById("profilePhotoCropOffsetY");

            var zoom = zoomInput ? parseFloat(zoomInput.value) : 1;
            var offsetXPercent = offsetXInput ? parseFloat(offsetXInput.value) : 0;
            var offsetYPercent = offsetYInput ? parseFloat(offsetYInput.value) : 0;

            if (isNaN(zoom) || zoom < 1) {
                zoom = 1;
            }

            var baseCropSize = Math.min(image.naturalWidth, image.naturalHeight);
            var cropSize = baseCropSize / zoom;

            var maxOffsetX = (image.naturalWidth - cropSize) / 2;
            var maxOffsetY = (image.naturalHeight - cropSize) / 2;

            var centerX = image.naturalWidth / 2 + (maxOffsetX * offsetXPercent / 100);
            var centerY = image.naturalHeight / 2 + (maxOffsetY * offsetYPercent / 100);

            var sourceX = centerX - cropSize / 2;
            var sourceY = centerY - cropSize / 2;

            sourceX = Math.max(0, Math.min(sourceX, image.naturalWidth - cropSize));
            sourceY = Math.max(0, Math.min(sourceY, image.naturalHeight - cropSize));

            context.clearRect(0, 0, outputSize, outputSize);
            context.fillStyle = "#f8f9fa";
            context.fillRect(0, 0, outputSize, outputSize);

            context.drawImage(
                image,
                sourceX,
                sourceY,
                cropSize,
                cropSize,
                0,
                0,
                outputSize,
                outputSize
            );
        }

        async function createCroppedProfilePhotoBlob() {
            return new Promise(function (resolve) {
                var canvas = document.getElementById("profilePhotoCropCanvas");

                if (!canvas) {
                    resolve(null);
                    return;
                }

                canvas.toBlob(function (blob) {
                    resolve(blob);
                }, "image/jpeg", 0.92);
            });
        }

        async function uploadCroppedProfilePhoto() {
            if (!profilePhotoCropState.image) {
                setProfilePhotoCropMessage("Please select an image first.");
                return;
            }

            renderProfilePhotoCropPreview();

            var croppedBlob = await createCroppedProfilePhotoBlob();

            if (!croppedBlob) {
                setProfilePhotoCropMessage("Unable to crop the image.");
                return;
            }

            var uploaded = await uploadProfilePhotoBlob(
                croppedBlob,
                normalizeCroppedProfilePhotoFileName(profilePhotoCropState.sourceFileName),
                setProfilePhotoCropMessage
            );

            if (uploaded) {
                showProfilePhotoPreview(croppedBlob);
                closeProfilePhotoCropModal();
                window.location.reload();
            }
        }

        function normalizeCroppedProfilePhotoFileName(fileName) {
            if (!fileName) {
                return "profile-photo-cropped.jpg";
            }

            var dotIndex = fileName.lastIndexOf(".");

            if (dotIndex <= 0) {
                return fileName + "-cropped.jpg";
            }

            return fileName.substring(0, dotIndex) + "-cropped.jpg";
        }

        async function openProfilePhotoWebcamCapture(event) {
            if (event) {
                event.preventDefault();
                event.stopPropagation();

                if (event.stopImmediatePropagation) {
                    event.stopImmediatePropagation();
                }
            }

            var modal = document.getElementById("profilePhotoWebcamModal");
            var video = document.getElementById("profilePhotoWebcamVideo");
            var preview = document.getElementById("profilePhotoWebcamPreview");

            if (!modal || !video || !preview) {
                return false;
            }

            setProfilePhotoWebcamMessage("Starting camera...");

            modal.classList.remove("d-none");
            preview.classList.add("d-none");
            preview.src = "";
            video.classList.remove("d-none");

            try {
                var stream = await navigator.mediaDevices.getUserMedia({
                    video: true,
                    audio: false
                });

                profilePhotoWebcamState.stream = stream;
                profilePhotoWebcamState.capturedBlob = null;

                video.srcObject = stream;
                setProfilePhotoWebcamMessage("Camera is ready.");
            } catch (error) {
                setProfilePhotoWebcamMessage("Unable to access the camera. Please allow camera permission and use HTTPS.");
            }

            return false;
        }

        function captureProfilePhotoWebcamPhoto() {
            var video = document.getElementById("profilePhotoWebcamVideo");
            var canvas = document.getElementById("profilePhotoWebcamCanvas");
            var preview = document.getElementById("profilePhotoWebcamPreview");

            if (!video || !canvas || !preview || !video.srcObject) {
                setProfilePhotoWebcamMessage("Camera is not ready.");
                return;
            }

            var width = video.videoWidth || 480;
            var height = video.videoHeight || 360;

            canvas.width = width;
            canvas.height = height;

            var context = canvas.getContext("2d");
            context.drawImage(video, 0, 0, width, height);

            canvas.toBlob(function (blob) {
                if (!blob) {
                    setProfilePhotoWebcamMessage("Unable to capture photo.");
                    return;
                }

                profilePhotoWebcamState.capturedBlob = blob;

                preview.src = URL.createObjectURL(blob);
                preview.classList.remove("d-none");
                video.classList.add("d-none");

                setProfilePhotoWebcamMessage("Photo captured. Review it and click Crop & Upload.");
            }, "image/jpeg", 0.9);
        }

        async function uploadProfilePhotoWebcamPhoto() {
            if (!profilePhotoWebcamState.capturedBlob) {
                setProfilePhotoWebcamMessage("Please capture a photo first.");
                return;
            }

            closeProfilePhotoWebcamCapture(false);
            openProfilePhotoCropModal(
                profilePhotoWebcamState.capturedBlob,
                "webcam-profile-photo.jpg",
                setProfilePhotoCropMessage
            );
        }

        function closeProfilePhotoWebcamCapture(clearCapturedBlob) {
            if (clearCapturedBlob === undefined) {
                clearCapturedBlob = true;
            }

            var modal = document.getElementById("profilePhotoWebcamModal");
            var video = document.getElementById("profilePhotoWebcamVideo");
            var preview = document.getElementById("profilePhotoWebcamPreview");

            if (profilePhotoWebcamState.stream) {
                profilePhotoWebcamState.stream.getTracks().forEach(function (track) {
                    track.stop();
                });
            }

            profilePhotoWebcamState.stream = null;

            if (clearCapturedBlob) {
                profilePhotoWebcamState.capturedBlob = null;
            }

            if (video) {
                video.srcObject = null;
            }

            if (preview) {
                preview.src = "";
                preview.classList.add("d-none");
            }

            if (modal) {
                modal.classList.add("d-none");
            }

            setProfilePhotoWebcamMessage("");
        }
    </script>
}

🧩 주요 기능

이 페이지는 다음 기능을 제공합니다:

  • 프로필 사진 업로드
  • 드래그 앤 드롭 업로드
  • 웹캠 촬영
  • 이미지 크롭
  • 서버 저장 (DB)
  • 다운로드 (파일명 자동 생성 포함)

💾 저장 방식

기본 구현에서는 다음과 같은 구조를 사용합니다.

  • 저장 위치: 데이터베이스
  • 데이터 타입: byte[]
  • MIME 타입: 서버에서 자동 감지

📥 다운로드 기능

서버 핸들러를 통해 파일을 직접 다운로드합니다.

  • Content-Type 자동 설정
  • 확장자 자동 감지
  • 사용자 기반 파일명 생성

🔐 보안 고려사항

다음 항목을 반드시 고려해야 합니다.

  • 인증된 사용자만 접근 가능
  • 파일 타입 검증 (image/*)
  • 파일 크기 제한 (예: 5MB)
  • CSRF 토큰 사용 (@Html.AntiForgeryToken())

♻️ 재사용성

이 구조의 가장 큰 장점은 다음과 같습니다.

  • 모든 ASP.NET Core Identity 프로젝트에 그대로 복사 가능
  • 별도의 서비스 계층 없이 즉시 사용 가능
  • Razor Pages 기반으로 의존성 최소화
  • 향후 Azure Blob, S3 등으로 확장 가능

✅ 정리

이 기능은 다음과 같은 특징을 갖습니다.

  • 간단한 파일 추가만으로 적용 가능
  • ASP.NET Core Identity 구조를 그대로 활용
  • 확장성과 재사용성 확보
더 깊이 공부하고 싶다면
DevLec에서는 실무 중심의 C#, .NET, ASP.NET Core, Blazor, 데이터 액세스 강좌를 단계별로 제공합니다. 현재 수강 가능한 강좌 외에도 더 많은 과정이 준비되어 있습니다.
DevLec.com에서 자세한 커리큘럼을 확인해 보세요.
DevLec 공식 강의
C# Programming
C# 프로그래밍 입문
프로그래밍을 처음 시작하는 입문자를 위한 C# 기본기 완성 과정입니다.
ASP.NET Core 10.0
ASP.NET Core 10.0 시작하기 MVC Fundamentals Part 1 MVC Fundamentals Part 2
웹 애플리케이션의 구조와 MVC 패턴을 ASP.NET Core로 실습하며 익힐 수 있습니다.
Blazor Server
풀스택 웹개발자 과정 Part 1 풀스택 웹개발자 과정 Part 2 풀스택 웹개발자 과정 Part 3
실무에서 바로 활용 가능한 Blazor Server 기반 관리자·포털 프로젝트를 만들어 봅니다.
Data & APIs
Entity Framework Core 시작하기 ADO.NET Fundamentals Blazor Server Fundamentals Minimal APIs
데이터 액세스와 Web API를 함께 이해하면 실무 .NET 백엔드 개발에 큰 도움이 됩니다.
VisualAcademy Docs의 모든 콘텐츠, 이미지, 동영상의 저작권은 박용준에게 있습니다. 저작권법에 의해 보호를 받는 저작물이므로 무단 전재와 복제를 금합니다. 사이트의 콘텐츠를 복제하여 블로그, 웹사이트 등에 게시할 수 없습니다. 단, 링크와 SNS 공유, Youtube 동영상 공유는 허용합니다. www.VisualAcademy.com
박용준 강사의 모든 동영상 강의는 데브렉에서 독점으로 제공됩니다. www.devlec.com