CSP(Content Security Policy)란 무엇인가?

웹 애플리케이션을 개발하다 보면 XSS(Cross-Site Scripting)와 같은 웹 보안 공격을 고려해야 한다.

공격자가 웹 페이지에 악성 JavaScript를 삽입하는 데 성공하면 사용자의 쿠키나 세션 정보가 탈취되거나, 사용자를 악성 사이트로 유도하는 등의 문제가 발생할 수 있다.

이러한 공격을 방어하기 위한 방법 중 하나가 CSP(Content Security Policy)다.

CSP는 브라우저가 웹 페이지에서 어떤 리소스를 어디에서 불러올 수 있는지 제한하는 보안 정책이다.

쉽게 말하면 서버가 브라우저에게 다음과 같은 규칙을 전달하는 것이다.

"이 페이지에서는 JavaScript를 이 출처에서만 실행해라."

"이미지는 이 출처에서만 가져와라."

"외부 iframe은 허용하지 마라."

브라우저는 서버가 전달한 CSP 정책을 기준으로 리소스의 로딩과 실행을 제한한다.

 


 

1. CSP가 필요한 이유

CSP를 이해하기 위해 대표적인 공격 방식인 XSS를 먼저 살펴볼 필요가 있다.

예를 들어 게시판의 댓글에 다음과 같은 악성 코드가 삽입됐다고 가정한다.

<script>
    alert('악성 코드 실행');
</script>

웹 애플리케이션이 사용자 입력을 제대로 검증하지 않는다면 해당 스크립트가 다른 사용자의 브라우저에서 실행될 수 있다.

실제 공격에서는 단순한 alert() 대신 사용자의 정보를 탈취하거나 특정 요청을 실행하는 등의 악성 코드가 사용될 수 있다.

CSP를 적용하면 브라우저가 실행할 수 있는 JavaScript의 출처나 실행 방식을 제한할 수 있다.

예를 들어 다음과 같은 정책을 설정할 수 있다.

Content-Security-Policy: script-src 'self'

이 정책은 현재 페이지와 동일한 출처에서 제공되는 JavaScript만 실행하도록 제한한다.

따라서 외부에서 삽입된 악성 스크립트가 있더라도 CSP 정책에 의해 실행이 차단될 수 있다.

다만 CSP는 XSS를 완벽하게 해결하는 방법은 아니다.

입력값 검증과 출력 시 인코딩, 적절한 세션 관리 등 기본적인 보안 조치와 함께 사용하는 추가적인 방어 계층으로 보는 것이 적절하다.

 


 

2. CSP는 어떻게 동작하는가?

CSP는 일반적으로 HTTP 응답 헤더를 통해 브라우저에 전달한다.

Content-Security-Policy: default-src 'self'

브라우저는 HTTP 응답을 받은 후 CSP 정책을 확인하고 해당 정책에 따라 리소스의 로딩과 실행을 허용하거나 차단한다.

예를 들어 서버가 다음과 같은 CSP를 전달한다고 가정한다.

Content-Security-Policy: default-src 'self'

'self'는 현재 웹 페이지와 동일한 Origin을 의미한다.

따라서 기본적으로 동일한 Origin에서 제공되는 리소스만 허용하고 다른 출처의 리소스는 차단한다.

 


 

3. CSP의 주요 Directive

CSP는 여러 가지 Directive를 사용해 리소스별로 세부적인 정책을 설정할 수 있다.

대표적인 Directive는 다음과 같다.

Directive설명

default-src 다른 Directive에서 별도로 지정하지 않은 리소스의 기본 정책
script-src JavaScript 실행 출처를 제한
style-src CSS 스타일의 출처를 제한
img-src 이미지 리소스의 출처를 제한
font-src 폰트 리소스의 출처를 제한
connect-src AJAX, Fetch, WebSocket 등의 연결 대상을 제한
media-src 오디오, 비디오 등의 미디어 출처를 제한
frame-src iframe 등 프레임에서 로드할 수 있는 출처를 제한
object-src <object>, <embed> 등의 리소스 출처를 제한
frame-ancestors 현재 페이지를 iframe으로 삽입할 수 있는 출처를 제한
base-uri <base> 태그에서 사용할 수 있는 URL을 제한
form-action <form> 요청을 보낼 수 있는 대상을 제한

default-src

default-src는 CSP의 기본 정책을 설정하는 Directive다.

Content-Security-Policy: default-src 'self'

별도의 정책이 지정되지 않은 리소스는 default-src의 정책을 따른다.

예를 들어 다음과 같이 설정하면

Content-Security-Policy: default-src 'self'

기본적으로 동일한 Origin의 리소스만 허용한다.

다만 특정 Directive를 별도로 설정하면 해당 Directive가 우선한다.

Content-Security-Policy:
    default-src 'self';
    img-src 'self' https://images.example.com;

이 경우 일반적인 리소스는 'self'만 허용하지만 이미지는 images.example.com에서도 가져올 수 있다.


script-src

script-src는 JavaScript의 실행 출처를 제어한다.

