View

발단

화요일 오전 9시 10분 쯤 서비스 중인 B2C LMS에서 대체인증이 정상적으로 처리되지 않는다는 문의가 들어왔다.

학습을 위한 대체인증(휴대폰 본인인증)시 사용자 화면에 아래와 같은 오류가 노출되고 있다는 것이었다.

java.sql.SQLException: ORA-00257: archiver error.
Connect internal only, until freed.

 

먼저 ORA-00257 코드가 어떤 의미인지 확인해보았다. 

ORA-로 시작하는 이 오류 코드는 Oracle Database에서 발생하는 오류로,

데이터를 새로 넣거나 바꾼 기록(히스토리)을 저장하는 '아카이브 로그' 스토리지가 꽉 차서, 더 이상 새로운 기록을 만들지 못해 발생하는 오류였다. 그리고 기록을 남겨야만 다음 작업을 진행하는 오라클의 특성 때문에 DB의 모든 입력과 수정 기능이 멈춰버린 상태였던 것이다. (지금 다시 생각해보면 생각만해도 아찔한 상황이다.)

 

오류 메시지를 확인하고나니 한 가지는 바로 판단할 수 있었다.

우리 서비스의 DB에서 발생한 오류는 아니었다.



그 다음으로 휴대폰 본인인증 모듈부터 확인했다

자사 DB 문제가 아니라는 것이 확실해지고 난 뒤, 오류가 발생된 대체인증 기능에 포커스를 맞추었다.

그래서 먼저 대체인증에서 사용하는 휴대폰 본인인증 모듈 자체에 문제가 있는지 확인했다.

 

다행히 같은 휴대폰 본인인증 모듈을 사용하는 기능이 하나 더 있었다.

바로 회원가입 본인인증이었다.

같은 환경에서 회원가입 휴대폰 본인인증을 직접 테스트해보니 인증 성공 결과가 정상적으로 처리되고 있었다.

[회원가입 본인인증]

휴대폰 본인인증
      ↓
인증 성공
      ↓
회원가입 처리
      ↓
정상 완료

동일한 본인인증 모듈을 사용하는 회원가입에서는 문제가 발생하지 않았다.

이를 통해 휴대폰 본인인증 모듈 자체가 장애의 원인일 가능성은 낮다고 판단했다.

 

그렇다면 다음으로 확인해야 할 것은 두 기능의 차이였다.

회원가입 본인인증은 인증 성공 이후 내부 처리를 진행하는 반면, 대체인증은 인증 성공 후 외부 서버로 인증 결과를 추가 전송하는 과정이 있었다.

[대체인증]

휴대폰 본인인증
      ↓
인증 성공
      ↓
우리 서버
      ↓
외부 서버 API 호출
      ↓
인증 결과 전송

 

정리하면 상황은 이랬다.

[동일한 휴대폰 본인인증 모듈]

회원가입 본인인증(NICE 휴대폰 본인인증) → 정상

 mOTP대체인증(NICE 휴대폰 본인인증)   → 실패
                 ↓
          외부 서버 호출 존재

 

같은 본인인증 모듈을 사용하면서도 외부 서버 호출이 추가되는 대체인증에서만 문제가 발생하고 있었다.

 

여기에 처음 확인한 오류가 Oracle 계열의 ORA-00257이라는 점까지 고려하면, 본인인증 모듈보다는 대체인증 성공 이후 호출되는 외부 시스템과 그 응답 처리 로직을 우선 확인하는 것이 합리적이었다.

 

그래서 다음 단계에서는 대체인증 이후 외부 API가 어떻게 호출되고, 그 응답을 우리 애플리케이션에서 어떻게 처리하는지 코드 흐름을 따라가기 시작했다.


대체인증 이후 어디까지 갔다 오는가

코드를 따라가 보니 전체 흐름은 대략 이랬다.

휴대폰 본인인증
      ↓
인증 성공 Callback
      ↓
우리 서버 Controller
      ↓
외부 기관 API 호출
      ↓
외부 응답 수신
      ↓
XML / JSON 응답 파싱
      ↓
사용자 메시지 반환

 

그리고 해당 외부 시스템은 Oracle 기반이었다.

 

이제 처음에 봤던 ORA-00257과 흐름이 맞아떨어졌다.

 

외부 시스템 Oracle 장애
        ↓
