IP 기반 인증 장애 사례와 OAuth 2.0 Credential 기반 인증으로의 개선기


들어가며

“김과장, 지금 A 업체에서 우리 측 API 호출할 때 통신이 안 된다고 하는데 확인해 주세요.”

어느 날 받은 팀장님의 확인 요청이었다.

첫 번째 확인:

  • A 업체 사이트에 직접 접속
  • 우리 서비스와 연계된 기능 호출
  • 정상 동작 확인

혼란의 시작:

  • 우리 쪽에서는 정상
  • 상대 업체에서는 간헐적으로 실패
  • 재현도 쉽지 않음

이 글은 간헐적 통신 장애를 추적하며 발견한 IP 기반 인증의 구조적 한계와, 이를 OAuth 2.0 Client Credentials 기반 인증으로 개선한 과정에 대한 기록이다.


1. 장애 추적 - 간헐적 403 Forbidden

확인된 사실

통신 오류 패턴:

  • 항상 발생하지 않음
  • 특정 상황에서만 403 Forbidden 발생
  • 요청이 들어오는 IP가 매번 동일하지 않음

결정적 단서 발견

로그 분석 결과:

# Nginx access log
XXX.XXX.XXX.100 - - [08/Jan/2025] "GET /public-api/occupant?aptNo=101&dongNo=1001 HTTP/1.1" 200
YYY.YYY.YYY.200 - - [08/Jan/2025] "GET /public-api/occupant?aptNo=101&dongNo=1001 HTTP/1.1" 403
XXX.XXX.XXX.100 - - [08/Jan/2025] "GET /public-api/occupant?aptNo=102&dongNo=1002 HTTP/1.1" 200
YYY.YYY.YYY.200 - - [08/Jan/2025] "GET /public-api/occupant?aptNo=102&dongNo=1002 HTTP/1.1" 403

발견한 사실:

  • 정상 처리: XXX.XXX.XXX.100
  • 403 발생: YYY.YYY.YYY.200
  • 우리 서비스에 등록되지 않은 IP로 호출되고 있었다

원인 파악

A 업체의 인프라 구조:

  • 서버가 2대 운영 중
  • 그중 1대의 IP가 변경됨
  • IP가 변경된 서버: 403 발생
  • 기존 서버: 정상 처리

결론:

“간헐적” 장애처럼 보였던 이유는 로드밸런싱으로 인해 두 서버로 요청이 분산되었기 때문


2. 기존 구조 - Nginx IP 기반 인증

Nginx 설정 확인

public-api.conf:

location /public-api {

    set $allowed_ip 0;

    # A 업체 IP 허용
    if ($http_x_forwarded_for = "XXX.XXX.XXX.100") {
        set $allowed_ip 1;
    }

    # B 업체 IP 허용
    if ($http_x_forwarded_for = "XXX.XXX.XXX.101") {
        set $allowed_ip 1;
    }

    # 허용되지 않은 IP는 403 반환
    if ($allowed_ip = 0) {
        return 403 "403 Forbidden";
    }

    proxy_redirect     off;
    proxy_set_header   Host              $host;
    proxy_set_header   X-Real-IP         $remote_addr;
    proxy_set_header   X-Forwarded-For   $http_x_forwarded_for;

    proxy_pass http://tomcat;
}

구조의 특징

장점:

  • 설정이 간단함
  • 빠른 적용 가능
  • 웹 서버 레벨에서 차단

문제점:

  • 인증 로직이 Nginx 설정에 숨어 있음
  • 애플리케이션 코드에서는 보이지 않음
  • IP 변경 시 즉시 장애 발생

3. 단기 해결 - IP 추가 등록

즉시 조치

변경된 Nginx 설정:

location /public-api {

    set $allowed_ip 0;

    # A 업체 IP 허용 (기존)
    if ($http_x_forwarded_for = "XXX.XXX.XXX.100") {
        set $allowed_ip 1;
    }

    # A 업체 IP 허용 (신규 추가)
    if ($http_x_forwarded_for = "YYY.YYY.YYY.200") {
        set $allowed_ip 1;
    }

    # 이하 동일
    ...
}

결과:

  • 즉각적으로 장애 해소
  • A 업체의 두 서버 모두 정상 통신

하지만 찜찜함

이 방식으로 계속 운영해도 괜찮을까?
다음에 또 IP가 변경되면?

4. 근본적인 문제점 분석

문제 1. 인증 로직의 위치가 불명확

상황:

  • 인증이 Nginx 설정에 숨어 있음
  • 히스토리를 모르는 개발자는 소스 코드를 아무리 봐도 인증 로직을 찾을 수 없음

실제 경험:

