코드를 보다 보면 아래와 같은 코드를 계속해서 마주친다.

try {
    if (user != null) {
        if (user.profile != null) {
            if (user.profile.email != null) {
                sendEmail(user.profile.email)
            }
        }
    }
} catch (Exception e) {
    e.printStackTrace()
}

 

안정성을 위한 코드이지만, 시간이 지나면 로직이 눈에 잘 안들어오고, 오류 처리 흐름과 비즈니스 흐름이 섞이며 디버깅조차 어려워진다.

이런 방어적 코딩의 지옥에서 벗어나기 위해선 어떤 개선 전략들이 필요할까?

 

 

1.  왜 null 체크와 try-catch가 많아지는가?

원인 1 : 도메인 설계 부족

  • 객체 간 관계를 강하게 표현하지 못함 → null 가능성이 많아짐
  • ex) user.profile?.email → 왜 profile이 null일 수 있지?

원인 2 : 예외의 범위를 너무 넓게 잡음

  • catch(Exception e)로 모든 예외를 흡수 → 예외의 원인이 불명확해진다

원인 3 : 디폴트 값 처리 전략 부재

  • null을 피하려고 임시 디폴트 값을 억지로 넣는 코드가 증가할 수 있다.

 

실전에서 이런 코드가 만들어낸 문제들은 다음과 같다.

문제 상황 실제 결과
try-catch가 5겹 중첩 로그만 쌓이고, 실패 이유 알기 어려움
모든 예외를 캐치 후 무시 장애는 발생했는데 아무도 모름
null일 때 기본값을 return 버그가 감춰짐 → 나중에 더 큰 문제 발생

 

 

2. 실제 개선 전략

 

2-1  try-catch는 최소한의 범위로 설정한다

 

잘못된 예시를 살펴보자

try {
    // DB 호출
    // 로직 처리
    // 다른 서비스 호출
    // 응답 생성
} catch (Exception e) {
    // 어디서 실패했는지 모름
}

 

위 코드를 개선해보자

User user;
try {
    user = userRepository.findById(id);
} catch (DataAccessException e) {
    logger.error("DB 오류", e);
    throw new ServiceException("사용자 조회 실패");
}

 

이처럼 실패 구간을 분리하면 디버깅, 로그 추적, 예외 처리가 쉬워진다. 필요하다면 사용자 정의 예외를 만드는 것도 도움이 될 수 있다.

 

 

2-2 예외를 로직 처리 수단으로 사용하지 않는다.

 

잘못된 예시

try {
    Product p = findProduct(code);
} catch (ProductNotFoundException e) {
    return defaultProduct;
}

 

존재 여부 판단은 로직으로, 예외는 "진짜 예외'로만 써야 유지보수성이 높아진다.

 

개선된 코드

Product product = productRepository.findByCode(code);
if (product == null) {
    return defaultProduct;
}

 

 

2-3 서비스 레이어에서 흐름을 단순화한다.

 

개선 전

if (user != null) {
    if (user.profile != null) {
        try {
            sendEmail(user.profile.email);
        } catch (MessagingException e) {
            ...
        }
    }
}

 

개선 후

fun trySendEmail(user: User?) {
    val email = user?.profile?.email ?: return
    runCatching { sendEmail(email) }
        .onFailure { log.error("이메일 전송 실패", it) }
}

 

 

 

Java에서 null 을 안전하게 처리하는 방법에 대해서도 알아보자

 

Java에서 null은 피할 수 없는 동료이다. Java는 오랫동안 null을 처리하기 위한 명시적 키워드나 구문을 제공하지 않았다.

그래서 다음과 같은 코드가 자주 등장한다.

if (user != null && user.getProfile() != null && user.getProfile().getEmail() != null) {
    sendEmail(user.getProfile().getEmail());
}

 

그럼 이러한 null 체크가 만들어내는 문제점들은 없을까? 있다..

 

  1. 방어코드 증가 : 코드 복잡도가 상승해 가독성이 떨어지고 의도 파악도 하기 힘들어진다.
  2. 예외 누락 : NPE 외의 다른 에러를 삼킨다. 이렇게 되면 장애 추적에 어려움이 있다.
  3. 책임 분산 실패 : 데이터가 왜 null인지 명확하게 파악할 수 없다.

 

개선하기 위한 방법은 다음과 같다.

 

1. 명확한 설계