Content-Security-Policy: script-src 'self'

현재 Origin에서 제공되는 JavaScript만 실행하도록 제한한다.

외부 CDN을 사용하는 경우에는 해당 출처를 명시할 수 있다.

Content-Security-Policy:
    script-src 'self' https://cdn.example.com;

이 경우 현재 Origin과 cdn.example.com에서 제공되는 JavaScript를 허용한다.


style-src

style-src는 CSS 리소스의 출처를 제한한다.

Content-Security-Policy:
    style-src 'self' https://cdn.example.com;

현재 Origin과 지정된 CDN의 CSS만 허용한다.

여기서 주의할 점은 Inline Style이다.

다음과 같은 코드는 CSP 정책에 따라 차단될 수 있다.

<div style="color: red;">
    Hello
</div>

style-src 'self'만 설정한 경우 Inline Style을 허용하지 않는 것이 기본적인 보안 방향이다.


img-src

이미지를 가져올 수 있는 출처를 제한한다.

Content-Security-Policy:
    img-src 'self' https://images.example.com;

현재 Origin과 지정된 이미지 서버에서 가져오는 이미지만 허용한다.

외부 이미지 서버를 사용하는 서비스라면 해당 도메인을 CSP에 추가해야 한다.


connect-src

connect-src는 JavaScript에서 서버와 통신할 수 있는 대상을 제한한다.

예를 들어 다음과 같이 설정할 수 있다.

Content-Security-Policy:
    connect-src 'self' https://api.example.com;

이 경우 Fetch, XMLHttpRequest, WebSocket 등의 연결을 현재 Origin과 api.example.com으로 제한한다.

프론트엔드와 API 서버가 서로 다른 도메인으로 구성된 환경에서는 특히 중요하다.


frame-ancestors

frame-ancestors는 현재 페이지를 어떤 사이트에서 iframe으로 삽입할 수 있는지를 제한한다.

Content-Security-Policy:
    frame-ancestors 'self';

이렇게 설정하면 동일한 Origin에서만 해당 페이지를 iframe으로 삽입할 수 있다.

아예 다른 사이트에서 iframe으로 삽입하는 것을 막으려면 다음과 같이 설정할 수 있다.

Content-Security-Policy:
    frame-ancestors 'none';

이는 Clickjacking 공격을 방어하는 데 활용할 수 있다.

 


 

4. CSP에서 자주 사용하는 값

CSP에서는 몇 가지 특별한 값을 사용할 수 있다.

'self'

현재 페이지와 동일한 Origin을 의미한다.

script-src 'self'

현재 Origin의 스크립트만 허용한다.


'none'

모든 리소스를 허용하지 않는다.

object-src 'none'

object와 관련된 리소스를 모두 차단한다.


'unsafe-inline'

Inline Script 또는 Inline Style을 허용한다.

script-src 'self' 'unsafe-inline'

다만 unsafe-inline은 CSP의 보안 효과를 약화시킬 수 있으므로 가능한 한 사용하지 않는 것이 좋다.


'unsafe-eval'

JavaScript의 eval()과 같이 문자열을 코드로 평가하는 일부 기능을 허용한다.

script-src 'self' 'unsafe-eval'

이 역시 보안 수준을 낮출 수 있으므로 필요한 경우에만 사용하는 것이 좋다.

 


 

5. Nonce를 이용한 Script 허용

CSP에서는 Inline Script를 무조건 허용하는 대신 Nonce를 이용해 특정 Script만 허용할 수 있다.

서버에서 랜덤한 Nonce 값을 생성한다.

Content-Security-Policy:
    script-src 'self' 'nonce-abc123';

HTML에서는 동일한 Nonce 값을 지정한다.

<script nonce="abc123">
    console.log("허용된 스크립트");
</script>

브라우저는 CSP에 지정된 Nonce와 Script의 Nonce가 일치하는 경우 해당 Script의 실행을 허용한다.

중요한 점은 Nonce를 매 요청마다 예측하기 어려운 랜덤 값으로 생성해야 한다는 것이다.

따라서 실제 환경에서는 고정된 값을 사용하는 것이 아니라 서버에서 요청마다 새로운 Nonce를 생성하는 방식으로 구현한다.

 


 

6. Hash를 이용한 Script 허용

Nonce 외에도 Script의 내용을 기반으로 Hash를 생성해 특정 Inline Script를 허용할 수 있다.

예를 들어 다음과 같은 Script가 있다고 가정한다.

<script>
    console.log("Hello");
</script>

해당 Script의 내용을 SHA-256 등의 방식으로 Hash한 후 CSP에 등록한다.

Content-Security-Policy:
    script-src 'self' 'sha256-...';

브라우저는 실제 Script의 내용과 CSP에 지정된 Hash를 비교한다.

Hash가 일치하는 Script만 실행할 수 있다.

정적인 Inline Script를 허용해야 하는 경우 활용할 수 있는 방식이다.

 


 

7. CSP를 적용하는 방법

CSP는 가장 일반적으로 HTTP Response Header를 통해 설정한다.