@RestController
@RequestMapping("/public-api")
public class PublicApiController {
    
    @GetMapping("/occupant")
    public ApiResponse getOccupant(@RequestParam String aptNo,
                                    @RequestParam String dongNo) {
        // 인증 로직이 어디에도 없다!
        // 어떻게 인증되는 거지?
        return occupantService.getOccupant(aptNo, dongNo);
    }
}

문제:

  • “왜 403이 나는지” 파악하는 데 시간이 오래 걸림
  • 인프라 담당자에게 문의해야 원인 파악 가능

문제 2. IP 기반 인증의 구조적 한계

1. 인프라 변경에 매우 취약

시나리오:
- 서버 증설
- 서버 교체
- 클라우드 오토스케일링
- IP 변경

결과:
- 무조건 장애 발생

2. 간헐적 장애를 유발

멀티 서버 환경에서:
- 서버 A: 정상
- 서버 B: 403

결과:
- 로드밸런싱으로 인해 간헐적 실패
- 재현이 어려움
- 장애 추적 시간 증가

3. 인증 주체 식별 불가

현재:
- "어디서 호출했는지"만 확인 가능
- XXX.XXX.XXX.100 → 허용
- YYY.YYY.YYY.200 → 차단

필요:
- "누가 호출했는지" 식별
- A 업체 → 허용
- B 업체 → 허용
- 미등록 업체 → 차단

4. 보안 확장성 부족

문제:
- IP 유출 시 즉시 무력화
- 호출 주체별 권한 분리 어려움
- 접근 로그에서 업체 구분 불가
- 통계 및 모니터링 한계

종합 정리

문제 영향 심각도
인증 로직 불명확 유지보수 어려움
인프라 변경 취약 잦은 장애 발생
간헐적 장애 유발 재현 및 추적 어려움
인증 주체 미식별 운영 및 모니터링 한계
보안 확장성 부족 장기 운영 리스크

5. 개선 방향 - 인증 체계 전환

개선 전략 수립

핵심 원칙:

1. 인증 로직을 애플리케이션 레벨로 이동
2. IP가 아닌 Credential 기반 인증
3. 인증 주체 식별 가능
4. 확장 가능한 구조

인증 방식 검토

초기 고민: API Key 방식

IP 기반 인증을 개선하기로 하면서 가장 먼저 떠올린 방식은 API Key였다.

GET /public-api/occupant?aptNo=101
X-API-Key: abc123def456

장점:

  • 구현이 매우 간단
  • 빠른 적용 가능
  • IP 변경과 무관

하지만 “API 인증 표준 방식”을 검색해보니…

발견한 사실: OAuth 2.0이 표준

검색 결과:

  • Google Cloud API: OAuth 2.0
  • AWS API: OAuth 2.0 (SigV4)
  • GitHub API: OAuth 2.0
  • 대부분의 공개 API: OAuth 2.0

OAuth 2.0 Client Credentials Grant:

# 1단계: Token 발급
POST /public-api/auth/token
{
  "clientId": "...",
  "clientSecret": "..."
}

# 2단계: Token으로 API 호출
GET /public-api/occupant?aptNo=101
Authorization: Bearer {JWT}

왜 OAuth + JWT를 선택했는가?

1. 업계 표준이었다

상황:
"어떻게 인증 구현하지?" 
→ 구글 검색: "REST API 인증 방식"
→ 결과: OAuth 2.0이 표준

생각:
"표준을 따르는 게 맞지 않을까?"
"나중에 문제 생겨도 참고할 자료 많을 것 같은데?"

2. 우리 상황과 정확히 일치했다

OAuth 2.0 Client Credentials Grant 사용 시나리오:

  • Server-to-Server 통신
  • 사용자가 아닌 애플리케이션 인증
  • 외부 업체 API 연동

우리 상황:

  • A 업체 서버 → 우리 서버 (Server-to-Server)
  • 사용자 로그인 없음 (애플리케이션 인증)
  • 외부 업체 연동
"어? 우리 상황이랑 정확히 똑같네?"

3. 팀 내 커뮤니케이션이 쉬웠다

만약 "API Key 방식"이라고 하면:
PM: "어떻게 발급하나요?"
개발자A: "만료는 어떻게 관리하나요?"
개발자B: "갱신은 어떻게 하나요?"
→ 매번 우리만의 방식 설명 필요

"OAuth 2.0 Client Credentials 방식"이라고 하면:
PM: "아, OAuth네요"
개발자A: "그럼 Client ID/Secret 발급하고"
개발자B: "Token 발급 API 만들고, JWT로 검증하는 거네요"
→ 부연 설명 없이 즉시 이해