예시: 사용자의 프로필은 필수다 vs 선택이다.

class User {
    private Profile profile; // null 가능
    private String name; // null 불가
}

 

→ 가능 여부를 명확히 주석 혹은 도메인 명세에 기록한다. 그에 따라 Getter 생성자, API Response를 구분한다.

 

2. Optional을 반환 값에 사용

Java 8부터 등장한 Optional<T>은 null을 직접 반환하지 않기 위한 Wrapper이다.

 

개선 전 

public User findUser(String id) {
    User user = userRepository.findById(id);
    if (user == null) {
        return new User(); // 무의미한 디폴트
    }
    return user;
}

 

개선 후

public Optional<User> findUser(String id) {
    return Optional.ofNullable(userRepository.findById(id));
}

 

Optional을 사용시 주의사항은 return value에만 사용해야 한다는 것이다. 필드에서 사용하게 되면 JPA가 처리하지 못하고 성능 저하를 유발할 수 있다.

 

3. Null Object 패턴 - 빈 객체를 대신 리턴

null 대신 기본 동작을 하는 객체를 리턴한다.

class NullUser extends User {
    public String getName() { return "알 수 없음"; }
    public Profile getProfile() { return new NullProfile(); }
}
User user = userService.findOrNullUser();
log.info(user.getProfile().getEmail()); // NPE 발생 X

 

장점 : 호출자에서 null 체크 생략이 가능하다.

단점 : NullObject가 진짜 의미를 오해할 수 있다.

 

 

4. 유틸리티 함수 사용 (Objects 클래스)

if (Objects.nonNull(user) && Objects.nonNull(user.getProfile())) {
    ...
}

 

  • Objects.nonNull()
  • Objects.requireNonNull(obj, "null 불가")
  • Objects.equals(a, b) → a가 null일 때도 안전하다.

 

 

 

오늘날 서버와 클라이언트 간 데이터 전송의 성능은 인터넷 상의 다양한 서비스 품질을 결정짓는 중요한 요소이다. 

 

인터넷 서비스의 발전과 함께, 데이터의 효율성은 채팅, 스트리밍, 온라인 게임, 금융 거래 등 다양한 분야에서 핵심 역할을 한다. 예를 들어, 실시간 게임에서는 지연 시간이 엄청 중요하고, 금융 거래에서는 신뢰성과 보안이 핵심이다.

 

 

TCP, UDP, QUIC 

  • TCP : 1970년대 초 창시되어 인터넷의 기본 프로토콜로 자리 잡았다. 안정적인 데이터 전송이 요구되는 서비스에 주로 사용된다.
  • UDP : TCP보다 단순한 구조로 빠른 전송이 필요하지만, 신뢰성은 덜 요구되는 서비스에 적합하다. 실시간 스트리밍, VoIP등이 대표적이다.
  • QUIC : 최근 Google에 의해 제안되어 빠른 연결 복원, 낮은 지연 시간, 내장 암호화를 특징으로 한다. HTTP/3의 기반 프로토콜로 각광받고 있다.

각 프로토콜은 당대의 기술적 한계와 요구사항에 맞춰 개발되었는데 TCP는 초기 인터넷의 안정성과 신뢰성을, UDP는 실시간 통신의 필요성을, QUIC은 모바일 환경과 클라우드 서비스 시대의 빠른 응답 속도 요구를 반영한다.

 

 

1. TCP(Transmission Control Protocol)

1.1 TCP의 기본 원리와 동작 방식

TCP는 연결 지향형 프로토콜로, 데이터 전송 전에 반드시 연결을 수립한다. 이를 통해 데이터의 순서와 무결성을 보장한다.

  • 3-way 핸드쉐이크 : 클라이언트와 서버가 서로 연결을 확인하는 과정 
  • 데이터 패킷 : 데이터를 작은 단위로 분활하여 전송 후 재조립

1.2 연결 지향형 통신의 특징

TCP는 연결을 유지하며 데이터를 주고받기 때문에, 중간에 패킷 손실이 발생하면 재전송하는 등 신뢰성을 보장한다. 이로 인해 대용량 파일 전송이나 은행 거래 같은 신뢰성이 중요한 서비스에서 널리 사용된다.

 

1.3 신뢰성 보장 메커니즘