예를 들어 Spring 환경에서는 응답 Header에 다음과 같은 정책을 추가할 수 있다.

Content-Security-Policy:
    default-src 'self';
    script-src 'self' https://cdn.example.com;
    style-src 'self' https://cdn.example.com;
    img-src 'self' data:;
    connect-src 'self' https://api.example.com;
    object-src 'none';
    frame-ancestors 'none';

프로젝트의 구조와 사용하는 외부 서비스에 따라 허용할 출처는 달라질 수 있다.

 


 

8. Meta 태그를 이용한 CSP

HTTP Header뿐만 아니라 HTML의 <meta> 태그를 통해서도 CSP를 설정할 수 있다.

<meta
    http-equiv="Content-Security-Policy"
    content="default-src 'self'; script-src 'self';"
>

다만 일반적으로는 HTTP Response Header 방식이 더 권장된다.

Header 방식이 정책을 적용하는 데 더 적합하고, 모든 CSP 기능이 Meta 방식에서 동일하게 지원되는 것도 아니기 때문이다.

 


 

9. Report-Only 모드

CSP를 실제로 적용하기 전에 기존 서비스에 어떤 영향을 미치는지 확인하고 싶다면 Content-Security-Policy-Report-Only Header를 사용할 수 있다.

Content-Security-Policy-Report-Only:
    default-src 'self';
    script-src 'self';

이 모드에서는 CSP 위반을 감지하지만 실제 리소스를 차단하지 않는다.

따라서 기존 서비스에서 어떤 리소스가 CSP 정책을 위반하는지 확인한 후 정책을 수정할 수 있다.

운영 중인 서비스에 CSP를 처음 적용할 때 유용한 방식이다.

 


 

10. CSP 적용 시 주의할 점

CSP는 강하게 설정한다고 무조건 좋은 것은 아니다.

기존 웹 애플리케이션이 다양한 외부 리소스를 사용하고 있다면 CSP를 적용하는 순간 정상적으로 동작하던 기능이 차단될 수 있다.

예를 들어 다음과 같은 서비스를 사용하고 있다면 각각의 출처를 CSP에 추가해야 할 수 있다.

  • Google Analytics
  • Google Fonts
  • 외부 CDN
  • 이미지 서버
  • 지도 API
  • 결제 모듈
  • OAuth 로그인
  • 외부 API
  • WebSocket 서버

특히 기존 JSP 기반 애플리케이션처럼 Inline Script가 많이 존재하는 서비스에 CSP를 적용하면 예상보다 많은 수정이 필요할 수 있다.

다음과 같은 코드는

<script>
    doSomething();
</script>

엄격한 CSP에서는 차단될 수 있다.

또한 다음과 같은 Inline Event Handler도 문제가 될 수 있다.

<button onclick="doSomething()">
    클릭
</button>

따라서 CSP를 적용하기 전에 현재 애플리케이션에서 사용하는 Script, Style, API, 이미지, 외부 CDN 등의 출처를 먼저 파악하는 것이 좋다.

 


 

11. 좋은 CSP 정책은 어떻게 구성하는가?

CSP를 구성할 때는 가능한 한 허용 범위를 최소화하는 것이 중요하다.

예를 들어 다음과 같이 모든 출처를 허용하는 정책은

Content-Security-Policy:
    default-src *

보안 측면에서 큰 의미가 없다.

대신 실제 서비스에서 사용하는 출처만 명시하는 것이 좋다.

Content-Security-Policy:
    default-src 'self';
    script-src 'self' https://cdn.example.com;
    style-src 'self' https://cdn.example.com;
    img-src 'self' https://images.example.com;
    connect-src 'self' https://api.example.com;
    object-src 'none';
    frame-ancestors 'none';

또한 가능하다면 다음과 같은 방식으로 보안 수준을 높일 수 있다.

  • unsafe-inline 사용 최소화
  • unsafe-eval 사용 최소화
  • Inline Script 제거
  • Nonce 또는 Hash 활용
  • object-src 'none' 설정
  • frame-ancestors 설정
  • 실제 필요한 출처만 허용
  • Report-Only로 사전 검증

 


 

12. CSP와 CORS의 차이

CSP와 CORS는 모두 웹 보안과 관련되어 있지만 목적이 다르다.

CSP는 브라우저가 어떤 리소스를 로드하거나 실행할 수 있는지를 제한하는 정책이다.

반면 CORS는 다른 Origin의 웹 페이지가 특정 서버의 리소스에 접근할 수 있는지를 제어하는 정책이다.

예를 들어

CSP
→ "이 페이지에서는 어느 서버의 JavaScript를 실행할 수 있는가?"

CORS
→ "다른 Origin의 JavaScript가 내 서버의 응답을 읽을 수 있는가?"

따라서 CSP와 CORS는 서로 대체하는 관계가 아니라 서로 다른 목적을 가진 보안 정책이다.

 


 

13. CSP와 XSS의 관계

CSP의 대표적인 목적 중 하나는 XSS 공격의 피해를 줄이는 것이다.