이게 표준의 힘이다.

4. 참고 자료가 풍부했다

API Key 방식:
- 구글링해도 각 회사마다 구현 방식 다름
- "우리만의 방식" 설계 필요
- 레퍼런스 부족

OAuth 2.0:
- RFC 6749 표준 문서
- Spring Security OAuth 라이브러리
- 수많은 블로그, 예제 코드
- 외부 업체 개발자도 익숙함

"삽질하지 말고 검증된 방식 쓰자"

5. 외부 업체 입장에서도 편했다

연동 가이드 작성 시:

"우리 회사 방식":
- 처음부터 끝까지 설명 필요
- 샘플 코드 직접 작성
- Q&A 많이 발생

"OAuth 2.0 표준":
- "OAuth 2.0 Client Credentials 방식입니다"
- 기존 OAuth 라이브러리 사용 가능
- 외부 개발자들도 이미 아는 방식

6. OAuth 2.0 Client Credentials Grant란?

간단하게 알아보자

앞서 우리는 OAuth 2.0 Client Credentials Grant 방식을 선택하기로 했다.

그런데 이게 정확히 뭘까?

가장 간단한 설명:

"서버가 다른 서버의 API를 호출할 때 사용하는 인증 방식"

OAuth 2.0 개요

OAuth 2.0이란?

인증(Authentication)과 인가(Authorization)를 위한 업계 표준 프로토콜

4가지 Grant Type:

Grant Type 사용 시나리오 예시
Authorization Code 사용자 인증 (가장 일반적) 소셜 로그인, “구글 계정으로 로그인”
Implicit 단순화된 사용자 인증 SPA (deprecated)
Password Credentials 사용자명/비밀번호 직접 사용 신뢰할 수 있는 앱
Client Credentials 서버 간 통신 우리 케이스

Client Credentials Grant의 특징

사용 시나리오:

  • 사용자가 아닌 애플리케이션 자체가 인증 주체
  • Server-to-Server 통신
  • 백그라운드 작업, 배치 처리
  • 외부 업체 API 연동

우리 상황:

A 업체 서버 → 우리 서버
- 사용자 로그인 없음
- 24시간 자동으로 데이터 조회
- Server-to-Server 통신

→ Client Credentials Grant 사용

표준 OAuth 2.0 흐름:

[Client Application]
    |
    | POST /oauth/token
    | Content-Type: application/x-www-form-urlencoded
    |
    | grant_type=client_credentials
    | client_id={CLIENT_ID}
    | client_secret={CLIENT_SECRET}
    v
[Authorization Server]
    |
    | 1. Client Credentials 검증
    | 2. Access Token 발급
    |
    | Response:
    | {
    |   "access_token": "...",
    |   "token_type": "Bearer",
    |   "expires_in": 3600
    | }
    v
[Client Application]
    |
    | GET /api/resource
    | Authorization: Bearer {access_token}
    v
[Resource Server]

핵심 구성 요소 이해

OAuth 2.0 Client Credentials Grant는 3가지 핵심 요소로 구성된다.

1. Client ID (클라이언트 식별자)

역할: 
  클라이언트 애플리케이션의 공개 식별자
  
특징:
  - Public Identifier (공개되어도 무방)
  - 애플리케이션을 식별하는 용도
  - URL에 포함되거나 로그에 노출 가능
  - Username과 유사한 개념

예시:
  company_a_20250108_a1b2c3d4
  
비유:
  은행 계좌번호 (공개 가능)

Client ID 생성 로직:

private String generateClientId(String appName) {
    // 1. 앱 이름을 prefix로 사용 (알파벳/숫자만)
    String prefix = appName.toLowerCase()
        .replaceAll("[^a-z0-9]", "");
    
    // 2. 생성 날짜 (yyyyMMdd)
    String timestamp = LocalDateTime.now()
        .format(DateTimeFormatter.ofPattern("yyyyMMdd"));
    
    // 3. 랜덤 8자리
    String random = UUID.randomUUID()
        .toString()
        .substring(0, 8);
    
    // 결과: companya_20250108_a1b2c3d4
    return String.format("%s_%s_%s", prefix, timestamp, random);
}

2. Client Secret (클라이언트 비밀키)

역할:
  클라이언트 애플리케이션의 비밀 키
  
특징:
  - Private Key (절대 공개되면 안 됨)
  - Password처럼 안전하게 보관
  - 주기적 갱신 필요 (예: 1년마다)
  - 반드시 해싱하여 DB 저장
  
예시:
  a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6
  (64자리 랜덤 문자열)
  
비유:
  은행 계좌 비밀번호 (절대 공개 금지)

Client Secret 생성 및 저장:

