올리브영 테크블로그 포스팅 권한마다 보안 솔루션이 다르다면? 하이브리드 환경에서 우아하게 파일 보호하기
Tech

권한마다 보안 솔루션이 다르다면? 하이브리드 환경에서 우아하게 파일 보호하기

전략 패턴과 AOP로 비즈니스 로직과 보안 정책 깔끔하게 분리하기

2025.12.26

목차

  1. 들어가며: 변화의 지침과 현실적인 제약
  2. 해결책 탐색: 왜 전략 패턴인가?
  3. 기술적 설계: 전략 패턴을 통한 추상화
  4. AOP를 활용한 자동 적용
  5. 삽질기: 실제 적용하면서 마주한 문제들
  6. 마치며


1. 들어가며: 변화의 지침과 현실적인 제약

올리브영의 오프라인 매장 운영을 책임지는 백오피스 시스템은 매일 수많은 엑셀과 PDF 문서를 생성하고 처리합니다. 그동안 우리는 이 문서들을 보호하기 위해 표준화된 설치형 에이전트(Agent) 보안 솔루션을 사용하여 파일을 암호화해 왔습니다.

최근 전사 보안 정책이 고도화됨에 따라, 문서 보안 체계를 신규 클라우드 기반 보안 시스템으로 전환하게 되었습니다. 이에 따라 백오피스 시스템 내 파일 암호화가 적용된 모든 영역을 새로운 방식으로 전환해야 했습니다.


하지만 분석 단계에서 중요한 현실적 제약에 직면했습니다. 신규 도입된 클라우드 보안 시스템은 라이선스 및 인프라 구조상 '본사 구성원'에게만 지원된다는 점이었습니다.

백오피스 시스템은 본사 구성원뿐만 아니라, 매장에서 근무하는 '매장 구성원'이나 외부 '협력사'들도 빈번하게 사용합니다. 결국, 우리는 두 가지 보안 체계를 동시에 운영해야 하는 상황이 되었습니다.


  • 본사 임직원: 신규 클라우드 기반 보안 (Type A)
  • 그 외 (매장/협력사): 기존 에이전트 기반 보안 (Type B)

즉, "로그인한 사용자의 권한(Role)에 따라 적절한 암호화 솔루션을 동적으로 선택하여 적용"해야 하는 과제가 주어진 것입니다.




2. 해결책 탐색: 왜 전략 패턴인가?

처음부터 전략 패턴을 떠올린 건 아니었습니다. 가장 먼저 떠오른 건 역시 단순한 if-else 분기였습니다.


2.1. 첫 번째 시도: 단순 if-else 분기

"일단 돌아가게 만들자"는 생각으로 빠르게 프로토타입을 작성해봤습니다.


// 첫 번째 시도: 단순 if-else
public byte[] downloadSalesReport(User user) {
    byte[] excelData = excelService.createExcel();

    if (user.isHeadquarters()) {
        // 클라우드 API 호출
        return cloudSecurityApi.encrypt(excelData, user.getId());
    } else {
        // 에이전트 라이브러리 호출
        return agentSecurityLib.protect(excelData);
    }
}

간단해 보이지만, 실제로 적용하려니 문제가 보이기 시작했습니다.

백오피스 시스템에는 파일을 다운로드하는 API가 수십 개에 달했습니다. 매출 리포트, 재고 현황, 발주서, 정산 내역... 이 모든 곳에 동일한 if-else 로직을 복붙해야 했습니다. 그리고 만약 향후 제3의 보안 솔루션이 추가된다면? 수십 개의 파일을 다시 찾아다니며 else if를 추가해야 하는 악몽이 그려졌습니다.


2.2. 두 번째 시도: 별도 서비스 클래스로 분리

그렇다면 암호화 로직을 별도 서비스로 분리하면 어떨까요?


// 두 번째 시도: 서비스 클래스 분리
@Service
public class FileEncryptionService {

    public byte[] encrypt(byte[] data, User user) {
        if (user.isHeadquarters()) {
            return cloudSecurityApi.encrypt(data, user.getId());
        } else {
            return agentSecurityLib.protect(data);
        }
    }
}