XSS 공격은 공격자가 악성 Script를 웹 페이지에 삽입하고 사용자의 브라우저에서 실행시키는 공격이다.

CSP는 브라우저가 실행할 수 있는 Script의 출처와 실행 방법을 제한함으로써 악성 Script가 실행되는 것을 어렵게 만든다.

따라서 CSP는 다음과 같은 구조로 이해하면 쉽다.

사용자 입력
    ↓
XSS 취약점 발생
    ↓
악성 Script 삽입
    ↓
브라우저에서 Script 실행 시도
    ↓
CSP 정책 확인
    ↓
허용되지 않은 Script라면 실행 차단

다만 CSP는 XSS 취약점 자체를 제거하는 것이 아니다.

따라서 다음과 같은 기본적인 보안 조치가 함께 필요하다.

입력값 검증
+
출력값 인코딩
+
안전한 세션/쿠키 관리
+
CSP

CSP는 이 중 하나의 방어 계층으로 보는 것이 적절하다.

 


 

14. 정리

CSP(Content Security Policy)는 웹 브라우저가 페이지에서 사용할 수 있는 리소스와 실행 가능한 Script를 제한하는 보안 정책이다.

특히 XSS 공격으로부터 사용자를 보호하기 위한 추가적인 보안 계층으로 활용할 수 있다.

CSP를 적용할 때는 단순히 다음과 같이 강한 정책을 적용하는 것보다

default-src *

실제 서비스에서 필요한 리소스만 명시적으로 허용하는 방식이 중요하다.

default-src 'self';
script-src 'self' https://cdn.example.com;
style-src 'self' https://cdn.example.com;
img-src 'self' https://images.example.com;
connect-src 'self' https://api.example.com;
object-src 'none';
frame-ancestors 'none';

또한 기존 서비스에 적용할 때는 Content-Security-Policy-Report-Only를 이용해 먼저 정책 위반 사항을 확인한 후 실제 CSP를 적용하는 것이 안전하다.

결국 CSP는 "이 웹 페이지가 어떤 리소스를 신뢰하고 실행할 것인지 브라우저에 명시하는 보안 정책"이라고 이해하면 된다.

 

 

1. N+1 문제란?

N+1 문제는 연관관계가 설정된 데이터를 조회할 때 하나의 쿼리로 데이터를 조회한 후, 연관된 데이터를 가져오기 위해 추가로 N번의 쿼리가 실행되는 문제다.

쉽게 말하면 다음과 같은 상황이다.

게시글 10개 조회
    ↓
게시글 조회 쿼리 1번
    ↓
각 게시글의 작성자 조회 쿼리 10번
    ↓
총 11번의 쿼리 실행

즉, 원래 하나의 조회 요청으로 끝날 수 있는 작업이 불필요하게 여러 개의 SQL을 실행하게 된다.

이 때문에 데이터가 많아질수록 데이터베이스에 불필요한 부하가 발생하고 애플리케이션의 성능이 저하될 수 있다.

 


 

2. N+1 문제가 발생하는 이유

N+1 문제는 주로 ORM의 연관관계 조회 방식 때문에 발생한다.

JPA에서는 객체 간의 연관관계를 다음과 같이 표현할 수 있다.

@Entity
public class Post {

    @Id
    private Long id;

    private String title;

    @ManyToOne
    private Member member;
}

게시글과 회원이 N:1 관계라고 가정해보자.

게시글 10개를 조회하면 개발자는 다음과 같은 SQL이 실행될 것이라고 생각할 수 있다.

SELECT * FROM post;

그리고 필요한 회원 정보까지 한 번에 가져올 것이라고 예상할 수 있다.

하지만 실제로 연관관계의 데이터를 조회하는 시점에 따라 추가적인 SQL이 실행될 수 있다.

SELECT * FROM post;

SELECT * FROM member WHERE id = 1;
SELECT * FROM member WHERE id = 2;
SELECT * FROM member WHERE id = 3;
...
SELECT * FROM member WHERE id = 10;

결과적으로

게시글 조회 1번
+
회원 조회 N번
=
총 N+1번의 쿼리

가 실행된다.

 


 

3. 실제 예시

다음과 같은 엔티티가 있다고 가정하자.

@Entity
public class Post {

    @Id
    @GeneratedValue
    private Long id;

    private String title;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}
@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    private String name;
}

그리고 모든 게시글을 조회한다고 해보자.

List<Post> posts = postRepository.findAll();

여기까지만 실행하면 Post만 조회하고 Member는 조회하지 않을 수 있다.

하지만 이후 다음과 같이 회원 정보에 접근하면 상황이 달라진다.

for (Post post : posts) {
    System.out.println(post.getMember().getName());
}

Member가 LAZY 로딩으로 설정되어 있기 때문에 getMember()를 통해 실제 회원 데이터가 필요한 순간 회원을 조회하기 위한 SQL이 실행된다.

예를 들어 게시글이 10개라면 다음과 같은 흐름이 된다.

findAll()
   ↓
SELECT * FROM post;
   ↓
Post 10개 조회
   ↓