/**
 * Client Secret 생성 및 저장
 * 
 * @param app ExternalApp 엔티티
 * @return 평문 Client Secret (1회만 반환)
 */
private String generateAndSaveClientSecret(ExternalApp app) {
    // 1. 생성: 64자리 랜덤 문자열
    String plainSecret = generateRandomSecret();
    
    // 2. 해싱 후 저장
    String hashedSecret = passwordEncoder.encode(plainSecret);
    app.setClientSecret(hashedSecret);
    
    // 3. 평문 반환 (DB에는 해싱된 값만 저장됨)
    return plainSecret;
}

/**
 * 랜덤 Secret 생성
 * 
 * @return 64자리 랜덤 문자열
 */
private String generateRandomSecret() {
    return UUID.randomUUID().toString().replace("-", "") +
           UUID.randomUUID().toString().replace("-", "");
}

중요: Client Secret는 재조회 불가능

Q: 웹 포털에서 Client Secret를 다시 확인할 수 있나요?

A: 아니요, 절대 불가능합니다.

이유:
1. DB에 해싱되어 저장 (복호화 불가능)
2. 평문은 생성/갱신 시 화면에 1회만 표시
3. [주의] 화면을 닫거나 새로고침 하면 영구적으로 확인 불가
4. 확인하지 못한 경우 → Client Secret 갱신 필요

웹 포털 동작 방식:
- 생성/갱신 시: 모달 창에 평문 표시 + 복사 버튼
- 모달 닫기 전 경고: "이 창을 닫으면 다시 확인할 수 없습니다"
- 모달 닫은 후: 마스킹 처리 (********************************)

웹 포털 화면에서 표시 방식:

[생성/갱신 직후 - 모달 창]
┌─────────────────────────────────────────────┐
│  Client Secret 발급 완료                      │
├─────────────────────────────────────────────┤
│                                             │
│  Client Secret (1회만 표시됩니다):              │
│  ┌─────────────────────────────────────────┐│
│  │ a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6...     ││
│  └─────────────────────────────────────────┘│
│                          [복사] [다운로드]      │
│                                             │
│  [주의사항]                                   │
│  • 이 화면을 닫으면 다시 확인할 수 없습니다.          │
│  • 반드시 복사하거나 안전한 곳에 저장하세요           │
│  • 분실 시 갱신을 통해 새로 발급받아야 합니다         │
│                                             │
│              [확인했습니다]                     │
└─────────────────────────────────────────────┘

[모달 닫은 후 - 일반 화면]
Client ID: company_a_20250108_a1b2c3d4 [복사]
Client Secret: ******************************** [갱신]
             (마지막 생성: 2025-01-08)
             
※ Client Secret는 보안상 재조회할 수 없습니다.
   분실한 경우 갱신 버튼을 클릭하여 새로 발급받으세요.

3. Access Token (JWT)

역할:
  API 호출 시 사용하는 인증 토큰
  
특징:
  - 짧은 수명 (1시간 ~ 24시간)
  - 자체 검증 가능 (서명 포함)
  - Bearer 토큰으로 사용
  - 만료 시 재발급 필요
  
구조 (JWT):
  Header.Payload.Signature
  
예시:
  eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
  eyJzdWIiOiJjb21wYW55LWEiLCJpYXQiOjE2NDA5OTUyMDAsImV4cCI6MTY0MTA4MTYwMH0.
  4Hb-5VxP8Qs_Yw1R2Zp3Xm6Nk7Lj8Ii9Hh0Gg1Ff2Ee3Dd

JWT 구조 분석:

// Header (알고리즘  타입)
{
  "alg": "HS256",
  "typ": "JWT"
}

// Payload (실제 데이터)
{
  "sub": "company-a",           // Subject: 외부  이름
  "iat": 1640995200,            // Issued At: 발급 시간
  "exp": 1641081600             // Expiration: 만료 시간
}

// Signature (서명)
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret
)

JWT 서명으로 어떻게 인증하는가?

핵심 원리: 서명 검증

JWT의 가장 중요한 특징은 자체 검증 가능(Self-contained)하다는 것입니다.

서명 생성 과정 (Token 발급 시):

// 1. Header + Payload 준비
String header = base64UrlEncode('{"alg":"HS256","typ":"JWT"}');
String payload = base64UrlEncode('{"sub":"company-a","iat":1640995200,"exp":1641081600}');

// 2. 서명 생성
String data = header + "." + payload;
String signature = HMACSHA256(data, secretKey);

// 3. JWT 완성
String jwt = header + "." + payload + "." + signature;
// 결과: eyJhbGc...xyz.eyJzdWI...abc.4Hb5VxP...def

