728x90

Redis란?

고성능 Key-Value 구조의 저장소로, 비정형 데이터를 저장 및 관리하기 위한 오픈 소스 기반의 NoSQL DB

 

In-Memory 데이터 구조를 사용해 웹 서버의 부담을 줄이고 고속으로 데이터를 제공하는 데 탁월하다

 


Redis의 주요 특징

1. In-Memory 데이터베이스

Redis는 데이터를 RAM에 저장하여 디스크 기반 데이터베이스보다 매우 빠른 속도를 자랑함

RAM을 사용함으로써 디스크 I/O가 발생하지 않아 데이터 처리 속도가 대폭 향상됨

  • 장점: 빠른 데이터 저장 및 조회
  • 단점: RAM 용량 초과 시 데이터 유실 가능 (휘발성)
    → 휘발성 문제 해결: 백업 기능(AOF, RDB)을 제공하여 데이터 유실 위험을 방지

 


2. Redis의 데이터 타입

Redis는 다양한 데이터 타입을 지원하며, 이를 활용하면 복잡한 데이터 작업을 간소화할 수 있다

  • String: 일반적인 Key-Value 형태
  • Hash: Key-Value 형태를 테이블 형식으로 저장
  • List: 순서가 있는 값들의 목록
  • Set: 중복 없는 값들의 집합
  • Sorted Set: 정렬 가능한 집합

eg) Sorted-Set을 사용하면 데이터를 정렬하는 로직을 단순화하고 처리 속도를 높일 수 있음

출처 : https://velog.io/@wnguswn7/Redis%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%BC%EA%B9%8C-Redis%EC%9D%98-%ED%8A%B9%EC%A7%95%EA%B3%BC-%EC%82%AC%EC%9A%A9-%EC%8B%9C-%EC%A3%BC%EC%9D%98%EC%A0%90


3.  Redis의 백업과 복구

Redis는 데이터를 디스크에 저장할 수 있는 두 가지 주요 방식을 제공

 

AOF(Append Only File)

개념

AOF는 Redis의 모든 Write/Update 연산을 파일에 순차적으로 기록하는 방식
Redis 서버가 중단되더라도, AOF 로그를 재실행하여 데이터 상태를 복구할 수 있다

동작 방식

  1. Redis에 Write/Update 연산 발생
  2. 해당 연산을 AOF 파일에 순서대로 기록
  3. Redis 서버 재시작 시 AOF 파일의 명령어를 순차적으로 실행하여 복구

장점

  • 데이터 복구 가능성이 매우 높음 (모든 연산을 기록)
  • Write/Update 중심의 데이터베이스에서 적합
  • 사람이 읽을 수 있는 명령어 형태로 저장되어 디버깅이 용이

단점

  • 파일 크기가 커질 수 있음 → 주기적인 파일 압축(Rewrite) 필요
  • 디스크 I/O 부담이 큼 (많은 Write 작업 발생 시)

추가 옵션

  • fsync 옵션: 데이터 기록 주기를 조정해 성능과 안정성을 조율
    • always: 매 Write마다 디스크에 기록 (가장 안전, 성능 낮음)
    • everysec: 1초마다 기록 (기본값, 안정성과 성능 균형)
    • no: Redis가 디스크 기록을 관리하지 않음 (가장 빠름, 안전성 낮음)

 

RDB(Snapshotting)

개념

RDB는 Redis 데이터를 특정 시점(Snapshot)에 전체적으로 저장하는 방식
정해진 간격으로 메모리에 있는 모든 데이터를 디스크에 저장하여 백업본을 생성한다

동작 방식

  1. 주기적 또는 특정 조건에서 Redis가 전체 데이터를 Snapshot 형태로 디스크에 저장
  2. Snapshot 파일(dump.rdb) 생성
  3. Redis 서버 재시작 시 Snapshot 파일을 로드하여 복구

장점

  • AOF보다 디스크 사용량이 적음 (스냅샷 방식)
  • 빠른 복구 시간 → 대량의 데이터를 효율적으로 복구 가능
  • 디스크 I/O 부하가 적음 → 고성능 애플리케이션에 적합

단점

  • 스냅샷 사이의 데이터는 유실될 가능성 있음
    • 예: 매 5분마다 Snapshot을 저장하는 경우, 마지막 5분간의 데이터는 복구 불가
  • Write 중심 환경에서는 적합하지 않을 수 있음

사용 방법

  • Redis 설정 파일에서 Snapshot 주기 설정 가능
    예: save 60 10000 → 1분 동안 10,000개 이상의 키가 변경될 경우 Snapshot 생성

4. 고가용성과 확장성

Redis는 Sentinel 및 Cluster를 통해 고가용성과 확장성을 제공한다

  • Sentinel: 자동 장애 복구, 모니터링, 알림 등 제공
  • Cluster: 자동 파티셔닝을 통해 데이터를 여러 Redis 인스턴스에 분산

● 파티셔닝(Partitioning): 다수의 Redis 인스턴스에 데이터를 분산 저장하여 확장성을 높이고, 각 인스턴스는 자신에게 할당된 데이터만 관리


5. 싱글 스레드 구조

Redis는 기본적으로 싱글 스레드로 동작하며, 하나의 명령만 순차적으로 처리한다

  • 장점: Race Condition(경쟁 상태) 문제를 방지하여 데이터 일관성 보장
  • 단점: 복잡하거나 처리 시간이 긴 요청이 있으면 Redis 서버의 응답이 느려질 수 있음

 


Redis 사용시 주의사항

 

메모리 관리

Redis는 데이터를 RAM에 저장하는 In-Memory 데이터베이스이기 때문에, 사용 가능한 메모리 용량을 초과할 경우 데이터 유실이나 성능 저하가 발생할 수 있다

