많은 개발자들이 경험해봤을 것이다. 로직은 정상적으로 동작하고, 기능도 잘 돌아가지만 정작 실제 트래픽이나 배치 상황에선 터질 듯한 지연과 병목이 발생하는 상황말이다.

 

이런 경우는 단순한 버그가 아니라, 성능 관점의 로직 결함이다.

 

그중에서도 가장 자주 반복되는 특징 5가지를 한번 정리해보자.

 

 

1. 반복문 안에서 DB or 네트워크 호출

나쁜 예 :

List<Long> userIds = getAllUserIds();
for (Long userId : userIds) {
    User user = userRepository.findById(userId); // DB 요청 n번 발생
    process(user);
}

 

위 로직의 문제점은

  • n건마다 DB/network 왕복 요청 발생
  • 1만 건이면 1만 번 DB에 연결한다.
  • N+1 문제로 대표되는 성능 병목

그렇다면 이 로직을 어떻게 개선할 수 있을까?

List<User> users = userRepository.findAllById(userIds); // 한번에 요청
for (User user : users) {
    process(user);
}

 

 

2. Stream API 혹은 람다에서 성능 생각 없이 사용

List<String> emails = users.stream()
    .filter(u -> u.getEmail().endsWith("@gmail.com"))
    .map(u -> u.getEmail())
    .distinct()
    .collect(Collectors.toList());

 

이 로직에서의 문제점은 

  • filter → map → distinct → collect 모두 내부적으로 새로운 리스트를 생성한다
  • 데이터가 많을수록 GC 부하와 메모리 사용량이 급증한다
  • 특히 nested Stream 구조가 있는 경우 O(n^2) 급 성능이 하락한다.

개선 포인트는

  • 대량 처리 시엔 stream 대신 명시적 for문을 사용한다
  • 성능 측정 필요 시 JMH나 System.nanoTime()으로 비교한다

이렇게 2가지가 가능하다.

 

 

3. 컬렉션 구조 선택 미스 (LinkedList vs ArrayList)

List<String> list = new LinkedList<>();
for (int i = 0; i < 1_000_000; i++) {
    list.add(i, "data"); // 중간 삽입
}

 

위 로직은 그냥 봤을땐 문제가 없어 보인다.

 

하지만 문제점이 있다.

  • LinkedList.add(index, element)는 O(n)
  • 실제로는 전체를 다 뒤져야 한다
  • ArrayList 보다 수십 배 느리다

이런 경우

  • 삽입/삭제가 많으면 LinkedList, 조회가 많으면 ArrayList

하지만 대부분의 경우 ArrayList가 빠르다.

 

 

 

4. 쓸데없는 객체 생성 or 스트링 조합

String result = "";
for (int i = 0; i < 10000; i++) {
    result += i; // 매번 새로운 문자열 생성됨
}

 

위 로직에서는 문자열이 계속해서 복사된다 → O(n^2) 그로인해 GC 빈도가 증가하고 CPU가 과도하게 사용된다

 

StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.append(i);
}

 

StringBuilder 를 사용해 쓸데없는 문자열 생성을 방지할 수 있다.

 

 

 

5. 캐시 or DB index 미사용

반복해서 부르는 데이터인데 매번 DB를 요청하거나, 검색 쿼리에 인덱스를 미사용하는 경우 → Full Table Scan이 발생할 수 있다.

SELECT * FROM orders WHERE status = 'PENDING';

 

→ status에 인덱스가 없다면 전체 테이블을 검색한다.

 

 

OAuth2가 무엇인가?

OAuth2는 로그인을 대신해주는 표준 방식이다. 사용자가 비밀번호를 입력하지 않고, 구글/카카오 계정으로 로그인하는 것을 가능하게 한다.

 

기본 흐름(단순화)를 살펴보면 

  1. 사용자가 구글 로그인 버튼 클릭
  2. Spring Security → 구글 서버로 리다이렉트
  3. 구글 로그인 → 우리 서버로 code 반환
  4. Spring Security가 code 로 access token 요청
  5. access token으로 사용자 정보 요청
  6. 사용자 정보 기반으로 인증 처리 완료

 

Spring Security OAuth2 구조를 이해해보자

Spring Security는 OAuth2 로그인을 처리하기 위해 다음 컴포넌트를 사용한다.