서명 검증 과정 (API 호출 시):

// 1. JWT를 "."으로 분리
String[] parts = jwt.split("\\.");
String header = parts[0];      // eyJhbGc...xyz
String payload = parts[1];     // eyJzdWI...abc
String signature = parts[2];   // 4Hb5VxP...def

// 2. 동일한 방식으로 서명 재생성
String data = header + "." + payload;
String expectedSignature = HMACSHA256(data, secretKey);

// 3. 비교
if (signature.equals(expectedSignature)) {
    // 검증 성공: 이 JWT는 우리가 발급한 것이 맞다
    // 내용이 변조되지 않았다
} else {
    // 검증 실패: 위조되었거나 변조되었다
}

왜 안전한가?

1. secretKey는 서버만 알고 있음
2. 공격자가 Payload를 변조하면:
   - 새로운 서명 = HMACSHA256(변조된데이터, ?)
   - secretKey를 모르므로 올바른 서명 생성 불가
3. 서버에서 검증 시:
   - 재계산한 서명 != JWT의 서명
   - 검증 실패

실제 예시:

// 정상 JWT
header:  {"alg":"HS256","typ":"JWT"}
payload: {"sub":"company-a","exp":1641081600}
signature: 4Hb5VxP8Qs_Yw1R2Zp3Xm6Nk7Lj8Ii9

// 공격자가 만료시간을 변조 시도
payload: {"sub":"company-a","exp":9999999999}  // 만료시간 변조
signature: 4Hb5VxP8Qs_Yw1R2Zp3Xm6Nk7Lj8Ii9  // 기존 서명 그대로 사용

// 서버 검증
String expectedSignature = HMACSHA256(header + "." + 변조된payload, secretKey);
// expectedSignature: XYZ123... (다른 값)
// 실제 signature: 4Hb5VxP8... 

// 불일치! 검증 실패!

DB 조회 없이 인증 가능:

// 기존 방식 (Session 등)
1. 클라이언트가 Token 전송
2. 서버가 DB에서 Token 조회
3. 유효한지 확인
4. 사용자 정보 조회

// JWT 방식
1. 클라이언트가 JWT 전송
2. 서버가 서명 검증 (DB 조회 없음)
3. Payload에서 바로 정보 추출 (sub, exp )
4. !

 DB 부하 감소, 빠른 검증

우리 구현에서의 JWT 검증:

public void validateToken(String token) {
    try {
        Jwts.parser()
            .setSigningKey(secretKey)  // 서명 검증에 사용
            .parseClaimsJws(token);    // 자동으로 서명 검증
        // 검증 성공
    } catch (SignatureException e) {
        // 서명 불일치 → 위조된 토큰
        throw new JwtException("Invalid signature", e);
    } catch (ExpiredJwtException e) {
        // 만료된 토큰
        throw new JwtException("Token expired", e);
    }
}

정리:

Q: 서명 정보로 어떻게 인증하는가?

A: 
1. Token 발급 시: Header + Payload를 secretKey로 서명
2. API 호출 시: 동일한 secretKey로 서명 재계산
3. 비교: 일치하면 우리가 발급한 토큰
4. 결과: DB 조회 없이 빠른 인증 가능

핵심: secretKey를 아는 사람만 올바른 서명 생성 가능
     → secretKey는 절대 노출되면 안 됨!

우리 구현 vs 표준 OAuth 2.0

표준 OAuth 2.0:

POST /oauth/token HTTP/1.1
Host: authorization-server.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials
&client_id=your_client_id
&client_secret=your_client_secret
&scope=read write

우리 구현 (단순화):

POST /public-api/auth/token HTTP/1.1
Host: api.example.com
Content-Type: application/json

{
  "clientId": "your_client_id",
  "clientSecret": "your_client_secret"
}

차이점:

  • OAuth 2.0 표준: grant_type, scope 등 더 많은 파라미터
  • 우리 구현: 핵심 개념만 차용하여 단순화
  • 목적: 복잡한 OAuth 서버 구축 없이 인증 개선

장점:

  • 업계 표준 방식
  • 다양한 라이브러리 지원
  • 보안 베스트 프랙티스 적용
  • 외부 업체가 이해하기 쉬움

7. 구현 - OAuth 2.0 Client Credentials 인증 시스템

구현 개요:

  • 인증 방식: OAuth 2.0 Client Credentials Grant
  • Token 포맷: JWT (JSON Web Token)
  • JWT는 Access Token을 구현하는 방법 중 하나

7-1. 외부 연동 Key 관리

설계 원칙:

인증키 갱신을 API로 제공하지 않는 이유:

  • API로 갱신 제공 시 인증키 유출자가 갱신 가능
  • 보안 취약점 발생

