diff --git a/src/InterviewAssistant.ApiService/Middlewares/GlobalExceptionHandler.cs b/src/InterviewAssistant.ApiService/Middlewares/GlobalExceptionHandler.cs
new file mode 100644
index 0000000..a08fc79
--- /dev/null
+++ b/src/InterviewAssistant.ApiService/Middlewares/GlobalExceptionHandler.cs
@@ -0,0 +1,33 @@
+using System.Net;
+using System.Text.Json;
+using Microsoft.AspNetCore.Diagnostics;
+
+namespace InterviewAssistant.ApiService.Middlewares;
+
+///
+/// 전역 예외 처리기
+///
+public class GlobalExceptionHandler(ILogger logger) : IExceptionHandler
+{
+ public async ValueTask TryHandleAsync(
+ HttpContext httpContext,
+ Exception exception,
+ CancellationToken cancellationToken)
+ {
+ logger.LogError(exception, "예외 발생: {Message}", exception.Message);
+
+ var response = new
+ {
+ error = "서버 오류가 발생했습니다.",
+ message = exception.Message,
+ timestamp = DateTime.UtcNow
+ };
+
+ httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
+ httpContext.Response.ContentType = "application/json";
+
+ await httpContext.Response.WriteAsync(JsonSerializer.Serialize(response), cancellationToken);
+
+ return true;
+ }
+}
diff --git a/src/InterviewAssistant.ApiService/Program.cs b/src/InterviewAssistant.ApiService/Program.cs
index bfbb690..909d944 100644
--- a/src/InterviewAssistant.ApiService/Program.cs
+++ b/src/InterviewAssistant.ApiService/Program.cs
@@ -6,6 +6,7 @@
using InterviewAssistant.ApiService.Data;
using InterviewAssistant.ApiService.Repositories;
using InterviewAssistant.ApiService.Extensions;
+using InterviewAssistant.ApiService.Middlewares;
using Microsoft.SemanticKernel;
using Microsoft.EntityFrameworkCore;
@@ -49,6 +50,7 @@
return McpClientFactory.CreateAsync(transport).GetAwaiter().GetResult();
});
+builder.Services.AddExceptionHandler();
builder.Services.AddSingleton(sp =>
{
diff --git a/test/InterviewAssistant.ApiService.Tests/Integration/GlobalExceptionHandlerTestServerTests.cs b/test/InterviewAssistant.ApiService.Tests/Integration/GlobalExceptionHandlerTestServerTests.cs
new file mode 100644
index 0000000..9c309db
--- /dev/null
+++ b/test/InterviewAssistant.ApiService.Tests/Integration/GlobalExceptionHandlerTestServerTests.cs
@@ -0,0 +1,314 @@
+using System.Net;
+using System.Text.Json;
+using System.Linq;
+
+using InterviewAssistant.ApiService.Data;
+using InterviewAssistant.ApiService.Endpoints;
+using InterviewAssistant.ApiService.Middlewares;
+using InterviewAssistant.ApiService.Repositories;
+using InterviewAssistant.ApiService.Services;
+using InterviewAssistant.Common.Models;
+
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.SemanticKernel;
+using ModelContextProtocol.Client;
+
+using NSubstitute;
+using NSubstitute.ExceptionExtensions;
+
+using OpenAI;
+
+using Shouldly;
+
+namespace InterviewAssistant.ApiService.Tests.Integration;
+
+[TestFixture]
+public class GlobalExceptionHandlerTestServerTests
+{
+ private TestServer _server;
+ private HttpClient _client;
+ private IKernelService _mockKernelService;
+ private IInterviewRepository _mockRepository;
+
+ [OneTimeSetUp]
+ public void OneTimeSetUp()
+ {
+ _mockKernelService = Substitute.For();
+ _mockRepository = Substitute.For();
+
+ var hostBuilder = CreateHostBuilder();
+ _server = new TestServer(hostBuilder);
+ _client = _server.CreateClient();
+ }
+
+ [OneTimeTearDown]
+ public void OneTimeTearDown()
+ {
+ _client?.Dispose();
+ _server?.Dispose();
+ }
+
+ [SetUp]
+ public void SetUp()
+ {
+ _mockKernelService.ClearReceivedCalls();
+ _mockRepository.ClearReceivedCalls();
+ }
+
+ [Test]
+ public async Task ChatCompletion_WhenKernelServiceThrowsException_ShouldReturnGlobalExceptionResponse()
+ {
+ // Arrange
+ var validResumeId = Guid.NewGuid();
+ var validJobId = Guid.NewGuid();
+
+ var chatRequest = new ChatRequest
+ {
+ ResumeId = validResumeId,
+ JobDescriptionId = validJobId,
+ Messages = new List
+ {
+ new() { Role = MessageRoleType.User, Message = "면접을 시작합니다" }
+ }
+ };
+
+ _mockRepository.GetResumeByIdAsync(validResumeId)
+ .Returns(new Models.ResumeEntry { Id = validResumeId, Content = "테스트 이력서" });
+ _mockRepository.GetJobByIdAsync(validJobId)
+ .Returns(new Models.JobDescriptionEntry { Id = validJobId, Content = "테스트 채용공고" });
+
+ _mockKernelService.InvokeInterviewAgentAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any>())
+ .Throws(new InvalidOperationException("커널 서비스 오류 발생"));
+
+ var json = JsonSerializer.Serialize(chatRequest, new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ });
+ var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+
+ // Act
+ var response = await _client.PostAsync("/api/chat/complete", content);
+
+ // Assert
+ response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError);
+ response.Content.Headers.ContentType!.MediaType.ShouldBe("application/json");
+
+ var responseContent = await response.Content.ReadAsStringAsync();
+ var errorResponse = JsonSerializer.Deserialize(responseContent);
+
+ errorResponse.GetProperty("error").GetString().ShouldBe("서버 오류가 발생했습니다.");
+ errorResponse.GetProperty("message").GetString().ShouldBe("커널 서비스 오류 발생");
+ errorResponse.TryGetProperty("timestamp", out _).ShouldBeTrue();
+ }
+
+ [Test]
+ public async Task ChatCompletion_WhenRepositoryThrowsException_ShouldReturnGlobalExceptionResponse()
+ {
+ // Arrange
+ var validResumeId = Guid.NewGuid();
+ var validJobId = Guid.NewGuid();
+
+ var chatRequest = new ChatRequest
+ {
+ ResumeId = validResumeId,
+ JobDescriptionId = validJobId,
+ Messages = new List
+ {
+ new() { Role = MessageRoleType.User, Message = "면접을 시작합니다" }
+ }
+ };
+
+ _mockRepository.GetResumeByIdAsync(validResumeId)
+ .Throws(new InvalidOperationException("데이터베이스 연결 오류"));
+
+ var json = JsonSerializer.Serialize(chatRequest, new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ });
+ var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+
+ // Act
+ var response = await _client.PostAsync("/api/chat/complete", content);
+
+ // Assert
+ response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError);
+ response.Content.Headers.ContentType!.MediaType.ShouldBe("application/json");
+
+ var responseContent = await response.Content.ReadAsStringAsync();
+ var errorResponse = JsonSerializer.Deserialize(responseContent);
+
+ errorResponse.GetProperty("error").GetString().ShouldBe("서버 오류가 발생했습니다.");
+ errorResponse.GetProperty("message").GetString().ShouldBe("데이터베이스 연결 오류");
+ errorResponse.TryGetProperty("timestamp", out _).ShouldBeTrue();
+ }
+
+ [Test]
+ public async Task InterviewData_WhenKernelServiceThrowsException_ShouldReturnGlobalExceptionResponse()
+ {
+ // Arrange
+ var interviewDataRequest = new InterviewDataRequest
+ {
+ ResumeId = Guid.NewGuid(),
+ JobDescriptionId = Guid.NewGuid(),
+ ResumeUrl = "https://example.com/resume.pdf",
+ JobDescriptionUrl = "https://example.com/job.pdf"
+ };
+
+ _mockKernelService.PreprocessAndInvokeAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any())
+ .Throws(new ArgumentException("잘못된 URL 형식"));
+
+ var json = JsonSerializer.Serialize(interviewDataRequest, new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ });
+ var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+
+ // Act
+ var response = await _client.PostAsync("/api/chat/interview-data", content);
+
+ // Assert
+ response.StatusCode.ShouldBe(HttpStatusCode.InternalServerError);
+ response.Content.Headers.ContentType!.MediaType.ShouldBe("application/json");
+
+ var responseContent = await response.Content.ReadAsStringAsync();
+ var errorResponse = JsonSerializer.Deserialize(responseContent);
+
+ errorResponse.GetProperty("error").GetString().ShouldBe("서버 오류가 발생했습니다.");
+ errorResponse.GetProperty("message").GetString().ShouldBe("잘못된 URL 형식");
+ errorResponse.TryGetProperty("timestamp", out _).ShouldBeTrue();
+ }
+
+ [Test]
+ public async Task ChatCompletion_WhenSuccessful_ShouldNotTriggerGlobalExceptionHandler()
+ {
+ // Arrange
+ var validResumeId = Guid.NewGuid();
+ var validJobId = Guid.NewGuid();
+
+ var chatRequest = new ChatRequest
+ {
+ ResumeId = validResumeId,
+ JobDescriptionId = validJobId,
+ Messages = new List
+ {
+ new() { Role = MessageRoleType.User, Message = "면접을 시작합니다" }
+ }
+ };
+
+ _mockRepository.GetResumeByIdAsync(validResumeId)
+ .Returns(new Models.ResumeEntry { Id = validResumeId, Content = "테스트 이력서" });
+ _mockRepository.GetJobByIdAsync(validJobId)
+ .Returns(new Models.JobDescriptionEntry { Id = validJobId, Content = "테스트 채용공고" });
+
+ var successResponses = new[] { "안녕하세요, 면접을 시작하겠습니다." };
+ _mockKernelService.InvokeInterviewAgentAsync(
+ Arg.Any(),
+ Arg.Any(),
+ Arg.Any>())
+ .Returns(successResponses.ToAsyncEnumerable());
+
+ var json = JsonSerializer.Serialize(chatRequest, new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ });
+ var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
+
+ // Act
+ var response = await _client.PostAsync("/api/chat/complete", content);
+
+ // Assert
+ response.StatusCode.ShouldBe(HttpStatusCode.OK);
+ var responseContent = await response.Content.ReadAsStringAsync();
+ responseContent.ShouldNotContain("서버 오류가 발생했습니다.");
+ }
+
+ [Test]
+ public async Task NonExistentEndpoint_ShouldReturn404_NotTriggerGlobalExceptionHandler()
+ {
+ // Act
+ var response = await _client.GetAsync("/api/nonexistent");
+
+ // Assert
+ response.StatusCode.ShouldBe(HttpStatusCode.NotFound);
+ }
+
+ private IWebHostBuilder CreateHostBuilder()
+ {
+ return new WebHostBuilder()
+ .UseTestServer()
+ .UseEnvironment("Testing")
+ .ConfigureServices(services =>
+ {
+ // 전역 예외 처리기 등록
+ services.AddExceptionHandler();
+ services.AddProblemDetails();
+
+ // 라우팅 서비스 추가
+ services.AddRouting();
+
+ // 테스트용 모의 서비스 등록
+ services.AddScoped(_ => _mockKernelService);
+ services.AddScoped(_ => _mockRepository);
+
+ // 테스트용 인메모리 데이터베이스
+ var connection = new SqliteConnection("DataSource=:memory:");
+ connection.Open();
+ services.AddSingleton(connection);
+
+ services.AddDbContext(options =>
+ options.UseSqlite(connection));
+
+ // 기본 모의 객체들 - sealed 클래스들은 실제 인스턴스나 대체 방법 사용
+ var kernelBuilder = Kernel.CreateBuilder();
+ var testKernel = kernelBuilder.Build();
+ services.AddSingleton(testKernel);
+
+ services.AddSingleton(Substitute.For());
+
+ // OpenAIClient도 sealed일 수 있으므로 null 또는 실제 인스턴스 사용
+ services.AddSingleton(_ => null!);
+ services.AddSingleton(Substitute.For());
+
+ // JSON 직렬화 설정
+ services.ConfigureHttpJsonOptions(options =>
+ {
+ options.SerializerOptions.WriteIndented = true;
+ options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+ options.SerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
+ options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
+ });
+
+ // 로깅 설정
+ services.AddLogging();
+ })
+ .Configure(app =>
+ {
+ // 전역 예외 처리기 사용
+ app.UseExceptionHandler();
+
+ // 라우팅 사용
+ app.UseRouting();
+
+ // 엔드포인트 매핑
+ app.UseEndpoints(endpoints =>
+ {
+ // Chat Completion 엔드포인트 매핑
+ endpoints.MapChatCompletionEndpoint();
+ });
+ });
+ }
+}
diff --git a/test/InterviewAssistant.ApiService.Tests/InterviewAssistant.ApiService.Tests.csproj b/test/InterviewAssistant.ApiService.Tests/InterviewAssistant.ApiService.Tests.csproj
index 0c6d6d7..e1f0002 100644
--- a/test/InterviewAssistant.ApiService.Tests/InterviewAssistant.ApiService.Tests.csproj
+++ b/test/InterviewAssistant.ApiService.Tests/InterviewAssistant.ApiService.Tests.csproj
@@ -11,6 +11,7 @@
+
diff --git a/test/InterviewAssistant.ApiService.Tests/Middlewares/GlobalExceptionHandlerTests.cs b/test/InterviewAssistant.ApiService.Tests/Middlewares/GlobalExceptionHandlerTests.cs
new file mode 100644
index 0000000..c36e4c8
--- /dev/null
+++ b/test/InterviewAssistant.ApiService.Tests/Middlewares/GlobalExceptionHandlerTests.cs
@@ -0,0 +1,99 @@
+using System.Net;
+using System.Text.Json;
+
+using InterviewAssistant.ApiService.Middlewares;
+
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+using NSubstitute;
+
+using Shouldly;
+
+namespace InterviewAssistant.ApiService.Tests.Middlewares;
+
+[TestFixture]
+public class GlobalExceptionHandlerTests
+{
+ private ILogger _logger;
+ private GlobalExceptionHandler _handler;
+ private DefaultHttpContext _httpContext;
+
+ [SetUp]
+ public void Setup()
+ {
+ _logger = Substitute.For>();
+ _handler = new GlobalExceptionHandler(_logger);
+ _httpContext = new DefaultHttpContext();
+ _httpContext.Response.Body = new MemoryStream();
+ }
+
+ [Test]
+ public async Task TryHandleAsync_WithException_ShouldReturnTrueAndSetCorrectResponse()
+ {
+ // Arrange
+ var exception = new InvalidOperationException("테스트 예외");
+
+ // Act
+ var result = await _handler.TryHandleAsync(_httpContext, exception, CancellationToken.None);
+
+ // Assert
+ result.ShouldBeTrue();
+ _httpContext.Response.StatusCode.ShouldBe(500);
+ _httpContext.Response.ContentType.ShouldBe("application/json");
+
+ // 응답 내용 확인
+ _httpContext.Response.Body.Position = 0;
+ var responseContent = await new StreamReader(_httpContext.Response.Body).ReadToEndAsync();
+ var responseObject = JsonSerializer.Deserialize(responseContent);
+
+ responseObject.GetProperty("error").GetString().ShouldBe("서버 오류가 발생했습니다.");
+ responseObject.GetProperty("message").GetString().ShouldBe("테스트 예외");
+ responseObject.TryGetProperty("timestamp", out _).ShouldBeTrue();
+ }
+
+ [Test]
+ [TestCase(typeof(NullReferenceException), "Null reference")]
+ [TestCase(typeof(FileNotFoundException), "File not found")]
+ [TestCase(typeof(UnauthorizedAccessException), "Access denied")]
+ public async Task TryHandleAsync_WithDifferentExceptions_ShouldHandleCorrectly(Type exceptionType, string message)
+ {
+ // Arrange
+ var exception = (Exception)Activator.CreateInstance(exceptionType, message)!;
+
+ // Act
+ var result = await _handler.TryHandleAsync(_httpContext, exception, CancellationToken.None);
+
+ // Assert
+ result.ShouldBeTrue();
+ _httpContext.Response.StatusCode.ShouldBe(500);
+
+ _httpContext.Response.Body.Position = 0;
+ var responseContent = await new StreamReader(_httpContext.Response.Body).ReadToEndAsync();
+ var responseObject = JsonSerializer.Deserialize(responseContent);
+
+ responseObject.GetProperty("message").GetString().ShouldBe(message);
+ }
+
+ [Test]
+ public async Task TryHandleAsync_ShouldGenerateValidJsonResponse()
+ {
+ // Arrange
+ var exception = new InvalidOperationException("JSON 검증");
+
+ // Act
+ await _handler.TryHandleAsync(_httpContext, exception, CancellationToken.None);
+
+ // Assert
+ _httpContext.Response.Body.Position = 0;
+ var responseContent = await new StreamReader(_httpContext.Response.Body).ReadToEndAsync();
+
+ // JSON 유효성 검증
+ Should.NotThrow(() => JsonSerializer.Deserialize(responseContent));
+
+ var responseObject = JsonSerializer.Deserialize(responseContent);
+ responseObject.TryGetProperty("error", out _).ShouldBeTrue();
+ responseObject.TryGetProperty("message", out _).ShouldBeTrue();
+ responseObject.TryGetProperty("timestamp", out _).ShouldBeTrue();
+ }
+}