컴포넌트 역할
OAuth2LoginAuthenticationFilter 로그인 요청 가로채기
DefaultOauth2UserService 사용자 정보 조회
OAuth2User 조회된 사용자 정보 모델
AuthenticationSuccessHandler 로그인 성공 후 처리
ClientReistration 클라이언트 정보(google, kakao 등)

 

 

카카오 / 구글 연동을 위한 기본 설정 예시

// application.yml 예시

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: <GOOGLE_CLIENT_ID>
            client-secret: <GOOGLE_CLIENT_SECRET>
            scope: profile, email
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            client-name: Google
          kakao:
            client-id: <KAKAO_REST_API_KEY>
            client-secret: <KAKAO_CLIENT_SECRET>
            scope: profile_nickname, account_email
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            client-name: Kakao
        provider:
          kakao:
            authorization-uri: https://kauth.kakao.com/oauth/authorize
            token-uri: https://kauth.kakao.com/oauth/token
            user-info-uri: https://kapi.kakao.com/v2/user/me
            user-name-attribute: id

 

 

 

핵심 커스터마이징 포인트

 

1. 사용자 정보 가공 - CustomOAuth2UserService

 

구글 / 카카오에서 가져오는 사용자 정보는 정형화돼 있지 않다. 서비스마다 반환 형식도 다르고, 내가 원하는 도메인 정보와 맞지 않을 수도 있다.

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2User oAuth2User = super.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId(); // google, kakao
        String userNameAttr = userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        Map<String, Object> attributes = oAuth2User.getAttributes();

        OAuthAttributes mappedAttributes = OAuthAttributes.of(registrationId, userNameAttr, attributes);

        // DB에 사용자 저장
        Member member = saveOrUpdate(mappedAttributes);

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                mappedAttributes.getAttributes(),
                mappedAttributes.getNameAttributeKey());
    }
}

 

 

2. 최초 로그인 시 회원가입 처리

private Member saveOrUpdate(OAuthAttributes attributes) {
    Optional<Member> user = memberRepository.findByEmail(attributes.getEmail());

    if (user.isEmpty()) {
        return memberRepository.save(attributes.toEntity());
    }

    return user.get(); // 기존 사용자
}

 

 

3. 사용자 도메인 모델 매핑

@Getter
public class OAuthAttributes {
    private Map<String, Object> attributes;
    private String nameAttributeKey;
    private String name;
    private String email;

    public static OAuthAttributes of(String provider, String userNameAttributeName, Map<String, Object> attributes) {
        if ("kakao".equals(provider)) {
            return ofKakao(userNameAttributeName, attributes);
        }
        return ofGoogle(userNameAttributeName, attributes);
    }

    private static OAuthAttributes ofGoogle(String userNameAttributeName, Map<String, Object> attributes) {
        return new OAuthAttributes(attributes, userNameAttributeName,
                (String) attributes.get("name"),
                (String) attributes.get("email"));
    }

    private static OAuthAttributes ofKakao(String userNameAttributeName, Map<String, Object> attributes) {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> profile = (Map<String, Object>) kakaoAccount.get("profile");

        return new OAuthAttributes(attributes, userNameAttributeName,
                (String) profile.get("nickname"),
                (String) kakaoAccount.get("email"));
    }

    public Member toEntity() {
        return new Member(name, email, Role.USER);
    }
}

 

 

 

4. JWT 발급 연동

OAuth2 로그인 성공 후 JWT를 발급하고 응답에 실어주는 구조도 자주 사용한다.

public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {

    private final JwtUtil jwtUtil;

    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) {
        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
        String email = (String) oAuth2User.getAttributes().get("email");

        String token = jwtUtil.createToken(email);

        response.setHeader("Authorization", "Bearer " + token);
    }
}

 

 

 

전체 흐름을 요약해보면

 

  1. 사용자가 /oauth2/authorication/kakao 클릭 
  2. 카카오 로그인
  3. code 반환
  4. Spring이 token 요청
  5. 사용자 정보 조회
  6. CustomOAuth2UserService 실행
  7. DB 회원가입 처리
  8. SecurityContext 인증 저장
  9. SuccessHandler에서 JWT 발급
  10. 클라이언트로 응답

 

OAuth2 로그인은 단순해 보이지만 실제 서비를 만들 때는 사용자 정보 가공, JWT 발급, 회원가입 처리 등 커스터마이징이 꼭 필요한 포인트들이 많다.

