
EMR 시스템은 단순한 데이터 저장소가 아닙니다.
환자의 생명과 직결된 정보, 복잡한 결제 로직이 얽혀 있기에 "장애가 발생했을![]
때 어떻게 대처하느냐"보다 "장애가 발생할 수 없는 구조를 어떻게 만드느냐"가 훨씬 중요합니다.
단순한 코드 정리를 넘어 운영 중 발생할 수 있는 잠재적 폭탄들을 구조적으로 제거한 리팩토링 과정을 상세히 공유합니다.
EMR은 도메인 경계가 매우 명확합니다.
진료, 재무, 지원 모듈이 서로 엉키면 유지보수 난이도가 기하급수적으로 상승합니다. 이
번 리팩토링에서는 의존성 방향을 강제하여 이를 해결했습니다.
emr-core: 공통 인프라(보안, 예외, 락, 유틸). 도메인이나 서비스 로직을 절대 몰라야 합니다.emr-domain: 핵심 비즈니스 엔티티 및 인터페이스. 외부 기술에 오염되지 않도록 보호합니다.emr-clinical / `emr-finance: 상위 비즈니스 모듈. domain과 core`에 의존합니다.실무 팁 (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 내부에서만 사용
}
기존에는 모든 컨트롤러가 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");
}
}
EMR에서 동일한 진료 예약이 두 번 생성되거나 결제가 중복 처리되는 것은 치명적인 사고입니다. 이를 방지하기 위해 Redis 기반의 설계를 도입했습니다.
락을 해제할 때, 내가 잡았던 락인지 확인하고 삭제하는 과정이 원자적이지 않으면 다른 스레드의 락을 잘못 해제하는 '락 오염'이 발생합니다.
// 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;
}
@Order)락과 멱등성이 함께 적용될 때는 락을 먼저 점유한 뒤 중복 여부를 체크해야 합니다.
@DistributedLock (Order 1): 자원 점유권을 먼저 획득.@Idempotent (Order 2): 이미 처리된 요청인지 Redis에서 확인.이 순서가 바뀌면 동시에 들어온 두 요청이 모두 멱등성 체크를 통과해버리는 레이스 컨디션이 발생할 수 있습니다.
초기 AOP 클래스에는 SpEL 파싱, 헤더 추출 등 온갖 로직이 섞여 있어 단일 책임 원칙를 위반하고 있었습니다. 이를 전략 패턴으로 분리했습니다.
// 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);
// ...
}
초당 수천 건의 요청이 몰리는 EMR 환경에서는 아주 미세한 오버헤드도 병목 현상을 일으킵니다.
매번 긴 스크립트 문자열을 Redis로 전송하는 것은 네트워크 낭비입니다.
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~50ms | 0.1~1ms | 최대 500배 |
| Lua 전송 | 50~100ms | 5~10ms | 약 10배 |
| 총 오버헤드 | 60~150ms | 2~11ms | 획기적 절감 |
NOSCRIPT 에러 발생 시 스크립트를 재로드하는 로직이 필수입니다.maximumSize를 반드시 설정하세요.코드 내용보다는 TTL을 왜 10초로 잡았는가?와 같은 의사결정의 근거를 남기는 데 집중하였습니다.이번 리팩토링의 진짜 가치는 최신 기술의 도입이 아니라 "장애 발생 시나리오를 설계 단계에서 원천 차단"한 것에 있습니다.
구조가 견고하면 개발자는 비즈니스 로직에만 집중할 수 있고, 이는 곧 팀 전체의 생산성과 서비스의 안정성으로 이어집니다.
[다음 단계로 추천하는 액션]
적용된 Caffeine 캐시의 TTL 설정 기준 설정 값이나, Redis 장애 발생 시 시스템의 Circuit Breaker 연동 전략에 대해 더 자세히 알기 위해 다음 포스팅에 작성 할예정입니다.