해결 방안:

  • 외부 업체가 우리 서비스에 회원가입
  • 웹 포털을 통해 직접 인증키 관리
  • 갱신 주체: 외부 업체 (셀프 서비스)
  • 우리 팀 개입 최소화

User 엔티티 (핵심 필드만):

@Entity
@Table(name = "users")
@Getter
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, unique = true)
    private String username;
    
    @Column(nullable = false)
    private String password;
    
    @Column(nullable = false)
    private String email;
    
    // ...existing fields...
}

ExternalApp 엔티티:

@Entity
@Table(name = "external_app")
@Getter
public class ExternalApp {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    // OAuth Credentials
    @Column(nullable = false, unique = true)
    private String appName;
    
    @Column(nullable = false, unique = true)
    private String clientId;
    
    @Column(nullable = false)
    private String clientSecret;  // 해싱되어 저장
    
    // 상태 관리
    @Column(nullable = false)
    private boolean enabled;
    
    @Column
    private LocalDateTime secretExpiresAt;
    
    // User 연관 (웹 포털 관리용)
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;
    
    // 핵심 비즈니스 로직
    public boolean isSecretExpired() {
        return secretExpiresAt != null && 
               LocalDateTime.now().isAfter(secretExpiresAt);
    }
    
    public void updateClientSecret(String newSecret, LocalDateTime expiresAt) {
        this.clientSecret = newSecret;
        this.secretExpiresAt = expiresAt;
    }
}

7-2. Token 발급 API

TokenController:

@RestController
@RequestMapping("/public-api/auth")
@RequiredArgsConstructor
public class TokenController {
    
    private final TokenService tokenService;
    
    @PostMapping("/token")
    public ResponseEntity<TokenResponse> issueToken(
            @RequestBody TokenRequest request) {
        
        String token = tokenService.issueToken(
            request.getClientId(),
            request.getClientSecret()
        );
        
        return ResponseEntity.ok(
            TokenResponse.builder()
                .accessToken(token)
                .tokenType("Bearer")
                .expiresIn(86400) // 24시간
                .build()
        );
    }
}

TokenService (핵심 로직만):

@Service
@RequiredArgsConstructor
public class TokenService {
    
    private final ExternalAppRepository externalAppRepository;
    private final PublicApiJwtManager jwtManager;
    private final PasswordEncoder passwordEncoder;
    
    @Transactional
    public String issueToken(String clientId, String clientSecret) {
        // 1. Client 조회
        ExternalApp app = externalAppRepository.findByClientId(clientId)
            .orElseThrow(() -> new UnauthorizedException("Invalid client"));
        
        // 2. 검증
        validateClient(app, clientSecret);
        
        // 3. JWT 발급
        return jwtManager.createToken(app.getAppName());
    }
    
    private void validateClient(ExternalApp app, String clientSecret) {
        if (!app.isEnabled()) {
            throw new UnauthorizedException("Client is disabled");
        }
        if (app.isSecretExpired()) {
            throw new UnauthorizedException("Client secret expired");
        }
        // 평문과 해싱값 비교
        if (!passwordEncoder.matches(clientSecret, app.getClientSecret())) {
            throw new UnauthorizedException("Invalid client secret");
        }
    }
}

7-3. JWT Manager (핵심 메서드만)

PublicApiJwtManager:

@Component
public class PublicApiJwtManager {

    @Value("${jwt.secret}")
    private String secretKey;
    
    @Value("${jwt.expiration}")
    private long tokenValidMillisecond; // 24시간

    @PostConstruct
    void init() {
        this.secretKey = Base64.getEncoder()
                .encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
    }

    // JWT 토큰 생성
    public String createToken(String subject) {
        Claims claims = Jwts.claims().setSubject(subject);
        Date issuedDate = new Date();
        Date expiredDate = new Date(issuedDate.getTime() + tokenValidMillisecond);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(issuedDate)
                .setExpiration(expiredDate)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();
    }

    // HTTP 헤더에서 JWT 토큰 추출
    public String getToken(HttpServletRequest request) {
        String authHeader = request.getHeader("Authorization");
        if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
            return authHeader.substring(7);
        }
        return null;
    }

    // JWT 토큰 유효성 검증
    public void validateToken(String token) {
        try {
            Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token);
        } catch (JwtException e) {
            throw new JwtException("Invalid token", e);
        }
    }

    // JWT 토큰에서 subject 추출
    public String getSubject(String token) {
        return Jwts.parser()
                .setSigningKey(secretKey)
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }
}

7-4. Interceptor 구현 (핵심 로직만)