// 사용하는 쪽
public byte[] exportExccel(User user) {
    byte[] excelData = excelService.createExcel();
    return fileEncryptionService.encrypt(excelData, user);  // 호출 필요
}

if-else가 한 곳으로 모이긴 했지만, 여전히 두 가지 문제가 남았습니다.

  1. 호출 누락 위험: 모든 다운로드 메서드에서 fileEncryptionService.encrypt()를 명시적으로 호출해야 합니다. 신규 API를 만들 때 깜빡하면? 암호화되지 않은 파일이 그대로 내려갑니다.
  2. OCP 위반: 새로운 보안 솔루션이 추가되면 FileEncryptionService 내부를 수정해야 합니다. "확장에는 열려 있고, 수정에는 닫혀 있어야 한다"는 개방-폐쇄 원칙(OCP)에 어긋납니다.

2.3. 세 번째 시도: 팩토리 패턴?

잠깐, 팩토리 패턴은 어떨까요? 사용자 타입에 따라 적절한 암호화 객체를 생성해서 반환하면 되지 않을까요?


// 팩토리 패턴 검토
public class EncryptorFactory {
    public Encryptor create(User user) {
        if (user.isHeadquarters()) {
            return new CloudEncryptor();
        } else {
            return new AgentEncryptor();
        }
    }
}

팩토리 패턴은 객체 생성에 초점을 맞춘 패턴입니다. 반면 우리가 필요한 건 이미 존재하는 암호화 구현체 중 하나를 선택하는 것이었습니다.

매번 new로 객체를 생성하는 방식은 여러 측면에서 적합하지 않았습니다.

  1. Spring 컨테이너 외부의 객체: new로 생성한 객체는 Spring 컨테이너가 관리하지 않습니다. 이 말은 @Autowired를 통한 의존성 주입, AOP 프록시, 트랜잭션 관리 등 Spring이 제공하는 이점을 전혀 활용할 수 없다는 의미입니다. 실제로 우리의 암호화 구현체들은 내부적으로 다른 Spring 빈(API 클라이언트, 설정 정보 등)에 의존하고 있었기에 이는 치명적인 단점이었습니다.

  2. 싱글톤 빈의 설계 철학과 불일치: Spring의 기본 빈 스코프는 싱글톤입니다. 애플리케이션 전체에서 하나의 인스턴스만 생성하여 재사용함으로써 메모리 효율성과 일관성을 보장합니다. 팩토리 패턴으로 매번 새 인스턴스를 생성하는 것은 이러한 Spring의 설계 철학에 역행하는 방식이었습니다.

결론적으로, 우리에게 필요한 것은 "객체를 생성"하는 것이 아니라 "이미 Spring 컨테이너가 관리하고 있는 싱글톤 빈 중 적절한 것을 선택"하는 것이었습니다. 이 요구사항에 가장 부합하는 패턴이 바로 전략 패턴이었습니다.


2.4. 결론: 전략 패턴 + AOP 조합

고민 끝에 내린 결론은 전략 패턴AOP의 조합이었습니다.

  • 전략 패턴: 암호화 방식을 인터페이스로 추상화하고, 각 구현체를 독립적으로 관리합니다. 새로운 보안 솔루션이 추가되면 기존 코드 수정 없이 구현체만 추가하면 됩니다.
  • AOP: 개발자가 명시적으로 암호화 메서드를 호출할 필요 없이, 어노테이션 하나로 자동 적용됩니다. 호출 누락의 위험이 사라집니다.

방식 중복 제거 확장성 누락 방지 채택
if-else 분기 X X X -
서비스 클래스 분리 O X X -
팩토리 패턴 O X -
전략 패턴 + AOP O O O 채택



3. 기술적 설계: 전략 패턴을 통한 추상화

전략 패턴이 낯선 분들을 위해 설명드리자면, 전략 패턴은 '게임기'와 '게임 팩'의 관계로 비유할 수 있습니다. 게임기(비즈니스 로직)는 꽂는 팩(전략: 클라우드 보안 또는 에이전트 보안)의 종류에 따라 플레이할 게임(암호화 방식)이 달라지죠. 이와 유사하게 우리는 사용자가 누구냐에 따라 알맞은 전략을 자동으로 반영하는 시스템을 설계했습니다.