post.getMember()
   ↓
SELECT * FROM member WHERE id = 1
   ↓
post.getMember()
   ↓
SELECT * FROM member WHERE id = 2
   ↓
...
   ↓
총 11번의 SQL 실행

이것이 대표적인 N+1 문제다.

 


 

4. 왜 문제가 되는가?

데이터가 적을 때는 N+1 문제가 눈에 잘 띄지 않을 수 있다.

게시글이 10개라면 11번 정도의 쿼리가 실행되더라도 큰 문제가 없어 보인다.

하지만 데이터가 10,000개라면 이야기가 달라진다.

게시글 조회          1번
회원 조회        10,000번
------------------------
총               10,001번

쿼리 하나하나가 빠르더라도 데이터베이스와 애플리케이션 사이의 통신이 반복되면 전체 응답 시간이 증가할 수 있다.

특히 다음과 같은 상황에서는 문제가 더욱 커질 수 있다.

  • 조회 데이터가 많은 경우
  • 연관관계가 여러 단계로 연결된 경우
  • API 요청이 빈번한 경우
  • 여러 사용자가 동시에 요청하는 경우
  • 데이터베이스와 애플리케이션 서버가 물리적으로 떨어져 있는 경우

따라서 ORM을 사용할 때는 실제로 어떤 SQL이 실행되는지 확인하는 습관이 중요하다.

 


 

5. N+1 문제 해결 방법

N+1 문제를 해결하는 대표적인 방법은 다음과 같다.

  • Fetch Join
  • EntityGraph
  • Batch Size
  • DTO 조회
  • 즉시 로딩 사용

다만 마지막 방법인 EAGER는 일반적인 해결책으로 권장되지 않는다.


5-1. Fetch Join

가장 대표적인 해결 방법 중 하나가 Fetch Join이다.

JPQL에서 JOIN FETCH를 사용하면 연관된 엔티티를 하나의 쿼리로 함께 조회할 수 있다.

@Query("""
    SELECT p
    FROM Post p
    JOIN FETCH p.member
""")
List<Post> findAllWithMember();

이 경우 다음과 같이 조회할 수 있다.

SELECT
    p.*,
    m.*
FROM post p
JOIN member m
    ON p.member_id = m.id;

기존에는

Post 조회 1번
+
Member 조회 N번

이었다면 Fetch Join을 사용하면

Post + Member 조회 1번

으로 줄일 수 있다.

장점

  • 쿼리 실행 횟수를 줄일 수 있다.
  • 필요한 연관관계를 한 번에 조회할 수 있다.
  • N+1 문제를 직접적으로 해결할 수 있다.

주의점

Fetch Join이 항상 만능인 것은 아니다.

특히 1:N 관계에서 Fetch Join을 사용할 경우 데이터가 중복되어 조회될 수 있다.

예를 들어 하나의 회원이 여러 개의 게시글을 가지고 있다면 JOIN 결과에서 회원 데이터가 게시글 개수만큼 반복된다.

또한 컬렉션 Fetch Join을 여러 개 사용하면 데이터가 기하급수적으로 증가하는 카테시안 곱 문제가 발생할 수 있다.

따라서 Fetch Join은 필요한 연관관계에 한정해서 사용하는 것이 좋다.


5-2. @EntityGraph

Spring Data JPA에서는 @EntityGraph를 이용해서 연관관계를 함께 조회할 수도 있다.

@EntityGraph(attributePaths = {"member"})
List<Post> findAll();

이렇게 설정하면 Post를 조회할 때 member도 함께 조회하도록 지정할 수 있다.

Fetch Join과 마찬가지로 연관된 데이터를 한 번에 조회하여 N+1 문제를 줄일 수 있다.

복잡한 JPQL을 직접 작성하지 않아도 된다는 장점이 있다.


5-3. Batch Size

Fetch Join을 사용할 수 없는 상황에서는 Batch Size를 활용할 수도 있다.

@BatchSize(size = 100)
@ManyToOne(fetch = FetchType.LAZY)
private Member member;

또는 설정 파일에서 전체적으로 설정할 수도 있다.

spring.jpa.properties.hibernate.default_batch_fetch_size=100

Batch Size를 사용하면 연관된 엔티티를 하나씩 조회하는 대신 여러 개를 묶어서 조회한다.

기존에는 다음과 같이 실행될 수 있다.

SELECT * FROM member WHERE id = 1;
SELECT * FROM member WHERE id = 2;
SELECT * FROM member WHERE id = 3;

Batch Size를 적용하면 다음과 같이 묶어서 조회할 수 있다.

SELECT *
FROM member
WHERE id IN (1, 2, 3, ...);

쿼리 실행 횟수를 크게 줄일 수 있다는 장점이 있다.

다만 Fetch Join과 달리 조회 자체를 하나의 SQL로 합치는 것은 아니다.


5-4. DTO로 필요한 데이터만 조회

화면이나 API에서 엔티티 전체가 필요하지 않다면 DTO를 직접 조회하는 방법도 있다.