TCP는 다음과 같은 메커니즘으로 데이터 전송의 안정성을 보장한다.

  • 흐름 제어 : 송수신 속도를 동기화하여 버퍼 오버플로우 방지
  • 혼잡 제어 : 네트워크 상황에 따라 전송 속도를 조절
  • 오류 검출 및 재전송 : 패킷 손실 발생 시 자동 재전송

1.4 TCP의 장단점 및 성능 분석

  • 장점 : 높은 신뢰성, 데이터 순서 보장, 오류 복가 기능
  • 단점 : 연결 수립 및 관리 오버헤드, 지연 시간 증가
  • 성능 분석 : 대역폭이 충분하고 지연에 민감하지 않은 서비스에서는 이상적이지만, 실시간 데이터 전송에는 한계가 있다.

 

 

2. UDP (User Datagram Protocol)

2.1 UDP의 기본 원리와 특징

UDP는 비연결형 프로토콜로, 연결 수립 과정 없이 데이터를 빠르게 전송한다.

  • 패킷 단위 전송 : 각 패킷은 독립적으로 전송되며 순서 보장이 없다.
  • 오버헤드 최소화 : 헤더 정보가 간단하여 빠른 데이터 전송이 가능하다.

2.2 비연결형 통신의 개념과 장점

UDP는 연결 설정 없이 데이터를 보내기 때문에, 지연이 적고 실시간 응답이 중요한 환경에서 유리하다.

예시 : 온라인 게임, VoIP, 실시간 스트리밍 등

 

2.3 신뢰성 보장 부재가 가져오는 효과와 사례

UDP는 신뢰성을 보장하지 않기 때문에, 패킷 손실이나 순서 뒤바뀜이 발생할 수 있다.

  • 효과 : 빠른 전송 속도와 낮은 지연
  • 문제 : 데이터 복원 및 오류 처리 로직을 애플리케이션 단에서 구현해야 함

2.4 UDP 사용 시 발생할 수 있는 문제와 해결 방안

패킷 손실이나 중복 전송 문제를 해결하기 위해

  • 애플리케이션 레벨에서의 재전송 로직
  • 시퀀스 번호 추가 및 검증
  • FEC(Forward Error Correction) 기법 등을 사용할 수 있다.

2.5 UDP 성능 최적화 및 활용 전략

UDP는 실시간 서비스에서 높은 성능을 발휘하지만,

  • 네트워크 상태에 따른 적응형 전송 기법을 적용하면 성능 극대화가 가능하다.
  • 예를 들어, 게임에서는 일정 수준의 패킷 손실을 허용하는 대신 빠른 응답 속도를 선택하는 전략을 사용할 수 있다.

 

 

3. QUIC (Quick UDP Internet Connections)

3.1 QUIC의 개요 및 설계 목적

QUIC는 Google에서 제안한 최신 전송 프로토콜로, 기본적으로 UDP 위에서 동작하지만 TCP의 신뢰성과 UDP의 속도를 결합한 형태이다.

  • 주요 목적 : 빠른 연결 수립, 낮은 지연 시간, 내장 암호화
  • 특징 : 연결 복원, 다중 스트림 처리, 헤더 압축

3.2 QUIC의 주요 기능

QUIC는 다음과 같은 혁신적인 기능들을 제공한다.

  • 연결 복원 : 연결 끊김 후에도 재연결 시 핸드 쉐이크 과정을 생략
  • 다중 스트림 : 하나의 연결에서 여러 데이터 스트림을 동시에 처리
  • 암호화 내장 : TLS/SSL 기반의 보안 기능이 기본 제공되어 데이터 보안이 강화된다.

3.3 TCP와 UDP의 장점을 결합한 QUIC의 차별점

QUIC는 기존 프로토콜의 단점을 보완한다.

  • TCP 대비 : 초기 연결 지연이 크게 줄어들며, 혼잡 제어 기법이 개선되었다.
  • UDP 대비 : 데이터 전송의 신뢰성을 높이고, 연결 복원 기능을 통해 안정성을 보장한다.

 

'네트워크' 카테고리의 다른 글

쿠버네티스 네트워크  (0) 2025.04.08
기본 인증  (0) 2024.08.15
클라이언트 식별과 쿠키  (0) 2024.08.09
캐시  (1) 2024.08.07
웹 서버  (1) 2024.08.06

왜 이걸 알아야 할까?

