웹 서버의 포트가 두 개라고 해서 실행 파일이나 프로세스도 반드시 두 개여야 하는 것은 아닙니다. 하나의 실행 파일을 한 번 실행해 생성된 하나의 프로세스가 여러 네트워크 주소에서 동시에 요청을 받을 수 있습니다.
실행 파일 1개
↓ 실행
프로세스 1개
├─ 8080 포트에서 요청 수신
└─ 9090 포트에서 요청 수신
포트는 프로세스의 개수를 나타내는 값이 아니라, 운영체제가 네트워크 연결을 구분하기 위해 사용하는 번호입니다. 하나의 건물에 여러 출입구를 둘 수 있는 것처럼 하나의 프로세스도 여러 포트를 열 수 있습니다.
실행 파일, 프로세스와 포트
| 용어 | 의미 |
|---|---|
| 실행 파일 | 운영체제가 실행할 수 있도록 빌드된 파일 |
| 프로세스 | 실행 파일이 메모리에 올라가 실제로 실행 중인 상태 |
| 웹 서버 | HTTP 요청을 받아 처리하는 프로그램 기능 |
| 포트 | 같은 IP 주소에서 네트워크 서비스를 구분하는 번호 |
| 리스닝 | 특정 IP 주소와 포트에서 연결을 기다리는 상태 |
Go 프로그램을 다음과 같이 빌드할 수 있습니다.
go build -o web-server .
생성된 web-server는 실행 파일입니다.
./web-server
이 명령으로 실행하면 운영체제에 하나의 프로세스가 만들어집니다. 이 프로세스 안에서 8080과 9090 포트를 함께 열 수 있습니다.
web-server 프로세스 1개
├─ :8080
└─ 127.0.0.1:9090
포트가 두 개라는 사실만으로 프로세스가 두 개 생성되지는 않습니다.
Go와 chi에서 여러 포트 사용하기
Go의 net/http 패키지는 HTTP 서버 기능을 제공합니다. go-chi는 요청 경로와 처리 함수를 연결하는 HTTP 라우터입니다.
chi.Router
→ URL 경로와 handler를 연결
http.Server
→ 지정한 네트워크 주소에서 HTTP 요청을 수신
포트를 직접 여는 주체는 chi 라우터가 아니라 http.Server입니다.
server := &http.Server{
Addr: ":8080",
Handler: router,
}
err := server.ListenAndServe()
Addr는 서버가 요청을 받을 TCP 주소이고, Handler는 수신한 HTTP 요청을 처리할 객체입니다. ListenAndServe는 Server.Addr에 지정된 주소에서 연결을 기다리고 요청을 Handler로 전달합니다.
포트를 두 개 열려면 주소가 서로 다른 http.Server를 각각 생성하면 됩니다.
externalServer := &http.Server{
Addr: ":8080",
Handler: externalRouter,
}
internalServer := &http.Server{
Addr: "127.0.0.1:9090",
Handler: internalRouter,
}
Go 프로세스 1개
├─ externalServer → :8080
└─ internalServer → 127.0.0.1:9090
실행 예제
먼저 프로젝트를 만들고 chi를 설치합니다.
mkdir multiple-ports
cd multiple-ports
go mod init example.com/multiple-ports
go get github.com/go-chi/chi/v5
go mod tidy
main.go를 다음과 같이 작성합니다.
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
)
func main() {
externalRouter := chi.NewRouter()
externalRouter.Get("/hello", externalHello)
internalRouter := chi.NewRouter()
internalRouter.Get("/healthz", healthcheck)
externalServer := &http.Server{
Addr: ":8080",
Handler: externalRouter,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
internalServer := &http.Server{
Addr: "127.0.0.1:9090",
Handler: internalRouter,
ReadHeaderTimeout: 3 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
IdleTimeout: 30 * time.Second,
}
errCh := make(chan error, 2)
go startServer("외부 서버", externalServer, errCh)
go startServer("내부 서버", internalServer, errCh)
signalCh := make(chan os.Signal, 1)
signal.Notify(signalCh, syscall.SIGINT, syscall.SIGTERM)
select {
case err := <-errCh:
log.Printf("서버 오류: %v", err)
case sig := <-signalCh:
log.Printf("종료 신호 수신: %s", sig)
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := externalServer.Shutdown(shutdownCtx); err != nil {
log.Printf("외부 서버 종료 오류: %v", err)
}
if err := internalServer.Shutdown(shutdownCtx); err != nil {
log.Printf("내부 서버 종료 오류: %v", err)
}
}
func startServer(name string, server *http.Server, errCh chan<- error) {
log.Printf("%s 시작: %s", name, server.Addr)
err := server.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- fmt.Errorf("%s: %w", name, err)
}
}
func externalHello(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("hello from 8080"))
}
func healthcheck(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
실행합니다.
go run .
다른 터미널에서 각 포트로 요청을 보냅니다.
curl http://127.0.0.1:8080/hello
curl http://127.0.0.1:9090/healthz
실행 결과는 다음과 같습니다.
hello from 8080
ok
ListenAndServe()는 서버가 종료되거나 오류가 발생할 때까지 반환하지 않는 blocking 메서드입니다. 다음처럼 순서대로 호출하면 첫 번째 호출에서 계속 기다리므로 두 번째 서버를 시작할 수 없습니다.
externalServer.ListenAndServe()
internalServer.ListenAndServe()
따라서 각 서버를 별도의 goroutine에서 실행합니다.
go externalServer.ListenAndServe()
go internalServer.ListenAndServe()
여기서 goroutine이 여러 개 만들어져도 운영체제 프로세스가 여러 개 생성되는 것은 아닙니다.
프로세스 1개
├─ 8080 서버를 실행하는 goroutine
├─ 9090 서버를 실행하는 goroutine
└─ 각 HTTP 요청을 처리하는 goroutine
:8080과 127.0.0.1:9090의 차이
Addr: ":8080"
:8080은 호스트 부분을 생략하고 포트만 지정한 주소입니다. Go는 이 주소로 TCP 리스너를 만들며, 일반적인 운영체제 환경에서는 특정 로컬 IP 하나로 제한하지 않고 사용 가능한 인터페이스에서 요청을 받을 수 있습니다. 실제 IPv4·IPv6 바인딩 동작은 운영체제와 네트워크 설정의 영향을 받을 수 있습니다.
반면 다음 주소는 IPv4 루프백 인터페이스에만 바인딩합니다.
Addr: "127.0.0.1:9090"
이 경우 같은 운영체제의 프로세스에서는 접근할 수 있지만 다른 컴퓨터는 서버 IP를 사용해 9090 포트에 직접 접근할 수 없습니다.
| 설정 | 의미 | 다른 컴퓨터의 직접 접근 | 일반적인 용도 |
|---|---|---|---|
:9090 |
호스트를 제한하지 않고 9090 포트에서 수신 | 네트워크와 방화벽 설정에 따라 가능 | 외부 또는 내부 네트워크 서비스 |
127.0.0.1:9090 |
IPv4 루프백에서만 수신 | 불가능 | 로컬 관리 API, 로컬 healthcheck |
192.168.0.10:9090 |
지정한 내부 IP에서만 수신 | 해당 네트워크에서 가능 | 내부망 전용 서비스 |
127.0.0.1:9090을 사용한 것은 Go에서 반드시 이렇게 설정해야 하기 때문이 아니라, 내부용 API가 다른 네트워크에 불필요하게 노출되는 범위를 줄이기 위해서입니다.
127.0.0.1바인딩만으로 인증과 권한 검사를 대신할 수는 없습니다. 내부 API라도 노출 범위, 방화벽, 인증, 권한 설정을 함께 검토해야 합니다.
Docker 컨테이너에서 127.0.0.1은 호스트가 아니라 현재 컨테이너 자신을 가리킵니다.
컨테이너 A의 127.0.0.1 → 컨테이너 A 자신
컨테이너 B의 127.0.0.1 → 컨테이너 B 자신
같은 컨테이너 안의 healthcheck는 http://127.0.0.1:9090/healthz로 호출할 수 있습니다. 다른 컨테이너가 접근해야 한다면 서버를 컨테이너 네트워크 인터페이스에서도 받을 수 있도록 열고, 호출하는 쪽에서는 Docker Compose 서비스 이름이나 컨테이너 IP를 사용해야 합니다.
라우터를 공유할지 분리할지
두 서버에 같은 라우터를 연결할 수도 있습니다.
router := chi.NewRouter()
router.Get("/hello", externalHello)
externalServer := &http.Server{
Addr: ":8080",
Handler: router,
}
internalServer := &http.Server{
Addr: "127.0.0.1:9090",
Handler: router,
}
이 경우 같은 경로가 두 포트에서 모두 제공됩니다.
http://127.0.0.1:8080/hello
http://127.0.0.1:9090/hello
포트별로 제공할 API가 다르면 라우터를 분리하는 편이 명확합니다.
:8080
└─ externalRouter
└─ /hello
127.0.0.1:9090
└─ internalRouter
└─ /healthz
두 포트가 모두 필수라면 어느 한 서버가 시작되지 못했을 때 전체 프로세스를 종료하도록 설계할 수 있습니다. 예를 들어 9090 포트를 다른 프로그램이 이미 사용하고 있다면 ListenAndServe()는 bind: address already in use와 같은 오류를 반환합니다. 전체 예제에서는 오류를 채널로 전달하고 두 서버를 함께 종료합니다.
Spring Boot에서 여러 포트 사용하기
Spring Boot에서도 하나의 실행 JAR을 한 번 실행한 하나의 JVM 프로세스가 여러 포트를 사용할 수 있습니다.
Spring Boot JAR 1개
↓ java -jar
JVM 프로세스 1개
├─ 8080 포트
└─ 9090 포트
사용 목적에 따라 다음 두 방법을 구분해야 합니다.
- Actuator 관리 엔드포인트만 별도 포트로 분리
- 임베디드 Tomcat에 일반 HTTP Connector 추가
Actuator 관리 포트 분리
health, metrics, Prometheus 같은 관리 엔드포인트만 별도 포트로 분리하려면 management.server.port를 사용합니다.
server:
port: 8080
management:
server:
port: 9090
address: 127.0.0.1
Spring Boot JVM 프로세스 1개
├─ 애플리케이션 서버: :8080
└─ 관리 서버: 127.0.0.1:9090
일반 Controller는 8080 포트에서 제공되고 Actuator 엔드포인트는 9090 포트에서 제공됩니다.
http://127.0.0.1:8080/users
http://127.0.0.1:9090/actuator/health
Gradle에서는 Actuator 의존성이 필요합니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
이 방식은 일반 업무용 @RestController를 9090 포트에 따로 제공하는 기능이 아닙니다. 관리 엔드포인트를 애플리케이션 포트와 분리하려는 경우에 적합합니다.
임베디드 Tomcat Connector 추가
일반 HTTP 요청을 받을 포트를 추가하려면 임베디드 Tomcat에 Connector를 추가할 수 있습니다.
server:
port: 8080
Spring Boot 3.x 기준 예제는 다음과 같습니다.
package com.example.demo.config;
import org.apache.catalina.connector.Connector;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class TomcatConfig {
@Bean
WebServerFactoryCustomizer<TomcatServletWebServerFactory>
additionalConnectorCustomizer() {
return factory -> {
Connector connector = new Connector(
TomcatServletWebServerFactory.DEFAULT_PROTOCOL
);
connector.setPort(9090);
connector.setProperty("address", "127.0.0.1");
factory.addAdditionalTomcatConnectors(connector);
};
}
}
애플리케이션은 한 번만 실행합니다.
java -jar demo.jar
JVM 프로세스 1개
└─ 임베디드 Tomcat
├─ 기본 Connector: :8080
└─ 추가 Connector: 127.0.0.1:9090
Tomcat의 하나의 Service는 하나 이상의 Connector가 같은 Container를 공유하도록 구성할 수 있습니다. 따라서 추가 Connector를 등록해도 기본적으로 별도의 Spring MVC 애플리케이션이 만들어지는 것은 아닙니다.
다음 Controller가 있다면 두 포트에서 모두 접근될 수 있습니다.
package com.example.demo.api;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HelloController {
@GetMapping("/hello")
public String hello() {
return "hello";
}
}
http://127.0.0.1:8080/hello
http://127.0.0.1:9090/hello
8080 Connector ─┐
├─ 같은 Tomcat Container
9090 Connector ─┘ ↓
같은 Spring MVC
↓
같은 Controller
즉, 포트를 추가하는 것과 포트별로 Controller를 분리하는 것은 서로 다른 문제입니다.
포트에 따라 경로 접근을 제한하려면 필터에서 요청이 들어온 로컬 포트를 확인할 수 있습니다.
package com.example.demo.filter;
import java.io.IOException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
public class InternalPortFilter extends OncePerRequestFilter {
private static final int INTERNAL_PORT = 9090;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
boolean internalPath = request.getRequestURI()
.startsWith("/internal/");
if (internalPath && request.getLocalPort() != INTERNAL_PORT) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return;
}
filterChain.doFilter(request, response);
}
}
포트 번호 검사만으로 강한 보안 경계가 만들어지지는 않습니다. 내부 IP 또는 루프백 바인딩, 운영체제 방화벽, 리버스 프록시 접근 제한, Spring Security 인증·인가 등을 함께 적용해야 합니다.
포트 분리와 프로세스 분리
Go와 Spring Boot 모두 하나의 프로세스에서 여러 포트를 사용할 수 있지만 포트를 나눈다고 프로그램이 독립적으로 분리되는 것은 아닙니다.
| 구분 | Go + chi | Spring Boot + Tomcat |
|---|---|---|
| 실행 파일 | Go 바이너리 1개 | 실행 JAR 1개 |
| 운영체제 프로세스 | 1개 | JVM 프로세스 1개 |
| 여러 포트 | 여러 http.Server 사용 |
관리 서버 또는 추가 Connector 사용 |
| 경로 처리 | chi.Router |
Spring MVC Controller |
| 포트별 경로 분리 | 서로 다른 Handler 연결 |
Connector 추가만으로는 자동 분리되지 않음 |
| 프로세스 종료 시 | 모든 포트가 함께 종료 | 모든 포트가 함께 종료 |
하나의 프로세스에 여러 포트를 열면 다음 자원을 함께 사용할 수 있습니다.
- 프로세스 메모리
- CPU 자원
- 데이터베이스 연결 풀
- 설정과 로그
- 배포와 재시작 시점
- 장애 범위
다음과 같은 요구가 있다면 포트만 나누기보다 프로세스나 애플리케이션 자체를 분리하는 편이 적합할 수 있습니다.
- 서로 독립적으로 배포하거나 재시작해야 하는 경우
- 한쪽 장애가 다른 쪽에 영향을 주면 안 되는 경우
- CPU와 메모리 제한을 다르게 적용해야 하는 경우
- 각각 별도로 확장해야 하는 경우
- 강한 보안 경계가 필요한 경우
여러 포트는 HTTP와 HTTPS를 분리할 때도 사용할 수 있습니다.
8080 → HTTP
8443 → HTTPS
Go에서는 서버마다 ListenAndServe() 또는 ListenAndServeTLS()를 선택할 수 있습니다.
go httpServer.ListenAndServe()
go httpsServer.ListenAndServeTLS("server.crt", "server.key")
Tomcat도 HTTP Connector와 HTTPS Connector를 함께 구성할 수 있습니다. 실제 운영 환경에서는 Nginx, Apache HTTP Server, 로드 밸런서 또는 클라우드 서비스가 TLS를 종료하고 애플리케이션에는 HTTP로 전달하는 구조도 사용됩니다.
하나의 실행 파일에서 만들어진 하나의 프로세스가 여러 포트를 동시에 열 수 있다는 것이 핵심입니다. Go에서는 여러 http.Server에 서로 다른 주소와 Handler를 연결합니다. Spring Boot에서는 Actuator 관리 포트 분리 또는 임베디드 Tomcat Connector 추가 방식을 사용할 수 있습니다.
다만 포트가 여러 개여도 프로세스는 하나이므로 프로세스가 종료되면 모든 포트가 함께 종료됩니다. 독립적인 배포, 장애 격리, 자원 제한이 필요하다면 포트 분리가 아니라 프로세스 또는 애플리케이션 분리를 검토해야 합니다.
참고 자료
- Go
net/http공식 문서: https://pkg.go.dev/net/http - Spring Boot 공식 문서 - Actuator 포트와 주소 변경: https://docs.spring.io/spring-boot/how-to/actuator.html
- Spring Boot 3.5 API -
TomcatServletWebServerFactory: https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/web/embedded/tomcat/TomcatServletWebServerFactory.html - Apache Tomcat API -
Service: https://tomcat.apache.org/tomcat-10.0-doc/api/org/apache/catalina/Service.html
댓글