운영 장애를 구조적으로 차단하는 설계 전략

EMR 시스템은 단순한 데이터 저장소가 아닙니다.
환자의 생명과 직결된 정보, 복잡한 결제 로직이 얽혀 있기에 "장애가 발생했을![]
때 어떻게 대처하느냐"
보다 "장애가 발생할 수 없는 구조를 어떻게 만드느냐"가 훨씬 중요합니다.

단순한 코드 정리를 넘어 운영 중 발생할 수 있는 잠재적 폭탄들을 구조적으로 제거한 리팩토링 과정을 상세히 공유합니다.


1. "나누는 것"보다 "막는 것"이 핵심 아키텍처

EMR은 도메인 경계가 매우 명확합니다.
진료, 재무, 지원 모듈이 서로 엉키면 유지보수 난이도가 기하급수적으로 상승합니다. 이
번 리팩토링에서는 의존성 방향을 강제하여 이를 해결했습니다.

모듈 구성 및 의존성 규칙

  • emr-core: 공통 인프라(보안, 예외, 락, 유틸). 도메인이나 서비스 로직을 절대 몰라야 합니다.
  • emr-domain: 핵심 비즈니스 엔티티 및 인터페이스. 외부 기술에 오염되지 않도록 보호합니다.
  • emr-clinical / `emr-finance: 상위 비즈니스 모듈. domaincore`에 의존합니다.

실무 팁 (Gradle): api는 하위 모듈로 의존성을 전파하고, implementation은 내부에서만 사용합니다. 불필요한 노출을 막기 위해 최대한 implementation을 사용하세요.

// emr-core/build.gradle.kts
dependencies {
    api("org.springframework.boot:spring-boot-starter-web") // 하위 모듈에서 공통 사용
    implementation("com.github.ben-manes.caffeine:caffeine") // core 내부에서만 사용
}

2. 컨트롤러의 '비만' 예외 처리의 중앙 집권화 해결

기존에는 모든 컨트롤러가 try-catch로 가득 차 있었습니다.
이는 가독성을 해칠 뿐만 아니라 응답 형식을 파편화하여 프론트엔드와의 협업을 어렵게 만듭니다.

"컨트롤러는 성공만, 실패는 핸들러가" 해결 하기

컨트롤러는 성공 시나리오만 담당하고, 예외는 도메인의 언어로 던진 뒤 GlobalExceptionHandler가 일괄 처리합니다.

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    private final Environment environment;

    // 비즈니스 예외 처리
    @ExceptionHandler(BaseException.class)
    public ResponseEntity<CustomErrorResponse> handleBaseException(BaseException e, HttpServletRequest request) {
        log.error("비즈니스 예외 발생: {}", e.getMessage(), e);
        return ResponseEntity.status(e.getErrorCode().getStatus())
                .body(CustomErrorResponse.of(e.getErrorCode(), request.getRequestURI()));
    }

    // 예상치 못한 최상위 예외 처리 (최후의 안전망)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<CustomErrorResponse> handleException(Exception e, HttpServletRequest request) {
        log.error("미정의 예외 발생: ", e);
        
        String stackTrace = isDevelopment() ? getStackTrace(e) : null;
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(CustomErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR, request.getRequestURI(), stackTrace));
    }

    private boolean isDevelopment() {
        return Arrays.asList(environment.getActiveProfiles()).contains("dev") || 
               Arrays.asList(environment.getActiveProfiles()).contains("local");
    }
}

3. "중복 요청은 반드시 온다" 분산 락 & 멱등성

EMR에서 동일한 진료 예약이 두 번 생성되거나 결제가 중복 처리되는 것은 치명적인 사고입니다. 이를 방지하기 위해 Redis 기반의 설계를 도입했습니다.

3.1 Redis + Lua 스크립트를 이용한 원자적 락 해제

락을 해제할 때, 내가 잡았던 락인지 확인하고 삭제하는 과정이 원자적이지 않으면 다른 스레드의 락을 잘못 해제하는 '락 오염'이 발생합니다.

// DistributedLockService.java 중 일부
private static final String UNLOCK_SCRIPT = 
    "if redis.call('get', KEYS[1]) == ARGV[1] then " +
    "    return redis.call('del', KEYS[1]) " +
    "else " +
    "    return 0 " +
    "end";

public boolean unlock(String key, String lockValue) {
    DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
    Long result = redisTemplate.execute(script, Collections.singletonList(LOCK_PREFIX + key), lockValue);
    return result != null && result > 0;
}

3.2 AOP 적용 순서 (@Order)

