재우니 개발자 블로그

 

 

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에 연결할 때 다음과 같은 문제들을 겪게 됩니다:

 

  1. ProfilesLocation 경로 문제: 명시적인 자격 증명 파일 경로 지정의 어려움
  2. 토큰 만료: SSO 토큰의 주기적 만료로 인한 런타임 오류
  3. 의존성 문제: 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 연동을 구현할 수 있습니다.

 

참고 자료