ASP.NET Core 8에서 AWS SSO를 활용한 S3 연동 구현하기
AWS SSO(Single Sign-On)를 사용하는 환경에서 ASP.NET Core 8 애플리케이션을 개발할 때, S3와 연동하는 과정에서 자주 마주치는 문제들이 있습니다. 특히 자격 증명 처리와 토큰 만료 상황에 대한 대응이 까다로운데요. 이번 포스트에서는 실무에서 바로 적용할 수 있는 견고한 S3 클라이언트 구현 방법을 소개하겠습니다.
문제 상황 분석
개발 환경에서 AWS CLI를 통해 SSO 로그인을 완료했다고 가정해봅시다:
aws sso login --profile UNIVESLUCKY
하지만 ASP.NET Core 애플리케이션에서 S3에 연결할 때 다음과 같은 문제들을 겪게 됩니다:
- ProfilesLocation 경로 문제: 명시적인 자격 증명 파일 경로 지정의 어려움
- 토큰 만료: SSO 토큰의 주기적 만료로 인한 런타임 오류
- 의존성 문제: AWS SSO 관련 NuGet 패키지 누락으로 인한 런타임 예외
필수 NuGet 패키지 설치
AWS SSO를 사용하려면 다음 패키지들이 반드시 필요합니다:
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="4.0.6.4" />
<PackageReference Include="AWSSDK.SSO" Version="4.0.0.12" />
<PackageReference Include="AWSSDK.SSOOIDC" Version="4.0.0.11" />
</ItemGroup>
이 패키지들이 누락되면 다음과 같은 런타임 오류가 발생합니다:
System.InvalidOperationException: Assembly AWSSDK.SSOOIDC could not be found or loaded.
완성된 S3StorageClient 구현
인터페이스 정의
먼저 S3 작업을 위한 인터페이스를 정의합니다:
public interface IS3StorageClient
{
Task<string> UploadFileAsync(string bucketName, string key, string filePath);
Task<Stream> DownloadFileAsync(string bucketName, string key);
Task<bool> DeleteFileAsync(string bucketName, string key);
Task<bool> FileExistsAsync(string bucketName, string key);
string GetFileUrl(string bucketName, string key);
}
핵심 구현 클래스
public class S3StorageClient : IS3StorageClient, IDisposable
{
private readonly IAmazonS3 _s3Client;
private readonly ILogger<S3StorageClient> _logger;
private readonly string _profileName;
private bool _disposed = false;
public S3StorageClient(IConfiguration configuration, ILogger<S3StorageClient> logger)
{
_logger = logger;
_profileName = configuration["AWS:ProfileName"] ?? "UNIVESLUCKY";
var region = Amazon.RegionEndpoint.APNortheast2;
var s3Config = new AmazonS3Config
{
RegionEndpoint = region,
MaxErrorRetry = 3,
RetryMode = Amazon.Runtime.RequestRetryMode.Standard,
UseHttp = false,
DisablePayloadSigning = false
};
_s3Client = CreateS3Client(s3Config);
}
}
AWS SSO 자격 증명 처리의 핵심
가장 중요한 부분인 자격 증명 생성 로직입니다:
private IAmazonS3 CreateS3Client(AmazonS3Config s3Config)
{
_logger.LogInformation("AWS S3 클라이언트 초기화 시작. 프로필: {ProfileName}", _profileName);
try
{
// 1단계: AWS SSO 프로필 사용 시도
if (TryCreateClientWithSSOProfile(s3Config, out var ssoClient))
{
_logger.LogInformation("AWS SSO 프로필을 사용하여 S3 클라이언트 초기화 성공");
return ssoClient;
}
// 2단계: 기본 자격 증명 체인 사용
_logger.LogInformation("기본 AWS 자격 증명 체인을 사용하여 S3 클라이언트 초기화");
var defaultClient = new AmazonS3Client(s3Config);
// 자격 증명 유효성 검증
ValidateCredentialsAsync(defaultClient).GetAwaiter().GetResult();
return defaultClient;
}
catch (Exception ex)
{
_logger.LogError(ex, "AWS S3 클라이언트 초기화 실패");
throw new InvalidOperationException("AWS S3 클라이언트를 초기화할 수 없습니다. AWS 자격 증명을 확인해주세요.", ex);
}
}
SSO 프로필 처리의 핵심 포인트
private bool TryCreateClientWithSSOProfile(AmazonS3Config s3Config, out IAmazonS3 client)
{
client = null;
try
{
// ProfilesLocation을 지정하지 않고 기본 경로 사용
var credentialProfileStoreChain = new CredentialProfileStoreChain();
if (credentialProfileStoreChain.TryGetProfile(_profileName, out var profile))
{
_logger.LogDebug("프로필 {ProfileName} 발견. 자격 증명 생성 시도", _profileName);
// SSO 프로필 감지
if (profile.Options.SsoSession != null ||
!string.IsNullOrEmpty(profile.Options.SsoStartUrl))
{
_logger.LogDebug("SSO 프로필로 감지됨");
}
var credentials = profile.GetAWSCredentials(credentialProfileStoreChain);
client = new AmazonS3Client(credentials, s3Config);
// 즉시 유효성 검증
ValidateCredentialsAsync(client).GetAwaiter().GetResult();
return true;
}
else
{
_logger.LogWarning("프로필 {ProfileName}을 찾을 수 없습니다", _profileName);
}
}
catch (AmazonServiceException ex) when (ex.ErrorCode == "ExpiredToken")
{
_logger.LogWarning("AWS SSO 토큰이 만료되었습니다. 'aws sso login --profile {ProfileName}' 명령을 실행해주세요", _profileName);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "SSO 프로필 기반 자격 증명 초기화 실패");
}
return false;
}
AWS SDK 4.0+ 버전 변경사항 대응
AWSSDK.S3 4.0+ 버전에서는 기존의 Timeout과 ReadWriteTimeout 속성이 제한적으로 작동합니다. 대신 개별 요청에서 CancellationToken을 사용하는 것이 권장됩니다:
public async Task<string> UploadFileWithTimeoutAsync(string bucketName, string key,
string filePath, TimeSpan timeout)
{
using var cts = new CancellationTokenSource(timeout);
try
{
var request = new PutObjectRequest
{
BucketName = bucketName,
Key = key,
FilePath = filePath,
ContentType = GetContentType(filePath),
ServerSideEncryptionMethod = ServerSideEncryptionMethod.AES256
};
var response = await _s3Client.PutObjectAsync(request, cts.Token);
return GetFileUrl(bucketName, key);
}
catch (OperationCanceledException) when (cts.Token.IsCancellationRequested)
{
_logger.LogWarning("S3 업로드가 타임아웃되었습니다: {Timeout}초", timeout.TotalSeconds);
throw new TimeoutException($"S3 업로드가 {timeout.TotalSeconds}초 후 타임아웃되었습니다.");
}
}
의존성 주입 설정
Program.cs에서 서비스를 등록합니다:
var builder = WebApplication.CreateBuilder(args);
// S3 클라이언트 등록
builder.Services.AddScoped<IS3StorageClient, S3StorageClient>();
var app = builder.Build();
appsettings.json 설정:
{
"AWS": {
"ProfileName": "UNIVESLUCKY"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"S3StorageClient": "Debug"
}
}
}
실제 사용 예제
컨트롤러에서의 사용법:
[ApiController]
[Route("api/[controller]")]
public class FileController : ControllerBase
{
private readonly IS3StorageClient _s3Client;
private readonly ILogger<FileController> _logger;
public FileController(IS3StorageClient s3Client, ILogger<FileController> logger)
{
_s3Client = s3Client;
_logger = logger;
}
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file, [FromForm] string bucketName)
{
if (file == null || file.Length == 0)
return BadRequest("파일이 선택되지 않았습니다.");
try
{
// 임시 파일 생성
var tempPath = Path.GetTempFileName();
using (var stream = new FileStream(tempPath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
// S3 업로드
var key = $"uploads/{DateTime.Now:yyyy/MM/dd}/{Guid.NewGuid()}-{file.FileName}";
var fileUrl = await _s3Client.UploadFileAsync(bucketName, key, tempPath);
// 임시 파일 삭제
File.Delete(tempPath);
return Ok(new { Url = fileUrl, Key = key });
}
catch (UnauthorizedAccessException ex)
{
_logger.LogError(ex, "AWS 인증 오류");
return Unauthorized("AWS SSO 토큰이 만료되었습니다. 재로그인이 필요합니다.");
}
catch (Exception ex)
{
_logger.LogError(ex, "파일 업로드 실패");
return StatusCode(500, "파일 업로드 중 오류가 발생했습니다.");
}
}
[HttpGet("download/{*key}")]
public async Task<IActionResult> Download(string key, [FromQuery] string bucketName)
{
try
{
var stream = await _s3Client.DownloadFileAsync(bucketName, key);
var fileName = Path.GetFileName(key);
return File(stream, "application/octet-stream", fileName);
}
catch (AmazonS3Exception ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return NotFound("파일을 찾을 수 없습니다.");
}
catch (Exception ex)
{
_logger.LogError(ex, "파일 다운로드 실패");
return StatusCode(500, "파일 다운로드 중 오류가 발생했습니다.");
}
}
}
주요 특징과 장점
1. 자동 자격 증명 탐지
- ProfilesLocation 경로를 명시하지 않아도 AWS 기본 자격 증명 체인을 통해 자동 탐지
- SSO와 일반 자격 증명을 모두 지원
2. 토큰 만료 상황 대응
- 초기화 시점에 자격 증명 유효성 검증
- 토큰 만료 시 명확한 에러 메시지와 해결 방법 제시
3. 견고한 오류 처리
- AWS 특화 예외 처리
- 단계적 폴백 전략 구현
4. 최신 AWS SDK 대응
- 4.0+ 버전의 변경사항 반영
- CancellationToken을 활용한 타임아웃 처리
주의사항 및 베스트 프랙티스
1. 배포 환경 고려사항
- 프로덕션 환경에서는 IAM Role 사용 권장
- SSO는 주로 개발/테스트 환경에서 사용
2. 토큰 갱신 자동화
개발 환경에서 토큰 만료를 방지하려면:
# cron job이나 스케줄러에 등록
aws sso login --profile UNIVESLUCKY --no-browser
3. 로깅 전략
- 민감한 정보(토큰, 키 등)는 로깅하지 않기
- 디버그 레벨에서만 상세 정보 출력
마무리
이번 구현 방식은 실제 프로덕션 환경에서 검증된 패턴입니다. AWS SSO를 사용하는 개발 환경에서 발생할 수 있는 다양한 문제 상황에 대비한 견고한 구현을 통해, 안정적인 S3 연동을 구현할 수 있습니다.
참고 자료