해결 방법

  • 메모리 정책 설정: Redis는 메모리 초과 시 데이터를 삭제하는 정책을 지원한다.
  • 설정 가능한 maxmemory-policy 값:
    • noeviction: 메모리 초과 시 새 데이터를 추가하지 않음 (기본값)
    • allkeys-lru: 가장 오래 사용되지 않은 키부터 삭제
    • volatile-lru: 만료 설정이 있는 키 중 가장 오래 사용되지 않은 키부터 삭제
    • allkeys-random: 임의의 키 삭제
    • volatile-random: 만료 설정이 있는 키 중 임의의 키 삭제
    • volatile-ttl: 만료가 가장 가까운 키 삭제
    적합한 정책을 설정해 메모리 부족 시 데이터 유실을 최소화

 

  • 메모리 사용량 모니터링:
    • 주기적으로 Redis 메모리 사용량을 확인 (INFO memory 명령 사용)
    • maxmemory 설정으로 Redis가 사용할 메모리 한도를 제한

 

  • 데이터 크기 관리:
    • TTL(Time-To-Live)을 설정하여 필요 없는 데이터가 Redis에 오래 남아 있지 않도록 관리
    • Redis에 대량의 데이터를 저장하기보다는, 캐싱 목적으로 사용

데이터 복구

Redis는 기본적으로 In-Memory 데이터베이스이므로, 시스템 장애나 전원 차단 시 데이터가 유실될 수 있다

해결 방법

  • 백업 설정:
    • AOF(Append Only File)와 RDB(Snapshotting)를 적절히 설정하여 데이터 복구를 대비
    • 주기적으로 백업 파일을 외부 저장소에 보관
  • 복구 테스트:
    • 장애 상황을 가정한 복구 프로세스를 사전에 테스트하여 복구 시간을 단축

명령어의 시간 복잡도

Redis 명령어의 시간 복잡도를 고려하지 않고 대량의 데이터를 처리하는 명령을 실행하면 서버 성능 저하나 장애를 유발할 수 있다

해결 방법

  • 시간 복잡도 확인:
    • 데이터 크기에 따라 처리 시간이 증가하는 명령어는 주의 (O(N) 이상의 명령어)
      • 예: KEYS, SORT, LRANGE(큰 범위) 등
    • 대체 명령어 사용 추천:
      • KEYS 대신 SCAN(Cursor 기반 점진적 조회)
      • 긴 LRANGE 대신 데이터 크기 제한 및 페이징 사용
  • 배치 처리:
    • 대량 데이터를 처리할 경우 파이프라이닝(Pipelining)을 사용해 성능 최적화

싱글 스레드 특성

Redis는 싱글 스레드로 동작하므로, 하나의 명령이 오래 걸리면 다른 명령의 처리가 지연될 수 있다

해결 방법

  • 처리 시간이 긴 작업 최소화:
    • 긴 명령어를 실행할 때는 데이터를 나누어 처리하거나 백그라운드로 실행
  • 클러스터링:
    • Redis Cluster를 구성해 작업을 분산 처리

보안

Redis는 기본적으로 인증과 네트워크 보안을 제공하지 않으므로, 외부 공격에 취약할 수 있다

해결 방법

  • 인증 설정:
    • Redis 설정 파일에서 비밀번호(requirepass)를 설정
  • 네트워크 보안:
    • Redis를 외부 네트워크에 노출하지 않고, 방화벽이나 VPN을 통해 접근 제어
    • 필요 시 TLS를 활성화하여 암호화된 통신 사용
  • IP 제한:
    • 설정 파일에서 bind 옵션으로 특정 IP에서만 Redis에 접근하도록 설정

메모리 단편화

크고 작은 데이터를 할당하고 해제하는 과정에서 메모리 단편화가 발생해 성능이 저하될 수 있다

해결 방법

  • Redis 메모리 관리 도구(mem_fragmentation_ratio)로 단편화 수준을 주기적으로 점검
  • Redis 설정에서 activedefrag 옵션을 활성화해 단편화를 자동으로 해소

클러스터 및 복제 구성 시 주의점

Redis Cluster와 복제를 잘못 구성하면 데이터 불일치, 성능 저하, 장애 복구 실패가 발생할 수 있습니다.

해결 방법

  • Redis Cluster:
    • 키 분배 방식(Hash Slot)을 이해하고, 동일한 키 패턴을 사용해 데이터 분산
    • 클러스터 환경에서 마스터-슬레이브 구조를 활용해 읽기 작업을 분산
  • Redis Sentinel:
    • Sentinel을 구성하여 마스터 장애 시 자동 복구 및 알림 설정

로그 및 모니터링

Redis 서버에서 발생하는 문제를 조기에 감지하지 못하면 심각한 장애로 이어질 수 있다

해결 방법

  • 모니터링 도구 사용:
    • Redis의 상태를 실시간으로 확인할 수 있는 도구 사용:
      • Grafana, Prometheus 등의 모니터링 툴과 통합
    • 주요 모니터링 지표:
      • 메모리 사용량(used_memory, maxmemory)
      • 명령 실행 속도 및 처리량(instantaneous_ops_per_sec)
      • 연결 상태(connected_clients)
  • 로그 관리:
    • Redis 로그 레벨을 notice로 설정하여 주요 이벤트 기록
    • 장애 발생 시 로그를 분석하여 원인 파악
728x90
728x90

WebSocket은 실시간 양방향 통신이 필요한 애플리케이션에 필수적인 기능이지만, 네트워크의 불안정 등으로 인해 연결이 예기치 않게 끊어질 수 있다.

 

이전 프로젝트를 진행할 때 단순한 채팅 기능만 구현했을 뿐, 이러한 에러들은 미쳐 생각하지 못했다.


 

기능 구현 스택

 

Java, Spring, WebSocket(STOMP), SockJS


해결방안

1. STOMP와 Heartbeat 설정을 통한 연결 상태 확인

WebSocket에서는 STOMP 프로토콜을 통해 주기적으로 연결 상태를 확인하는 heartbeat 기능을 제공한다. 클라이언트와 서버 간 heartbeat 설정을 통해 실시간으로 연결 상태를 모니터링할 수 있다.

 

클라이언트에서 Heartbeat 설정

function connect() {
    let socket = new SockJS(contextPath + '/chat-websocket');
    stompClient = Stomp.over(socket);

    // 10초마다 heartbeat 설정
    stompClient.heartbeat.outgoing = 10000; // 클라이언트 -> 서버로 heartbeat 전송
    stompClient.heartbeat.incoming = 10000; // 서버 -> 클라이언트로 heartbeat 수신

    stompClient.connect({}, function(frame) {
        console.log('Connected: ' + frame);
        stompClient.send("/app/chat.addUser", {}, JSON.stringify({sender: username, type: 'JOIN'}));
    });
}

 