PublicApiJwtInterceptor:

@Component
@RequiredArgsConstructor
@Slf4j
public class PublicApiJwtInterceptor extends HandlerInterceptorAdapter {

    private final PublicApiJwtManager jwtManager;
    private final ExternalAppRepository externalAppRepository;

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws Exception {

        // 1. 토큰 추출
        String token = jwtManager.getToken(request);
        if (token == null) {
            sendUnauthorized(response, "Missing token");
            return false;
        }

        // 2. 토큰 검증
        try {
            jwtManager.validateToken(token);
        } catch (JwtException e) {
            sendUnauthorized(response, "Invalid token");
            return false;
        }

        // 3. 앱 정보 추출 및 활성화 상태 확인
        String appName = jwtManager.getSubject(token);
        ExternalApp app = externalAppRepository.findByAppName(appName)
            .orElse(null);
        
        if (app == null || !app.isEnabled()) {
            sendUnauthorized(response, "Client is disabled");
            return false;
        }

        // 4. Context에 인증 정보 저장
        request.setAttribute("appName", appName);
        
        return true;
    }

    private void sendUnauthorized(HttpServletResponse response, String message) 
            throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.getWriter().write(
            String.format("{\"error\":\"%s\"}", message)
        );
    }
}

7-5. Interceptor 등록

WebMvcConfiguration:

@Configuration
@RequiredArgsConstructor
public class WebMvcConfiguration implements WebMvcConfigurer {
    
    private final PublicApiJwtInterceptor publicApiJwtInterceptor;
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(publicApiJwtInterceptor)
                .addPathPatterns("/public-api/**")
                .excludePathPatterns("/public-api/auth/**"); // 토큰 발급 API 제외
    }
}

8. Nginx 설정 변경

변경 전 (IP 기반)

location /public-api {
    set $allowed_ip 0;

    if ($http_x_forwarded_for = "XXX.XXX.XXX.100") {
        set $allowed_ip 1;
    }

    if ($allowed_ip = 0) {
        return 403 "403 Forbidden";
    }

    proxy_pass http://tomcat;
}

변경 후 (JWT 기반)

location /public-api {
    # IP 체크 제거
    # 인증은 애플리케이션에서 처리
    
    proxy_redirect     off;
    proxy_set_header   Host              $host;
    proxy_set_header   X-Real-IP         $remote_addr;
    proxy_set_header   X-Forwarded-For   $http_x_forwarded_for;
    proxy_set_header   Authorization     $http_authorization;

    proxy_pass http://tomcat;
}

변경 사항:

  • IP 체크 로직 제거
  • Authorization 헤더 전달 추가
  • 인증은 애플리케이션에서 처리

9. 외부 업체 연동 가이드

9-1. 초기 설정 프로세스

Step 1: 웹 포털 접속

1. 담당자 이메일로 발송된 초기 계정 정보 확인
   - Username: company-a-admin
   - Temp Password: Ab12Cd34
   
2. 웹 포털 접속: https://api-portal.example.com

3. 초기 비밀번호 변경 (필수)
   - 8자 이상
   - 영문, 숫자, 특수문자 조합

Step 2: Client Credentials 확인

1. 로그인 후 대시보드 접속

2. 발급된 Client Credentials 확인
   - Client ID: company_a_20250108_a1b2c3d4 (언제든지 조회 가능)
   - Client Secret: 생성 시 화면에 1회만 표시
   
3. Client Secret 보안 정책
   - 생성/갱신 시 모달 창에 평문 표시
   - 모달 창을 닫으면 영구적으로 재조회 불가능
   - DB에 해싱되어 저장되므로 복호화 불가
   - 분실 시 갱신을 통해 새로 발급
   
4. Client Secret 즉시 저장 (필수)
   - 모달 창의 [복사] 또는 [다운로드] 버튼 사용
   - 소스 코드에 하드코딩 절대 금지

Step 3: 만료 관리

1. 만료일 확인: 대시보드에서 확인 가능

2. 만료 30일 전 이메일 알림 발송

3. 만료 시 웹 포털에서 직접 갱신
   - [Client Secret 갱신] 버튼 클릭
   - 확인 모달 표시
   - 새로운 Secret가 모달 창에 표시 (1회만)
   - 반드시 복사 후 모달 닫기
   - 이전 Secret 즉시 무효화
   
4. 애플리케이션에 새로운 Secret 적용
   - 환경변수 업데이트
   - 애플리케이션 재기동 (또는 설정 리로드)

9-2. API 인증 가이드

Token 발급:

## 1. Token 발급

### Request
POST /public-api/auth/token
Content-Type: application/json

{
  "clientId": "your_client_id",
  "clientSecret": "your_client_secret"
}