외부 애플리케이션에서 SQLException 발생
        ↓
오류 메시지가 응답에 포함됨
        ↓
우리 서버가 그대로 수신
        ↓
외부 응답 메시지를 그대로 사용
        ↓
사용자 화면에 출력

 

 

외부 시스템에서 발생한 Oracle 오류가 응답을 타고 우리 사용자 화면까지 그대로 넘어온 것이었다.

 

여기까지만 보면 단순히 “외부 시스템 장애였네” 하고 끝낼 수도 있다.

 

그런데 코드를 조금 더 살펴보니 오히려 우리 쪽에서 신경 써야 할 부분이 더 많이 보였다.

 

외부 시스템이 장애 나는 것 자체는 우리가 통제할 수 없다.

하지만 외부 시스템의 장애가 우리 서비스 안으로 어떤 형태로 들어오는지는 우리가 통제할 수 있다.


문제 1. HTTP 통신은 잡는데 응답 파싱 오류는 못 잡고 있었다

기존 코드는 HTTP 요청 자체에는 try/catch가 적용되어 있었다.

try {

    // HTTP 요청 및 응답 읽기

} catch (Exception e) {

    resultMsg = e.getMessage();
    result = -1;

} finally {

    if (connection != null) {
        connection.disconnect();
    }
}

 

여기까지만 보면 예외 처리를 하고 있는 것처럼 보인다.

문제는 그 아래였다.

 

if (result == 1) {

    JSONObject jsonObject =
        XML.toJSONObject(resultMsg);

    JSONObject resultObject =
        (JSONObject) jsonObject.get("Result");

    ...
}

 

응답을 파싱하는 부분은 try/catch 밖에 있었다.

즉 상대 서버가 언제나 우리가 기대하는 형식으로 응답한다는 전제가 깔려 있었다.

 

정상 상황에서는 문제가 없다.

하지만 장애 상황에서는 외부 서버가 전혀 다른 응답을 줄 수도 있다.

 

예를 들어 내부 Exception 문자열이 평문으로 내려올 수도 있다.

java.sql.SQLException: ORA-00257 ...

 

혹은 HTML 오류 페이지가 내려올 수도 있다.

이 응답을 정상 XML이라고 생각하고 파싱하면 다시 JSONException 같은 별도의 예외가 발생한다.

 

더 문제였던 점은 이 예외가 발생했을 때 아래쪽의 인증 실패 이력 저장 코드까지 실행되지 않을 수 있었다는 것이다.

정리하면 이랬다.

외부 장애
   ↓
비정상 응답
   ↓
파싱 실패
   ↓
Exception 발생
   ↓
HTTP 500
   ↓
실패 이력 저장 안 됨

 

장애가 발생했는데 정작 장애가 발생했다는 기록조차 남지 않는 경로가 존재했다.


기존 코드


문제 2. Timeout이 없었다

외부 API는 HttpURLConnection 기반으로 호출하고 있었다.

connection =
    (HttpURLConnection) url.openConnection();

connection.setRequestMethod(method);

connection.setRequestProperty(
    "Content-Type",
    "application/x-www-form-urlencoded"
);

그런데 connectTimeout과 readTimeout이 명시적으로 설정되어 있지 않았다.

setConnectTimeout(...)
setReadTimeout(...)

이 부분이 이번 장애에서 꽤 위험하다고 느꼈다.

이 외부 API 호출은 별도의 Worker가 아니라 사용자 요청을 처리하는 서버 Thread 안에서 동기적으로 실행되고 있었기 때문이다.

외부 서버가 장애 상태에서 응답을 오래 주지 않는다면 요청이 들어올 때마다 Thread가 대기하게 된다.

사용자 A → 외부 응답 대기 → Thread 점유
사용자 B → 외부 응답 대기 → Thread 점유
사용자 C → 외부 응답 대기 → Thread 점유
...

처음 들어온 문의는 단순히 “대체인증이 안 된다”였지만,

구조를 보면 상황에 따라서는 외부 연동 하나의 장애가 서비스 전체 요청 처리 능력에 영향을 줄 수도 있는 구조였다.

이번 오류는 단순한 오류 메시지 처리 문제가 아니였으며, 외부 API 서버의 장애가 내부 장애로 전파되는 것을 방지도록 장애를 격리시켜야하는 문제였다.