서버에서 Heartbeat 설정

@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
    config.enableSimpleBroker("/topic", "/queue")
          .setHeartbeatValue(new long[]{10000, 10000}); // 10초마다 heartbeat
    config.setApplicationDestinationPrefixes("/app");
}

 

클라이언트와 서버 간에 주기적으로 heartbeat가 오가지 않으면 연결이 비정상적으로 종료된 것으로 간주하고 재연결 로직을 활성화할 수 있다

 


2. WebSocket의 자동 재연결 구현

WebSocket 연결이 끊어졌을 때 자동으로 재연결할 수 있도록 클라이언트 코드에 onclose 이벤트 핸들러를 추가하여 재연결을 시도한다

 

function connect() {
    let socket = new SockJS(contextPath + '/chat-websocket');
    stompClient = Stomp.over(socket);

    stompClient.connect({}, function(frame) {
        console.log('Connected: ' + frame);
        stompClient.send("/app/chat.addUser", {}, JSON.stringify({sender: username, type: 'JOIN'}));
    }, function(error) {
        console.error("Connection error: ", error);
        setTimeout(connect, 5000); // 5초 후 자동 재연결 시도
    });

    socket.onclose = function() {
        console.log("Connection closed. Attempting to reconnect...");
        setTimeout(connect, 5000); // 5초 후 자동 재연결 시도
    };
}

 

연결이 끊어졌을 때 setTimeout을 사용하여 일정 시간 후 재연결을 시도함으로써 연결의 안정성을 높일 수 있다


3. 서버에서 @OnClose 이벤트 활용

서버 측에서 WebSocket 연결이 끊겼을 때 @OnClose 이벤트를 활용하여 리소스를 정리하고, 사용자의 상태를 업데이트할 수 있다

@OnClose
public void onClose(Session session, CloseReason reason) {
    System.out.println("Connection closed: " + session.getId() + ", reason: " + reason);
    // 리소스 정리 및 사용자 상태 관리
}

 

@OnClose 이벤트는 세션이 닫힐 때 실행되므로 연결이 종료되었을 때 필요한 정리 작업을 수행할 수 있다

 


4. Ping-Pong 프레임 활용 (WebSocket 표준 기능)

만약 STOMP나 SockJS를 사용하지 않을 경우, WebSocket 표준 기능인 Ping-Pong 프레임을 활용하여 서버와 클라이언트가 연결 상태를 주기적으로 확인할 수 있다

서버가 주기적으로 Ping 메시지를 보내고 클라이언트가 응답(Pong)함으로써 연결 상태를 유지한다

