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

 

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

 

이 문제의 원인은 내 컴퓨터와 서버의 환경(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 발급, 회원가입 처리 등 커스터마이징이 꼭 필요한 포인트들이 많다.

+ Recent posts