@Query("""
    SELECT new com.example.dto.PostDto(
        p.id,
        p.title,
        m.name
    )
    FROM Post p
    JOIN p.member m
""")
List<PostDto> findPostDtos();

이 방식은 애초에 필요한 데이터만 조회하기 때문에 불필요한 연관관계 탐색 자체를 줄일 수 있다.

특히 조회 화면이나 API에서는 엔티티를 그대로 반환하기보다 DTO를 이용해서 필요한 데이터만 조회하는 방식이 유용하다.


5-5. EAGER 로딩은 해결책인가?

JPA에서는 연관관계의 Fetch 전략을 다음과 같이 설정할 수 있다.

@ManyToOne(fetch = FetchType.LAZY)

또는

@ManyToOne(fetch = FetchType.EAGER)

EAGER를 사용하면 연관된 데이터를 즉시 조회하기 때문에 N+1 문제가 해결될 것처럼 보인다.

하지만 EAGER로 변경하는 것을 일반적인 해결책으로 사용해서는 안 된다.

예를 들어 다음과 같이 설정했다고 하자.

@ManyToOne(fetch = FetchType.EAGER)
private Member member;

이후 Post를 조회하면 JPA 구현체나 조회 방식에 따라 연관된 Member를 추가적으로 조회하면서 여전히 여러 개의 SQL이 실행될 수 있다.

즉,

LAZY → EAGER

로 변경한다고 해서 반드시

N+1 문제 해결

이 되는 것은 아니다.

오히려 연관관계가 많아질수록 예상하지 못한 데이터 조회가 발생할 수 있기 때문에 일반적으로는 LAZY 로딩을 기본으로 사용하고 필요한 조회 상황에서 Fetch Join, EntityGraph, Batch Size 등을 사용하는 방식이 권장된다.

 


 

6. Fetch Join과 Batch Size의 차이

두 방법 모두 N+1 문제를 줄일 수 있지만 동작 방식에는 차이가 있다.

Fetch Join

Post + Member
       ↓
하나의 SQL에서 JOIN
       ↓
한 번에 조회

Batch Size

Post 조회
   ↓
Member ID 여러 개 수집
   ↓
IN 절을 이용하여 묶음 조회

따라서 연관된 데이터를 반드시 한 번에 가져와야 한다면 Fetch Join을 고려할 수 있고, 여러 연관 데이터를 상황에 따라 지연 로딩해야 한다면 Batch Size가 유용할 수 있다.

 


 

7. N+1 문제를 확인하는 방법

N+1 문제를 해결하기 위해서는 먼저 실제로 SQL이 몇 번 실행되는지 확인해야 한다.

Hibernate SQL 로그를 활성화하면 실행되는 SQL을 확인할 수 있다.

spring.jpa.show-sql=true

또는 Hibernate SQL 로그를 활용할 수 있다.

logging.level.org.hibernate.SQL=DEBUG

로그를 확인했을 때 다음과 같이 동일한 형태의 SQL이 반복된다면 N+1 문제를 의심할 수 있다.

select ... from post
select ... from member where id=?
select ... from member where id=?
select ... from member where id=?
select ... from member where id=?
...

특히 where id = ?와 같은 형태의 쿼리가 반복적으로 실행되는지 확인하는 것이 중요하다.

 


 

8. N+1 문제는 JPA만의 문제인가?

N+1 문제는 JPA에만 존재하는 문제는 아니다.

핵심은 애플리케이션에서 하나의 조회를 처리하기 위해 불필요하게 여러 번의 데이터 조회가 발생하는 것이다.

따라서 ORM을 사용하는 다른 기술에서도 비슷한 문제가 발생할 수 있다.

다만 JPA에서는 객체 간 연관관계를 편리하게 사용할 수 있는 만큼 개발자가 의도하지 않은 시점에 연관 데이터를 조회하면서 N+1 문제가 발생하기 쉽다.

 


 

9. N+1 문제를 예방하는 방법

N+1 문제를 예방하기 위해서는 단순히 Fetch Join을 많이 사용하는 것보다 조회 목적에 맞는 데이터 접근 방식을 선택하는 것이 중요하다.

다음과 같은 기준으로 접근할 수 있다.

연관 데이터를 함께 조회해야 한다
        ↓
Fetch Join / EntityGraph 고려

연관 데이터를 지연 조회해야 한다
        ↓
Batch Size 고려

화면/API에서 특정 데이터만 필요하다
        ↓
DTO 조회 고려

단순히 EAGER로 변경
        ↓
지양

또한 개발 과정에서 SQL 로그를 확인하고 실제 실행되는 쿼리 개수를 확인하는 습관을 들이는 것이 좋다.

 


 

10. 정리

N+1 문제는 1번의 조회로 끝날 것으로 예상한 작업에서 연관 데이터를 조회하기 위해 추가적인 N번의 쿼리가 실행되는 문제다.

대표적인 발생 과정은 다음과 같다.

게시글 N개 조회
      ↓
게시글 조회 1번
      ↓
각 게시글의 회원 정보 조회
      ↓
회원 조회 N번
      ↓