// 서버 측 Ping-Pong 핸들러 예시
public void sendPing(Session session) {
    try {
        session.getBasicRemote().sendPing(java.nio.ByteBuffer.wrap(new byte[0]));
    } catch (IOException e) {
        System.out.println("Ping failed, closing session.");
        try {
            session.close();
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

 

728x90
728x90

JPA를 사용하면서 N+1 문제 왜 발생하는가?


 

1. N+1 문제가 무엇인가?

N+1 문제는 기본적으로 지연 로딩(Lazy Loading) 때문에 발생한다. JPA는 엔티티 간의 연관 관계를 설정하면, 연관된 엔티티를 실제로 필요할 때(지연 로딩) 가져오는 전략을 사용하는데, 이것이 잘못 사용될 경우 N+1 문제를 일으킬 수 있다.

 

2. 어떻게 발생하는가? (예시)

예를 들어, 부모(Parent)와 자식(Child)이라는 두 개의 엔티티가 있다. Parent 엔티티는 여러 Child 엔티티와 1 : N 관계를 가진다.

@Entity
public class Parent {
    @Id
    private Long id;

    @OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
    private List<Child> children;
}

@Entity
public class Child {
    @Id
    private Long id;

    @ManyToOne
    private Parent parent;
}

이 경우, Parent 엔티티는 여러 Child 엔티티를 가진다.

이제 데이터베이스에 Parent 엔티티가 10개 있다고 가정해보면, 이 때 Parent 엔티티의 리스트를 조회하면 지연 로딩 때문에 자식 엔티티는 바로 로딩되지 않는다.

List<Parent> parents = entityManager.createQuery("SELECT p FROM Parent p", Parent.class).getResultList();

 

  • 이 쿼리는 부모 엔티티 목록만 가져오는 한 개의 쿼리가 실행된다.
  • 하지만 이후 각 부모 엔티티에 연관된 자식 엔티티를 가져오기 위해, 각각의 부모에 대해 자식 엔티티를 조회하는 추가적인 쿼리가 발생한다.

SQL 쿼리 실행 순서를 살펴보면:

SELECT * FROM Parent; -- 1개의 쿼리로 10명의 부모를 조회

SELECT * FROM Child WHERE parent_id = 1; -- 첫 번째 부모의 자식 조회 (추가 쿼리)
SELECT * FROM Child WHERE parent_id = 2; -- 두 번째 부모의 자식 조회 (추가 쿼리)
...
SELECT * FROM Child WHERE parent_id = 10; -- 열 번째 부모의 자식 조회 (추가 쿼리)
// 총 11개의 쿼리가 실행

 

이처럼 처음에 부모를 가져오는 1개의 쿼리와 각 부모마다 자식을 가져오는 10개의 추가 쿼리가 발생하여 총 11개의 쿼리가 실행된다.

부모 엔티티가 더 많아질수록 쿼리 수는 기하급수적으로 늘어나므로 성능 문제가 발생하게되고

이것이 바로 N+1 문제이다.

 


N+1 문제 해결 방법

 

1. Fetch Join 사용

Fetch Join을 사용하면 여러 연관된 엔티티를 한 번의 쿼리로 모두 가져올 수 있다. 이를 통해 쿼리 횟수를 1로 줄일 수 있다.


아까 예시로 나타냈던 기존 쿼리는

List<Parent> parents = entityManager.createQuery("SELECT p FROM Parent p", Parent.class).getResultList();

 

부모 엔티티에 연관된 자식 엔티티를 가져오기 위해, 각각의 부모에 대해 자식 엔티티를 조회하는 추가적인 쿼리가 발생하게 되는데

 

Fetch Join을 이용한 쿼리는

List<Parent> parents = entityManager.createQuery(
    "SELECT p FROM Parent p JOIN FETCH p.children", Parent.class).getResultList();

 

=> 실행되는 쿼리

SELECT p.*, c.* FROM Parent p
JOIN Child c ON p.id = c.parent_id;

 

이렇게 한번의 쿼리만 실행되게 할 수 있다.

 

N+1 문제를 해결하는 방식 중 Fetch Join은 JPQL(JPA Query Language)로 작성하는 경우가 많다. JPQL은 엔티티 객체를 대상으로 하는 쿼리 언어로, SQL과 달리 데이터베이스 테이블이 아닌 JPA 엔티티를 대상으로 동작한다.

 

그렇다면 FetchJoin의 장단점은 어떤것이 있을까?

 

장점:

  • N+1 문제 해결: 다중 쿼리의 오버헤드를 없애고 성능을 최적화한다.
  • 한 번의 쿼리로 여러 엔티티 조회: 연관된 데이터를 한꺼번에 가져오므로 데이터베이스와의 네트워크 왕복 횟수를 줄일 수 있음.
  • 즉시 로딩 가능: 연관된 데이터를 즉시 사용할 수 있어 Lazy 로딩과 달리 성능 이슈가 적음.
  • 데이터 일관성: 트랜잭션이 종료된 이후에도 필요한 데이터를 모두 사용할 수 있어 데이터 일관성 문제를 방지.
  • 복잡한 관계의 단순화: 복잡한 연관 관계를 Fetch Join으로 쉽게 처리할 수 있음.

단점:

  • 데이터 양이 많을 경우 성능이 저하될 수 있다. 조인된 결과가 많아지면 메모리 사용량이 늘어나고, 애플리케이션 성능에 부정적인 영향을 미칠 수 있다.
  • N:N 관계나 매우 복잡한 쿼리에서는 Fetch Join이 오히려 문제를 야기할 수 있다. 예를 들면, 중복된 결과가 반환될 수 있으며, 이는 개발자가 추가적인 처리를 해야 할 수도 있다.
  • FetchType 설정 무시: Fetch Join을 사용하면 Lazy로 설정해도 즉시 로딩이 이루어지기 때문에, 연관 엔티티를 언제 로딩할지에 대한 세부적인 제어가 불가능하다.
  • 페이징 처리 불가: Fetch Join은 결과를 한 번에 로딩하기 때문에, 연관된 데이터를 페이징 단위로 가져오는 것이 불가능하다. 따라서 대용량 데이터를 처리할 때 페이징 쿼리와 함께 사용하기 어렵다.

2. @EntityGraph 사용

@EntityGraph는 특정 쿼리에서 명시적으로 어떤 연관 엔티티를 함께 로딩할지 정의하는 방법이다. Fetch Join과 달리 쿼리에서 명시적으로 작성하는 것이 아닌, 애노테이션으로 설정할 수 있다.

특정 엔티티의 조회 시, @EntityGraph 어노테이션을 사용하여 즉시 로딩할 연관 엔티티를 명시하면 내부적으로 JPA가 JPQL로 변환하여 처리한다.

 

예시:

@EntityGraph(attributePaths = {"children"})
List<Parent> findAll();

 

@EntityGraph의 장단점 :

 

장점:

  • 코드가 간결: JPQL 쿼리를 직접 작성할 필요 없이 어노테이션으로 필요한 연관 엔티티를 즉시 로딩할 수 있다.
  • OUTER JOIN 사용: Fetch Join과 달리 OUTER JOIN을 사용하므로, 연관된 엔티티가 없어도 주 엔티티는 조회된다.

단점:

  • 복잡한 연관 관계가 많을 경우 성능이 저하될 수 있다.
  • Fetch Join과 마찬가지로 중복 데이터가 발생할 가능성이 있으며, 이를 방지하기 위한 추가적인 조치가 필요하다.

Fetch Join@EntityGraph의 공통점과 차이점

 

 

공통점

  • 두 방법 모두 지연 로딩(Lazy Loading) 설정을 무시하고, 연관된 엔티티를 즉시 로딩(Eager Fetching) 한다.
  • JPQL을 사용하여 JOIN을 수행하는 방식이지만, @EntityGraph는 개발자가 직접 JPQL을 작성할 필요가 없고, Fetch Join은 JPQL에서 명시적으로 사용해야 한다.
  • N+1 문제 해결에 유용하며, 한 번의 쿼리로 여러 연관된 엔티티를 함께 조회할 수 있다.

차이점

조인 방식

  • Fetch Join: INNER JOIN을 사용하여 연관된 엔티티가 없으면 해당 엔티티는 결과에서 제외.
  • @EntityGraph: OUTER JOIN을 사용하여 연관된 엔티티가 없더라도 주 엔티티는 결과에 포함.

JPQL 작성 여부

  • Fetch Join: 개발자가 JPQL을 작성해야 하고, 직접적으로 JOIN FETCH를 명시해야 한다.
  • @EntityGraph: 개발자가 JPQL을 작성하지 않고도 애노테이션을 사용해 설정할 수 있으며, JPA가 내부적으로 JPQL로 변환하여 쿼리를 실행한다.

주의할 점

카테시안 곱(Cartesian Product)

  • 두 방식 모두 다대다 또는 일대다 관계에서 Cartesian Product가 발생할 수 있습니다. 연관된 엔티티 수만큼 중복된 데이터가 조회될 수 있습니다. 예를 들어, 부모-자식 관계에서 부모가 여러 자식을 가지고 있을 때 부모 엔티티가 중복되어 나타날 수 있습니다.

 

 

중복데이터를 제거할 수 있는 방법으로는 ?

 

1. Set 컬렉션 사용

  • Set중복을 허용하지 않는 자료구조이므로, Fetch Join이나 @EntityGraph로 인해 중복된 데이터가 조회되더라도 컬렉션이 Set으로 정의되어 있다면 중복된 엔티티가 자동으로 제거된다..
  • 예를 들어, 부모와 자식 관계에서 부모가 여러 자식을 가질 때, 조인 결과로 중복된 부모 엔티티가 반환될 수 있다.
    하지만 Set을 사용하면 중복된 부모 엔티티가 자동으로 제거된다
@OneToMany(fetch = FetchType.LAZY)
private Set<Child> children;  // Set 사용으로 중복된 자식 제거

 

주의 할 점은 Set은 데이터의 순서를 보장하지 않으므로, 순서가 중요한 경우에는 적합하지 않을 수 있다.

 

2. JPQL에서 DISTINCT 사용

  • JPQL의 DISTINCT 키워드를 사용하면 중복된 데이터가 조회되지 않도록 할 수 있다.
  • JPQL에서 DISTINCT는 SQL의 DISTINCT처럼 작동하여 결과 집합에서 중복된 행을 제거한다.
  • 하지만 DISTINCT는 엔티티 전체가 동일할 때만 중복을 제거하므로, 부모 엔티티와 자식 엔티티의 일부 데이터가 다르다면 여전히 중복된 부모 엔티티가 나타날 수 있다. 그럼에도 불구하고, 연관된 여러 엔티티의 중복 문제를 완화할 수 있는 방법이다.
String jpql = "SELECT DISTINCT p FROM Parent p JOIN FETCH p.children";
List<Parent> parents = entityManager.createQuery(jpql, Parent.class).getResultList();

 

장점:

  • SQL에서 DISTINCT와 유사하게 동작하여 중복된 데이터를 제거할 수 있습니다.
  • Fetch Join을 사용할 때 발생하는 중복된 부모 데이터를 줄일 수 있습니다.

단점:

  • 연관된 엔티티가 동일하지 않으면 중복이 완전히 제거되지 않을 수 있습니다.
  • 결과적으로 성능에 약간의 영향을 줄 수 있습니다.

3. Batch Fetching

 

Batch Fetching은 Hibernate에서 제공하는 성능 최적화 방법으로, 연관된 엔티티들을 배치로 묶어서 조회하는 방식이다.

  • 개념: 연관된 엔티티들을 여러 번에 나누어 가져오되, 각 번의 조회에서 일정 개수씩 묶어 한꺼번에 로딩하는 방식 hibernate.default_batch_fetch_size 속성을 사용하여 설정할 수 있다.

1. application.properties에서 설정하는 방법

spring.jpa.properties.hibernate.default_batch_fetch_size=10

 

2. 엔티티를 사용해서 설정

@Entity
public class Parent {

    @Id
    private Long id;

    @OneToMany(fetch = FetchType.LAZY)
    @BatchSize(size = 10)  // Batch Fetching을 엔티티 수준에서 설정할 수도 있음
    private List<Child> children;
}

@Entity
public class Child {

    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Parent parent;
}

 

장점:

  • Lazy Loading 유지: 지연 로딩을 그대로 유지하면서 성능 최적화를 할 수 있다.
  • 대규모 데이터를 처리할 때 적절히 설정하면 성능 개선에 큰 도움이 된다.

단점:

  • Hibernate에 종속적인 기능으로, JPA 표준이 아니다.
  • 배치 크기를 잘못 설정하면 오히려 성능에 부정적인 영향을 미칠 수 있다.

FetchType.EAGER 사용

 

즉시 로딩(Eager Fetching)은 연관된 엔티티를 항상 즉시 로딩하는 방식이다. 연관된 모든 엔티티를 한 번에 조회할 수 있지만, 무조건적으로 사용하기에는 부담이 될 수 있다.

  • 개념: 연관된 엔티티를 즉시 로딩하도록 FetchType.EAGER로 설정한다. 이는 연관된 엔티티를 무조건 즉시 로딩하기 때문에 추가적인 N+1 문제를 방지할 수 있다.

    장점
    • 모든 연관된 데이터를 한 번에 로딩하므로 N+1 문제를 해결할 수 있다.
    단점:
    • 불필요한 데이터 로딩이 발생할 수 있다. 즉시 로딩은 항상 연관된 데이터를 가져오기 때문에 필요하지 않은 데이터를 로딩할 경우 성능 저하를 일으킬 수 있다.

QueryDSL, Mybatis등 QueryBuilder를 사용해서 n+1 문제를 해결 할 수도 있다

 

1. QueryDSL로 N+1 문제 해결

import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
import java.util.List;

@Repository
public class ParentRepository {

    private final JPAQueryFactory queryFactory;

    // EntityManager 주입을 통해 JPAQueryFactory 초기화
    public ParentRepository(EntityManager entityManager) {
        this.queryFactory = new JPAQueryFactory(entityManager);
    }

    public List<Parent> findAllParentsWithChildren() {
        QParent parent = QParent.parent;  // Parent 엔티티의 Q 클래스
        QChild child = QChild.child;      // Child 엔티티의 Q 클래스

        // Fetch Join을 사용해 Parent와 Child를 한 번에 로딩하여 N+1 문제 해결
        return queryFactory
            .selectFrom(parent)
            .leftJoin(parent.children, child).fetchJoin()  // Parent와 Child 간의 Fetch Join
            .distinct()  // 중복된 Parent 제거
            .fetch();    // 결과 조회
    }
}

 

쿼리 설명:

  • leftJoin(parent.children, child).fetchJoin(): Parent 엔티티와 연관된 Child 엔티티를 Fetch Join으로 한 번의 쿼리로 가져온다
  • distinct(): 중복된 Parent 엔티티를 제거하여 카테시안 곱 문제를 방지한다.
  • fetch(): 결과를 쿼리하여 리스트로 반환한다.

QueryDSL의 장점:

  • 타입 세이프 쿼리: 컴파일 타임에 오류를 잡아낼 수 있다.
  • 동적 쿼리: 여러 조건을 동적으로 추가하여 유연한 쿼리를 작성할 수 있다.
  • Fetch Join으로 N+1 문제 해결: 연관 엔티티를 한 번의 쿼리로 가져오기 때문에 N+1 문제를 해결한다.

 

2. MyBatis로 N+1 문제 해결

MyBatis는 직접적으로 SQL 쿼리를 작성하므로, 연관된 엔티티를 JOIN을 통해 한 번의 쿼리로 가져올 수 있다.

ResultMap을 사용하여 Parent와 Child를 매핑할 수 있으며, 이를 통해 N+1 문제를 해결할 수 있다.

 

<resultMap id="ParentWithChildrenMap" type="Parent">
    <id column="parent_id" property="id" />
    <result column="parent_name" property="name" />
    
    <!-- children 필드를 Child 리스트로 매핑 -->
    <collection property="children" ofType="Child">
        <id column="child_id" property="id" />
        <result column="child_name" property="name" />
    </collection>
</resultMap>

<select id="findAllParentsWithChildren" resultMap="ParentWithChildrenMap">
    SELECT p.id AS parent_id, p.name AS parent_name,
           c.id AS child_id, c.name AS child_name
    FROM Parent p
    LEFT JOIN Child c ON p.id = c.parent_id
</select>
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;

@Mapper
public interface ParentMapper {

    @Select("SELECT * FROM Parent")
    List<Parent> findAllParentsWithChildren();
}
@Getter @Setter
public class Parent {
    private Long id;
    private String name;
    private List<Child> children;  // OneToMany 관계

}

@Getter @Setter
public class Child {
    private Long id;
    private String name;
    private Parent parent;  // ManyToOne 관계

}

MyBatis에서 N+1 문제 해결:

  • LEFT JOIN을 통해 Parent와 Child 데이터를 한 번의 쿼리로 가져오므로, N+1 문제를 해결할 수 있다.
  • ResultMap을 사용하여 SQL 결과를 Parent와 Child 엔티티에 매핑한다. 이를 통해 다대일 또는 일대다 관계를 쉽게 처리할 수 있다.
728x90
728x90

교차 출처 리소스 공유 (Cross-Origin Resource Sharing, CORS)

 

 

CORS는 다른 출처에서 리소스를 요청할 때 발생하는 문제를 해결하기 위한 표준이다.

현대 웹 개발에서는 한 출처의 클라이언트가 다른 출처의 서버에 API 요청을 해야 하는 경우가 많지만, 보안상의 이유로 웹 브라우저는 동일 출처 정책(Same-Origin Policy, SOP)에 따라 기본적으로 다른 출처에서의 요청을 차단한다.

이때 CORS를 사용하면 특정 출처의 리소스를 허용할 수 있다.

.


1. 동일 출처 정책(SOP)과 교차 출처 리소스 공유(CORS)란?

웹 보안을 강화하기 위해 브라우저는 SOP를 따른다.

→ 동일 출처(SOP)란, 프로토콜(http/https), 호스트(domain.com), 포트(8080)의 세 가지가 모두 같을 때를 의미한다.

예를 들어, http://example.com에서 https://another-domain.com에 요청을 보내면 SOP 규칙에 의해 차단된다.

 

그러나 CORS는 SOP 정책을 우회하여 특정 출처에 대해서만 리소스를 허용할 수 있게 하는 해결책이다.

서버에서 허용된 출처를 명시함으로써 클라이언트가 다른 출처의 리소스를 요청할 수 있게 된다.

 


2. CORS 동작 원리

1. 클라이언트에서 Origin 헤더를 포함한 HTTP 요청 전송

브라우저는 요청을 보낼 때, 요청의 출처(Origin)를 헤더에 포함하여 서버로 전송한다.

예를 들어, http://localhost:3000에서 API 요청을 보내면 요청 헤더에 Origin: http://localhost:3000이 포함된다.

 

 

2. 서버는 응답 헤더에 Access-Control-Allow-Origin 포함

서버는 요청에 대한 응답 시, Access-Control-Allow-Origin 헤더에 특정 출처 또는 와일드카드(*)를 포함하여 해당 출처의 요청을 허용할지 여부를 결정한다.

 

 

 

3. 클라이언트에서 출처 비교

브라우저는 서버가 보낸 Access-Control-Allow-Origin 헤더의 값을 자신이 보낸 Origin 헤더와 비교한다.

만약 이 값이 일치한다면, 브라우저는 리소스를 허용하고, 그렇지 않다면 CORS 에러가 발생한다.

 


3. CORS 동작 방식 시나리오

CORS는 단순 요청, 예비 요청(Preflight), 인증된 요청의 세 가지 시나리오에 따라 동작한다.

 

1. 단순 요청(Simple Request)

단순 요청은 HTTP 메서드가 GET, POST, HEAD 중 하나일 때 발생합니다.

이 경우 예비 요청 없이 바로 서버에 본 요청이 전달되고 또한 요청 헤더는 Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain이어야만 한다.

 

GET /api/data HTTP/1.1
Host: api.another-domain.com
Origin: http://localhost:3000

 

서버는 응답 시, Access-Control-Allow-Origin 헤더를 포함시켜야 한다.

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000

 

 

2. 예비 요청(Preflight Request)

만약 요청이 단순 요청의 조건을 만족하지 못하면, 브라우저는 실제 요청을 보내기 전에 OPTIONS 메서드를 이용해

예비 요청(Preflight Request)을 보낸다. 이것은 서버가 실제 요청을 허용할지를 먼저 확인하는 과정이다.

 

예비 요청은 세개의 정보를 포함한다.

  • Origin: 요청 출처
  • Access-Control-Request-Method: 본 요청에서 사용할 HTTP 메서드
  • Access-Control-Request-Headers: 본 요청에서 사용할 헤더
OPTIONS /api/data HTTP/1.1
Host: api.another-domain.com
Origin: http://localhost:3000
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type

 

서버는 이에 대한 응답으로 허용된 메서드와 헤더를 명시한 응답을 보내야 한다.

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: POST, GET
Access-Control-Allow-Headers: Content-Type

  

 

3. 인증된 요청(Credentialed Request) 

인증된 요청은 쿠키 또는 인증 토큰과 같은 자격 인증 정보(Credential)를 포함하는 요청이다.

이를 통해 클라이언트는 로그인 상태 유지, 세션 관리, 보안 토큰 등을 다른 출처의 서버로 전달할 수 있다.

 

이때 서버는 다음과 같은 사항을 준수해야 한다

  • Access-Control-Allow-Credentials: true로 설정해, 자격 증명이 포함된 요청을 허용해야 한다.
  • Access-Control-Allow-Origin 헤더에서 와일드카드(*)를 사용할 수 없으며, 반드시 명확한 출처(URL)를 지정해야 한다. 이유는 자격 증명 데이터(쿠키, 토큰 등)가 민감한 정보이므로 특정 출처에만 허용해야 하기 때문이다.
fetch('https://example.com/api/data', {
  method: 'POST',
  credentials: 'include', // 쿠키 및 인증 정보를 포함
});

 

  • credentials: 'include': 모든 출처에 대해 쿠키 및 인증 정보 전송
  • credentials: 'same-origin': 동일 출처의 요청에만 쿠키 및 인증 정보 전송 (기본값)
  • credentials: 'omit': 쿠키 및 인증 정보를 전송하지 않음

 

 

서버의 응답 예시:

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Credentials: true

 

 

인증된 요청을 사용할 때는 보안을 위해 추가적으로 고려해야 할 몇 가지 사항이 있다.

 

1. HTTPS 사용 필수

  • 인증 정보(쿠키, 토큰 등)는 민감한 데이터이므로 HTTPS를 사용해 전송해야 한다.
  • HTTPS를 사용하지 않으면 중간자 공격(Man-in-the-Middle, MITM)에 취약해질 수 있다.

 

2. SameSite 쿠키 설정

  • 쿠키를 사용하는 경우, CSRF(Cross-Site Request Forgery) 공격을 방지하기 위해 SameSite 속성을 설정하는 것이 중요하다
  • SameSite 쿠키 설정 옵션:
    • Strict: 동일 출처에서만 쿠키 전송 (CSRF 방지 효과 큼)
    • Lax: 안전한 요청(GET, HEAD 등)에서만 쿠키 전송
    • None: 모든 요청에 쿠키 전송 (HTTPS 필수)

예시:

Set-Cookie: sessionId=abc123; SameSite=Lax; Secure

 

 

3. CSRF 공격 방지

  • 인증된 요청에서 쿠키를 사용하는 경우, CSRF 공격에 노출될 수 있다.
  • 이것을 방지하기 위해 서버에서 CSRF 토큰을 사용하거나 SameSite 속성을 설정하는 것이 필수적이다.

4. CORS 문제 해결 방법

CORS 문제를 해결하는 방법에는 여러 가지가 있다.

 

 

1. 서버에서 CORS 설정

서버에서 CORS 문제를 해결하는 가장 정석적인 방법은 응답 헤더에 올바르게 설정하는 것

 

Spring 예시:

// 스프링 서버 전역적으로 CORS 설정
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
        	.allowedOrigins("http://localhost:8080", "http://localhost:8081") // 허용할 출처
            .allowedMethods("GET", "POST") // 허용할 HTTP method
            .allowCredentials(true) // 쿠키 인증 요청 허용
            .maxAge(3000) // 원하는 시간만큼 pre-flight 리퀘스트를 캐싱
    }
}
// 특정 컨트롤러에만 CORS 적용하고 싶을때.
@Controller
@CrossOrigin(origins = "*", methods = RequestMethod.GET) 
public class customController {

	// 특정 메소드에만 CORS 적용 가능
    @GetMapping("/url")  
    @CrossOrigin(origins = "*", methods = RequestMethod.GET) 
    @ResponseBody
    public List<Object> findAll(){
        return service.getAll();
    }
}

 

JSP / Servlet 세팅

import javax.servlet.*;

public class CORSInterceptor implements Filter {

    private static final String[] allowedOrigins = {
            "http://localhost:3000", "http://localhost:5500", "http://localhost:5501",
            "http://127.0.0.1:3000", "http://127.0.0.1:5500", "http://127.0.0.1:5501"
    };

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;

        String requestOrigin = request.getHeader("Origin");
        if(isAllowedOrigin(requestOrigin)) {
            // Authorize the origin, all headers, and all methods
            ((HttpServletResponse) servletResponse).addHeader("Access-Control-Allow-Origin", requestOrigin);
            ((HttpServletResponse) servletResponse).addHeader("Access-Control-Allow-Headers", "*");
            ((HttpServletResponse) servletResponse).addHeader("Access-Control-Allow-Methods",
                    "GET, OPTIONS, HEAD, PUT, POST, DELETE");

            HttpServletResponse resp = (HttpServletResponse) servletResponse;

            // CORS handshake (pre-flight request)
            if (request.getMethod().equals("OPTIONS")) {
                resp.setStatus(HttpServletResponse.SC_ACCEPTED);
                return;
            }
        }
        // pass the request along the filter chain
        filterChain.doFilter(request, servletResponse);
    }

    private boolean isAllowedOrigin(String origin){
        for (String allowedOrigin : allowedOrigins) {
            if(origin.equals(allowedOrigin)) return true;
        }
        return false;
    }
}

 

 