락과 멱등성이 함께 적용될 때는 락을 먼저 점유한 뒤 중복 여부를 체크해야 합니다.

  1. @DistributedLock (Order 1): 자원 점유권을 먼저 획득.
  2. @Idempotent (Order 2): 이미 처리된 요청인지 Redis에서 확인.

이 순서가 바뀌면 동시에 들어온 두 요청이 모두 멱등성 체크를 통과해버리는 레이스 컨디션이 발생할 수 있습니다.


4. 전략 패턴 SOLID 기반 리팩토링 하기

초기 AOP 클래스에는 SpEL 파싱, 헤더 추출 등 온갖 로직이 섞여 있어 단일 책임 원칙를 위반하고 있었습니다. 이를 전략 패턴으로 분리했습니다.

KeyGenerator 전략 분리

// 1. 인터페이스 정의
public interface LockKeyGenerator {
    String generate(String expression, Method method, Object[] args);
}

// 2. 전략 구현 (SpEL 방식)
@Component
public class SpelKeyGenerator implements LockKeyGenerator {
    private final ExpressionParser parser = new SpelExpressionParser();
    
    @Override
    public String generate(String expression, Method method, Object[] args) {
        // SpEL 파싱 로직...
    }
}

// 3. Aspect에서의 사용
@Around("@annotation(distributedLock)")
public Object around(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
    // 팩토리를 통해 전략을 주입받아 사용 (OCP 준수)
    LockKeyGenerator generator = keyGeneratorFactory.getLockKeyGenerator(distributedLock.keyType());
    String lockKey = generator.generate(distributedLock.key(), method, args);
    // ...
}

5. Lua & SpEL 고트래픽 성능

초당 수천 건의 요청이 몰리는 EMR 환경에서는 아주 미세한 오버헤드도 병목 현상을 일으킵니다.

5.1 Redis Lua 스크립트 최적화

매번 긴 스크립트 문자열을 Redis로 전송하는 것은 네트워크 낭비입니다.

  • 해결: 스크립트를 Redis에 미리 로드하고 고유 ID만 보내 실행합니다.

5.2 SpEL 파싱 최적화

Spring의 SpEL 파싱은 비용이 매우 큽니다.

  • 해결: Caffeine 라이브러리를 사용해 한 번 파싱된 Expression 객체를 캐싱합니다.
private final Cache<String, Expression> expressionCache = Caffeine.newBuilder()
        .maximumSize(1000)
        .expireAfterWrite(1, TimeUnit.HOURS)
        .build();

public String generate(String expression, Method method, Object[] args) {
    // 캐시에서 가져오거나 새로 파싱 (파싱 시간 500배 단축)
    Expression expr = expressionCache.get(expression, parser::parseExpression);
    return expr.getValue(context, String.class);
}

최적화 벤치마크

항목최적화 전최적화 후개선 효과
SpEL 파싱10~50ms0.1~1ms최대 500배
Lua 전송50~100ms5~10ms약 10배
총 오버헤드60~150ms2~11ms획기적 절감

6. 실무 적용 시 주의사항

  1. Redis 재시작 대응: Redis가 꺼졌다 켜지면 캐싱된 Lua SHA1 값이 사라집니다. NOSCRIPT 에러 발생 시 스크립트를 재로드하는 로직이 필수입니다.
  2. 캐시 Eviction: SpEL 캐시가 무한정 커지지 않도록 maximumSize를 반드시 설정하세요.
  3. 주석의 철학: 코드 내용보다는 TTL을 왜 10초로 잡았는가?와 같은 의사결정의 근거를 남기는 데 집중하였습니다.

7. 사고 날 가능성을 구조적으로 제거하기

이번 리팩토링의 진짜 가치는 최신 기술의 도입이 아니라 "장애 발생 시나리오를 설계 단계에서 원천 차단"한 것에 있습니다.

  • 멀티 모듈로 엉킨 의존성을 풀고,
  • 중앙 예외 처리로 응답의 신뢰성을 높였으며,
  • 분산 락과 최적화로 데이터 무결성과 성능을 동시에 잡았습니다.

구조가 견고하면 개발자는 비즈니스 로직에만 집중할 수 있고, 이는 곧 팀 전체의 생산성과 서비스의 안정성으로 이어집니다.


[다음 단계로 추천하는 액션]
적용된 Caffeine 캐시의 TTL 설정 기준 설정 값이나, Redis 장애 발생 시 시스템의 Circuit Breaker 연동 전략에 대해 더 자세히 알기 위해 다음 포스팅에 작성 할예정입니다.

profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글