### Response
{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "tokenType": "Bearer",
  "expiresIn": 86400
}

## 2. API 호출

### Request
GET /public-api/company-a/occupant?aptNo=101&dongNo=1001
Authorization: Bearer {accessToken}

## 3. 주의사항

- Token은 24시간 유효
- 만료 전에 재발급 권장
- clientSecret은 안전하게 보관
- Authorization 헤더 필수

9-3. 샘플 코드

Java 예시:

// 1. Token 발급
RestTemplate restTemplate = new RestTemplate();

TokenRequest request = TokenRequest.builder()
    .clientId("your_client_id")
    .clientSecret("your_client_secret")
    .build();

TokenResponse tokenResponse = restTemplate.postForObject(
    "https://api.example.com/public-api/auth/token",
    request,
    TokenResponse.class
);

String accessToken = tokenResponse.getAccessToken();

// 2. API 호출
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);

String url = "https://api.example.com/public-api/company-a/occupant?aptNo=101&dongNo=1001";
HttpEntity<Void> entity = new HttpEntity<>(headers);

OccupantResponse response = restTemplate.exchange(
    url,
    HttpMethod.GET,
    entity,
    OccupantResponse.class
).getBody();

10. 변경 전후 비교

인증 방식 비교

항목 IP 기반 (변경 전) Credential 기반 (변경 후)
인증 위치 Nginx 애플리케이션
인증 기준 IP 주소 Client Credentials
Token 포맷 - JWT
장애 원인 파악 어려움 로그 기반 추적 가능
IP 변경 대응 즉시 장애 영향 없음
호출 주체 식별 불가능 가능
권한 관리 어려움 업체별 관리 가능
확장성 낮음 높음
보안 수준 낮음 높음

11. 추가 보안 고려사항

11-1. Client Secret 주기적 갱신 (웹 포털)

갱신 방식 설계 원칙:

API로 갱신 제공 시 문제점:

시나리오: Client Secret 유출
→ 유출자가 API로 Secret 갱신 시도
→ 새로운 Secret도 탈취 가능
→ 보안 취약점

해결 방안: 웹 포털 기반 셀프 서비스

1. 외부 업체 담당자가 우리 서비스에 회원가입
2. 로그인 후 자신의 ExternalApp 관리
3. 웹 포털에서 Secret 갱신

12. 장단점 정리

장점

운영 관점:

  • IP 변경과 무관한 안정성
  • 인프라 확장 시 영향 없음
  • 장애 추적 및 디버깅 용이

보안 관점:

  • 업체별 Credential 관리
  • 주기적 Secret 갱신 가능
  • 접근 제어 강화

개발 관점:

  • 인증 로직이 코드로 명확히 표현
  • 테스트 작성 용이
  • 유지보수 편의성 증가

단점

초기 비용:

  • 설계 및 구현 시간 필요
  • 테스트 및 검증 시간 필요
  • 외부 업체 연동 변경 필요

운영 복잡도:

  • Token 관리 로직 필요
  • Client Secret 관리 필요
  • 만료 정책 운영 필요

학습 곡선:

  • JWT 이해 필요
  • OAuth 개념 이해 필요

하지만

IP 기반 인증의 장기 운영 리스크를 고려하면 초기 투자 대비 효과가 매우 큼


13. 마무리

핵심 교훈

1. 간헐적 장애의 원인은 예상 밖에 있을 수 있다

증상: 간헐적 403 Forbidden
원인: 로드밸런싱 + IP 변경

2. 인증은 인프라 설정이 아니라 도메인 로직이다

인증 로직이 Nginx에 숨어 있으면:
- 파악하기 어렵다
- 유지보수가 어렵다
- 확장이 어렵다

3. 기술 부채는 쌓이기 전에 해결해야 한다

"IP 하나만 추가하면 되는데..."
→ 다음에 또 발생
→ 점점 복잡해짐
→ 결국 큰 리팩토링 필요

개선의 효과

Before:

  • IP 변경 시 즉시 장애
  • 원인 파악 어려움
  • 업체 식별 불가
  • 확장 어려움

After:

  • IP 변경과 무관
  • 로그 기반 추적 가능
  • 업체별 관리 가능
  • 확장 용이

최종 메시지

“인증이 어디서 어떻게 이루어지고 있는지 아무도 명확히 설명할 수 없는 구조”

이것이 이번 장애의 근본 원인이었다.

IP 기반 인증은 빠른 해결책일 수는 있지만, 운영이 길어질수록 기술 부채가 된다.

인증은 인프라 설정이 아니라 도메인 로직으로 관리되어야 한다.


댓글남기기