3.1. 전략 인터페이스와 구현체

먼저 FileProtectionStrategy라는 인터페이스(규격)를 정의하고, 각각의 보안 방식을 따르는 구현체(게임 팩)를 만들었습니다. 이렇게 하면 향후 또 다른 보안 솔루션이 추가되더라도 기존 코드를 수정할 필요 없이 새로운 구현체만 추가하면 됩니다.


public interface FileProtectionStrategy {

    /** 전략 식별자 (예: "CLOUD", "AGENT") */
    String getType();

    /** 파일 보호(암호화) 처리 */
    byte[] protect(byte[] inputBytes, String fileName);

    /** 파일 보호 해제(복호화) 처리 */
    byte[] unprotect(byte[] protectedBytes);
}

각각의 구현체는 전략에 맞는 API 혹은 라이브러리를 호출하여 실제 암호화를 수행합니다.


/**
 * 전략 A: 신규 클라우드 보안 구현체
 */
@Component
public class CloudFileProtectionStrategy implements FileProtectionStrategy {
    @Override
    public String getType() { return "CLOUD"; }

    @Override
    public byte[] protect(byte[] inputBytes, String fileName) {
        // GW API를 호출하여 클라우드 방식 암호화 수행
        return inputBytes; 
    }
    // ... unprotect 구현 생략
}

/**
 * 전략 B: 기존 에이전트 보안 구현체
 */
@Component
public class AgentFileProtectionStrategy implements FileProtectionStrategy {
    @Override
    public String getType() { return "AGENT"; }

    @Override
    public byte[] protect(byte[] inputBytes, String fileName) {
        // 기존 에이전트 방식 암호화 수행
        return inputBytes;
    }
    // ... unprotect 구현 생략
}


3.2. 전략 선택기 (Resolver)

다음으로, 런타임 시점에 필요한 전략을 빠르게 찾을 수 있도록 Resolver를 구성했습니다.

여기서 주목할 점은 생성자에서 List<FileProtectionStrategy>를 주입받는 부분입니다. 이는 Spring의 다중 빈 자동 주입(Autowiring Collections) 기능을 활용한 것으로, 동일한 인터페이스를 구현한 모든 빈을 한 번에 주입받을 수 있습니다.

전략의 개수가 적을 때는 리스트를 순회해도 문제가 없지만, 호출 빈도가 매우 높은 보안 모듈 특성상 확장 시에도 성능 저하가 없는 구조를 목표로 했습니다. 따라서 주입받은 전략 리스트를 애플리케이션 시작 시점에 단 한 번만 Map으로 변환하여 캐싱해둠으로써, 런타임에는 별도의 조건문(if-else)이나 리스트 순회 없이 O(1) 시간 복잡도로 전략을 즉시 조회할 수 있도록 구현했습니다.

이 접근법의 장점은 새로운 전략 구현체를 추가할 때 Resolver 코드를 전혀 수정할 필요가 없다는 것입니다. @Component로 등록된 새 전략 빈은 애플리케이션 시작 시점에 Spring이 자동으로 감지하여 리스트에 포함시킵니다. 이는 OCP(개방-폐쇄 원칙)를 자연스럽게 만족하는 구조입니다.

참고문서: Spring 공식 문서 - Using @Autowired


@Component
public class FileProtectionStrategyResolver {

    private final Map<String, FileProtectionStrategy> registry;

    // 생성자 주입 시점에 모든 전략 구현체를 Map으로 변환하여 캐싱
    public FileProtectionStrategyResolver(List<FileProtectionStrategy> strategies) {
        this.registry = strategies.stream()
                .collect(Collectors.toMap(FileProtectionStrategy::getType, Function.identity()));
    }

    /**
     * 지정한 전략 식별자(Type)로 구현체를 O(1)로 조회
     */
    public FileProtectionStrategy resolve(String type) {
        FileProtectionStrategy strategy = registry.get(type);
        
        if (strategy == null) {
            throw new IllegalArgumentException("지원하지 않는 보안 타입입니다: " + type);
        }
        
        return strategy;
    }
}