2. 프록시 서버 사용

클라이언트에서 직접 다른 출처의 서버에 요청할 수 없을 때, 중간에 프록시 서버를 두어 모든 요청을 대신 처리하게 할 수 있다. 프록시 서버는 클라이언트 대신 외부 서버에 요청을 보내고 응답을 전달해준다.

const url = 'https://google.com'; // 요청할 서버의 URL
fetch(`https://cors-anywhere.herokuapp.com/${url}`)
  .then(response => response.text())
  .then(data => console.log(data));

 

3. Chrome 확장 프로그램 사용

개발 환경에서는 Allow CORS: Access-Control-Allow-Origin과 같은 Chrome 확장 프로그램을 사용하여 간단하게 CORS 문제를 해결할 수 있다. 이 방법은 실무보다는 개발 환경에서 API 테스트에 유용하다.

 

 


5. CORS 최적화: Preflight 캐싱

Preflight 요청은 성능에 영향을 줄 수 있다. 많은 API 요청이 있을 경우, OPTIONS 메서드를 통해 서버에 불필요한 예비 요청이 발생할 수 있기 때문에, 이를 캐싱하여 최적화할 수 있다.

서버는 Access-Control-Max-Age 헤더를 통해 예비 요청 결과를 캐싱할 수 있는 시간을 지정할 수 있다.