문제 3. 외부 시스템의 기술 오류를 사용자에게 그대로 보여주고 있었다

기존 코드에서는 외부 응답에 포함된 메시지를 그대로 사용하고 있었다.

msg = resultObject.get("Remark").toString();

그리고 클라이언트 응답에 그대로 넣었다.

resultBox.put("msg", msg);

Frontend에서도 그대로 출력했다.

alert(response.msg);

결국 외부 서버가

java.sql.SQLException: ORA-00257 ...

라고 보내면 사용자도 그대로 그 문구를 보게 된다.

사용자에게는 Oracle이 무엇인지, Archive Log가 왜 가득 찼는지 중요하지 않다.

필요한 것은 오히려 이런 안내다.

현재 외부 인증 시스템과의 통신이 원활하지 않습니다.
잠시 후 다시 시도해 주세요.

 

게다가 기술적인 오류를 그대로 보여주면 외부 시스템의 내부 구현 정보까지 노출될 수 있다.

다만 모든 실패 메시지를 같은 문구로 바꾸는 것도 문제다.

 

예를 들어

인증번호가 일치하지 않습니다.

 

같은 메시지는 장애가 아니라 정상적인 업무 실패다.

사용자는 왜 인증에 실패했는지 알아야 한다.

그래서 업무 실패와 시스템 오류를 구분해서 처리할 필요가 있었다.


문제 4. 장애가 발생해도 운영에서 확인하기 어려운 경로가 있었다

코드를 확인하면서 로그 설정도 같이 보게 됐다.

일부 외부 연동 결과는 info 수준으로 로그를 남기고 있었지만, 운영 환경에서는 그보다 높은 로그 레벨을 사용하고 있었다.

결국 장애 분석에 필요한 로그가 운영에서는 남지 않는 경우가 있었다.

여기에 앞에서 확인한 파싱 Exception까지 겹치면,

파싱 실패
    ↓
실패 이력 저장 안 됨

+

진단 로그 확인 어려움

상태가 된다.

실제로 이번 장애도 사용자의 문의를 통해 처음 인지했다.

 

이 부분을 보면서 특정 기능 한 곳만의 문제인지 다른 외부 연동도 같이 확인해보기로 했다.


다른 외부 API 코드에서도 비슷한 패턴이 보였다

프로젝트 내 다른 외부 연동 코드를 함께 살펴봤다.

Timeout이나 예외 처리가 잘 적용된 코드도 있었지만, 과거부터 남아 있던 일부 레거시 코드에서는 비슷한 패턴이 반복되고 있었다.

예를 들어

  • Timeout이 명시되지 않은 호출
  • HTTP Status를 확인하지 않는 호출
  • 응답 Parsing이 통신 예외 처리와 분리된 코드
  • 비슷한 HTTP 호출 로직이 여러 클래스에 각각 존재하는 구조

등이 있었다.

특정 코드 한 곳에서만 발생한 실수라기보다는,

외부 API를 어떻게 호출하고 실패를 어떻게 처리할지에 대한 공통 기준이 없었던 것에 가까웠다.


비슷한 openConnection()이 여러 곳에 흩어져 있었다

여러 클래스를 확인해 보니 비슷한 형태의 openConnection() 메서드가 반복적으로 존재했다.

구조도 상당히 비슷했다.

  • DataOutputStream을 이용한 요청 전송
  • try-catch-finally 구성
  • 성공/실패 상태 값을 별도로 반환
  • Exception 메시지를 문자열로 저장

Exception 처리 방식도 대략 이런 형태였다.

catch (Exception e) {

    resultMsg = e.getMessage();
    result = -1;
}

처음 보면 예외를 처리한 것처럼 보인다.

그런데 호출부를 따라가 보니 더 위험한 패턴이 있었다.


삼킨 Exception이 다음 줄에서 다른 Exception으로 다시 나타났다

일부 외부 연동에서는 이런 흐름이 있었다.

ResultBox response =
    openConnection(url, method, params);

int result =
    response.getInt("result");

String resultMsg =
    response.getString("resultMsg");

DataBox parsed =
    parseXml(resultMsg);

만약 openConnection() 내부에서 연결 실패가 발생하면 이런 값이 반환될 수 있다.

result = -1
resultMsg = "Connection refused"