4. AOP를 활용한 자동 적용

마지막 퍼즐은 AOP(Aspect Oriented Programming)입니다.

앞서 전략 패턴을 게임과 게임 팩에 비유했다면, AOP는 공항 보안 검색대에 가깝습니다. 파일을 생성하는 모든 코드마다 resolve()를 호출하고 protect()를 실행하는 방식은 번거로울 뿐 아니라, 호출을 누락할 위험도 있습니다. 이런 반복적이고 빠뜨리기 쉬운 보안 처리를 AOP에 맡기면 훨씬 안정적으로 관리할 수 있습니다. 마치 승객이 비행기를 타기 전에 반드시 보안 검색대를 지나가야 하듯, AOP는 비즈니스 로직으로 진입하기 전에 데이터를 자동으로 검사하고 통과시키는 관문 역할을 합니다.


우리는 @ProtectFile이라는 커스텀 어노테이션만 붙이면, AOP가 자동으로 개입하여 보안 처리를 수행하도록 했습니다.


@Aspect
public class ProtectFileAspect {

    private final FileProtectionStrategyResolver resolver;

    public ProtectFileAspect(FileProtectionStrategyResolver resolver) {
        this.resolver = resolver;
    }

    @Around("@annotation(protectFile)")
    public Object aroundProtect(ProceedingJoinPoint pjp, Object protectFile) throws Throwable {
        // 1. 비즈니스 로직 실행 (파일 생성/다운로드)
        // (예시를 위해 반환 타입이 byte[]라고 가정)
        byte[] raw = (byte[]) pjp.proceed(); 

        // 2. 보안 전략 결정
        // 실제 운영 코드에서는 사용자 세션 정보(SessionUser)를 기반으로
        // "CLOUD" 또는 "AGENT" 타입을 동적으로 결정합니다.
        String securityType = getCurrentUserSecurityType(); 

        // 3. 전략 조회 및 암호화 수행
        FileProtectionStrategy strategy = resolver.resolve(securityType);
        
        // 4. 암호화된 결과 반환
        // (전략 구현체 내부에서 처리)
        return strategy.protect(raw, "sample_report.pdf");
    }

    // 사용자 권한에 따른 보안 타입 결정 로직 (예시)
    private String getCurrentUserSecurityType() {
        // 실제로는 SecurityContextHolder 등에서 사용자 권한을 확인합니다.
        // return userContext.isHeadquarters() ? "CLOUD" : "AGENT";
        return "CLOUD"; // 데모를 위해 고정값 사용
    }
}

이제 비즈니스 로직 개발자는 파일 생성 코드에 집중하고, 아래처럼 @ProtectFile 어노테이션 하나만 붙여주면 됩니다.

@ProtectFile // 이 어노테이션 하나로 암호화 로직이 자동 적용됩니다.
public byte[] downloadSalesReport() {
// 순수 비즈니스 로직 (파일 생성)
return reportService.createPdf(...);
}



5. 삽질기: 실제 적용하면서 마주한 문제들

설계는 깔끔했지만, 실제 적용 과정은 순탄치 않았습니다. 여기서는 개발 중 마주했던 문제들과 해결 과정을 공유합니다.


5.1. 로컬 개발 환경에서 에이전트 솔루션이 동작하지 않는 문제

가장 먼저 부딪힌 벽은 로컬 개발 환경이었습니다.

클라우드 보안 API는 개발 환경에서도 문제없이 동작했지만, 기존 에이전트 기반 솔루션은 상황이 달랐습니다. 에이전트 솔루션은 네이티브 라이브러리에 의존하고 있었는데, 이 라이브러리가 macOS 아키텍처를 지원하지 않았습니다.


// 문제: macOS 로컬 환경에서 에이전트 솔루션 호출 시
agentSecurityLib.protect(data);
// -> UnsatisfiedLinkError: 네이티브 라이브러리 로드 실패