Spring Security는 쉽게 말해 보안 담당자이다.

사이트에 누가 로그인했는지 확인하고, 접근해도 되는 페이지인지 판단해주는 경비원 역할을 한다. 

 

기본 기능만 사용해도 로그인/로그아웃, 세션 관리 등이 된다. 하지만 실무에서 사용하려면 100% 커스터마이징이 필수이다.

 

왜 커스터마이징이 필요할까?

요구사항 기본 Spring Security로 가능?  해결 방법
JWT 인증으로 로그인하고 싶다 커스텀 필터 필요
로그인 실패 시 로그 찍고 싶다 핸들러 오버라이드
/admin/** 경로는 관리자만 보게 하고 싶다 hasRole() 설정 필요
인증 실패 응답을 JSON으로 주고 싶다 AuthenticationEntryPoint 등록

 

즉, 기능은 있지만, 내가 원하는 방식은 아닐 것이다. 그래서 진짜 실무에선 필터, 핸들러, 서비스 다 바꿔서 쓰는게 기본이다.

 

 

실무에서 자주 쓰는 Spring Security 커스터마이징 TOP 5 에 대해서 알아보자

 

 

1. JWT 인증 필터 추가하기

Spring Security는 기본적으로 세션 기반인데, 요즘은 대부분 JWT 기반을 사용한다.

 

구현 흐름 : 

  1. UsernamePasswordAuthhenticationFilter 이전에 커스텀 필터 등록
  2. 요청 헤더에서 JWT 호출 → 유효성 검사 → 인증객체 생성
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) {
        String token = request.getHeader("Authorization");
        if (token != null && jwtUtil.validateToken(token)) {
            Authentication auth = jwtUtil.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }
        filterChain.doFilter(request, response);
    }
}

 

등록하기

http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

 

 

 

2. 로그인 성공/실패 핸들러 커스터마이징

로그인 성공/실패 시, 특정 페이지로 리디렉션하거나 로그를 남기고 싶을 때 사용한다.

@Component
public class CustomLoginSuccessHandler implements AuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request,
                                        HttpServletResponse response,
                                        Authentication authentication) {
        System.out.println("로그인 성공: " + authentication.getName());
        response.sendRedirect("/dashboard");
    }
}

 

등록은 .successHandler() / .failuerHandler() 에서 하면 된다.

 

 

 

3. URL 별로 접근 권한 다르게 설정

기본 설정만으로는 URL마다 세세한 권한 제어가 어렵다.

http.authorizeHttpRequests()
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .requestMatchers("/user/**").authenticated()
    .anyRequest().permitAll();

 

  • hasRole("ADMIN") : 관리자만 접근 가능
  • authenticated() : 로그인한 사람만 가능
  • permitAll() : 아무나 접근 가능

 

 

4. 사용자 인증 커스터마이징

로그인 시 사용자를 어떻게 검증할지 우리가 직접 정할 수 있다.

@Service
public class CustomUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) {
        Member member = memberRepository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("사용자 없음"));
        return new User(member.getEmail(), member.getPassword(),
                List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole())));
    }
}

 

등록은 Security 설정에서 이렇게 한다.

.auth.userDetailsService(customUserDetailsService)

 

 

 

 

5. 예외 응답을 JSON으로 주기

기본적으로 인증 실패하면 403 페이지가 뜬다. API 서버는 JSON으로 에러 응답을 줘야 한다.

 

* 인증 실패(401 Unauthorized)

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.getWriter().write("{\"error\": \"인증이 필요합니다\"}");
    }
}

 

* 권한 없음(403 Forbidden)

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException accessDeniedException) {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json");
        response.getWriter().write("{\"error\": \"접근 권한이 없습니다\"}");
    }
}

 

등록은 마찬가지로 Security 설정에서 한다.

http.exceptionHandling()
    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
    .accessDeniedHandler(jwtAccessDeniedHandler);

데이터베이스에 대량의 데이터를 삽입(insert) 할 때, 같은 데이터를 넣더라도 방식에 따라 속도 차이가 최대 5배 이상 나는 경우가 있다.

 

 100만 건의 데이터를 DB에 넣는다고 가정해보자. (Oracle, PostgreSQL, MySQL 모두 해당된다)

 

설명
1. 단건 insert (1건씩 insert 수행, 오토커밋)
2. 단건 insert + 수동 커밋 (트랜잭션 한 번에 묶음)
3. PreparedStatement 반복 (JDBC 배치 처리)
4. ORM (BatchSize 설정)
5. Bulk insert SQL (insert into values (...), (....))

 

위와 같은 방식이 있을것이다. 각각의 방식이 왜 차이가 생기는지 정리를 해보자.

 

 

1. 단건 insert (1건씩 insert + 오토커밋)

for (int i = 0; i < 1000000; i++) {
    String sql = "INSERT INTO users (name, email) VALUES (?, ?)";
    try (Connection conn = dataSource.getConnection();
         PreparedStatement pstmt = conn.prepareStatement(sql)) {
        pstmt.setString(1, "User" + i);
        pstmt.setString(2, "user" + i + "@example.com");
        pstmt.executeUpdate(); // 매번 DB round-trip 발생
    }
}

 

위 방식은 

  • 커넥션 매번 생성/종료 : 너무 느림
  • 매번 오토커밋 : DB 트랜잭션 비용 증가
  • I/O 부하 증가 : 디스크 flush가 반복됨

이런 단점들로 인해 가장 느리다(100만 건 기준 수 분 이상 소요될 수 있음)

 

 

 

2. 단건 insert + 수동 커밋 (트랜잭션 묶기)

Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);
PreparedStatement pstmt = conn.prepareStatement("INSERT INTO users (name, email) VALUES (?, ?)");

for (int i = 0; i < 1000000; i++) {
    pstmt.setString(1, "User" + i);
    pstmt.setString(2, "user" + i + "@example.com");
    pstmt.executeUpdate();
}
conn.commit();

 

위의 1번 방식보다는 개선되었다.

  • 트랜잭션이 한 번만 발생 → 비용 절감
  • 커넥션 재사용
  • 단건 처리지만 I/O flush 횟수 감소

하지만 여전히 100만 건 DB 요청을 100만 번 한다.

 

 

 

3. JDBC Batch Insert

Connection conn = dataSource.getConnection();
conn.setAutoCommit(false);
PreparedStatement pstmt = conn.prepareStatement("INSERT INTO users (name, email) VALUES (?, ?)");

for (int i = 0; i < 1000000; i++) {
    pstmt.setString(1, "User" + i);
    pstmt.setString(2, "user" + i + "@example.com");
    pstmt.addBatch();

    if (i % 1000 == 0) {
        pstmt.executeBatch();
        pstmt.clearBatch();
    }
}
pstmt.executeBatch(); // 남은 것 처리
conn.commit();

 

위 방식의 핵심은

  • addBatch() 로 모아서 executeBatch()로 일괄처리
  • DB round-trip이 100만 번 1,000번으로 줄어든다
  • 트랜잭션도 한 번만 발생

그렇다면 성능은 개선되었을까?

  • 일반적인 기본 insert의 3~5배 이상 빠름
  • PostgreSQL, Oracle, MySQL 모두 지원한다
  • 메모리 사용량만 주의가 필요하다 (Batch 크기가 너무 크면 OOM 발생 가능성이 있다)

 

 

4. ORM (JAP/Hibernate) + BatchSize 설정

@Entity
public class User {
    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private String email;
}

 

기본 insert (JAP) : 

for (int i = 0; i < 1000000; i++) {
    User u = new User("User" + i, "user" + i + "@email.com");
    entityManager.persist(u);
}

 

위 방식에서의 문제점은

  • insert 쿼리 100만 번 발생
  • flush/commit 지연 발생
  • 트랜잭션이 너무 길어지면 성능 저하/락 발생

개선하는 방법은 Batch 설정이다

properties : 

spring.jpa.properties.hibernate.jdbc.batch_size=1000
spring.jpa.properties.hibernate.order_inserts=true

 

→ Hibernate가 내부적으로 JDBC addBatch()를 호출하도록 해준다

 

이렇게 개선후의 성능은

  • 거의 JDBC batch insert 수준까지 도달 가능하다
  • ORM 코드 유지 + 성능 모두 확보가 가능하다

 

 

5. Raw SQL bulk insert (특정 DB 지원)

INSERT INTO users (name, email)
VALUES 
('User1', 'user1@email.com'),
('User2', 'user2@email.com'),
('User3', 'user3@email.com');
-- 수천 개 VALUES 묶음

 

특징 : 

  • 가장 빠른 방식 (DB 엔진 최적화)
  • 단점 : 가변 데이터 처리에 어려움이 있다
  • 일반적으로 ORM에서는 사용하기 힘들다
  • 대량 데이터 마이그레이션, 초기 로딩에 적합하다

 

마지막으로  실행 결과를 비교해보자

방식 실행 시간 특징
단건 insert (오토커밋) 약 800초 최악
단건 insert + 수동 커밋 약 400초 약간 개선
JDBC batch insert 약 90초 훨씬 빠름
JPA + batch 설정 약 110초 안정적 + 실무 적합
raw bulk insert 약 40초 가장 빠름 (단, 가변 데이터 처리에 어려움이 있다)

 

Bean이라는 말, 대체 뭘까?

Bean은 Spring이 대신 만들어주고 관리해주는 Java 객체(물건) 이다.

 

예를 들어보자.

 

엄마가 아침에 계란후라이를 만들어줬다고 가정해보자. 계란후라이는 내가 직접 만들 수도 있지만, 엄마가 미리 만들어 놓으면 내가 그냥 꺼내먹을 수 있을것이다.

 

Spring Bean도 똑같다. 

Java에서는 우리가 객체를 new 키워드를 사용해 직접 만들어야 하는데, Spring은 객체를 미리 만들어서 냉장고처럼 보관해놓는다.

 

 

그럼 Bean은 왜 필요할까?

프로그래밍에서는 많은 객체들이 서로 협력을 통해 작동을한다.

 

예로 커피 앱을 만들었다고 가정해보면

  • 커피를 내리는 객체
  • 주문을 받는 객체
  • 알림을 보내는 객체

등이 있을것이고 이 객체들을 하나하나 new로 만들고 연결하고 관리가 필요한데 이건 너무 복잡하고 비효율적일 것이다.

 

그래서 Spring이 "내가 다 해줄게~" 라고 해서 Bean을 대신 만들어주고, 서로 연결까지 해준다.

 

 

Bean은 어떻게 만들까?

방법1. @Component 스티커 붙이기

 

@Component
public class CoffeeMachine {
    public String make() {
        return "커피 나왔어요!";
    }
}

 

이렇게 @Component 라는 표시를 붙이면, Spring이 알아서 이 클래스를 Bean으로 만들어서 보관해준다.

 

 

방법2. @Bean 사용하기(조금 더 수동적)

@Configuration
public class AppConfig {
    @Bean
    public CoffeeMachine coffeeMachine() {
        return new CoffeeMachine();
    }
}

 

이건 프로그래머가 직접 등록하고 싶은 경우이다.

 

 

이렇게 생성한 Bean은 어떻게 사용할까?

Bean은 Spring이 만들어서 저장해 놓은 객체라고 했다. 우리는 그 Bean을 그냥 요청만 하면 된다.

@Component
public class Cafe {
    private final CoffeeMachine machine;

    public Cafe(CoffeeMachine machine) {
        this.machine = machine;
    }

    public void serve() {
        System.out.println(machine.make());
    }
}

 

Spring이 자동으로 CoffeeMachine을 Cafe에 딱 끼워 넣어준다. 이걸 의존성 주입(Dependency Injection)이라고 부른다.

 

 

Bean의 특징 3가지

특징 설명
자동으로 만들어진다 @Component@Bean 으로 표시만 하면 끝!
하나만 만들어진다 기본적으로 Bean은 하나만 만들어진다 (Singleton)
서로 연결이 가능하다 하나의 Bean이 다른 Bean을 사용할 수 있다

 

 

 

Bean이 없는 상황을 한번 살펴보면 편리함이 더 잘 느껴질것 같다.

public class App {
    public static void main(String[] args) {
        CoffeeMachine machine = new CoffeeMachine();
        Cafe cafe = new Cafe(machine);
        cafe.serve();
    }
}

 

이렇게 직접 new로 다 만들어야 한다. 코드가 길어지고, 테스트하기도 어렵고, 관리도 힘들어진다.

 

 

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

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일 때도 안전하다.

 

 

 

왜 이걸 알아야 할까?

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

  • 스레드가 고갈돼서 서버가 멈춘다.
  • @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, 세션 등 "스레드 고정이 전제된 기능"이 깨질 수 있다.

코드는 돌아가기만 하면 되는 거 아니야?? 라는 생각을 처음엔 했었다. 하지만 서비스가 커지고 사용자 수가 늘어나거나, 잘못된 로직으로 인해 장애, 운영 비용 증가, 사용자 이탈로 이어지게 된다.

 

그래서 개발자는 기능과 성능을 동시에 잡는 코드를 짤 줄 알아야 한다.

 

그럼 성능이 안나오는 코드는 어떤 특징을 가지고 있는지 알아보자

 

 

1. 데이터 구조를 무시한 코드

일을 하다보면 무의식적으로 무조건 List에 담고 for문을 돌리는 경우가 있다. 그러다 해시맵을 써야 할 곳에 배열을 쓰게 되는 경우가 발생한다.

 

이렇게 되면 검색, 삽입, 삭제할 때 시간 복잡도가 폭발하게 된다.

// 10,000건 데이터 중에서 특정 ID 찾기
for (User user : userList) {
    if (user.getId() == targetId) {
        return user;
    }
}

 

처음부터 Map<Integer, User> 로 관리했다면 O(1) 시간에 찾을 수 있다.

 

 

2. 쓸데없이 많은 IO (DB/네트워크 호출)

for문 안에서 매번 DB나 API 호출하는 경우나 같은 데이터를 반복해서 조회하는 경우 네트워크 지연, DB부하 폭발 → 시스템 전체가 느려짐으로 연결될 수 있다.

 

for (Order order : orders) {
    Customer customer = customerRepository.findById(order.getCustomerId());
    // ...
}

 

위 코드에서 orders가 1만개라면 DB를 1만 번 호출하게 된다. 

 

이럴경우 한 번에 Customer 데이터를 IN 조건으로 미리 가져오거나 조인(Join)으로 조회해 DB 접근을 최소화 한다.

 

 

 

3. 캐시를 쓰지 않는다.

자주 조회하는 데이터를 매번 새로 계산하는 경우나 매번 외부 API를 호출하는 경우 CPU, 메모리 낭비 + 응답 지연이 발생할 수 있다.

 

예를 들어

public Product getBestSeller() {
    // 매번 DB를 때려서 상위 10개를 조회
    return productRepository.findTop10BySalesOrderByDesc();
}

 

하루에도 수만 번 호출될 수 있다.

 

인기상품 같은 건 5분 간격으로 캐시에 저장하거나 Redis, Caffeine 같은 캐시를 활용한다.

 

 

 

4. 데이터 양에 대한 고려가 없을 때

어차피 데이터가 몇 건 없을거야 가정하고 코딩을했을 때 만약 한 번에 100만건 select 후 메모리에 올리게 되면 어떻게 될까?

데이터가 늘어나면 OOM(OutOfMemoryError) 혹은 서버가 터질수도 있다.

 

List<User> allUsers = userRepository.findAll(); // 수십만 건

 

초반엔 문제 없을 수 있지만, 추후 데이터가 쌓이게 되면 대규모 트래픽 시 서버가 뻗을 수 있다.

페이징 처리(LIMIT, OFFSET) 혹은 스트리밍 처리(cursor 기반 조회) 으로 개선이 필요하다.

 

 

 

5. 비효율적인 반복 로직

이중 for문, 삼중 for문을 남발하는 경우 or 같은 계산을 여러 번 반복하는 경우 시간복잡도가 기하급수적으로 증가할 수 있다.

for (Item itemA : itemList) {
    for (Item itemB : itemList) {
        if (itemA.equals(itemB)) continue;
        // 비교 처리
    }
}

 

O(n^2) 알고리즘이다. 사전에 정렬하거나 해시셋(HashSet)을 활용, 필요없는 반복을 최소화해서 개선을 할 수 있다.

 

 

 

정리하자면 

항목 핵심 요약
데이터 구조 상황에 맞는 자료구조를 써야 한다
IO 최소화 DB, API 호출을 줄여야 한다
캐시 적극 사용 비싼 연산은 저장하고 재사용한다
데이터 양 고려 언제든 데이터가 늘어날 수 있다
반복 최소화 이중/삼중 for문은 반드시 경계한다

쿼리 계획이란?

쿼리 계획(Execution Plan)은 오라클 데이터베이스가 SQL 문장을 실행할 때 "어떤 방법으로 데이터를 읽고 처리할지" 미리 계산해서 보여주는 설명서이다.

 

즉,

  • 테이블을 풀스캔할지?
  • 인덱스를 탈지?
  • 조인을 어떤 순서로 할지? 

등을 알려주는 실행 청사진이다.

 

이걸 보면 쿼리 성능 문제를 미리 예측하거나 최적화할 수 있다.

 

 

SQL Developer에서 Explain Plan 확인하는 기본 방법

  1. SQL Developer 실행
  2. 오라클 DB에 로그인
  3. 쿼리 작성  ex) SELECT * FROM employees WHERE department_id = 10;
  4. 쿼리 블록을 드래그하거나 선택한 후, 상단 메뉴에서 Explain Plan(F10) 버튼 클릭 또는 오른쪽 클릭 → Explain Plan 선택

 

Explain Plan 결과 화면 읽는 방법

컬럼 설명
Operation 어떤 작업을 했는지(Full Table Scan, Index Scan 등)
Object Name 대상 테이블이나 인덱스 이름
Rows (Cardinality) 예측 결과로 읽게 될 데이터 건수
Cost 이 작업에 필요한 예상 리소스 소비량(CPU, IO 등)
Bytes 읽어야 할 데이터 크기

 

Cardinality 가 높다 = 많은 데이터가 나온다. / Cardinality가 낮다 = 적은 데이터가 나온다.

 

자주 나오는 Operation 종류 및 의미

Operation 의미
TABLE ACCESS FULL 테이블 전체를 읽는다( 성능 위험 신호 But 더 좋을때도 있음!)
INDEX RANGE SCAN 인덱스를 범위 검색(좋음)
INDEX UNIQUE SCAN 인덱스를 정확히 하나만 검색(매우 좋음)
NESTED LOOPS 두 테이블을 반복적으로 조인(작은 데이터에 적합)
HASH JOIN 대량 데이터 조인 (메모리 사용 많음)
SORT AGGREGATE 집계 연산(SUM, COUNT 등)

 

 

 

Explain Plan 볼 때 주의할 점!

1. Cost가 낮다고 무조건 좋은 게 아니다.

Cost는 참고용이고 "IO 비용"을 줄이는게 최종 목표이다.

 

2. 풀스캔(TABLE ACCESS FULL)이 무조건 나쁜 건 아니다.

아주 작은 테이블이라면 풀스캔이 더 빠를 수도 있다. 하지만 큰 테이블이라면 반드시 인덱스 활용을 고려해봐야한다.

 

3. 실행계획이 변할 수 있다.

바인드 변수, 통계 정보, 힌트 등에 따라 변동이 가능하다. ALWAYS 실제 데이터와 상황을 고려해야 한다.

 

4. AUTOTRACE나 DBMS_XPLAN.DISPLAY를 써보자

Explain Plan은 예측, Autotrace는 진짜 실행 기반이므로 함께 보는 게 좋다.

대부분의 웹 애플리케이션은 DB Connection Pool을 사용한다. DB 커넥션 풀 덕분에 동시 사용자 요청을 빠르게 처리할 수 있다.

 

하지만 어느 순간, 예상치 못한 상황이 발생한다. 

 

그 순간중 하나가 바로 커넥션 풀 고갈(Connection Exhaustion) 이다.

 

  • DB 연결 대기 시간이 급격히 증가한다
  • 요청이 지연된다
  • 최악의 경우 애플리케이션 전체가 다운된다.

 

그렇다면 DB Connection Pool에 대해서 알아보자.

 

DB Connection Pool 이란?

DB 연결(Connection)을 매번 새로 열고 닫으면 비용이 크기 때문에, 일정 수의 커넥션을 미리 생성해 풀(pool)에 담아두고 재사용하는 구조이다.

 

특징 설명
커넥션 생성 비용 절감 연결이 미리 준비되어 빠른 응답 가능
커넥션 수 제한 가능 DB에 과한 부하를 주지 않음
대기시간 단축 필요시 즉시 커넥션 제공 가능

 

인기 있는 커넥션 풀 라이브러리는 다음과 같다.

  • HikariCP (스프링 부트 기본)
  • Tomcat JDBC Pool
  • DBCP2 (Apache)

그렇다면 커넥션 풀 고갈이란 무엇일까?

 

 

커넥션 풀 고갈(Connection Pool Exhaustion) 이란?

커넥션 풀 안의 커넥션이 모두 사용 중이어서, 새로운 요청에 빌려줄 커넥션이 없는 상태를 말한다.

 

발생 과정을 간략하게 살펴보면

  1. 사용자는 요청을 보낸다.
  2. 서버는 DB 작업을 하려고 커넥션을 풀에서 꺼낸다.
  3. 풀에 남은 커넥션이 없다.
  4. 요청은 대기(wait) 하거나, 시간 초과(timeout) 되며 실패한다.

커넥션 풀이 고갈되면

현상 설명
서버 응답 지연 커넥션을 얻을 때까지 기다리면서 전체 응답이 느려진다
타임아웃 발생 일정 시간 안에 커넥션을 못 얻으면 에러가 발생한다
DB 부하 급증 또는 다운 일부 트랜잭션이 커넥션을 오래 점유하면 DB에 과부화
서버 과부화/장애 요청이 몰리면서 스레드/큐가 포화, 시스템 전반 문제 발생

 

이런 현상들이 발생할 수 있다.

 

스프링부트 + HikariCP 에서 고갈상황을 억지로 만들어 보자.

spring.datasource.hikari.maximum-pool-size: 10
spring.datasource.hikari.connection-timeout: 30000

 

위 설정을 추가해

  • 최대 10개의 커넥션만 풀에 존재시키고
  • 커넥션을 못 빌리면 최대 30초 동안 대기 후 타임아웃이 발생

만약 동시에 요청이 50개 들어오고, 각각 DB 작업을 오래 잡고 있으면 어떻게 될까?

 

결과 : 

  • 처음 10개는 커넥션 얻고 정상 처리
  • 나머지 40개는 커넥션 풀에서 대기
  • 일부 요청은 30초 대기하다 타임아웃 발생
  • 서버 전체가 느려지고 장애 징후가 보인다.

 

이런 커넥션 풀 고갈의 주요 원인은 다음과 같은 것들이 있다.

 

1. 커넥션 미반납(Conneciton Lock)

val conn = dataSource.connection
// ... 쿼리 수행
// conn.close() 빠뜨림!!

 

close()를 호출하지 않으면, 커넥션 풀로 반환되지 않는다. 시간이 지나면 커넥션이 모두 소비되고 고갈된다.

항상 try-with-resources 또는 finally 블록에서 명시적으로 close 해야 한다.

 

 

2. 긴 트랜잭션(Long Transaction)

트랜잭션 안에서 오래 걸리는 로직(슬로우 쿼리, 외부 API 호출 등)이 있으면, 커넥션을 장시간 점유하게 된다. 이렇게 되면

커넥션을 회수하지 못하고 대기열만 쌓임 → 고갈 상태가 된다.

 

 

3. 예상 외 트래픽 폭주

대규모 이벤트, 외부 봇 공격, 잘못된 반복 호출 등으로 서버가 과도하게 많은 요청을 동시에 처리하게 되면, 커넥션 풀 한계를 초과할 수 있다.

 

 

4. 커넥션 풀 설정 오류

  • maximumPoolSize를 너무 작게 설정
  • connectionTimeout을 너무 짧거나 길게 잡은경우

위와 같은 설정 문제도 고갈을 부추길 수 있다.

 

 

 

고갈 대응 방법

방법 설명
커넥션 풀 크기 조정 CPU 코어 수 x 2 또는 예상 TPS에 맞춰 조정
커넥션 타임아웃 설정 connectionTimeout을 적절하게 조절(ex: 5초)
커넥션 반납 철저 관리 close 누락 방지(try-with-resources 적극 사용)
트랜잭션 최소화 DB 점유 시간을 줄인다(슬로우 쿼리 개선)
커넥션 풀 모니터링 HikariCP Metrics, JMX를 통해 실시간 감지
슬로우 쿼리 탐지 및 개선 DB 쿼리 튜닝, 인덱스 최적화
백프레셔(Backpressure) 적용 서버에 요청이 몰릴 때, 내부적으로 대기시키거나 제한
이중화 및 읽기 전용 Replica DB 활용 부하 분산

+ Recent posts