Access-Control-Max-Age: 600  // 600초 동안 예비 요청을 캐싱

 

이 설정을 통해 클라이언트는 예비 요청 없이 바로 본 요청을 보낼 수 있음!

 


결론

CORS는 웹 보안을 유지하면서도 다른 출처의 리소스를 안전하게 사용할 수 있도록 해주는 중요한 메커니즘이다.

클라이언트와 서버 간 상호작용에서 발생하는 CORS 문제는 서버의 적절한 설정을 통해 해결할 수 있으며, 개발 과정에서는 프록시 서버나 브라우저 확장 프로그램을 활용해 임시로 문제를 해결할 수 있다. 또한, 예비 요청 캐싱과 같은 최적화 방법을 통해 성능도 개선할 수 있다.

 

 

 

 

 


 

자료 참조 : 

https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-CORS-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95-%F0%9F%91%8F#%EC%B6%9C%EC%B2%98origin_%EB%9E%80?

728x90
728x90

해시 알고리즘(Hash Algorithm)이란?



- 데이터를 빠르게 저장하고 검색하기 위한 알고리즘으로 ‘키(key)’를 사용하여 ‘데이터 값(value)’을 조회하는 방법을 의미함. 이러한 ‘키-값’을 ‘해시 테이블’이라는 데이터 구조에 저장하여 키에 매핑되는 인덱스 값으로 빠르게 찾을 수 있다.