총 N+1번의 쿼리 실행

대표적인 해결 방법은 다음과 같다.

방법특징

Fetch Join JOIN을 통해 연관 데이터를 한 번에 조회
EntityGraph 조회할 연관관계를 어노테이션으로 지정
Batch Size 여러 연관 데이터를 IN 절로 묶어서 조회
DTO 조회 필요한 데이터만 직접 조회
EAGER 일반적인 해결책으로 권장하지 않음

결국 N+1 문제의 핵심은 ORM을 사용하면서 실제 데이터베이스에서 어떤 SQL이 실행되는지를 놓치지 않는 것이다.

JPA가 객체 중심으로 편리하게 데이터를 다룰 수 있도록 해주더라도 최종적으로는 SQL이 실행되기 때문에, 객체 코드만 보고 판단하지 말고 실제 실행 쿼리와 데이터 조회량까지 함께 확인하는 것이 중요하다.

 

 


1. Kafka란?


Kafka는 대용량의 데이터를 실시간으로 처리하고 전달하기 위한 분산 메시지 브로커(Message Broker)이자 이벤트 스트리밍 플랫폼(Event Streaming Platform)이다.

쉽게 말해, 여러 시스템 간에 데이터를 안정적으로 전달하고 처리할 수 있도록 도와주는 플랫폼이다.

예를 들어 쇼핑몰에서 주문이 발생하면 다음과 같은 여러 작업이 동시에 수행될 수 있다.

  • 주문 정보 저장
  • 결제 처리
  • 재고 차감
  • 배송 시스템 호출
  • 알림 발송
  • 포인트 적립

만약 애플리케이션이 모든 작업을 직접 처리한다면 각 서비스 간의 의존성이 높아지고, 하나의 서비스에 장애가 발생했을 때 전체 시스템에 영향을 줄 수 있다.

Kafka를 사용하면 주문 이벤트를 Kafka에 전달하고, 각 서비스가 필요한 이벤트를 구독하여 독립적으로 처리할 수 있다.

     주문 서비스
    	  │
      	  ▼
   	Kafka
 ┌─────┼─────┼─────┐
 ▼     ▼     ▼     ▼
결제  재고  배송  알림
 

 


2. Kafka를 사용하는 이유


Kafka를 사용하는 가장 큰 이유는 서비스 간 결합도를 낮추고 안정적인 비동기 처리를 제공하기 때문이다.

예를 들어 주문 서비스에서 결제 서비스와 배송 서비스를 직접 호출하는 경우를 생각해 보자.

주문
 ├── 결제 API 호출
 ├── 배송 API 호출
 ├── 문자 발송
 └── 포인트 적립
 

이 경우 결제 서비스가 장애가 발생하면 주문 처리까지 영향을 받을 수 있다.

반면 Kafka를 사용하면 주문 서비스는 이벤트만 Kafka에 저장하면 된다.

주문
 │
 ▼
Kafka
 ├── 결제
 ├── 배송
 ├── 알림
 └── 포인트
 

각 서비스는 자신의 속도에 맞게 이벤트를 가져와 처리하므로 서비스 간 의존성이 크게 줄어든다.

 


3. Kafka Architecture


Kafka는 다음과 같은 구성 요소로 이루어진다.

Producer
    │
    ▼
 Topic
    │
    ▼
 Broker
    │
    ▼
 Consumer
 

각 구성 요소의 역할은 다음과 같다.

구성 요소 설명
Producer 메시지를 Kafka에 전송하는 애플리케이션이다.
Broker 메시지를 저장하고 관리하는 Kafka 서버이다.
Topic 메시지를 저장하는 논리적인 공간이다.
Consumer Kafka에서 메시지를 읽는 애플리케이션이다.

 

 

Producer

Producer는 Kafka에 메시지를 전송하는 역할을 한다.

예를 들어 주문이 발생하면 Producer가 주문 이벤트를 Kafka로 전송한다.

OrderCreatedEvent event = new OrderCreatedEvent(...);

kafkaTemplate.send("order-topic", event);
 

Producer는 메시지를 Topic으로 전달하며, Topic 내부의 어떤 Partition에 저장할지 결정한다.

 

Topic

Topic은 Kafka에서 메시지를 저장하는 논리적인 단위이다.

예를 들어 다음과 같이 Topic을 구성할 수 있다.

order-topic
payment-topic
member-topic
notification-topic
 

각 Topic에는 관련된 이벤트만 저장된다.

예를 들어 주문 관련 이벤트는 order-topic에 저장된다.

 

Partition

Topic은 하나 이상의 Partition으로 구성된다.

order-topic
 ├── Partition 0
 ├── Partition 1
 └── Partition 2
 

Partition을 사용하는 이유는 병렬 처리를 위해서다.

Producer는 메시지를 여러 Partition에 분산 저장하고, Consumer는 각 Partition을 동시에 읽을 수 있다.

Partition이 많을수록 더 많은 Consumer가 동시에 데이터를 처리할 수 있다.

 

Broker

Broker는 Kafka 서버를 의미한다.