대부분의 개발자가 macOS 환경에서 개발하고 있었기 때문에, 에이전트 방식 암호화를 테스트하려면 매번 개발 서버에 배포하거나 Docker 환경에서 실행해야 했습니다. 로컬에서 코드 수정 후 즉시 테스트하면 피드백 루프가 1~2분이면 충분하지만, 개발 서버 배포를 거치면 커밋 → CI/CD 파이프라인 → 서버 재시작까지 적지 않은 시간이 소요되어 개발 생산성이 크게 저하되는 문제가 있었습니다.


해결책: 로컬 환경용 Mock 전략 구현체

이 문제를 해결하기 위해 로컬 개발 환경에서만 활성화되는 Mock 전략을 추가하여, 로컬 환경에서는 Mock 전략이 에이전트 전략을 대체하도록 설정했습니다.


@Component
@Profile("local")  // 로컬 환경에서만 활성화
public class MockFileProtectionStrategy implements FileProtectionStrategy {

    @Override
    public String getType() { return "AGENT"; }  // 에이전트 타입을 대체

    @Override
    public byte[] protect(byte[] inputBytes, String fileName) {
        // 실제 암호화 없이 원본 그대로 반환
        log.debug("[Mock] 에이전트 암호화 스킵: {}", fileName);
        return inputBytes;
    }
}

이제 우리는 실제 암호화는 수행하지 않지만, 전략 패턴의 흐름은 그대로 테스트할 수 있게 되었습니다.

실제 암호화 동작 검증이 필요할 때만 개발 서버에 배포하면 되니, 암호화 에이전트를 사용할 수 없는 macOS 환경에서 코드와 흐름에 대한 불확실성을 제거할 수 있었습니다.

이는 인프라의 종속성에서 개발 환경을 완전히 독립시킨 의미 있는 리팩터링었습니다.


5.2. 기존 코드에 어노테이션 일괄 적용

마지막으로, 기존에 존재하는 수십 개의 다운로드 API에 @ProtectFile 어노테이션을 붙이는 작업이 남아있었습니다.

단순 작업이라 생각했지만, 막상 시작하니 까다로운 케이스들이 있었습니다.

  • 파일이 아닌 JSON을 반환하는 API에 실수로 어노테이션을 붙이면?
  • 이미 다른 방식으로 암호화가 적용된 레거시 코드는?
  • 암호화가 필요 없는 공개 문서(약관 등)는?

해결책: API 목록화 + 꼼꼼한 QA 테스트

무작정 적용하기보다, 먼저 모든 파일 다운로드 API를 목록화하고 각각의 특성을 파악했습니다.

[파일 다운로드 API 체크리스트]
✅ /api/report/sales - 매출 리포트 (암호화 필요)
✅ /api/report/inventory - 재고 현황 (암호화 필요)
⬜ /api/template - 양식받기 (공개 문서, 암호화 불필요)

목록화 이후에는 QA 엔지니어분과 함께 꼼꼼한 테스트 과정을 거쳤습니다. 사용자 권한별로 파일을 다운로드하고, 암호화가 정상 적용되는지 하나하나 검증했습니다.

이 자리를 빌려 늦은 시간까지 수십 개의 API를 빠짐없이 테스트해주신 QA팀 여러분께 깊은 감사를 전합니다.🙇‍♀️




6. 마치며

이번 프로젝트는 전사적인 보안 체계 전환이라는 미션을 수행함과 동시에, 현장의 다양한 사용자 환경을 모두 고려해야 하는 과제였습니다.

전략 패턴과 AOP의 조합으로 본사 구성원에게는 최신 클라우드 보안 환경을, 매장 구성원에게는 익숙한 기존 환경을 제공하면서도 코드의 복잡도는 최소화할 수 있었습니다.

단순히 "돌아가는 코드"를 넘어, 변화에 유연하게 대처할 수 있는 "좋은 구조"를 고민했던 이번 경험이, 복잡한 레거시 환경과 새로운 요구사항 사이에서 고민하는 개발자분들에게 작은 힌트가 되기를 바랍니다.

문서보안AOPStrategy Pattern
올리브영 테크 블로그 작성 권한마다 보안 솔루션이 다르다면? 하이브리드 환경에서 우아하게 파일 보호하기
🌱
Anna |
Back-end Engineer
내일 한걸음 더 나아가는 개발자