- 키는 해시 함수를 통과하여 해시 값으로 변환되며 이 해시 값은 데이터가 저장되는 배열의 인덱스를 결정한다.

 

cf)

Map<String, Object> result = new HashMap<>();
result.put("key1", "value1");
result.put("key2", "value2");
result.put("key3", "value3");

System.out.println("key1 : " + result.get("key1"));
System.out.println("key2 : " + result.get("key2"));
System.out.println("key3 : " + result.get("key3"));

 


 

 해시 알고리즘 처리 방식


- key, value의 형태의 해시는 해시 함수를 통해서 key의 값은 ‘고유한 해시값’으로 변환된다.
- 이 변환된 해시 값을 기반으로 ‘해시 인덱스’를 결정하여 해시 테이블에는 해시 인덱스와 고유한 해시 값이 저장이 된다.
- 탐색을 위해서는 key를 호출하면 인덱스를 찾아서 값을 반환해 주기에 빠르게 조회가 가능하다.

1. 키(Key)

- 문자열 혹은 정수 형태로 구성된 키이며, key-value 형태로 실제 데이터 구조를 가지고 있다. 

  키는 실제 데이터를 찾기 위해 사용되는 고유 식별자를 의미한다.
- 해당 과정에서는 Key 값은 해시함수(Hash Function)로 전달되어 처리된다.

