개발자라면 한번쯤 겪어봤을 문제가 있다. 

 

"엥? 로컬에서는 잘 됐는데... 왜 서버에서는 안되는걸까?"

 

이 문제의 원인은 내 컴퓨터와 서버의 환경(OS, 자바 버전, 설치된 라이브러리 등)이 다르기 때문이다. Docker는 이런 문제를 해결하기 위해 나왔다.

 

Docker는 '컨테이너'라는 기술을 사용한다. 컨테이너는 내 애플리케이션과 그 실행에 필요한 모든 환경(자바, 라이브러리 등)을 하나의 패키지로 묶어서 격리시킨 공간이다.

 

이 패키지는 어디서든 똑같이 동작하기 때문에, 내 컴퓨터에서 실행되던 것이 서버에서도, 동료의 컴퓨터에서도 똑같이 실행되는 것을 보장한다.

 

꼭 알아야 할 Docker 핵심 용어 3가지

붕어빵을 만든다고 생각해보자.

 

1. 이미지 (Image) : 

  •  비유 : 붕어빵 틀에 해당한다. 붕어빵을 만들기 위한 모든 재료(밀가루 반죽, 팥)와 레시피가 담겨있는 '설계도' 또는 '압축 파일'같은 것이다.
  •  애플리케이션을 실행하는 데 필요한 모든 것(Java, Spring 애플리케이션 코드, 라이브러리 등)이 포함된 읽기 전용(Read-Only) 템플릿이다.

2. 컨테이너 (Container) : 

  • 비유 : 붕어빵 틀(이미지)로 찍어낸 실제 붕어빵이다. 우리는 붕어빵을 먹는 것이지, 붕어빵 틀을 먹는게 아니다.
  • 이미지(Image)를 실제로 메모리에 올리고 실행한 인스턴스(프로세스)이다. 하나의 이미지로 여러 개의 컨테이너를 만들 수 있다. 

3. Dockerfile : 

  • 비유 : 붕어빵 레시피이다. "밀가루 반죽을 넣고, 팥을 넣고, 3분간 굽는다"와 같이 이미지를 어떻게 만들지 순서대로 적어놓은 텍스트 파일이다.
  • 개발자가 이미지를 만들기 위해 작성하는 스크립트이다. 이 Dockerfile을 실행하면 Docker가 알아서 이미지를 만들어준다.

정리해보면 개발자는 Dockerfile을 작성해서, Image를 만들고, 그 이미지를 실행해서 Container를 띄운다.

 

 

 

그렇다면 직접 내 애플리케이션을 Docker에 띄워보자

 

STEP 0 : Docker 설치하기

 

가장 먼저 내 컴퓨터에 Docker를 설치해야 한다. 아래 링크에서 'Docker Desktop'을 다운로드하여 설치한다. (Windows나 Mac에 맞는 버전을 설치하시면 된다.)

설치 후 Docker Desktop을 실행하면 준비 완료이다.

 

STEP 1: Spring Boot 애플리케이션 준비

 

1. 간단한 "Hello World"를 출력하는 Spring Boot 애플리케이션을 준비한다.

 

2. 가장 중요한 단계! 애플리케이션을 .jar 파일로 빌드해야 한다.

  • Gradle 사용자: 터미널에서 .\gradlew build (또는 ./gradlew build) 실행
  • Maven 사용자: 터미널에서 .\mvnw package (또는 ./mvnw package) 실행

3. 빌드가 성공하면 build/libs 또는 target 폴더 안에 프로젝트명-0.0.1-SNAPSHOT.jar 같은 파일이 생성된 것을 확인합니다.

 

STEP 2: Dockerfile 작성하기 (레시피 만들기)

# 1. 베이스 이미지 선택 (Java 17 버전을 사용)
# 'slim' 버전은 불필요한 것들을 빼서 용량이 가볍다.

FROM openjdk:17-jdk-slim

# 2. 컨테이너 내에서 작업할 디렉토리 생성

WORKDIR /app

# 3. 빌드된 .jar 파일을 컨테이너의 /app 디렉토리로 복사
# Gradle: build/libs/*.jar, Maven: target/*.jar 경로를 확인한다.

COPY build/libs/*.jar app.jar

# 4. 컨테이너가 시작될 때 실행할 명령어
# "java -jar app.jar" 명령어를 실행하여 애플리케이션을 구동한다.

ENTRYPOINT ["java", "-jar", "app.jar"]

 

 

STEP 3: Docker 이미지 빌드하기 (붕어빵 틀 만들기)

이제 터미널을 열고 프로젝트 최상위 폴더(Dockerfile이 있는 위치)에서 아래 명령어를 실행한다.

 

docker build -t my-spring-app .

 

 

  • docker build: 이미지를 만드는 명령어이다.
  • -t my-spring-app: 이미지에 my-spring-app이라는 이름(태그)을 붙여줍니다. 이름은 원하는 대로 바꿀 수 있다.
  • . : 현재 디렉토리에 있는 Dockerfile을 사용하라는 의미이고, 마지막에 점(.)을 꼭 찍어야한다.

 

STEP 4: Docker 컨테이너 실행하기 (붕어빵 굽기)

이미지가 완성되었으니, 이제 이 이미지로 컨테이너를 실행할 차례이다. 터미널에 아래 명령어를 입력한다.

 

docker run -p 8080:8080 my-spring-app

 

 

  • docker run: 이미지를 컨테이너로 실행하는 명령어이다.
  • -p 8080:8080: 포트 포워딩(Port Forwarding) 설정이다. "내 컴퓨터(Host)의 8080 포트를 컨테이너의 8080 포트와 연결해줘" 라는 의미이다. Spring Boot는 기본적으로 8080 포트를 사용하므로 이렇게 설정한다.
  • my-spring-app: 아까 STEP 3에서 만든 이미지의 이름이다.

명령어를 실행하면 Spring Boot 애플리케이션이 실행될 때의 로그가 터미널에 나타난다.

 

STEP 5: 실행 확인

웹 브라우저를 열고 주소창에 http://localhost:8080 을 입력해보면  애플리케이션이 정상적으로 보인다면 성공이다!

 

 

 

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

 

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

 

그중에서도 가장 자주 반복되는 특징 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일 때도 안전하다.

 

 

 

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

 

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

 

 

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
캐시  (0) 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, 세션 등 "스레드 고정이 전제된 기능"이 깨질 수 있다.

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

 

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

 

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

 

 

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문은 반드시 경계한다

+ Recent posts