그런데 호출부에서 result가 실패인지 확인하지 않고 바로 resultMsg를 XML로 파싱한다면,

Connection refused

라는 문자열을 XML로 해석하려다가 다시 Parsing Exception이 발생한다.

IOException 발생
     ↓
catch에서 문자열로 변환
     ↓
호출부에서 실패 여부 확인 안 함
     ↓
XML Parsing
     ↓
다른 Exception 발생

처음 IOException을 처리했다고 생각했지만 실제로는 한 단계 뒤에서 더 알아보기 어려운 형태의 Exception이 되어 다시 나타나는 셈이다.

이 부분을 보면서 Exception을 catch했다는 것과 예외 처리를 제대로 했다는 것은 다른 문제라는 걸 다시 느꼈다.


실제 장애가 발생한 연동을 수정했다

이번에는 실제 장애가 발생한 대체인증 연동부터 수정했다.

핵심은 네 가지였다.

1. Timeout 추가

connection.setConnectTimeout(5000);
connection.setReadTimeout(10000);

외부 서버가 응답하지 않을 때 사용자 요청 Thread가 계속 묶이지 않도록 했다.


2. HTTP 오류와 Parsing 예외 처리

HTTP Status를 먼저 확인하고, 4xx/5xx 응답은 errorStream을 읽도록 변경했다.

또 외부 서버가 항상 정상적인 XML/JSON을 준다고 가정하지 않고 Parsing 영역도 별도로 예외 처리했다.

try {
    // Response Parsing
} catch (Exception e) {
    code = "API_PARSE_ERROR";
    msg = USER_ERROR_MESSAGE;
}

이제 비정상 응답이 와도 Controller까지 예외가 전파되지 않는다.


3. 사용자 메시지와 내부 오류를 분리

인증번호가 일치하지 않습니다.

같은 업무 실패 메시지는 그대로 유지했다.

반면 ORA-, SQLException, JDBC 같은 기술 오류가 포함된 경우에는 사용자용 안내 메시지로 변경하고, 실제 원문은 로그와 DB에 남기도록 했다.

외부 오류
   ↓
사용자 → 안내 메시지
운영자 → 실제 오류 원문

4. 실패 유형을 구분해서 기록

API_TIMEOUT
API_PARSE_ERROR
API_EXTERNAL_ERROR
API_HTTP_ERROR

장애 유형을 나눠 기록해서 같은 문제가 다시 발생했을 때 사용자 문의에만 의존하지 않도록 했다.

 


Stub Server로 장애 상황을 재현했다

실제 외부 시스템에 장애를 만들 수는 없어서 JDK의 HttpServer로 Stub Server를 만들었다.

주요 테스트는 다음과 같았다.

ORA 오류 응답
XML이 아닌 평문
응답 지연
HTTP 500
정상 응답
업무 실패 응답

변경 전에는

ORA 오류  → 원문 노출
평문 응답 → Parsing Exception
응답 지연 → 계속 대기

였지만,

변경 후에는

ORA 오류  → API_EXTERNAL_ERROR
평문 응답 → API_PARSE_ERROR
응답 지연 → API_TIMEOUT
HTTP 500  → API_HTTP_ERROR

로 처리됐다.

정상 인증과 정상적인 업무 실패 메시지는 기존과 동일하게 유지되는 것도 확인했다.

마지막으로 빌드까지 확인했다.

BUILD SUCCESS

결국 이번에 고친 것은 Oracle 장애가 아니었다

외부 시스템의 Oracle 장애 자체는 우리가 해결할 수 없다.

대신 그 장애가 우리 서비스까지 그대로 넘어오는 경로는 바꿀 수 있었다.

변경 전에는

외부 장애
   ↓
오류 응답
   ↓
Parsing Exception / 장시간 대기
   ↓
사용자 오류

였다면, 수정 후에는 아래와 같이 바뀌었다.

외부 장애
   ↓
Timeout / HTTP / Parsing 처리
   ↓
실패 기록
   ↓
사용자용 메시지 반환

 

이번 장애를 통해 외부 시스템에서 발생하는 장애는 막을 수 없지만, 그 장애가 우리 서비스의 장애가 되지 않도록 경계를 만드는 것이 개발자의 책임이라는 것을 깨닫게 되었다.

Share Link
댓글