2. 해시 함수(Hash Function)

- 전달받은 키(Key) 값을 통해서 해시 함수가 수행이 된다. 이 해시 함수 내에서는 키를 ‘고유한 해시값’으로 변환한다.
- 또한 변환된 해시 값은 해시 테이블(Hash Table)의 해시 인덱스(Hash Index)로 저장이 된다.

3. 해시 테이블(Hash Table)

- 해시 함수로 전달받은 ‘고유한 해시값’을 기반으로 ‘해시 인덱스(Hash Index)’를 결정하고, 해시 값(Hash Value)은 실제 값이 들어간다.
- 고유한 해시값을 가지고 있기에 ‘키’를 통해서 빠르게 값을 검색할 수 있다.

 

이 과정에서 키(key)의 원래 순서는 사라지게 된다.

 

 해시 알고리즘과 해시 기반의 데이터 구조는 데이터의 빠른 검색과 접근을 우선시하며,
이를 위해 데이터의 입력 순서나 정렬 상태를 보장하지 않음
이러한 특징은 알고리즘의 효율성과 속도를 높이는 데 중요한 역할을 한다.

 


해시 알고리즘

HashMap, HashTable, LinkedHashMap

 

 

 

클래스 / 인터페이스 Map 인터페이스와 관계 내용
HashMap 구현체 Map 인터페이스를 구현한 클래스로, 해싱 기법을 사용하여 키와 값의 쌍을 저장
HashTable 구현체 Map 인터페이스를 구현한 클래스로, 해싱 기법을 사용하여 키와 값의 쌍을 저장.
동기화된 메소드를 제공하여 스레드에 안전함
LinkedHashMap 구현체 HashMap의 하위 클래스로, 키-값 쌍을 해시맵에 저장하는 동시에 더블 링크드 리스트에도 입력 순서에 따라 저장
SortedMap 상속 인터페이 Map 인터페이스의 확장으로, 키의 자연적인 순서로 키-값 쌍을 유지

 


주요 특징 

 

 

 

HashMap

→ Key와 Value 값에 모두 NULL 값을 허용하여 Key에도 null 값이 들어가거나 Value에도 null 값이 들어갈 수 있다.

 

HashTable

HashMap과 다르기게 스레드에 안전(thread-safe)한 동기화된 메서드를 제공한다. 이는 멀티 스레드 환경에서 여러 스레드가 동시에 HashTable을 수정하지 못하도록 한다. 따라서 HashTable은 동시성 문제에 대해 더 안전하게 사용할 수 있다.

또 다른 차이점은 HashTable이 null key 또는 null value를 허용하지 않는다는 점. 이는 HashMap이 null key와 null value를 모두 허용하는 것과 대조적이다.

 


 

HashMap과 HashTable의 차이

 

 

1. 동기화

- Hashtable은 동기화되어 Thread-safe 하다. 즉, 멀티스레드 환경에서 한 번에 하나의 스레드만 Hashtable에 변경을 가할 수 있다.
- 반면에, HashMap은 비동기화되어 있어 Thread-safe 하지 않다.. 이로 인해 HashMap이 Hashtable보다 빠르며, 동시성을 요구하지 않는 경우에는 HashMap이 더 적합하다.

2. NULL 허용 여부

- Null Key 및 Null Value: HashMap은 하나의 Null Key와 여러 개의 Null Value를 허용. 반면에 Hashtable은 Null Key 또는 Null Value를 허용하지 않는다.

 

 

 

 

LinkedHashMap

키와 값에 NULL을 허용하며 키는 중복되지 않는다. 만약 동일한 키로 데이터를 저장하면, 기존 데이터를 덮어쓰게 된다.

→  LinkedHashMap은 해시맵의 빠른 접근성을 유지하면서도 더블 링크드 리스트로 인해 입력 순서대로 데이터를 반복할 수 있는 기능을 제공한다.

 

 

728x90

+ Recent posts