개발 중에 이런 상황을 겪어볼 수 있다.

  • 스레드가 고갈돼서 서버가 멈춘다.
  • @Async 붙였는데 오히려 느려졌다.
  • WebClient 썼는데 로그 순서가 뒤죽박죽이다.
  • ThreadLocal 데이터가 갑자기 사라진다.

이 문제들의 공통점은 "스레드" 이다.

 

내가 만든 코드가 실제로 어떤 스레드에서 돌아가고 있는지 모른다면, 성능파악과 튜닝도 어려울것이다.

 

그래서 HTTP 요청 하나당 실제로 몇 개의 스레드가 작동하는지 환경별로 한번 알아보자.

 

환경 설명
Spring MVC 기본 동기 처리(Tomcat)
Spring MVC + @Async 일부 작업 비동기 분리
Spring MVC + WebClient 외부 API 호출 시 비동기
WebFlux + Netty 전체 논블로킹, 이벤트 기반

 

 

요청 하나 = 스레드 하나?는 옛말?

보통 아래와 같이 생각한다.

 

이 구조는 맞다 그런데 여기에 다음이 붙는 순간 스레드가 복제된다.

  • @Async 사용
  • WebClient 비동기 콜백
  • 로깅 비동기 처리
  • Netty 기반 이벤트 루프

각 케이스를 로그와 함께 한번 알아보자.

 

 

1. [Spring MVC] 동기 방식

@RestController
public class TestController {

    @GetMapping("/sync")
    public String sync() {
        log.info("스레드: {}", Thread.currentThread().getName());
        return "ok";
    }
}

 

실행 결과 

 

  • Controller → Service → Repository → 응답까지 모두 하나의 워커 스레드(exec-4) 에서 작동한다.
  • 요청 하나 = 스레드 하나 (정확)

 

 

2. [Spring MVC + @Async] - 작업을 나눴더니 스레드가 늘어났다.

@Service
public class MyService {
    @Async
    public void doAsync() {
        log.info("비동기 스레드: {}", Thread.currentThread().getName());
    }
}

@GetMapping("/async")
public String async() {
    log.info("요청 스레드: {}", Thread.currentThread().getName());
    myService.doAsync();
    return "ok";
}

 

실행 결과

 

  • 요청은 exec-1 이 처리했다.
  • 내부 로직은 별도 스레드(task-1)로 넘어갔다.
  • 요청은 하나지만 2개의 스레드가 동작했다.

 

 

3. [Spring MVC + WebClient] - 응답을 기다리는 동안 다른 스레드가 개입

@GetMapping("/webclient")
public Mono<String> callApi() {
    log.info("요청 시작: {}", Thread.currentThread().getName());

    return webClient.get()
        .uri("http://localhost:8081/slow")
        .retrieve()
        .bodyToMono(String.class)
        .doOnNext(response -> log.info("응답 받은 스레드: {}", Thread.currentThread().getName()));
}

 

실행 결과 (Spring WebClinet + 비동기)

  • 시작은 exec-2 에서 진행했다.
  • WebClient는 내부적으로 다른 스레드 풀에서 응답을 처리했다.
  • 요청 하나에 최소 2개 이상의 스레드가 동작했다.

 

 

4. [Spring WebFlux + Netty] - 더 이상 스레드 흐름은 예측 불가

@GetMapping("/webflux")
public Mono<String> reactive() {
    log.info("요청 시작: {}", Thread.currentThread().getName());

    return Mono.just("응답")
        .delayElement(Duration.ofMillis(500))
        .doOnNext(data -> log.info("응답 처리 스레드: {}", Thread.currentThread().getName()));
}

 

실행 결과

  • 요청은 reactor-http-nio-* 에서 수신했다.
  • 응답은 완전히 다른 워커 스레드에서 처리했다 (parallel-*)
  • 1 요청에 최소 2개 이상 스레드, 심지어 바뀔 수도 있다.

 

정리해보자면

환경 요청당 스레드 수 특징
Spring MVC 1 예측 가능, 단순
MVC + @Async 2+ 스레드 분기
MVC + WebClient 2+ 응답 처리 별도
WebFlux 2 ~ n 스레드 다변화, 예측 어려움

 

스레드가 바뀌면 ThreadLocl에 저장한 값은 사라지고, 

로깅, 트랜잭션, MDC, 세션 등 "스레드 고정이 전제된 기능"이 깨질 수 있다.

+ Recent posts