Nuxt3 특정 IP 대역만 접근 허용하기 - Plugin, Middleware, Spring Boot 연동
💡 [참고] Nuxt3 관련 시리즈 포스트입니다. 순서대로 읽어보시길 권장합니다.
1. 개요
서비스를 운영하다 보면 특정 환경에서만 접근을 허용해야 하는 상황이 생긴다.
사내 네트워크에서만 접근 가능한 관리자 페이지, 배포 전 QA 환경, 또는 서비스 점검 중 내부 인원만 접근해야 하는 경우가 대표적이다. 이번 글에서는 Nuxt3 환경에서 특정 IP 대역만 접근을 허용하고, 그 외의 접속에는 점검 페이지를 보여주는 방법을 정리한다.
📌 전체 구조
이 기능을 구현하기 위해 크게 세 가지 레이어를 활용한다.
| 레이어 | 역할 |
|---|---|
| Nginx | 1차 방어선 - 리버스 프록시, 실제 IP 헤더 주입 |
| Nuxt3 SSR | 2차 방어선 - Plugin + Global Middleware |
| Spring Boot API | IP 허용 정책 데이터 제공 (Redis 기반) |
Nginx에서 1차로 처리하고, Nuxt 레벨에서 2차로 처리하는 이중 구조이며, 이 글에서는 Nuxt3 + Spring Boot 레벨의 구현에 집중한다.
2. Plugin vs Middleware
Nuxt3에서 접근 제어를 구현하려면 먼저 Plugin과 Middleware의 차이를 정확히 이해해야 한다.
📌 실행 라이프사이클
Nuxt 공식 문서(Nuxt Lifecycle)에 따르면 SSR 요청 시 실행 순서는 다음과 같다.
이 순서가 핵심이다. Plugin이 Middleware보다 먼저 실행되기 때문에, Plugin에서 데이터를 준비하고 Middleware에서 그 데이터를 활용하는 패턴이 가능하다.
📌 Plugin
Nuxt 공식 문서(plugins)에 따르면, Plugin은 Vue 앱 인스턴스가 생성되는 시점에 단 1회 실행된다.
주요 특성은 아래와 같다.
plugins/디렉토리에 파일을 두면 자동으로 등록된다nuxtApp인스턴스 하나만을 인자로 받는다.server.ts/.client.ts접미사로 실행 환경을 제한할 수 있다- 파일명 앞에 숫자를 붙여(
01.plugin.ts) 실행 순서를 보장할 수 있다
💡 Plugin은 앱이 시작될 때 한 번만 실행되므로, 매 라우트 이동마다 반복적으로 실행되어서는 안 되는 초기화 작업에 적합하다. IP 허용 정책을 API에서 불러오는 작업이 바로 이 케이스다.
📌 Middleware
Nuxt 공식 문서(middleware)에 따르면, Middleware는 특정 라우트로 이동하기 전마다 실행되는 Navigation Guard다.
3가지 종류가 있다.
- Anonymous (inline): 페이지 컴포넌트 내
definePageMeta에 직접 정의 - Named:
middleware/디렉토리에 파일로 생성, 페이지에서 이름으로 참조 - Global:
middleware/*.global.ts파일명으로 생성, 모든 라우트에 자동 적용
IP 접근 제어는 모든 페이지에 적용되어야 하므로 Global Middleware가 적합하다. to, from 두 인자로 현재/목적지 라우트 정보를 받으며, navigateTo()와 abortNavigation()으로 라우트 이동을 제어한다.
📌 역할 분리
| 구분 | Plugin | Global Middleware |
|---|---|---|
| 실행 시점 | 앱 초기화 시 1회 | 라우트 이동마다 |
| 이번 구현에서의 역할 | IP 정책 API 호출 및 전역 상태 저장 | IP 허용 여부 체크 및 리다이렉트 |
| 왜 이 역할인가 | API는 1번만 호출하면 충분 | 모든 라우트를 가드해야 함 |
3. 클라이언트 IP 추출
📌 왜 서버에서만 IP를 알 수 있나
브라우저(클라이언트) 환경에서는 자신의 공인 IP를 직접 알 수 없다. window 객체나 JavaScript API 어디에도 현재 클라이언트 IP를 반환하는 수단이 없기 때문에, 알려면 외부 API를 별도로 호출해야 한다.
반면 서버는 클라이언트가 HTTP 요청을 보낼 때 TCP 소켓 연결 자체에서 상대방 IP를 알 수 있다. Nuxt SSR 환경에서는 useRequestEvent()로 현재 HTTP 요청 객체를 가져와 이 정보를 추출한다.
📌 리버스 프록시 환경의 문제
실제 운영 환경에서는 클라이언트와 Nuxt 서버 사이에 Nginx 같은 리버스 프록시가 존재한다.
이 구조에서 Nuxt가 socket.remoteAddress만 보면 실제 클라이언트 IP가 아닌 Nginx의 IP를 클라이언트 IP로 인식하게 된다. 이 문제를 해결하기 위해 Nginx는 원래 클라이언트 IP를 HTTP 헤더에 담아 Nuxt로 전달한다.
1
2
3
# nginx.conf
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
📌 X-Real-IP vs X-Forwarded-For
X-Real-IP는 Nginx가 $remote_addr(실제 연결된 클라이언트 IP)를 직접 세팅하는 헤더다. 단일 프록시 환경에서 가장 신뢰도가 높다.
X-Forwarded-For는 여러 프록시를 경유할 때 IP가 누적되는 헤더다. 쉼표로 구분된 IP 목록에서 첫 번째 값이 원래 클라이언트 IP다.
1
2
3
클라이언트 → 프록시A → 프록시B → Nuxt
X-Forwarded-For: "203.0.113.5, 10.0.0.1, 172.16.0.1"
↑ 원래 클라이언트 IP
💡
X-Forwarded-For는 클라이언트가 직접 이 헤더를 조작할 수 있어X-Real-IP보다 신뢰도가 낮다. 따라서 IP 추출 우선순위는 X-Real-IP → X-Forwarded-For → socket.remoteAddress 순으로 설정한다.
📌 useState와 payload를 통한 SSR/CSR 상태 공유
일반 ref()는 SSR과 CSR 각각 독립적인 상태를 가진다. 즉, 서버에서 만든 ref 값이 클라이언트에서 재사용되지 않는다.
반면 useState는 서버에서 생성한 상태를 hydration 시 클라이언트가 그대로 이어받는다. 또한 Nuxt SSR은 HTML을 생성할 때 __NUXT__ 라는 전역 객체를 HTML 안에 포함시켜 클라이언트로 전달하는데, nuxtApp.payload가 바로 이 데이터 공간이다.
서버(SSR)에서 추출한 클라이언트 IP를 payload에 저장해두면, 이후 CSR 라우트 이동 시 Middleware에서 동일한 IP 값을 별도 API 호출 없이 재사용할 수 있다.
4. CIDR 기반 IP 대역 체크
IP 허용 정책은 단일 IP뿐만 아니라 IP 대역(CIDR 표기)으로 관리하는 것이 일반적이다.
CIDR(Classless Inter-Domain Routing) 표기법은 10.0.0.0/8처럼 IP 주소와 서브넷 마스크 비트 수를 함께 표현한다. /8은 앞 8비트가 네트워크 주소이고 나머지 24비트가 호스트 주소임을 의미한다.
CIDR 포함 여부 체크는 비트마스크 연산으로 수행한다. IP 문자열을 32bit 정수로 변환한 뒤, 마스크를 적용해 네트워크 주소 부분만 비교한다.
5. Redis에서 IP 정책 관리
IP 허용 대역을 코드나 설정 파일에 하드코딩하면, 변경할 때마다 배포가 필요하다. 이를 Redis에 공통코드로 관리하면 서버 재시작 없이 실시간으로 정책을 변경할 수 있다.
📌 Redis 저장 구조
1
2
3
4
5
6
7
8
9
10
11
12
key: cmcd:list
value: JSON 배열 문자열
[
{
"cmcd": "ipAddr",
"cmcdVal": "ip허용대역",
"comCdNm": "10.0.0.0/8, 192.168.1.0/24",
"useYn": "Y"
},
...
]
comCdNm 필드에 쉼표로 구분하여 여러 대역을 저장하고, * 값은 전체 허용을 의미하는 특수값으로 사용한다. useYn 필드로 정책의 활성화 여부를 관리한다.
💡
RedisTemplate.opsForValue().get(key)는 Redis String 타입의 값을 조회한다.opsForList().range()와 달리 키 하나에 값 하나(전체 JSON 문자열)가 저장된 구조이므로, 한 번의 조회로 전체를 가져온 뒤 Java Stream에서 필터링한다.
6. Spring Boot 구현
📌 디렉토리 구조
1
2
3
4
5
6
7
8
src/main/java/
└── dto/
├── CommonCode.java
└── IpPolicyResponse.java
└── service/
└── CommonCodeService.java
└── controller/
└── IpPolicyController.java
📌 CommonCode.java
1
2
3
4
5
6
7
8
9
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class CommonCode {
private String cmcd; // 공통코드 구분 (ex: "ipAddr")
private String cmcdVal; // 코드값
private String comCdNm; // 코드명 (IP 대역이 쉼표로 저장된 필드)
private String useYn; // 사용여부 (Y/N)
}
📌 IpPolicyResponse.java
1
2
3
4
5
6
@Getter
@Builder
public class IpPolicyResponse {
private boolean allowedAll; // true면 전체 허용 (*)
private List<String> allowedRanges; // 허용 IP 대역 목록
}
📌 CommonCodeService.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@Slf4j
@Service
@RequiredArgsConstructor
public class CommonCodeService {
private static final String REDIS_KEY = "cmcd:list";
private static final String TARGET_CMCD = "ipAddr";
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
/**
* Redis에서 IP 허용 정책 조회
*/
public IpPolicyResponse getIpPolicy() {
List<String> ranges = resolveIpRanges();
return IpPolicyResponse.builder()
.allowedAll(ranges.contains("*"))
.allowedRanges(ranges)
.build();
}
private List<String> resolveIpRanges() {
// opsForValue().get() : Redis String 타입 단일 값 조회
String raw = redisTemplate.opsForValue().get(REDIS_KEY);
if (raw == null || raw.isBlank()) {
log.warn("[IpPolicy] Redis 키 없음 - key: {}", REDIS_KEY);
return List.of();
}
try {
return objectMapper.readValue(raw, new TypeReference<List<CommonCode>>() {})
.stream()
// useYn = Y 이고 cmcd = ipAddr 인 항목만 필터
.filter(code -> "Y".equalsIgnoreCase(code.getUseYn()) && TARGET_CMCD.equals(code.getCmcd()))
.findFirst()
.map(code -> parseIpRanges(code.getComCdNm()))
.orElseGet(() -> {
log.warn("[IpPolicy] cmcd='{}' 항목 없음 또는 useYn=N", TARGET_CMCD);
return List.of();
});
} catch (JsonProcessingException e) {
log.error("[IpPolicy] 파싱 실패 - key: {}", REDIS_KEY, e);
return List.of();
}
}
/**
* comCdNm 필드 파싱
* - "*" → 전체 허용
* - "10.0.0.0/8, 192.168.1.0/24" → 리스트로 분리
*/
private List<String> parseIpRanges(String comCdNm) {
if (comCdNm == null || comCdNm.isBlank()) return List.of();
// 전체 허용
if ("*".equals(comCdNm.trim())) return List.of("*");
// 쉼표로 분리 후 공백 제거
return Arrays.stream(comCdNm.split(","))
.map(String::trim)
.filter(s -> !s.isBlank())
.collect(Collectors.toList());
}
}
📌 IpPolicyController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j
@RestController
@RequestMapping("/api/common")
@RequiredArgsConstructor
public class IpPolicyController {
private final CommonCodeService commonCodeService;
/**
* Nuxt 초기 진입 시 IP 허용 정책 조회
* GET /api/common/ip-policy
*/
@GetMapping("/ip-policy")
public ResponseEntity<IpPolicyResponse> getIpPolicy() {
return ResponseEntity.ok(commonCodeService.getIpPolicy());
}
}
📌 API 응답 예시
1
2
3
4
5
6
7
8
// comCdNm = "10.0.0.0/8, 192.168.1.0/24" 인 경우
{ "allowedAll": false, "allowedRanges": ["10.0.0.0/8", "192.168.1.0/24"] }
// comCdNm = "*" 인 경우 (전체 허용)
{ "allowedAll": true, "allowedRanges": ["*"] }
// Redis 키 없거나 파싱 실패인 경우 (전체 차단)
{ "allowedAll": false, "allowedRanges": [] }
7. Nuxt3 구현
📌 디렉토리 구조
1
2
3
4
5
6
7
8
plugins/
└── 01.ip-policy.ts ← 서버 API 호출 & IP 정책 전역 저장
middleware/
└── ip-guard.global.ts ← 모든 라우트에서 IP 체크 & 리다이렉트
composables/
└── useIpPolicy.ts ← IP 상태 관리 composable
pages/
└── maintenance.vue ← 점검 페이지
📌 composables/useIpPolicy.ts
IP 접근 정책 상태를 전역으로 관리하는 composable이다.
일반 ref()는 SSR/CSR 간 상태가 공유되지 않기 때문에 useState를 사용한다. useState는 서버에서 만든 상태를 클라이언트 hydration 시 그대로 이어받는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/**
* IP 접근 정책 상태 인터페이스
*/
interface IpPolicy {
allowedAll: boolean // true = 전체 허용 (Redis comCdNm = "*")
allowedRanges: string[] // 허용 IP 대역 목록 ex) ["10.0.0.0/8", "192.168.1.0/24"]
isLoaded: boolean // 정책 로딩 완료 여부 (미들웨어에서 로딩 전 접근 방지용)
}
/**
* IP 정책 전역 상태 반환
* Plugin에서 API 호출 후 이 상태를 채워넣고,
* Middleware에서 이 상태를 읽어 IP 체크에 활용한다.
*/
export const useIpPolicy = () => {
return useState<IpPolicy>('ipPolicy', () => ({
allowedAll: false,
allowedRanges: [],
isLoaded: false,
}))
}
/**
* 클라이언트 IP가 현재 정책상 허용되는지 종합 판단
*
* 판단 순서:
* 1. 정책이 아직 로딩 안 됐으면 → 차단 (false)
* 2. allowedAll = true 이면 → 전체 허용 (true)
* 3. 허용 대역이 비어있으면 → 전체 차단 (false)
* 4. 하나라도 대역에 포함되면 → 허용 (true)
*/
export const checkIpAllowed = (clientIp: string, policy: IpPolicy): boolean => {
if (!policy.isLoaded) return false
if (policy.allowedAll) return true
if (!policy.allowedRanges.length) return false
return policy.allowedRanges.some(range => isIpInRange(clientIp, range))
}
/**
* 단일 IP가 특정 범위(단일 IP 또는 CIDR)에 속하는지 체크
*/
const isIpInRange = (ip: string, range: string): boolean => {
if (!range?.trim()) return false
// 단일 IP 비교
if (!range.includes('/')) return ip === range.trim()
// CIDR 비트마스크 비교
try {
const [rangeIp, bits] = range.split('/')
/**
* 비트마스크 계산
* ex) /8 → mask = 11111111.00000000.00000000.00000000
* /24 → mask = 11111111.11111111.11111111.00000000
*
* clientIp & mask === rangeIp & mask 이면 같은 네트워크 대역
*/
const mask = ~((1 << (32 - parseInt(bits))) - 1)
const ipNum = ipToNum(ip)
const rangeNum = ipToNum(rangeIp)
return (ipNum & mask) === (rangeNum & mask)
} catch {
return false
}
}
/**
* IPv4 문자열을 32bit 정수로 변환
* ex) "10.0.0.1" → 167772161
*
* >>> 0 : JavaScript 비트 연산의 부호 있는 정수 처리를
* 부호 없는 32bit 정수로 강제 변환
* (255.x.x.x 같은 큰 IP에서 음수로 처리되는 것을 방지)
*/
const ipToNum = (ip: string): number => {
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0) >>> 0
}
📌 plugins/01.ip-policy.ts
앱 초기화 시 IP 정책을 서버 API에서 로딩하고 전역 상태에 저장한다.
파일명 앞 01.은 여러 플러그인이 있을 때 실행 순서를 보장하기 위한 것이다. ip-policy가 다른 플러그인보다 먼저 실행되어야 미들웨어에서 상태를 사용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/**
* h3는 Nuxt 내부 서버 엔진(Nitro)이 사용하는 HTTP 프레임워크
* plugins/ 디렉토리에서는 자동 import가 안되므로 명시적으로 import 필요
*/
import { getRequestHeader } from 'h3'
export default defineNuxtPlugin(async (nuxtApp) => {
const ipPolicy = useIpPolicy()
/**
* [SSR 전용 블록]
* 서버에서만 실행되는 코드로, 클라이언트 IP를 추출해 payload에 저장
*
* payload에 저장하는 이유:
* - SSR 완료 후 HTML과 함께 클라이언트로 payload 데이터가 전달됨
* - CSR hydration 시 동일한 payload 값을 꺼내 쓸 수 있음
*
* IP 추출 우선순위:
* 1. X-Real-IP : nginx에서 직접 세팅한 실제 클라이언트 IP (가장 신뢰)
* 2. X-Forwarded-For : 프록시 체인 경유 시 첫 번째 값이 원래 클라이언트 IP
* 3. remoteAddress : 위 헤더가 없을 때 TCP 연결의 직접 IP (fallback)
*/
if (import.meta.server) {
const event = useRequestEvent()
if (event) {
nuxtApp.payload.clientIp =
getRequestHeader(event, 'x-real-ip') ||
getRequestHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket.remoteAddress ||
''
}
}
/**
* 이미 정책이 로딩된 경우 API 재호출 skip
*
* SSR에서 정책을 로딩하고 useState에 저장하면,
* CSR hydration 시 useState가 서버 상태를 그대로 이어받아 isLoaded = true
* 따라서 CSR에서 플러그인이 다시 실행되어도 API를 중복 호출하지 않음
*/
if (ipPolicy.value.isLoaded) return
try {
/**
* Spring Boot API 호출
* $fetch는 Nuxt 내장 fetch 유틸로 SSR/CSR 환경을 자동으로 처리
*/
const data = await $fetch<{ allowedAll: boolean; allowedRanges: string[] }>(
'/api/common/ip-policy'
)
ipPolicy.value = {
allowedAll: data.allowedAll,
allowedRanges: data.allowedRanges,
isLoaded: true,
}
} catch (e) {
/**
* API 호출 실패 시 보수적 차단 정책 적용
* - 정책을 알 수 없는 상황에서 허용하는 것보다 차단이 더 안전
* - isLoaded는 true로 설정하여 미들웨어에서 무한 재시도 방지
*/
console.error('[ip-policy] 정책 로딩 실패:', e)
ipPolicy.value = { allowedAll: false, allowedRanges: [], isLoaded: true }
}
})
📌 middleware/ip-guard.global.ts
모든 라우트 진입 시 IP 허용 여부를 체크하여 비허용 IP를 점검 페이지로 차단한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* [Global Middleware]
* 파일명에 '.global' 접미사를 붙이면 모든 라우트에 자동 적용
* Plugin 다음, 페이지 렌더링 전에 실행되는 라이프사이클 보장
*/
export default defineNuxtRouteMiddleware((to) => {
/**
* 체크 제외 경로 설정
*
* /maintenance 를 제외하는 이유:
* - 점검 페이지 자체에도 미들웨어가 실행되므로
* 제외하지 않으면 "/maintenance → 차단 → /maintenance → ..." 무한 루프 발생
*
* /_nuxt 를 제외하는 이유:
* - Nuxt의 JS 번들, CSS 등 정적 리소스 요청 경로
* - 이 경로까지 차단하면 점검 페이지 자체도 스타일/스크립트 로딩 불가
*/
const EXCLUDED_PATHS = ['/maintenance', '/favicon.ico']
if (EXCLUDED_PATHS.includes(to.path) || to.path.startsWith('/_nuxt')) return
// Plugin에서 이미 로딩되어 있으므로 API 재호출 없이 즉시 사용
const ipPolicy = useIpPolicy()
/**
* 정책 미로딩 상태 방어 처리
* Plugin에서 API 호출이 실패했을 때 보수적으로 점검 페이지로 이동
*/
if (!ipPolicy.value.isLoaded) return navigateTo('/maintenance')
// 전체 허용(*) 설정 시 즉시 통과 (이후 IP 체크 로직 실행 불필요)
if (ipPolicy.value.allowedAll) return
/**
* 클라이언트 IP 추출
*
* nuxtApp.payload.clientIp를 사용하는 이유:
* - Plugin에서 서버 측 HTTP 헤더로 추출한 실제 클라이언트 IP
* - SSR에서 payload에 저장 → 클라이언트 hydration 시 그대로 전달
* - CSR 라우트 이동 시에도 최초 접속 IP를 그대로 재사용
*/
const nuxtApp = useNuxtApp()
const clientIp = (nuxtApp.payload.clientIp as string) || ''
// IP 값이 없는 경우 차단 (헤더 파싱 실패, nginx 설정 누락 등)
if (!clientIp) return navigateTo('/maintenance')
// CIDR 비트마스크 연산으로 허용 여부 최종 판단
if (!checkIpAllowed(clientIp, ipPolicy.value)) {
return navigateTo('/maintenance')
}
// 모든 체크를 통과하면 아무것도 반환하지 않음 → 정상적으로 목적지 라우트 진행
})
📌 pages/maintenance.vue
비허용 IP 접속 시 미들웨어에 의해 강제 이동되는 점검 페이지다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<template>
<div class="wrap">
<div class="card">
<p class="icon">🔧</p>
<h1>서비스 점검 중입니다</h1>
<p class="desc">
보다 나은 서비스 제공을 위해 점검을 진행하고 있습니다.<br />
이용에 불편을 드려 대단히 죄송합니다.
</p>
</div>
</div>
</template>
<script setup lang="ts">
/**
* definePageMeta({ middleware: [] }) 를 설정하는 이유:
* - global middleware는 기본적으로 모든 페이지에 실행됨
* - 이 설정으로 이 페이지만 미들웨어 실행 대상에서 제외
* - EXCLUDED_PATHS 설정과 이중으로 방어
*/
definePageMeta({ middleware: [] })
</script>
<style scoped>
.wrap {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: #f5f7fa;
}
.card {
text-align: center;
padding: 60px 48px;
background: #fff;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08);
max-width: 480px;
width: 100%;
}
.icon { font-size: 56px; margin-bottom: 20px; }
h1 { font-size: 24px; font-weight: 700; color: #1a1a2e; margin-bottom: 16px; }
.desc { font-size: 15px; color: #666; line-height: 1.7; }
</style>
8. 전체 흐름
📌 비허용 IP 접근 시
1
2
3
4
5
6
7
8
9
10
브라우저 요청 (비허용 IP: 8.8.8.8)
│
▼
[Plugin] payload.clientIp = "8.8.8.8", 정책 로딩 완료
│
▼
[Middleware] checkIpAllowed("8.8.8.8", policy) → false
│
▼
navigateTo('/maintenance') → 점검 페이지 렌더링
9. 주요 트러블슈팅
📌 getRequestHeader is not defined 오류
getRequestHeader는 H3의 유틸 함수인데, plugins/나 middleware/ 파일에서는 자동 import가 되지 않는다. server/ 디렉토리 내부에서만 자동 import된다.
해결 방법 1: h3에서 직접 import
1
import { getRequestHeader } from 'h3'
해결 방법 2: event.node.req.headers로 직접 접근
1
2
3
const headers = event.node.req.headers
const xRealIp = headers['x-real-ip']
const xForwardedFor = headers['x-forwarded-for']
💡
h3는 Nuxt가 내부적으로 사용하는 패키지라 별도 설치 없이 바로 사용 가능하다. 명시적 import 방식이 타입도 명확하게 잡혀 권장한다.
📌 점검 페이지에서 무한 루프
Global Middleware의 EXCLUDED_PATHS에 /maintenance를 추가하지 않으면 점검 페이지 자체에서도 미들웨어가 실행되어 무한 리다이렉트가 발생한다.
middleware: [] 설정과 EXCLUDED_PATHS 두 가지를 모두 적용하여 이중으로 방어하는 것이 좋다.
10. 정리
이 구현의 핵심은 역할의 명확한 분리다.
- Plugin: “어떤 IP를 허용할 것인가” 라는 정책 데이터를 API에서 한 번 가져오는 역할
- Middleware: “이 접속자가 허용된 IP인가” 를 매 라우트마다 검사하는 역할
또한 SSR에서 추출한 클라이언트 IP를 payload를 통해 CSR로 전달함으로써, 클라이언트 측에서 별도 API 호출 없이도 일관된 IP 체크가 가능하다. Redis에 IP 정책을 관리하면 서버 재시작 없이 * 값 하나로 전체 허용 전환, useYn = N으로 정책 비활성화 등 운영 유연성을 확보할 수 있다.
참고 자료
- Nuxt3 공식 문서 - Plugins: https://nuxt.com/docs/3.x/guide/directory-structure/plugins
- Nuxt3 공식 문서 - Middleware: https://nuxt.com/docs/guide/directory-structure/middleware
- Nuxt3 공식 문서 - Nuxt Lifecycle: https://nuxt.com/docs/4.x/guide/concepts/nuxt-lifecycle
- Nuxt3 공식 문서 - Server Directory: https://nuxt.com/docs/4.x/directory-structure/server
- H3 공식 문서: https://h3.unjs.io
- MDN - X-Forwarded-For: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For
- RFC 4632 - CIDR: https://datatracker.ietf.org/doc/html/rfc4632