Broker는 다음과 같은 역할을 수행한다.

  • 메시지 저장
  • Consumer에게 메시지 전달
  • 복제(Replication)
  • 장애 복구

실제 운영 환경에서는 여러 Broker를 함께 구성하여 하나의 Kafka Cluster를 운영한다.

Kafka Cluster
 ├── Broker 1
 ├── Broker 2
 └── Broker 3
 

여러 Broker를 사용하면 일부 서버에 장애가 발생하더라도 서비스는 계속 동작할 수 있다.

 

Consumer

Consumer는 Kafka에서 메시지를 읽어 처리하는 역할을 한다.

예를 들어 주문 이벤트가 발생하면 다음과 같이 여러 Consumer가 각각 이벤트를 처리할 수 있다.

   order-topic
       │
       ▼
 ┌─────┼─────┬─────┐
 ▼     ▼     ▼     ▼
결제  재고  배송  알림
 

Consumer는 자신이 필요한 이벤트만 가져와 처리한다.

 

Consumer Group

Kafka에서 매우 중요한 개념 중 하나가 Consumer Group이다.

동일한 Consumer Group에 속한 Consumer들은 Partition을 나누어 처리한다.

예를 들어 Topic에 3개의 Partition이 있다면,

Partition 0
Partition 1
Partition 2
 

Consumer Group에 Consumer가 3개 있다면,

Consumer A → Partition 0
Consumer B → Partition 1
Consumer C → Partition 2
 

각 Consumer가 하나의 Partition을 담당하여 병렬 처리할 수 있다.

반대로 서로 다른 Consumer Group이라면 동일한 메시지를 각각 독립적으로 소비할 수 있다.

예를 들어 주문 이벤트 하나를 결제 서비스와 통계 서비스가 모두 사용해야 하는 경우,

     order-topic
          │
 ┌────────┴────────┐
 ▼                 ▼
결제 Group     통계 Group
 

각 Group은 같은 메시지를 독립적으로 소비한다.

 


4. Offset


Kafka는 메시지를 삭제하지 않고 Offset이라는 번호를 통해 메시지를 관리한다.

Consumer는 자신이 어디까지 읽었는지를 Offset으로 기록한다.

예를 들어 Offset 10까지 처리했다면 다음에는 Offset 11부터 읽는다.

이러한 방식 덕분에 Consumer가 중간에 장애가 발생하더라도 마지막으로 처리한 위치부터 다시 시작할 수 있다.

 


5. Replication


Kafka는 데이터를 여러 Broker에 복제하여 장애에 대비한다.

Broker 1
Partition 0 (Leader)
        │
        ▼
Broker 2
Partition 0 (Follower)
        │
        ▼
Broker 3
Partition 0 (Follower)
 

Leader Broker에 장애가 발생하면 Follower 중 하나가 새로운 Leader가 되어 서비스를 계속 제공한다.

 


6. Kafka의 장점


  • 대용량 데이터 처리에 적합하다.
  • 높은 처리량(High Throughput)을 제공한다.
  • 낮은 지연 시간(Low Latency)을 지원한다.
  • 서비스 간 결합도를 낮출 수 있다.
  • 뛰어난 확장성을 제공한다.
  • 장애 복구를 지원한다.
  • 메시지 재처리가 가능하다.
  • 실시간 이벤트 처리에 적합하다.

 


7. Kafka의 단점


  • 초기 구축과 운영이 복잡하다.
  • Broker, Topic, Partition 설계가 중요하다.
  • Consumer Group과 Offset 등 학습해야 할 개념이 많다.
  • 메시지 순서는 Partition 단위로만 보장된다.
  • 모니터링과 운영 관리가 필요하다.

 


8. Kafka 사용 사례


Kafka는 다음과 같은 환경에서 많이 사용된다.

  • 주문 및 결제 시스템
  • 로그 수집 및 분석
  • 실시간 알림 시스템
  • 채팅 서비스
  • IoT 데이터 수집
  • 사용자 행동 분석
  • 실시간 모니터링
  • MSA(Microservice Architecture) 환경의 이벤트 기반 통신

예를 들어 주문이 발생하면 하나의 이벤트를 Kafka에 전송하고, 여러 서비스가 이를 동시에 처리할 수 있다.

 

 

 


정리

Kafka는 대용량 데이터를 안정적으로 저장하고 여러 시스템에 실시간으로 전달하기 위한 분산 이벤트 스트리밍 플랫폼이다.

Producer는 이벤트를 Topic에 전송하고, Broker는 이를 저장하며, Consumer는 필요한 이벤트를 읽어 처리한다. 또한 Partition을 통해 병렬 처리를 지원하고, Consumer Group을 활용하여 메시지를 효율적으로 분산 처리할 수 있다.

특히 MSA 환경에서는 서비스 간 직접 호출을 줄이고 이벤트 기반 아키텍처(Event-Driven Architecture)를 구현할 수 있어, 결합도를 낮추고 시스템의 확장성과 안정성을 높이는 데 매우 효과적이다.




+ Recent posts