1. 해시 테이블

  • 키워드:
    • "중복 제거", "빠른 탐색", "Key-Value 매핑", "주어진 값을 빠르게 찾기"
    • "빈도 계산", "사전(dictionary) 구조", "해시맵", "특정 조건 만족 여부 확인"
  • 예시 문제:
    • 배열에서 중복되는 숫자 제거.
    • 문자열에서 가장 많이 등장한 문자 구하기.
    • 두 개의 배열이 같은 원소를 포함하는지 확인.
    • 특정 합을 가지는 두 숫자의 인덱스를 찾기 (Two Sum).
    • 주어진 문자열의 아나그램을 찾기.
    • 주어진 패턴을 만족하는 문자열 매칭.

2. 스택, 큐

  • 키워드:
    • "후입선출 (LIFO)", "전입선출 (FIFO)"
    • "괄호 짝 맞추기", "스케줄 처리", "순서대로 처리", "백트래킹"
  • 예시 문제:
    • 괄호의 유요성을 탐색
    • 히스토리를 관리하는 웹 브라우저의 뒤로 가기/앞으로 가기 구현.
    • 큐를 사용한 프로세스 스케줄링 문제.
    • 중위 표기법을 후위 표기법으로 변환.
    • 큐를 사용해 너비 우선 탐색 (BFS) 구현.
    • 스택으로 최대 히스토그램 넓이를 구하기.

3. DFS (깊이 우선 탐색)

  • 키워드:
    • "그래프", "트리", "모든 경로 탐색", "재귀", "연결된 노드 찾기"
    • "한 번 방문 후 되돌아오기", "미로 찾기"
  • 예시 문제:
    • 그래프에서 연결 요소의 개수 구하기.
    • 특정 시작점에서 모든 노드 방문하기.
    • 트리에서 모든 루트-리프 경로 찾기.
    • 미로의 출구까지 가는 경로 찾기.
    • 섬의 개수를 세는 문제.
    • 특정 노드가 도달 가능한 노드의 집합 구하기.

4. BFS (너비 우선 탐색)

  • 키워드:
    • "최단 거리", "층별 탐색", "그래프", "큐 활용"
    • "최소 단계", "주변 노드 먼저 탐색", 단 모든 노드 탐색에 사용, 최소 신장 트리는 최소 비용 
  • 예시 문제:
    • 미로에서 출발점에서 도착점까지의 최단 거리 찾기.
    • 그래프에서 두 노드 간 최단 경로 구하기.
    • 특정 레벨에 존재하는 노드 출력하기.
    • 바이러스 퍼짐 문제 (감염된 컴퓨터 찾기).
    • 특정 거리 이하로 연결된 노드 집합 구하기.
    • 토마토 숙성 문제 (2D 배열 BFS).


5. 플러드 필 (Flood Fill) = DFS를 활용한 재귀적 탐색 방법

  • 키워드:
    • "이미지 채우기", "영역 탐색", "같은 색 그룹 찾기"
    • "2D 배열", "인접 노드 탐색"
  • 예시 문제:
    • 2D 배열에서 연결된 동일 색상 영역 채우기.( 블록 색상을 채우는 게임에서 같은 색으로 연결된 블록을 선택하면 블록들이 전부 사라지거나 다른 색으로 바뀝니다. )
    • 섬의 개수를 세는 문제 (2D 배열).
    • 특정 지점으로부터 연결된 영역의 크기 구하기.
    • 게임 맵에서 특정 조건을 만족하는 영역 색칠하기.
    • 특정 구역에서 물이 퍼지는 경로 찾기.
    • 특정 값과 같은 인접한 셀을 모두 변환.

6. 브루트 포스 (완전 탐색)

  • 키워드:
    • "모든 경우의 수 탐색", "가능한 모든 조합/경우 시도"
    • "효율성보다는 정답 보장", "제약 조건 없는 탐색"
  • 예시 문제:
    • 숫자 배열에서 가능한 모든 부분 집합의 합 구하기.
    • n개의 숫자로 만들 수 있는 모든 조합 출력.
    • 주어진 단어의 모든 순열 생성.
    • 특정 길이의 모든 부분 문자열을 탐색.
    • 특정 조건을 만족하는 수열 조합 찾기.
    • 특정 위치에서 나이트의 이동 가능성 확인.

7. 그리디 알고리즘

  • 키워드:
    • "매 순간 최적의 선택", "국소 최적 → 전체 최적"
    • "정렬된 데이터", "최소/최대 값 구하기"
  • 예시 문제:
    • 동전의 최소 개수로 금액 만들기.
    • 가장 많은 회의를 배정하는 회의실 문제.
    • 배낭 문제 (Greedy Knapsack).
    • 문자열을 뒤집지 않고 사전순으로 가장 작은 문자열 만들기.
    • 최소한의 구간으로 커버하기.
    • 높은 점수를 우선적으로 선택하는 문제.


8. 이분 탐색

  • 키워드:
    • "정렬된 데이터", "탐색 범위 줄이기", "중간값 비교"
    • "정확한 값 찾기", "최소/최대 조건 만족"
  • 예시 문제:
    • 정렬된 배열에서 특정 값을 찾기.
    • 특정 조건을 만족하는 최소/최대 값 찾기 (예: 부피, 시간).
    • 특정 목표 값을 만들기 위한 숫자 조합 탐색.
    • 제곱근 계산 (수치 해석).
    • 배열의 가장 가까운 값을 찾기.
    • 정렬된 배열에서 삽입 위치 구하기.

9. 그래프

  • 키워드:
    • "노드와 간선", "연결된 데이터", "인접 리스트/행렬"
    • "경로 찾기", "연결 여부 확인"
  • 예시 문제:
    • 그래프에서 두 노드 간 경로가 존재하는지 확인.
    • 가중치 없는 그래프에서 최단 경로 구하기.
    • 특정 그래프가 사이클을 포함하는지 확인.
    • 트리의 높이 구하기.
    • 그래프가 연결되어 있는지 확인.
    • 이분 그래프인지 판별하기.

10. 투 포인터, 슬라이딩 윈도

  • 키워드:
    • "배열의 구간", "서로 다른 두 포인터", "연속된 부분"
    • "특정 조건 만족하는 구간", "최소/최대 길이"
  • 예시 문제:
    • 배열에서 특정 합을 만족하는 부분 배열 찾기.
    • 문자열에서 가장 긴 중복 없는 부분 문자열 찾기.
    • 배열의 최대/최소 평균 구간 찾기.
    • 정렬된 배열에서 두 합이 특정 값을 만족하는 두 숫자 찾기.
    • 특정 조건을 만족하는 최소 구간 길이 찾기.
    • K개를 초과하지 않는 서로 다른 문자가 포함된 가장 긴 문자열 찾기.

11. 유니온 파인드 (Union-Find)

  • 키워드:
    • "집합 찾기", "그래프에서 사이클 확인", "연결 여부"
    • "최소 신장 트리", "네트워크 연결 확인"
  • 예시 문제:
    • 그래프에서 사이클 확인하기.
    • 특정 네트워크의 연결 상태 파악하기.
    • 최소 신장 트리 생성 (Kruskal 알고리즘).
    • 도시 간 연결 여부를 확인하는 문제.
    • 특정 노드 그룹이 같은 집합인지 확인.
    • 친구 관계를 그룹으로 묶기.

12. 최소 신장 트리 (MST, Kruskal/Prim)

  • 키워드:
    • "가중치 그래프", "모든 노드 연결", "최소 비용"
    • "네트워크 설계", "간선 선택"
  • 예시 문제:
    • 도시를 연결하는 도로의 최소 비용 계산.
    • 특정 지역 네트워크 연결 비용 줄이기.
    • 통신망을 최소 비용으로 구축하기.
    • 그래프의 모든 노드를 연결하는 최소 간선 찾기.
    • 전기 배선을 최적화하는 문제.
    • 케이블 설치를 위한 최소 비용 계산.

 


13. 비트 마스킹

  • 키워드:
    • "이진수 표현(0, 1로 표현 가능한 경우)", "상태 저장", "조합/부분집합"
    • "효율적인 데이터 표현", "AND, OR, XOR"
  • 예시 문제:
    • n개의 숫자 중 조건을 만족하는 모든 부분집합 구하기.
    • 집합의 모든 부분 집합 출력하기.
    • 두 집합의 교집합 계산하기.
    • 특정 비트가 켜져 있는지 확인하기.
    • 비트 상태를 변환해 최소 전환 횟수 계산하기.
    • 스위치 상태를 변경해 최적 상태로 만들기.

'Coding Test > 학습 방식' 카테고리의 다른 글

코딩테스트 풀이 공식  (3) 2024.11.20

코딩테스트 풀이 공식

 

1. 문제 분석

  1. 문제 읽기 및 요구사항 파악
    • 입력과 출력의 형식, 제약 조건을 정확히 확인.
    • 해결해야 할 핵심 목표가 무엇인지 정의.
  2. 예제 분석
    • 제공된 예제를 통해 요구사항을 검증.
    • 주어진 예제가 부족하면 추가 예제를 손수 만들어 테스트 케이스를 확장.
  3. 핵심 키워드 도출
    • "그래프", "최단 거리", "조합", "정렬" 등 문제의 성격을 결정짓는 단서를 찾기.

2. 풀이 설계

  1. 문제를 작은 단위로 나누기
    • 문제를 여러 단계로 쪼개서 처리해야 할 작업 목록 작성.
      예) 데이터 입력 → 전처리 → 탐색 → 결과 출력.
  2. 적합한 알고리즘 및 자료구조 선택
    • 키워드에 맞는 알고리즘 (e.g., DFS, BFS, DP) 및 자료구조 선택.
      • 그래프 → DFS, BFS
      • 최적화 → DP, 그리디
      • 정렬 → 힙, 퀵소트
    • 자료구조를 고려 (e.g., 배열, 해시맵, 스택, 큐, 트리).
  3. 클래스 또는 함수 설계
    • 문제를 해결하는 데 필요한 클래스나 함수 정의.
      • 데이터 처리: InputHandler 클래스.
      • 계산/탐색: Solver 클래스.
      • 결과 출력: OutputHandler 클래스.
    • 설계를 통해 코드의 유지보수성과 가독성 향상.
  4. 의사코드 작성
    • 알고리즘의 흐름을 간략히 적어서 문제 풀이의 기본 구조 설계.

3. 구현

  1. 입력 처리
    • 입력 형식에 맞는 데이터 전처리. (e.g., 문자열 → 리스트 변환, 그래프 → 인접 리스트 생성)
  2. 알고리즘 구현
    • 설계한 대로 알고리즘을 차례로 작성.
  3. 클래스/함수 호출
    • 전체 구조에 맞게 코드의 흐름 정리.

4. 테스트 및 디버깅

  1. 제공된 예제 테스트
    • 문제에서 제공된 예제를 사용해 정상적으로 동작하는지 확인.
  2. 엣지 케이스 추가 테스트
    • 예외 상황이나 극단적인 입력을 넣어 프로그램의 안정성 점검.
  3. 시간 복잡도 및 메모리 최적화 검토
    • 입력의 범위와 제약 조건에 따라 효율성을 재검토.

5. 최종 제출

  1. 코드 정리 및 주석 추가
    • 가독성을 위해 불필요한 코드를 제거하고 주석으로 설명.
  2. 최종 테스트
    • 마지막으로 다시 한 번 전체 흐름을 확인 후 제출.

 

 

예제

 

체계적 풀이 적용 (DFS 활용)

문제: N개의 노드와 M개의 간선이 주어진 그래프에서 연결 요소의 개수를 구하라.


  1. 문제 분석
    • 입력: 노드 수, 간선 정보 (간선의 연결 리스트).
    • 출력: 연결 요소의 개수.
  2. 풀이 설계
    • 그래프 탐색 필요 → DFS 사용.
    • 방문 여부를 저장할 visited 배열 필요.
    • 모든 노드에 대해 DFS를 호출하며 연결 요소 카운트.
  3. 클래스 설계
    • Graph 클래스: 그래프 생성 및 데이터 저장.
    • DFS 함수: 연결 요소 탐색.
  4. 구현
class Graph:
    def __init__(self, n):
        self.graph = [[] for _ in range(n + 1)]
        self.visited = [False] * (n + 1)
    
    def add_edge(self, u, v):
        self.graph[u].append(v)
        self.graph[v].append(u)
    
    def dfs(self, node):
        self.visited[node] = True
        for neighbor in self.graph[node]:
            if not self.visited[neighbor]:
                self.dfs(neighbor)

def count_connected_components(n, edges):
    graph = Graph(n)
    for u, v in edges:
        graph.add_edge(u, v)
    
    count = 0
    for node in range(1, n + 1):
        if not graph.visited[node]:
            graph.dfs(node)
            count += 1
    return count

'Coding Test > 학습 방식' 카테고리의 다른 글

알고리즘 별 문제 파악 키워드  (1) 2024.11.21

프로젝트 구상

 

 

  • 목표
    • 여러 출처에서 수집한 고객 데이터를 기반으로 행동 패턴을 분석하여 개인 맞춤형 마케팅 전략을 수립합니다. 이 프로젝트는 데이터 레이크, 웨어하우스, 데이터 마트, 그리고 시각화 도구를 통합하여 최적의 분석 환경을 구축하고자 합니다.
  • 기술 스택
    • 데이터 레이크: AWS S3 (원시 데이터를 저장)
    • 데이터 웨어하우스: AWS Redshift (정제된 데이터를 효율적으로 쿼리하고 분석)
    • 데이터 마트: Redshift 기반 데이터 마트 (자주 사용하는 고객 세그먼트 데이터를 저장)
    • 데이터 처리: Apache Spark (데이터 레이크의 원본 데이터를 전처리하고 정제)
    • 시각화 도구: Apache Superset, Tableau (분석 결과를 시각화하여 대시보드 형태로 제공)
  • 구현 단계
    • 데이터 수집 및 저장
      • 고객의 웹사이트 클릭 로그, 구매 이력, 고객 서비스 요청 데이터를 AWS S3에 저장하여 데이터 레이크를 구축합니다. 비정형 데이터(로그)와 구조화된 데이터(이력, 요청)를 모두 수용할 수 있습니다.
    • 데이터 처리 및 정제
      • Apache Spark를 통해 S3에 저장된 원시 데이터를 전처리하여 필요한 정보만 추출합니다. 예를 들어, 고객의 제품 카테고리 선호도나 특정 페이지의 체류 시간 등을 정리합니다.
    • 데이터 웨어하우스로 이동
      • 정제된 데이터를 AWS Redshift로 이동하여 구조화된 데이터베이스에 저장합니다. Redshift를 통해 정제된 고객 데이터를 쿼리하고 분석할 수 있습니다.
    • 데이터 마트 생성
      • Redshift 상에서 자주 사용하는 분석 쿼리를 미리 실행해두어 성능을 최적화합니다. 예를 들어, 고객의 최근 구매 제품 카테고리나 방문 빈도가 높은 페이지 정보를 데이터 마트로 저장해 빠른 응답을 지원합니다.
    • 시각화 및 리포트 생성
      • Tableau와 Superset을 통해 고객 행동 분석 대시보드를 구축합니다. 데이터 시각화는 마케팅 팀이 손쉽게 접근할 수 있도록 구성하여, 특정 고객 세그먼트의 행동을 즉시 확인하고 맞춤형 마케팅 전략을 수립할 수 있도록 합니다.

 

요구사항 분석

 

1. 비즈니스 요구사항

  • 고객 행동 분석
    • 고객의 웹사이트 행동 데이터를 기반으로 구매 가능성이 높은 고객 세그먼트를 파악할 수 있어야 합니다.
    • 고객의 관심사, 구매 패턴을 기반으로 타겟 마케팅을 위한 개인화된 정보를 생성해야 합니다.
  • 시각화와 접근성
    • 마케팅 팀이 데이터에 쉽게 접근하여 고객 행동을 이해하고 분석 결과를 빠르게 적용할 수 있어야 합니다.
    • 특정 기간, 특정 고객 그룹별로 분석 데이터를 자유롭게 조회하고, 필요한 경우 시각화 대시보드에서 데이터를 직접 다운로드할 수 있어야 합니다.

2. 데이터 수집 요구사항

  • 다양한 데이터 소스 연동
    • 고객 웹사이트의 행동 로그 데이터, 구매 이력 데이터, 고객 서비스 데이터 등 다양한 데이터 소스를 연동해야 합니다.
    • 실시간 또는 일 단위로 데이터를 자동 수집하고, 누적 저장할 수 있는 메커니즘이 필요합니다.
  • 데이터의 포맷 통일
    • 수집된 비정형 및 구조화된 데이터를 모두 수용할 수 있도록 데이터 포맷을 통일하고 저장하는 방식이 필요합니다.

3. 데이터 처리 요구사항

  • 데이터 전처리 및 정제
    • Apache Spark를 활용하여 데이터 레이크에 있는 원본 데이터를 정제합니다. 예를 들어, 필요한 컬럼 추출, Null 값 처리, 중복 데이터 제거 등의 전처리 작업이 필요합니다.
    • 고객의 구매 빈도, 선호 카테고리 등 추가적인 파생 변수를 생성할 수 있어야 합니다.
  • 데이터 업데이트 주기
    • 데이터는 최소 일 단위로 업데이트 되어야 하며, 최신 상태의 데이터를 사용할 수 있어야 합니다.

4. 데이터 저장 및 쿼리 요구사항

  • 데이터 웨어하우스 요구사항
    • 정제된 데이터를 AWS Redshift 또는 GCP BigQuery에 저장해 데이터 분석을 위한 데이터 웨어하우스를 구축합니다.
    • 빠른 쿼리를 지원하고, 대용량 데이터를 효율적으로 조회할 수 있어야 합니다.
  • 데이터 마트 요구사항
    • 자주 조회하는 세그먼트 데이터(예: 주요 구매 카테고리, 월별 구매 패턴)는 미리 데이터 마트로 구성해 빠르게 접근할 수 있어야 합니다.
    • 데이터 마트는 최신화된 데이터를 기반으로 주기적으로 업데이트되어야 합니다.

5. 데이터 시각화 및 대시보드 요구사항

  • 대시보드 요구사항
    • Superset이나 Tableau 같은 도구를 활용해 대시보드를 구축하고, 마케팅 팀이 쉽게 고객 행동 데이터를 이해할 수 있어야 합니다.
    • 시각화는 다양한 필터와 차트(예: 파이 차트, 바 차트, 라인 그래프 등)를 제공해, 특정 시간대, 고객 세그먼트별로 맞춤 조회할 수 있도록 구성합니다.
  • 데이터 접근성 및 보안
    • 대시보드에는 접근 권한이 필요하며, 마케팅 팀 구성원만 접근할 수 있어야 합니다.
    • 데이터 접근 및 시각화 권한은 역할 기반으로 설정하여 보안을 강화해야 합니다.

6. 성능 및 확장성 요구사항

  • 확장 가능한 인프라 구축
    • 데이터의 양이 증가해도 성능이 저하되지 않도록 확장 가능한 클라우드 인프라(AWS, GCP)를 기반으로 시스템을 설계합니다.
    • Spark 및 Redshift 등의 분산 시스템을 통해 대용량 데이터를 효율적으로 처리할 수 있어야 합니다.
  • 실시간 데이터 처리 지원
    • 실시간 또는 거의 실시간에 가까운 데이터 분석을 지원하여 최신 고객 행동을 반영한 마케팅 캠페인이 가능해야 합니다.

 

설계 패턴

 

1. 계층형 아키텍처 (Layered Architecture)

  • 설명: 데이터 수집, 전처리, 저장, 분석, 시각화가 단계적으로 진행되므로, 각 단계를 독립된 계층으로 구성하여 유지보수와 확장성을 높입니다.
  • 구성:
    • 데이터 수집 계층: 원본 데이터를 수집하고 데이터 레이크(S3/GCS)에 저장.
    • 데이터 처리 계층: Apache Spark를 이용해 데이터 전처리 및 파생 변수 생성.
    • 데이터 저장 계층: 정제된 데이터를 AWS Redshift 또는 BigQuery에 저장하여 데이터 웨어하우스를 구축.
    • 데이터 마트 계층: 자주 사용하는 분석 데이터를 별도로 정리해 데이터 마트로 저장.
    • 시각화 계층: Superset 또는 Tableau와 같은 시각화 도구로 대시보드를 생성하여 마케팅 팀에 제공.

2. 파이프라인 패턴 (Pipeline Pattern)

  • 설명: 데이터 수집부터 분석까지 일련의 단계를 파이프라인으로 처리하여 일관된 데이터 흐름을 보장합니다.
  • 구성:
    • ETL 파이프라인: Apache Spark를 이용해 S3/GCS에서 데이터 전처리를 수행하여 Redshift 또는 BigQuery에 저장하는 파이프라인을 구성합니다.
    • 데이터 파이프라인 자동화: 주기적 스케줄링을 통해 데이터 파이프라인이 자동으로 실행되도록 설정합니다. AWS Glue, Apache Airflow 등을 활용해 스케줄링 및 오류 복구를 자동화할 수 있습니다.

3. 리포지토리 패턴 (Repository Pattern)

  • 설명: 데이터 저장소와 비즈니스 로직을 분리하여 각 계층에서 데이터를 효율적으로 저장하고 조회할 수 있도록 합니다.
  • 구성:
    • 데이터 웨어하우스 리포지토리: AWS Redshift나 BigQuery에서 데이터를 조회하고 필요한 데이터를 데이터 마트로 제공하는 역할을 수행합니다.
    • 데이터 마트 리포지토리: 데이터 마트 테이블을 통해 최적화된 데이터를 제공합니다. 자주 조회되는 고객 세그먼트와 같은 데이터는 미리 집계하여 성능을 향상시킵니다.
    • API 리포지토리: 시각화 도구와 연결된 API가 데이터 웨어하우스와 데이터 마트에서 필요한 데이터를 조회하여 제공하는 인터페이스로 작동합니다.

4. 팩토리 패턴 (Factory Pattern)

  • 설명: 다양한 데이터 소스를 처리할 수 있도록 객체 생성을 캡슐화하여 유연성을 높입니다.
  • 구성:
    • 데이터 수집 팩토리: 웹사이트 로그, 구매 이력, 고객 서비스 데이터를 수집하는 다양한 모듈을 팩토리 패턴을 사용해 관리합니다.
    • 데이터 처리 팩토리: 수집된 데이터 유형에 맞춰 적절한 전처리 클래스를 생성하여 처리합니다. 예를 들어, 로그 데이터 처리 클래스, 구매 이력 데이터 처리 클래스 등을 별도로 생성할 수 있습니다.

5. 전략 패턴 (Strategy Pattern)

  • 설명: 데이터를 분석하는 다양한 전략을 유연하게 적용할 수 있도록 설계합니다.
  • 구성:
    • 고객 세그먼트 전략: 고객 행동 분석에 필요한 다양한 세그먼트 전략(예: 구매 이력 기반, 웹사이트 클릭 기반, 시간대 기반 등)을 정의하고 적용할 수 있습니다.
    • 마케팅 캠페인 전략: 분석된 고객 세그먼트를 기반으로 맞춤형 마케팅 전략을 구성할 수 있도록 합니다. 전략을 각각의 세그먼트 특성에 맞춰 정의할 수 있습니다.

6. 프록시 패턴 (Proxy Pattern)

  • 설명: 시각화 도구와 데이터베이스 간의 접근 제어를 위해 프록시 패턴을 사용하여 보안을 강화합니다.
  • 구성:
    • 데이터베이스 프록시: Superset, Tableau 등 시각화 도구에서 Redshift와 BigQuery와 같은 데이터 웨어하우스에 직접 접근하지 않고 프록시를 통해 접근하도록 합니다. 프록시는 쿼리 요청을 관리하고, 데이터 접근 권한을 검증하며, 로깅을 통해 데이터 보안을 강화합니다.

7. 옵저버 패턴 (Observer Pattern)

  • 설명: 데이터가 업데이트되었을 때 이를 시각화 도구나 다른 관련 시스템이 즉시 반영할 수 있도록 옵저버 패턴을 활용합니다.
  • 구성:
    • 데이터 업데이트 알림: 데이터 파이프라인이 업데이트되거나 새로운 데이터가 저장될 때 시각화 도구가 자동으로 이를 감지해 최신 데이터로 업데이트합니다. 예를 들어, 데이터 파이프라인 완료 시 이벤트가 발생하면 Superset 또는 Tableau가 이를 받아 최신 데이터를 반영하도록 할 수 있습니다.

 

구현

1. 계층형 아키텍처: 데이터 수집, 처리, 저장 계층

  • 데이터 수집 계층: AWS S3에 데이터를 저장
import boto3

def upload_data_to_s3(data, bucket_name, file_path):
    s3 = boto3.client('s3')
    s3.put_object(Bucket=bucket_name, Key=file_path, Body=data)
    print("Data uploaded to S3")

# 예시 데이터 업로드
data = "customer_id,action,timestamp\n1,view_product,2023-10-10 10:00"
upload_data_to_s3(data, 'my-data-lake', 'raw_data/customer_logs.csv')

 

  • 데이터 처리 계층: Spark로 데이터 전처리
from pyspark.sql import SparkSession

spark = SparkSession.builder.appName("DataProcessing").getOrCreate()

# S3에서 데이터 로드
raw_data = spark.read.csv("s3://my-data-lake/raw_data/customer_logs.csv", header=True, inferSchema=True)

# 전처리 작업
processed_data = raw_data.filter(raw_data['action'] == 'view_product').dropna()

# 결과를 데이터 웨어하우스로 저장 (Redshift 예시)
processed_data.write \
    .format("jdbc") \
    .option("url", "jdbc:redshift://my-redshift-cluster/mydb") \
    .option("dbtable", "processed_customer_logs") \
    .option("user", "username") \
    .option("password", "password") \
    .mode("overwrite") \
    .save()

 

2. 파이프라인 패턴: ETL 파이프라인 구성 (Airflow 예시)

  • Airflow DAG를 사용하여 매일 S3에서 데이터를 가져와 Spark로 처리하고 Redshift에 저장하도록 구성합니다.
from airflow import DAG
from airflow.operators.python_operator import PythonOperator
from datetime import datetime

def extract_data():
    # 데이터 수집 로직 구현
    pass

def transform_data():
    # Spark 전처리 로직
    pass

def load_data():
    # Redshift에 저장하는 로직
    pass

default_args = {
    'owner': 'airflow',
    'start_date': datetime(2023, 10, 10),
}

with DAG('daily_etl_pipeline', default_args=default_args, schedule_interval='@daily') as dag:
    extract_task = PythonOperator(task_id='extract_data', python_callable=extract_data)
    transform_task = PythonOperator(task_id='transform_data', python_callable=transform_data)
    load_task = PythonOperator(task_id='load_data', python_callable=load_data)

    extract_task >> transform_task >> load_task

 

3. 리포지토리 패턴: 데이터 접근 계층 구현

import psycopg2

class CustomerDataRepository:
    def __init__(self, connection_string):
        self.conn = psycopg2.connect(connection_string)

    def get_customer_segments(self):
        query = "SELECT segment, COUNT(*) FROM customer_segments GROUP BY segment"
        cursor = self.conn.cursor()
        cursor.execute(query)
        return cursor.fetchall()

# 예시 리포지토리 사용
repository = CustomerDataRepository("dbname=mydb user=username password=password host=my-redshift-cluster")
segments = repository.get_customer_segments()

4. 전략 패턴: 고객 세그먼트 전략 적용

from abc import ABC, abstractmethod

class CustomerSegmentationStrategy(ABC):
    @abstractmethod
    def segment_customers(self, data):
        pass

class PurchaseBasedSegmentation(CustomerSegmentationStrategy):
    def segment_customers(self, data):
        return data.filter(lambda x: x['purchase_amount'] > 100)

class WebsiteVisitSegmentation(CustomerSegmentationStrategy):
    def segment_customers(self, data):
        return data.filter(lambda x: x['visit_count'] > 10)

# 전략 사용
data = [...]  # 고객 데이터 예시
segmentation_strategy = PurchaseBasedSegmentation()
segments = segmentation_strategy.segment_customers(data)

5. 시각화 계층: 데이터 시각화 (Superset 설정 예시)

 

Superset에서 Redshift와 연결하여 시각화를 생성합니다.

  1. Superset 설정 페이지에서 Data > Databases로 이동합니다.
  2. Redshift 연결 정보 (데이터베이스 URI) 추가:
redshift+psycopg2://username:password@my-redshift-cluster/mydb
  1. 연결 후, 데이터베이스를 선택하여 시각화할 테이블을 추가합니다.
  2. 대시보드를 생성하여 마케팅 팀에서 사용할 수 있도록 설정합니다.

 

6. 옵저버 패턴: 데이터 업데이트 시 알림 처리

예를 들어, 데이터 파이프라인이 완료될 때 Superset API를 사용하여 최신 데이터를 강제로 새로고침하는 방식입니다.

import requests

def notify_superset():
    # Superset 데이터 새로고침 요청
    response = requests.post("http://superset-instance/api/v1/dataset/<dataset_id>/refresh", headers={"Authorization": "Bearer <token>"})
    print("Superset notified" if response.status_code == 200 else "Notification failed")

# ETL 파이프라인 완료 후 알림 호출
notify_superset()

 

★Enum (열거형)

 

Enum의 주요 메서드

  • values(): 열거형에 정의된 모든 값을 배열로 반환.
  • valueOf(String name): 이름으로 열거형 상수를 찾음.
// 모든 값을 배열로 반환
enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

public class EnumValuesExample {
    public static void main(String[] args) {
        Day[] days = Day.values();
        for (Day day : days) {
            System.out.println(day);  // MONDAY, TUESDAY, ... 출력
        }
    }
}

 

사용 시 유의점

  • Enum은 상수 값을 정의하는 데 유용하지만, 값의 추가나 변경이 자주 일어날 경우 유연성에서 부족할 수 있습니다.
  • Enum을 사용할 때는 상수의 집합이 의미가 있는지, 그 값들이 변경되지 않도록 보장해야 하는 경우 적합합니다.

 

< 예제 >

enum Day {
    MONDAY("월요일"), TUESDAY("화요일"), WEDNESDAY("수요일"),
    THURSDAY("목요일"), FRIDAY("금요일"), SATURDAY("토요일"), SUNDAY("일요일");

    private String koreanName;

    Day(String koreanName) {
        this.koreanName = koreanName;
    }

    public String getKoreanName() {
        return koreanName;
    }
}

public class EnumExample {
    public static void main(String[] args) {
        Day today = Day.MONDAY;
        System.out.println(today.getKoreanName());  // 월요일 출력
    }
}

//////////////////////////////응용


enum Season {
    SPRING, SUMMER, FALL, WINTER
}

public class EnumComparisonExample {
    public static void main(String[] args) {
        Season currentSeason = Season.SUMMER;

        if (currentSeason == Season.SUMMER) {
            System.out.println("It's summer!");
        }
    }
}

 

 

★람다 (Lambda Expression)

 

 

★스트림 (Stream)

 

 

 

★제네릭 (Generics)

📌 클래스, 인터페이스, 메서드 등에서 사용하는 타입을 매개변수화하여 코드의 재사용성유연성을 높여주는 Java의 중요한 기능

제네릭의 사용 상황

  1. 컬렉션 프레임워크:
    • List, Set, Map 등에서 제네릭을 사용하여 타입을 명확히 지정함으로써 타입 안전성을 보장하고, 불필요한 캐스팅을 피할 수 있습니다.
  2. 유틸리티 클래스 작성:
    • 제네릭을 사용하면 다양한 타입에 대해 범용적으로 사용할 수 있는 클래스나 메서드를 작성할 수 있습니다. 예를 들어, Box 클래스를 사용하면 Integer, String 등 다양한 타입을 처리할 수 있습니다.
  3. 타입 제한이 필요한 경우:
    • 특정 타입이나 그 하위 타입에 대해서만 작동하도록 제네릭을 사용할 때 타입 제한을 두어, 보다 강력한 타입 안전성을 유지할 수 있습니다.
     
  4. API 설계:
    • 제네릭을 사용하면 클래스나 메서드의 재사용성을 높이기 위해, 라이브러리나 프레임워크를 설계할 때 유용합니다. 예를 들어, Java의 ArrayList는 다양한 타입을 받을 수 있도록 제네릭을 사용합니다.
public <T extends Number> void printNumber(T num) {
    System.out.println(num);
}

제네릭의 단점

  • 타입 불일치: 컴파일 타임에 타입을 지정하므로 런타임에 타입을 변경할 수 없습니다.
  • 다형성 제한: 제네릭 타입은 **원시 타입(primitive type)**을 사용할 수 없습니다. 예를 들어, int 대신 Integer와 같은 래퍼 클래스를 사용해야 합니다.
  • 복잡성: 제네릭을 사용할 때 코드가 다소 복잡해질 수 있습니다.

1. 제네릭 클래스

// 제네릭 클래스를 정의할 때 타입 파라미터 <T>를 사용
class Box<T> {
    private T item;

    public void setItem(T item) {
        this.item = item;
    }

    public T getItem() {
        return item;
    }
}

public class Main {
    public static void main(String[] args) {
        Box<Integer> intBox = new Box<>();
        intBox.setItem(10);
        System.out.println(intBox.getItem());  // 10

        Box<String> strBox = new Box<>();
        strBox.setItem("Hello");
        System.out.println(strBox.getItem());  // Hello
    }
}

 

2. 제네릭 메서드

// 제네릭 메서드는 메서드 레벨에서 타입 파라미터를 지정
public class Main {
    public static <T> void printArray(T[] array) {
        for (T element : array) {
            System.out.print(element + " ");
        }
        System.out.println();
    }

    public static void main(String[] args) {
        Integer[] intArray = {1, 2, 3};
        String[] strArray = {"Hello", "World"};

        printArray(intArray);  // 1 2 3
        printArray(strArray);  // Hello World
    }
}

 

3. 제네릭 인터페이스

interface Pair<K, V> {
    K getKey();
    V getValue();
}

class KeyValuePair<K, V> implements Pair<K, V> {
    private K key;
    private V value;

    public KeyValuePair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public K getKey() {
        return key;
    }

    public V getValue() {
        return value;
    }
}

public class Main {
    public static void main(String[] args) {
        Pair<Integer, String> pair = new KeyValuePair<>(1, "One");
        System.out.println(pair.getKey() + ": " + pair.getValue());  // 1: One
    }
}

 

 

★static

📌 Java에서 클래스나 객체의 특정 구성 요소에 대한 정적(Static) 특성을 정의하는 키워드입니다. 이를 사용하면 해당 구성 요소가 클래스에 속하게 되며(객체가 아닌 클래스의 변수, 메소드 선언 가능), 객체의 인스턴스화 없이 접근할 수 있습니다.

 

 

★추상 클래스 (Abstract Class)

📌 구현되지 않은 메서드(즉, 추상 메서드)를 포함할 수 있는 클래스입니다. 추상 클래스는 직접 인스턴스를 생성할 수 없으며, 다른 클래스에서 상속받아 사용하도록 설계됩니다.

 

정의:

  • 추상 클래스abstract 키워드로 선언되며, 구현되지 않은 메서드를 포함할 수 있습니다.
  • 인스턴스를 생성할 수 없다는 특징이 있으며, 이를 상속받은 자식 클래스에서 추상 메서드를 구현해야 합니다.

사용처:

    • 공통된 속성과 동작을 여러 클래스에서 공유해야 할 때 사용합니다.
    • 공통된 기본 기능을 부분적으로 구현하고, 그 외의 구체적인 구현은 자식 클래스에서 하도록 유도할 때 유용합니다.

  • 저 인터페이스 공통된 기능을 다른 클래스들에게 강제 한다는 말은 인터페이스에서 사용된 변수, 메소드는 반드시 인터페이스를 구현할 클래스에서 반드시 구현되어야만 한다. (추상 클래스는 안해도 상관없다.)
  • 이런 일이 발생하는 이유는 객체의 청사진이 클래스라면, 클래스의 청사진이 인터페이스이기 때문

 

★인터페이스

📌 클래스의 청사진으로, 인터페이스를 구현할 클래스는 반드시 인터페이스에서 사용된 변수, 메소드를 반드시 구현해야한다.

 

  • implements 키워드를 사용하여 인터페이스를 구현할 수 있습니다. 
public class 클래스명 implements 인터페이스명 { 
			// 추상 메서드 오버라이딩
			@Override
	    public 리턴타입 메서드이름(매개변수, ...) {
			       // 실행문
	    }
}

 

  • 인터페이스 간의 상속은 implements 가 아니라 extends 키워드를 사용합니다.
public class Main extends D implements C {

    @Override
    public void a() {
        System.out.println("A");
    }

    @Override
    public void b() {
        System.out.println("B");
    }

    @Override
    void d() {
        super.d();
    }

    public static void main(String[] args) {
        Main main = new Main();
        main.a();
        main.b();
        main.d();
    }
}

interface A {
    void a();
}

interface B {
    void b();
}

interface C extends A, B {
}

class D {
    void d() {
        System.out.println("D");
    }
}

 

 

This

📌 현재 객체 자신을 참조하는 키워드로, 객체 내부에서 객체의 멤버(필드, 메서드, 생성자)를 명확히 참조하기 위해 사용됩니다.

정의 객체의 현재 인스턴스를 참조하는 키워드로, 객체 내부에서 멤버 변수와 메서드를 호출하거나 생성자 호출에 사용.
특징 ✔ 객체 자신의 주소를 나타냄
✔ 인스턴스 멤버에 접근할 때 사용
✔ 메서드나 생성자에서 주로 활용됨
✔ 정적(static) 맥락에서는 사용 불가

 

  • 위 경우 입력값 model이 객체의 model이 아닌 매개변수 model에 입력됨

 

  • 위 경우 입력값 model이 객체의 model에 입력됨
  • 매개변수, 지역 변수, 클래스 변수의 이름이 동일할 경우 구분을 위해 사용
  • this. = 현재 객체의 변수
  • this() = 현재 객체 생성자

 

final

📌 현재 클래스, 변수를 더이상 변경 불가하게 만들어 버린다. 상속도 막힌다.

 

Object

📌 Java 내 모든 클래스들의 최상위 부모 클래스, 다양한 기능 제공

 


 

★객체지향 프로그래밍

class Car {
    String model;
    String color;
    
    // 기본 생성자
    public Car(String model, String color) {
        this.model = model;
        this.color = color;
    }
    
    // gasPedal 메서드: 속도를 설정하는 메서드로 kmh 값을 speed 필드에 저장하고 반환
    public double gasPedal(double kmh) {
        speed = kmh; // 자동차의 현재 속도를 kmh 값으로 설정
        return speed; // 설정된 속도 반환
    }
}

public class Main {
    public static void main(String[] args) {
        // 인스턴스화 과정
        Car car1 = new Car("Sedan", "Red"); // Car 클래스로부터 car1 객체 생성
        Car car2 = new Car("SUV", "Blue");  // Car 클래스로부터 car2 객체 생성
        
        System.out.println(car1.model);  // Sedan 출력
        System.out.println(car2.model);  // SUV 출력
    }
}

Car car1 = new Car("Sedan", "Red");는 Car 클래스를 사용하여 "Sedan" "Red"라는 속성값을 가지는 새로운 Car 객체를 생성하고, 그 객체를 car1이라는 참조형 변수에 참조하도록 하는 코드

인스턴스(객체) car1, car2
인스턴스화 new
메서드 public double gasPedal(double kmh)
클래스 class Car
생성자 public Car(String model, String color)    /     생성자는 메서드와 다르게 클래스와 이름이 같다

 

 

★오버라이딩

 🐳 부모 클래스로부터 상속받은 메서드의 내용을 재정의 하는 것을 오버라이딩이라고 합니다.

 

  1. 선언부가 부모 클래스의 메서드와 일치해야 합니다.
  2. 접근 제어자를 부모 클래스의 메서드 보다 좁은 범위로 변경할 수 없습니다.
  3. 예외는 부모 클래스의 메서드 보다 많이 선언할 수 없습니다

오버라이딩 사용 상황:

  • 부모 클래스의 메서드를 자식 클래스에서 새롭게 정의하고자 할 때.
  • 상속 구조에서 특정 메서드를 변경하고자 할 때.
  • 다형성을 활용하여, 자식 클래스의 특성에 맞는 기능을 구현하고자 할 때.

 

★오버로드

 🐳 오버로딩은 하나의 클래스 내에서 동일한 이름을 가진 메서드를 여러 개 정의하는 것입니다. 하지만 오버로딩된 메서드는 매개변수의 개수나 타입이 달라야 합니다.

  • 즉, 메서드 이름은 같지만 매개변수의 타입, 개수, 순서가 다르면 컴파일러가 어떤 메서드를 호출할지 구분할 수 있습니다. 반환 타입만 달라서는 오버로딩이 발생하지 않습니다. 컴파일 시점에 메서드가 결정됩니다

오버로딩 사용 상황:

  • 하나의 메서드 이름으로 다양한 입력을 처리하고자 할 때.
  • 매개변수의 개수나 타입이 다른 경우에도 같은 작업을 처리하고 싶을 때.
  • 메서드를 다수 정의해야 하는 경우, 메서드 이름을 통일하고 코드의 가독성을 높이기 위해.

 

 

다형성 & 추상화

동일한 메서드 이름이지만 다른 방식으로 동작할 수 있게 함

  • sound를 한 부모에서 상속받았지만 내용은 다름 = 다형성은 오버라이딩으로 구현됨
// 부모 클래스
class Animal {
    public void sound() {
        System.out.println("Animal makes a sound");
    }
}

// 자식 클래스 1
class Dog extends Animal {
    @Override
    public void sound() {
        System.out.println("Dog barks");
    }
}

// 자식 클래스 2
class Cat extends Animal {
    @Override
    public void sound() {
        System.out.println("Cat meows");
    }
}

 

참조형 자료구조 

 

 

클래스 & 객체 & 인터페이스

 

 

인스턴스 맴버와 클래스 맴버

 

 

생성자

항목 내용
정의 객체 생성 시 초기화 작업을 수행하는 특수한 메서드. 클래스 이름과 동일한 이름을 가지며 반환 타입이 없음.
(초기화의 이유 : 안정적이고 예측 가능한 상태에서 객체를 사용할 수 있도록 보장하기 위해서 )
특징 ✔ 클래스 이름과 동일
✔ 반환 타입이 없음 (void도 사용하지 않음)
✔ 객체 생성 시 자동 호출
✔ 오버로딩 가능
사용처 ▶ 객체 초기화 시 필수 데이터를 설정하거나 특정 작업 수행
객체를 생성하면서 동시에 원하는 상태로 설정해야 할 때 사용
종류 - 기본 생성자: 매개변수가 없는 생성자 (컴파일러가 자동 생성, 명시적 정의 가능).
- 매개변수 생성자: 원하는 값으로 객체 초기화
기본값 - 생성자를 정의하지 않으면 컴파일러가 기본 생성자를 자동으로 제공.
- 생성자를 정의하면 기본 생성자는 제공되지 않음.

 

 

< 접근 제어자 정리표 >

접근 제어자 같은 클래스 같은 패키지 (클래스 폴더) 자식 클래스 외부 클래스
public O O O O
protected O O O X
default O O X X
private O X X X

 

 

접근 제어

< 접근 제어자 정리표 >

접근 제어자 같은 클래스 같은 패키지 (클래스 폴더) 자식 클래스 외부 클래스
public O O O O
protected O O O X
default O O X X
private O X X X

 

< 사용 가능한 접근 제어자  >

클래스 public, default
메서드 & 멤버 변수 public, protected, default, private
지역변수 없음

 

 

'Back-End (Web) > JAVA' 카테고리의 다른 글

[JAVA] HASH란 무엇인가  (1) 2024.11.21
[JAVA] 자바의 정렬  (0) 2024.11.21
[JAVA] NULL  (0) 2024.11.13
[JAVA] 쓰레드 & 람다 함수 & 스트림  (3) 2024.11.13
[JAVA] Generic  (3) 2024.11.12

데이터 엔지니어링

📌 데이터 엔지니어링은 원시 데이터(raw data)를 가져와 분석 및 머신러닝과 같은 다운스트림 사용 사례를 지원하고, 고품질의 일관된 정보를 생성하는 시스템과 프로세스의 개발, 구현 및 유지 관리를 의미합니다. 

 

 

데이터 엔지니어링 수명 주기

 

데이터 엔지니어링 수명 주기의 단계는 다음과 같습니다.

 

● 데이터 생성, generation

● 데이터 저장, storage

● 데이터 수집, ingestion

● 데이터 변환, transformation

● 데이터 서빙, serving

 

데이터 엔지니어링 수명 주기는 전체 수명 주기에 걸쳐 중요한 아이디어인 드러나지 않는 요소라는 개념을 포함합니다. 여기에는 보안, 데이터 관리, 데이터옵스, 데이터 아키텍처, 오케스트레이션, 소프트웨어 엔지니어링이 포함됩니다. 

 

데이터 엔지니어링 주요개념

  1. RDBMS: 관계형 데이터베이스 관리 시스템으로, 구조화된 데이터를 테이블 형식으로 저장하고 관리합니다. SQL을 통해 데이터를 관리하며, 보통 트랜잭션성이 중요한 애플리케이션에서 많이 사용됩니다.
  2. Spark: 대규모 데이터 처리를 위한 분산 데이터 처리 엔진으로, 대용량 데이터를 효율적으로 처리하고 분석하는 데 주로 사용됩니다. RDD(Resilient Distributed Dataset)를 사용하여 데이터를 분산 처리하며, 배치 처리뿐만 아니라 스트리밍 처리도 지원합니다.
  3. 데이터 레이크: 클라우드 스토리지 서비스(AWS S3, GCP GCS 등)에 비정형 데이터와 구조화된 데이터를 모두 저장할 수 있는 대규모 스토리지입니다. 다양한 형태의 데이터를 원본 그대로 저장하고 나중에 분석할 수 있도록 하는 방식입니다.
  4. 데이터 웨어하우스: 데이터 레이크에서 데이터를 가져와서 쿼리할 수 있는 데이터베이스로, 구조화된 데이터를 효율적으로 분석하기 위해 사용됩니다. 예로 AWS의 Redshift, GCP의 BigQuery가 있으며, 빠른 쿼리 성능과 분석 기능을 제공합니다.
  5. 데이터 마트: 데이터 웨어하우스에서 자주 사용하는 데이터들을 특정 요구에 맞춰 통합하고 저장하는 저장소입니다. 주로 빈번히 조회되거나 조인되는 데이터를 사전에 통합하여 데이터 마트로 구성해 두면, 성능에 긍정적인 영향을 줍니다.
  6. Superset, Tableau: 데이터 시각화 도구로, 앞서 언급한 RDBMS, Spark, 데이터 레이크, 데이터 웨어하우스, 데이터 마트와 연결하여 데이터 분석 결과를 시각화하고 대시보드로 나타내는 데 사용됩니다.

Spring 웹 애플리케이션 계층 구조

📌  계층 구조는 애플리케이션을 여러 층으로 나누어 각 계층의 책임을 분리하여 관리하는 구조입니다. 이렇게 계층화하면 유지보수성, 확장성, 테스트 용이성 등이 향상됩니다.

  • 대표적인 계층 구조는 Controller, Service, Repository, Domain(또는 Entity) 계층으로 나누어져 있습니다.
  • 네트워크 계층 구조와는 다른 개념입니다만, 계층구조라는 점에선 같습니다.

Spring 웹 계층 구조

 

1. Controller 계층 (Web Layer)

  • 책임: 클라이언트의 HTTP 요청을 받아서 적절한 비즈니스 로직( 실제 문제를 해결하는 코드 = 문제 해결책 )을 처리할 서비스 계층에 전달하고, 처리 결과를 반환하는 계층입니다.
  • 역할:
    • 클라이언트(웹 브라우저, 모바일 앱 등)로부터 들어오는 HTTP 요청을 처리합니다.
    • Service 계층을 호출하여 비즈니스 로직을 수행합니다.
    • 결과를 View에 전달하여 응답을 반환합니다. JSON 데이터를 반환할 수도 있습니다 (REST API).
    • 주로 @Controller나 @RestController로 정의됩니다.
  • 주요 어노테이션: @Controller, @RestController, @GetMapping, @PostMapping
@Controller
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/users")
    public String getUsers(Model model) {
        List<User> users = userService.getAllUsers();
        model.addAttribute("users", users);
        return "userList"; // view name을 반환 (thymeleaf 템플릿에서 사용)
    }
}

 


 

 

어노테이션(Annotation)

 🐳 사람보다는 프레임워크 컴퓨터가 코드를 이해하고 처리하는 데 도움을 주는 메타데이터입니다. 즉, 어노테이션은 단순한 주석이 아니라 코드의 동작 방식을 지정하는 기능을 부여하는 역할을 합니다.

 

일반적인 주석은 코드의 설명을 사람에게 제공하는 반면, 어노테이션은 Spring과 같은 프레임워크가 코드의 동작을 자동으로 처리하도록 도와주는 특별한 주석입니다. 예를 들어, @Controller 어노테이션은 Spring에게 "이 클래스는 웹 요청을 처리하는 컨트롤러"라고 알려줍니다. 이를 통해 Spring은 해당 클래스를 자동으로 관리하고 필요한 처리를 해줍니다

 

// 이 메서드는 사용자 정보를 반환합니다.
public User getUser() {
    return userService.getUser();
}


@GetMapping("/users")
public List<User> getUsers() {
    return userService.getAllUsers();
}

 

  • 위의 주석에서는 아무일도 일어나지 않지만
  • 밑의 @GetMapping 어노테이션은 Spring에게 "이 메서드는 GET 요청을 처리하고, /users 경로로 요청이 오면 실행되어야 한다"는 의미를 전달한다. Spring은 이 어노테이션을 보고 자동으로 이 메서드와 URL 경로를 연결해 준다.
  • 즉, 어노테이션은 프로그래머가 제작한 코드가 스프링에게 호출을 보낼 때 둘사이의 중간 연락망의 역활을 한다.

+ 프레임워크는 소프트웨어 개발을 위한 템플릿이다. 엄연히 하나의 프로그램임으로 내가 만든 코드와 소통을 해야한다.


2. 서비스 (Service)

  • 책임: 비즈니스 로직을 처리하는 계층 (실제로 일하는 노동자 = 해결사)입니다. Controller에서 전달받은 데이터를 처리하고, 레포지토리를 사용하여 데이터를 가져옵니다. 비즈니스 규칙을 적용하고 필요한 데이터를 처리한 후 반환합니다.
  • 역할:
    • 애플리케이션의 핵심 비즈니스 로직을 처리합니다.
    • 여러 도메인 객체와의 연산을 통해 데이터를 처리합니다.
    • Controller에서 요청을 받은 후 레포지토리를 통해 데이터를 조회하거나 업데이트합니다.
@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    public List<User> getAllUsers() {
        return userRepository.findAll();
    }

    public User getUserById(Long id) {
        return userRepository.findById(id).orElse(null);
    }
}

 

3. 도메인 (Domain)

  • 책임: 애플리케이션에서 처리할 비즈니스 데이터를 모델링 (구현 대상[문제] 정의)하는 계층입니다. 도메인 객체(예: User, Product)는 애플리케이션에서 다루는 실제 데이터를 나타내며, 그 데이터를 위한 필드와 메서드를 정의합니다.
  • 도메인은 기획의 요구사항을 구현하고, 문제를 풀기 위해 설계된 소프트웨어 프로그램의 기능성을 정의하는 영역이라고 설명합니다.
  • 역할:
    • 비즈니스 데이터(중요정보)를 표현하는 클래스입니다.
    • 주로 **엔티티(Entity)**로 불리며, 데이터베이스 테이블과 매핑되는 객체입니다.
    • 서비스 계층에서 처리되는 데이터를 담고 있으며, 필요한 경우 **DTO (Data Transfer Object)**로 변환되기도 합니다.
  • 간단히 말해서, 앱의 기능(사진기, 앨범 등), 회사의 비즈니스 로직(회원, 주문 등)을 구현 대상을 정의하는 파트라고 생각하면 편하다.
  • 쿠팡의 창을 보면 화장품 - 립스틱, 아이브러쉬, 파운데이션 등의 선택창이 있다. 이 중 뭐가 도메인일까?
  • 정답은 화장품, 립스틱, 아이브러쉬 등 전부 다이다.
  • 왜냐 전부 구현이 필요한 대상이니까
  • 현업에서는 특정 비지니스에 대한 전문 지식을 말한다. 금융쪽 기업이 스프링을 통해 서버를 구축하면 당연히 금융 관련 지식과 거래 기능 서버 관리 등의 특정 전문성을 포함하는 프로그래밍을 진행해야한다. 여기서 특정 전문 분야를 도메인이라 한다.
@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String email;

    // getters and setters
}

 

4. 레포지토리 (Repository)

  • 책임: 데이터베이스와의 상호작용을 담당하는 계층입니다. 레포지토리 JPA 또는 MongoDB와 같은 ORM(Object-Relational Mapping) 기술을 사용하여 데이터베이스와의 CRUD(Create, Read, Update, Delete) 작업을 처리합니다.
  • 역할:
    • 데이터베이스에서 데이터를 조회하고, 저장하며, 수정하거나 삭제하는 작업을 처리합니다.
    • Spring Data JPA를 사용하면 Repository 인터페이스만 작성하여 자동으로 CRUD 기능을 구현할 수 있습니다.
    • 서비스에서 요청한 데이터를 처리하고 반환합니다.
  • 자바 서버라는 회사의 직원 중 데이터베이스 회사랑 소통할 때 필요한 중개인(번역가) 정도로 보면 된다.
  • 번역가라는 표현이 정확한데 자바는 말 그대로 Java 언어로 보내면 레포지토리는 데이터베이스가 이해할 수 있도록 SQL문으로 변역해서 전달한다.
  • 그런데 단순 번역기의 역활이라면 JPA나 MongoDB와 같이 왜 레포지토리의 종류가 다양한가? 
  • JPA는 관계형 데이터베이스를  MongoDB를 비관계형 데이터베이스를 번역한다 = 즉, 번역하는 언어가 다르다.
  • 전문적으로 데이터 소스가 다양하다보니 각 데이터 소스에 맞는 레포지토리 구현이 필요했다.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    List<User> findByName(String name);

    Optional<User> findById(Long id);
}

 

 

 

5. 데이터베이스 (Database)

  • 책임: 실제 데이터를 영구적으로 저장하는 계층입니다. 데이터베이스는 관계형(RDBMS) 또는 비관계형(NoSQL) 데이터베이스를 사용할 수 있습니다. 데이터베이스는 레포지토리에서 처리한 데이터를 영속적으로 저장하고 조회하는 역할을 합니다.
  • 역할:
    • 레포지토리를 통해 데이터에 접근하며, 데이터를 저장하고, 수정하며, 삭제할 수 있습니다.
    • SQL 또는 NoSQL 쿼리를 통해 데이터를 관리합니다.

< 전체 계층 구조 예시 >

+-----------------------+     +------------------------+     +-------------------------+
|     Controller        |     |       Service          |     |       Repository        |
|   (입구, 요청 처리)    | --> |   (비즈니스 로직 처리)  | --> |  (데이터베이스 CRUD)    |
+-----------------------+     +------------------------+     +-------------------------+
         |                           |                           |
         v                           v                           v
+----------------------------+  +-------------------------+  +----------------------------+
|        View (HTML, JSP)    |  |      Domain (Entity)     |  |        Database (SQL/NoSQL)  |
|   (결과 응답, UI 표시)      |  |   (데이터 객체, 모델)    |  |  (데이터 저장, 조회, 수정)  |
+----------------------------+  +-------------------------+  +----------------------------+

 

각 계층의 흐름

  1. Controller는 클라이언트의 요청을 처리하고 Service로 전달합니다.
  2. Service는 비즈니스 로직을 처리하고, 필요한 데이터를 Repository에서 조회하거나 저장합니다.
  3. Repository는 데이터베이스와 상호작용하여 실제 데이터를 저장하거나 조회합니다.
  4. 데이터를 처리한 후, Service는 결과를 Controller로 반환하고, Controller는 이를 View (HTML 등)로 전달하여 사용자에게 응답합니다.

 

< 결론 >

컨트롤러 클라이언트와 서버 사이의 소통 중개인
서비스 문제 해결 일꾼
도메인 문제 정의
레포지토리 DB와 서버간의 번역가
DB 정보 저장 창고

 

'Back-End (Web) > Spring' 카테고리의 다른 글

[Spring] Spring MVC 패턴  (1) 2024.12.14
[Spring] Spring Boot  (0) 2024.12.10
[Spring] Spring Framework  (2) 2024.12.09
[Spring] 웹 개발의 흐름  (1) 2024.12.05
[Spring] 스프링 웹 개발 기초  (0) 2024.11.13

웹 개발 방식

📌 스프링에서 웹 개발 기초는 크게 세 가지 방식으로 나뉩니다: 정적 콘텐츠 제공 방식, MVC와 템플릿 엔진 방식, 그리고 API 방식입니다. 각 방식이 웹 애플리케이션에서 데이터를 제공하는 데 사용되는 방법이 다릅니다.

 

 

정적 콘텐츠 제공 방식

📌 정적 콘텐츠는 서버에서 변하지 않는 고정된 데이터를 제공하는 방식입니다.

  • HTML, CSS, JavaScript 파일 등과 같은 리소스를 정적 파일로서 서버가 직접 제공하므로, 사용자 요청 시 서버에서 바로 해당 파일을 전달해 줍니다.
  • 이런 방식은 스프링에서는 주로 /static, /public 폴더에 파일을 넣어 제공할 수 있습니다. 정적 콘텐츠는 빠르게 응답할 수 있지만
  • 동적인 데이터 제공이나 로직 처리가 불가능하다는 한계가 있습니다. 주로 단순한 웹사이트나 빠르게 로드해야 하는 리소스에 사용됩니다.
  • 데이터를 추가적인 가공 없이 서버가 클라이언트에게 생 데이터를 제공하는 방식
  • 단순히 HTML 파일을  src/main/resources/static 폴더에 생성하면 된다.
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Static Content</title>
</head>
<body>
    <h1>Hello, this is static content!</h1>
</body>
</html>

 

 

MVC와 템플릿 엔진 방식

📌 MVC(Model-View-Controller) 패턴은 스프링 웹 개발에서 가장 흔히 사용하는 방식입니다. 사용자 요청을 Controller가 받고, Model을 통해 데이터를 처리하여 View로 전달하는 구조입니다.

= 서버에서 클라이언트 요청을 처리한 뒤 **HTML 뷰(View)**를 생성하여 반환합니다.

 

클라이언트가 요청을 보냄 → 서버에서 컨트롤러(Controller)가 처리 → 템플릿 엔진을 통해 동적으로 HTML View 생성 → 브라우저에 반환.

  • View는 서버에서 HTML 템플릿 엔진(e.g., Thymeleaf, JSP)을 사용해 데이터를 화면에 렌더링한 후 사용자에게 보냅니다.
  • 템플릿 엔진을 사용하면 서버에서 동적으로 생성된 HTML 페이지를 제공할 수 있어 데이터가 자주 바뀌는 웹 애플리케이션에 적합합니다.
  • 스프링 MVC는 서버에서 데이터와 화면을 함께 처리하므로, 작은 웹 애플리케이션에서 빠르게 결과를 얻기 좋습니다.

📌 템플릿 엔진(template engine) View 부분에서 화면을 구성할 때 사용하는 도구입니다. 서버에서 HTML 페이지를 동적으로 생성할 수 있도록 해주는 역할을 합니다. 예를 들어, Thymeleaf, JSP, Freemarker 같은 템플릿 엔진을 사용하면 서버에서 Model 데이터를 받아 HTML을 생성해 사용자에게 반환할 수 있습니다.

 

< Controller 예제: src/main/java/com/example/demo/controller/MvcController.java >

import org.springframework.stereotype.Controller;  // Spring MVC에서 Controller로 사용할 클래스를 선언
import org.springframework.ui.Model;  // 뷰에 데이터를 전달하는 데 사용되는 Model 클래스를 임포트
import org.springframework.web.bind.annotation.GetMapping;  // HTTP GET 요청을 처리하기 위한 어노테이션

@Controller  // 이 클래스는 Spring MVC의 Controller로 사용됨을 나타냄
public class MvcController {

    @GetMapping("/mvc")  // "/mvc" URL로 들어오는 GET 요청을 처리하는 메서드
    public String helloMvc(Model model) {  // 클라이언트로부터 요청이 오면 호출되는 메서드
        model.addAttribute("name", "Spring MVC!");  // "name"이라는 변수에 "Spring MVC!"라는 값을 설정, 이 데이터는 뷰에서 사용됨
        return "hello";  // "hello.html" 템플릿 파일을 반환, 이 템플릿을 뷰로 사용하여 클라이언트에게 응답
    }
}

 

< 템플릿 파일 예제: src/main/resources/templates/hello.html  >

<!DOCTYPE html>  <!-- HTML5 문서 형식을 선언 -->
<html xmlns:th="http://www.thymeleaf.org">  <!-- Thymeleaf 템플릿 엔진을 사용하기 위한 XML 네임스페이스 선언 -->
<head>
    <title>MVC Example</title>  <!-- 페이지의 제목을 설정 -->
</head>
<body>
    <!-- th:text는 Thymeleaf에서 데이터를 동적으로 출력하는 속성 -->
    <h1 th:text="'Hello, ' + ${name}">Hello</h1>  <!-- "name" 변수를 받아서 "Hello, Spring MVC!"를 출력 -->
</body>
</html>

 

  • 장점:
    • 서버에서 완성된 HTML을 반환하므로 클라이언트는 단순히 HTML을 받아 띄우기만 한다.
    • 클라이언트 로직이 단순해져 유지보수가 쉬운 경우가 많다.
  • 단점:
    • 서버가 화면 렌더링까지 담당하므로 서버에 부하가 커질 수 있다.
    • 클라이언트와 서버가 강하게 결합되어 있어, 클라이언트와 서버의 독립성이 낮다.
    • 동일한 UI를 사용하는 모바일 앱이나 다른 플랫폼에 대응하기 어렵다.

 

API 방식

📌 API 방식은 서버가 JSON과 같은 형식의 데이터'만'을 클라이언트에 제공하고, 클라이언트가 이를 받아 렌더링하는 방식입니다. 이 경우 서버는 데이터만을 전송하며, 프론트엔드가 해당 데이터를 활용해 화면을 구성합니다.

  • 보통 RESTful API나 GraphQL을 사용하여 데이터를 주고받습니다.
  • 이 방식은 프론트엔드와 백엔드의 역할을 분리해 줄 수 있어 모바일 앱, 싱글 페이지 애플리케이션(SPA), 프론트엔드 프레임워크(React, Vue 등)를 활용한 웹 애플리케이션에서 유리합니다.
  • 서버에서는 화면 렌더링을 하지 않고 데이터를 제공하는 역할만 하므로 확장성과 유지보수 측면에서 효과적입니다.
  • 우리가 보는 화면은 F12를 눌러 개발자 환경에 들어가면 코드를 확인할 수 있다. 정적 콘텐츠, MVC 방식은 서버가 클라이언트에게 제공한 HTML 코드가 확인이 되지만, API는 데이터만 제공한다.
  • 그러니까 코드를 전달하는게 아니라 코드를 실행한 결과를 JSON이라는 파일 형태로 클라이언트에게 보낸다.

 

 

  • 장점:
    • 서버와 클라이언트가 독립적으로 개발 및 유지보수 가능.
    • 동일한 API를 다양한 클라이언트(웹, 모바일, 데스크톱 애플리케이션 등)에서 재사용 가능.
    • 클라이언트 측에서 동적인 UI를 구현하기 용이.
  • 단점:
    • 클라이언트가 데이터 처리 및 화면 렌더링을 모두 담당해야 하므로 클라이언트 로직이 복잡해질 수 있습니다.
    • 클라이언트에서 데이터를 표시하려면 추가적인 프론트엔드 개발이 필요합니다.

 

 

 

 

< 페이지 소스 확인 >

MVC API
<html xmlns:th="http://ww.tymeleaf.org">
<body>
<p th:text="'hello ' + ${name}">hello</p>
</body>
</html>
hello spring!!!!!!!

 

  • MVC로 서버에서 정보를 처리한 경우 위 처럼 페이지 소스를 확인하면 코드를 확인할 수 있는데, API는 코드의 실행 결과만 클라이언트에게 전달한다.
더보기

MVC 방식에서는 서버가 템플릿 엔진을 사용해 HTML 파일을 렌더링하고, 이 렌더링된 HTML이 클라이언트로 전달되므로, 페이지 소스를 확인하면 서버에서 처리된 코드가 HTML에 반영된 상태로 볼 수 있습니다. 예를 들어, ${name} 부분이 실제 값으로 변환되어 <p>hello spring!!!!!!!</p>와 같이 표시됩니다.

 

반면, API 방식에서는 서버가 HTML 페이지를 생성하지 않고 데이터만을 JSON 같은 형식으로 전달합니다. 따라서 클라이언트는 데이터를 받아 자체적으로 화면을 구성하며, API 방식은 순수하게 데이터만 전송되기 때문에 HTML이나 템플릿 코드 자체는 전달되지 않습니다. 이 차이가 MVC와 API 방식의 큰 차이점 중 하나입니다.

 

import org.springframework.web.bind.annotation.GetMapping;  // HTTP GET 요청을 처리하기 위한 어노테이션을 임포트
import org.springframework.web.bind.annotation.RestController;  // REST API의 컨트롤러로 사용할 어노테이션을 임포트

@RestController  // 이 클래스는 REST API에서 데이터를 반환하는 컨트롤러임을 나타내는 어노테이션
public class ApiController {

    @GetMapping("/api/hello")  // "/api/hello" 경로로 GET 요청을 처리하는 메서드
    public HelloResponse helloApi() {  // helloApi 메서드는 클라이언트에게 HelloResponse 객체를 반환
        return new HelloResponse("Hello, API!");  // HelloResponse 객체를 생성하고, "Hello, API!"라는 메시지를 반환
    }

    // HelloResponse 클래스는 API 응답의 구조를 정의하는 클래스
    static class HelloResponse {
        private String message;  // 응답으로 전달할 메시지를 저장하는 변수

        public HelloResponse(String message) {  // 생성자: 메시지를 초기화
            this.message = message;
        }

        public String getMessage() {  // 메시지를 반환하는 getter 메서드
            return message;
        }

        public void setMessage(String message) {  // 메시지를 설정하는 setter 메서드
            this.message = message;
        }
    }
}

 

 

'Back-End (Web) > Spring' 카테고리의 다른 글

[Spring] Spring MVC 패턴  (1) 2024.12.14
[Spring] Spring Boot  (0) 2024.12.10
[Spring] Spring Framework  (2) 2024.12.09
[Spring] 웹 개발의 흐름  (1) 2024.12.05
[Spring] Spring 웹 애플리케이션 계층 구조  (2) 2024.11.13

요구사항 정의 및 설계

  1. 요구사항 정의
    • 어떤 계산기 기능이 필요한지 명확히 합니다.
    • 예를 들어, 기본적인 사칙연산(덧셈, 뺄셈, 곱셈, 나눗셈), 괄호 사용, 제곱근, 제곱 등.
    • 사용자 인터페이스 방식 결정 : 콘솔 기반인지, GUI(그래픽 사용자 인터페이스) 기반인지.
    • 예외 처리 : 0으로 나누기와 같은 오류를 어떻게 처리할지 결정합니다.
  2. 설계
    • 클래스 다이어그램: 계산기에 필요한 클래스를 설계합니다. 예를 들어, Calculator, Operation, Parser 등이 될 수 있습니다.
    • 기능 분해: 주요 기능을 메소드로 분리합니다. 예를 들어, add(), subtract(), multiply(), divide(), evaluateExpression() 등.
    • 데이터 흐름 설계: 데이터가 클래스 간에 어떻게 흐를지를 결정합니다. 입력, 처리, 출력의 흐름을 이해합니다.
요구사항
LV 1
1. 양의 정수(0 포함)를 입력받기
2. 사칙연산 기호(,,✖️,)를 입력받기
3. 위에서 입력받은 양의 정수 2개와 사칙연산 기호를 사용하여 연산을 진행한 후 결과값을 출력하기
4. 반복문을 사용하되, 반복의 종료를 알려주는 “exit” 문자열을 입력하기 전까지 무한으로 계산을 진행할 수 있도록 소스 코드를 수정하기


LV 2
1. App 클래스의 main 메서드에 Calculator 클래스가 활용될 수 있도록 수정
2. App 클래스의 main 메서드에서 Calculator 클래스의 연산 결과를 저장하고 있는 컬렉션 필드에 직접 접근하지 못하도록 수정 (캡슐화)


LV 3
1. 계산된 결과 값들을 기록
2. 컬렉션의 가장 먼저 저장된 데이터를 삭제
3. 양의 정수만 받았지만 이제부터는 실수도 받을수 있게 수정
4. 결과가 저장되어 있는 컬렉션을 조회하는 기능을 만든다.
5. 그때 특정 값 보다 큰 결과 값을 출력 할 수 있도록.
설계( 클래스 다이어그램 )
클래스 다이어그램
Calculator : 계산의 전반적인 부분을 담당
Main : 반복문으로 실제 순회와 화면 표시등, 기능의 통합과 출력을 담당
Folder : 데이터를 저장하는 공간을 창출
Option : 계산의 조건을 담당

설계
Option
Calculator
Folder
Main
설계( 기능 분해  )
Calculator : 계산의 전반적인 부분을 담당
- add(), subtract(), multiply(), divide()

Main : 반복문으로 실제 순회와 화면 표시등, 기능의 통합과 출력을 담당

Folder : 데이터를 저장하는 공간을 창출
- save content (), removecontent()

Operator : 계산의 조건을 담당
- caloption()

설계(  데이터 흐름 설계 )
1. 입력 
- 사용자 데이터 입력 -> Main에서 요청
2. 처리
- Calculator 클래스 객체 생성 -> 계산 -> Folder -> 데이터 저장, 수정, 출력
3. 출력
-  Main 출력
더보기
더보기

### 1. **클래스 다이어그램**

각각의 클래스의 역할과 관계를 명확히 하고, 이를 바탕으로 클래스를 설계해 보겠습니다.

#### 주요 클래스 및 관계
1. **Option (부모 클래스)**  
   - **역할**: 계산기의 설정 및 옵션을 관리합니다.  
   - **속성**:
     - `operator`: 계산에 사용할 연산자(예: +, -, *, /)
   - **메소드**:
     - `getOperator()`: 현재 연산자 반환
     - `setOperator(String operator)`: 연산자 설정

2. **Calculator (자식 클래스, Option을 상속)**  
   - **역할**: 실제 계산을 담당하는 클래스입니다. `Option` 클래스를 상속받아 연산자와 계산을 처리합니다.  
   - **속성**:
     - `result`: 계산된 결과값
   - **메소드**:
     - `add(double a, double b)`: 덧셈
     - `subtract(double a, double b)`: 뺄셈
     - `multiply(double a, double b)`: 곱셈
     - `divide(double a, double b)`: 나눗셈
     - `evaluateExpression(double a, double b)`: 두 숫자와 연산자로 계산을 처리

3. **Folder (데이터 저장 클래스)**  
   - **역할**: 계산된 결과를 저장하고 관리하는 클래스입니다.  
   - **속성**:
     - `history`: 계산 결과를 저장하는 리스트나 컬렉션
   - **메소드**:
     - `storeResult(double result)`: 계산된 결과를 저장
     - `getHistory()`: 저장된 결과 반환
     - `removeOldestResult()`: 가장 오래된 결과를 삭제

4. **Main (메인 클래스)**  
   - **역할**: 프로그램의 실행과 사용자와의 상호작용을 담당합니다. 반복문을 사용해 계속해서 계산을 수행할 수 있게 합니다.  
   - **속성**:
     - `calculator`: `Calculator` 객체
     - `folder`: `Folder` 객체
     - `parser`: `Parser` 객체
   - **메소드**:
     - `runCalculator()`: 계산기 실행
     - `displayResults()`: 결과를 화면에 출력
     - `exitProgram()`: 프로그램 종료

5. **Parser (입력 처리 클래스)**  
   - **역할**: 사용자 입력을 검증하고 파싱하는 역할을 합니다.  
   - **속성**: 없음
   - **메소드**:
     - `parseInput(String input)`: 사용자 입력을 분석하여 유효성 검사 및 필요한 데이터 반환
     - `isValidOperator(String operator)`: 연산자가 유효한지 검사
     - `isValidNumber(String number)`: 숫자 유효성 검사

---

### 2. **기능 분해**

각각의 클래스가 수행하는 주요 기능을 메소드로 분해하면 다음과 같습니다:

1. **Option 클래스**:
   - `getOperator()`: 계산에 사용할 연산자 반환
   - `setOperator(String operator)`: 연산자 설정

2. **Calculator 클래스** (Option을 상속받음):
   - `add(double a, double b)`: 덧셈 수행
   - `subtract(double a, double b)`: 뺄셈 수행
   - `multiply(double a, double b)`: 곱셈 수행
   - `divide(double a, double b)`: 나눗셈 수행 (0으로 나누기 예외 처리 포함)
   - `evaluateExpression(double a, double b)`: 주어진 두 숫자와 연산자로 계산을 실행

3. **Folder 클래스**:
   - `storeResult(double result)`: 계산된 결과를 저장
   - `getHistory()`: 저장된 결과를 반환
   - `removeOldestResult()`: 가장 오래된 계산 결과를 제거

4. **Main 클래스**:
   - `runCalculator()`: 계산기 실행 (무한 루프)
   - `displayResults()`: 저장된 결과 출력
   - `exitProgram()`: 프로그램 종료

5. **Parser 클래스**:
   - `parseInput(String input)`: 사용자 입력을 분석하여 유효한 숫자와 연산자 추출
   - `isValidOperator(String operator)`: 연산자 검증 (예: `+`, `-`, `*`, `/`)
   - `isValidNumber(String number)`: 숫자 유효성 검사 (예: 실수 또는 정수인지 확인)

---

### 3. **데이터 흐름 설계**

데이터 흐름 설계는 각 클래스 간에 데이터가 어떻게 이동하고 처리되는지 보여줍니다.

#### 데이터 흐름 단계
1. **사용자 입력**:
   - 사용자는 `Main` 클래스에서 계산식을 입력합니다. 예: `5 + 3`.
   
2. **입력 파싱 및 검증**:
   - `Main` 클래스는 입력값을 **Parser** 클래스에 전달합니다.
   - **Parser** 클래스는 입력값을 분석하여 연산자(`+`, `-`, `*`, `/`)와 숫자 두 개를 분리합니다.
   - 숫자와 연산자에 대한 유효성 검사를 진행합니다.
   
3. **연산 처리**:
   - **Main** 클래스는 파싱된 입력을 **Calculator** 클래스에 전달합니다.
   - **Calculator** 클래스는 연산자를 바탕으로 해당 연산을 실행하고, 결과를 반환합니다.
   
4. **결과 저장**:
   - **Calculator** 클래스는 계산된 결과를 **Folder** 클래스에 저장합니다.
   - **Folder** 클래스는 결과를 `history` 컬렉션에 저장하고, 이를 관리합니다.
   
5. **결과 출력**:
   - **Main** 클래스는 `Folder` 클래스에서 계산된 결과를 조회하고, 출력합니다.
   
6. **계속 진행**:
   - 사용자가 `"exit"`을 입력할 때까지 반복적으로 계산을 수행하고 결과를 출력합니다.

---

### **전체 흐름 요약**

1. **사용자 입력** → `Main` → **Parser** (입력 파싱 및 검증)
2. **Parser** → `Operation` 객체 생성 → **Calculator** (연산 처리)
3. **Calculator** → 결과 반환 → `Folder` (결과 저장)
4. **Folder** → 저장된 결과 조회 → `Main` (결과 출력)
5. **반복**: 사용자가 `"exit"`을 입력하기 전까지 계산 반복

---

위 설계를 바탕으로, 프로그램의 각 클래스가 어떻게 협력하여 계산을 수행하고 데이터를 처리하는지를 명확히 할 수 있습니다.

 

 

챗 GPT의 답인데 인간이 필요 없는거 아닌가...

 

< MAIN >

import java.util.List;
import java.util.Scanner;

public class Main {
    public static void main(String[] args) {
        double result = 0.0;
        String input1, input2, input3, operator;
        Scanner scanner = new Scanner(System.in);
        Calculator calc = new Calculator();
        DataManager dataManager = new DataManager(); // 데이터 매니저 인스턴스 생성

        while (true) {
            try {
                System.out.print("Enter first number or command (exit/delete data/find data): ");
                input1 = scanner.nextLine();

                // break 문을 통해 루프를 중지합니다.
                if (input1.equalsIgnoreCase("exit")) {
                    System.out.println("Exiting the program.");
                    break;
                }

                if (input1.equalsIgnoreCase("delete data")) {
                    System.out.println("Deleting all data...");
                    dataManager.clearAllData(); // 모든 데이터 삭제
                    continue;
                }

                if (input1.equalsIgnoreCase("delete one data")) {
                    System.out.print("Deleting one data: ");
                    input2 = scanner.nextLine();

                    try {
                        double number2 = Double.parseDouble(input2);
                        System.out.println(dataManager.confirm(input1, number2));
                    } catch (NumberFormatException e) {
                        System.out.println("Invalid input. Please enter a valid number.");
                    }
                    continue;
                }

                // 각 상황별 대응을 코딩하였습니다.
                // 추가로 클래스를 이용한 인스턴스를 통해 문제를 해결했습니다.
                if (input1.equalsIgnoreCase("modified data")) { //데이터 수정
                    try {
                        System.out.print("Origin data: ");
                        input2 = scanner.nextLine();
                        double number2 = Double.parseDouble(input2);

                        System.out.print("Modified data: ");
                        input3 = scanner.nextLine();
                        double number3 = Double.parseDouble(input3);

                        System.out.println(dataManager.confirm(input1, number2, number3));
                    } catch (NumberFormatException e) {
                        System.out.println("Invalid input. Please enter valid numbers.");
                    }
                    continue;
                }

                if (input1.equalsIgnoreCase("add data")) {// 데이터 추가
                    System.out.print("Enter add number: ");
                    input2 = scanner.nextLine();

                    try {
                        double number2 = Double.parseDouble(input2);
                        System.out.println(dataManager.confirm(input1, number2));
                    } catch (NumberFormatException e) {
                        System.out.println("Invalid input. Please enter a valid number.");
                    }
                    continue;
                }

                if (input1.equalsIgnoreCase("find data")) {// 데이터 찾기
                    System.out.print("Enter comparison operator (e.g., '>'): ");
                    String findOption = scanner.nextLine();

                    System.out.print("Enter the number to compare with: ");
                    try {
                        double comparisonValue = Double.parseDouble(scanner.nextLine());

                        // 조건에 맞는 데이터 검색 후 출력
                        List<Double> foundData = dataManager.findDataByCondition(findOption, comparisonValue);
                        System.out.println("Found data: " + foundData);
                    } catch (NumberFormatException e) {
                        System.out.println("Invalid input. Please enter a valid number.");
                    }
                    continue;
                }

                // 사측연산 구현
                double number1 = Double.parseDouble(input1);

                System.out.print("Enter second number: ");
                input2 = scanner.nextLine();
                double number2 = Double.parseDouble(input2);

                System.out.print("Enter an operator (+, -, *, /): ");
                operator = scanner.nextLine();

                try {
                    Operator operatorEnum = Operator.fromString(operator);

                    result = calc.calculate(number1, number2, operatorEnum);

                    System.out.println("Result: " + result);

                    // 결과를 DataManager에 저장
                    dataManager.addData(result);
                    // 아래는 예외처리입니다. 글자가 입력된 경우, 연산자 오류인경우, 잘못된 산술연산이 발생한 경우, 특정 예외 전부를 탐색하기 위해 Exception클래스를 사용한 전체 예외처리
                } catch (IllegalArgumentException e) {
                    System.out.println("Invalid operator. Please enter one of +, -, *, /.");
                } catch (ArithmeticException e) {
                    System.out.println(e.getMessage());
                }
            } catch (NumberFormatException e) {
                System.out.println("Invalid input. Please enter a valid number.");
            } catch (Exception e) {
                System.out.println("An unexpected error occurred: " + e.getMessage());
            }
        }

        scanner.close();
    }
}

 

 

< Calculator >

public class Calculator {

    public <T extends Number> double calculate(T x, T y, Operator operator) {
        double num1 = x.doubleValue(); // double형 변환
        double num2 = y.doubleValue();

        switch (operator) {
            case ADD:
                return num1 + num2;
            case SUBTRACT:
                return num1 - num2;
            case MULTIPLY:
                return num1 * num2;
            case DIVIDE:
                if (num2 == 0) {
                    throw new ArithmeticException("Cannot divide by zero");
                }
                return num1 / num2;
            default:
                throw new IllegalArgumentException("Unsupported operator: " + operator);
        }
    }
}

< DataManager >

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

public class DataManager {
    private List<Double> dataList;

    public DataManager() {
        this.dataList = new ArrayList<>();
    }

    // 데이터 추가
    public void addData(double data) {
        dataList.add(data);
    }

    // 조건에 맞는 데이터를 찾기 (>, <, = 조건) using Streams
    public List<Double> findDataByCondition(String condition, double value) {
        return dataList.stream()
                .filter(data -> {
                    switch (condition) {
                        case ">": return data > value;
                        case "<": return data < value;
                        case "=": return data == value;
                        default:
                            System.out.println("Invalid condition. Use >, <, or =.");
                            return false;
                    }
                })
                .collect(Collectors.toList());
    }

    // `add data` 및 `delete one data` 작업을 위한 메서드 (제네릭 사용)
    public <T extends Number> String confirm(String condition, T value) {
        switch (condition) {
            case "add data":
                dataList.add(value.doubleValue());
                return "Data added: " + value.doubleValue();

            case "delete one data":
                // 람다, 데이터 삭제
                boolean removed = dataList.removeIf(data -> data == value.doubleValue());
                return removed ? "Complete delete one data" : "Data not found for deletion";

            default:
                return "Unknown condition: " + condition;
        }
    }

    // `modify data` 작업을 위한 메서드 (제네릭 사용)
    public <T extends Number> String confirm(String condition, T value, T value2) {
        if ("modified data".equals(condition)) {
            for (int i = 0; i < dataList.size(); i++) {
                if (dataList.get(i) == value.doubleValue()) {
                    dataList.set(i, value2.doubleValue());
                    return "Complete data modify: " + value + " -> " + value2;
                }
            }
        }
        return "Condition not found for modification.";
    }

    // 모든 데이터 삭제
    public void clearAllData() {
        dataList.clear();
        System.out.println("All data has been deleted.");
    }
}

< Operator >

public enum Operator { // 스트림을 통한 사측연산 구현
    ADD("+"),
    SUBTRACT("-"),
    MULTIPLY("*"),
    DIVIDE("/");

    private final String symbol;

    Operator(String symbol) {
        this.symbol = symbol;
    }

    public String getSymbol() {
        return symbol;
    }

    public static Operator fromString(String symbol) {
        for (Operator operator : Operator.values()) {
            if (operator.getSymbol().equals(symbol)) {
                return operator;
            }
        }
        throw new IllegalArgumentException("Invalid operator: " + symbol);
    }
}

 

 

 

구현

Lv 1. 클래스 없이 기본적인 연산을 수행할 수 있는 계산기 만들기

  • [ ] 양의 정수(0 포함)를 입력받기
    • [ ] Scanner를 사용하여 양의 정수 2개(0 포함)를 전달 받을 수 있습니다.
    • [ ] 양의 정수는 각각 하나씩 전달 받습니다.
    • [ ] 양의 정수는 적합한 타입으로 선언한 변수에 저장합니다.
  • [ ] 사칙연산 기호(➕,➖,✖️,➗)를 입력받기
    • [ ] Scanner를 사용하여 사칙연산 기호를 전달 받을 수 있습니다.
    • [ ] 사칙연산 기호를 적합한 타입으로 선언한 변수에 저장합니다. (charAt(0))
  • [ ] 위에서 입력받은 양의 정수 2개와 사칙연산 기호를 사용하여 연산을 진행한 후 결과값을 출력하기
    • [ ] 키워드 : if switch
    • [ ] 사칙연산 기호에 맞는 연산자를 사용하여 연산을 진행합니다.
    • [ ] 입력받은 연산 기호를 구분하기 위해 제어문을 사용합니다. (예를 들면 if, switch)
    • [ ] 연산 오류가 발생할 경우 해당 오류에 대한 내용을 정제하여 출력합니다.
      • [ ] ex) “나눗셈 연산에서 분모(두번째 정수)에 0이 입력될 수 없습니다.“
  • [ ] 반복문을 사용하되, 반복의 종료를 알려주는 “exit” 문자열을 입력하기 전까지 무한으로 계산을 진행할 수 있도록 소스 코드를 수정하기
    • [ ] 키워드 : 무한으로 반복, 수정하기 (처음부터 무한 반복하는 것이 아니라, 위 스텝별로 진행하며 수정)
    • [ ] 반복문을 사용합니다. (예를 들어, for, while…)

Lv 2. 클래스를 적용해 기본적인 연산을 수행할 수 있는 계산기 만들기

  • [ ] 사칙연산을 수행 후, 결과값 반환 메서드 구현 & 연산 결과를 저장하는 컬렉션 타입 필드를 가진 Calculator 클래스를 생성
    • [ ] 사칙연산을 수행한 후, 결과값을 반환하는 메서드 구현
    • [ ] 연산 결과를 저장하는 컬렉션 타입 필드를 가진 Calculator 클래스를 생성
    • [ ] 1) 양의 정수 2개(0 포함)와 연산 기호를 매개변수로 받아 사칙연산(➕,➖,✖️,➗) 기능을 수행한 후 2) 결과 값을 반환하는 메서드와 연산 결과를 저장하는 컬렉션 타입 필드를 가진 Calculator 클래스를 생성합니다.
  • [ ] Lv 1에서 구현한 App 클래스의 main 메서드에 Calculator 클래스가 활용될 수 있도록 수정
    • [ ] 연산 수행 역할은 Calculator 클래스가 담당
      • [ ] 연산 결과는 Calculator 클래스의 연산 결과를 저장하는 필드에 저장
    • [ ] 소스 코드 수정 후에도 수정 전의 기능들이 반드시 똑같이 동작해야합니다.
  • [ ] App 클래스의 main 메서드에서 Calculator 클래스의 연산 결과를 저장하고 있는 컬렉션 필드에 직접 접근하지 못하도록 수정 (캡슐화)
    • [ ] 간접 접근을 통해 필드에 접근하여 가져올 수 있도록 구현합니다. (Getter 메서드)
    • [ ] 간접 접근을 통해 필드에 접근하여 수정할 수 있도록 구현합니다. (Setter 메서드)
    • [ ] 위 요구사항을 모두 구현 했다면 App 클래스의 main 메서드에서 위에서 구현한 메서드를 활용 해봅니다.
  • [ ] Calculator 클래스에 저장된 연산 결과들 중 가장 먼저 저장된 데이터를 삭제하는 기능을 가진 메서드를 구현한 후 App 클래스의 main 메서드에 삭제 메서드가 활용될 수 있도록 수정
    • [ ] 키워드 : 컬렉션
      • [ ] 컬렉션에서 ‘값을 넣고 제거하는 방법을 이해한다.’가 중요합니다!

도전 기능 가이드

3. Enum, 제네릭, 람다 & 스트림을 이해한 계산기 만들기

  • [ ] 현재 사칙연산 계산기는 (➕,➖,✖️,➗) 이렇게 총 4가지 연산 타입으로 구성되어 있습니다.
    • [ ] Enum 타입을 활용하여 연산자 타입에 대한 정보를 관리하고 이를 사칙연산 계산기 ArithmeticCalculator 클래스에 활용 해봅니다.
  • [ ] 실수, 즉 double 타입의 값을 전달 받아도 연산이 수행하도록 만들기
    • [ ] 키워드 : 제네릭
      • [ ] 단순히, 기존의 Int 타입을 double 타입으로 바꾸는 게 아닌 점에 주의하세요!
    • [ ] 지금까지는 ArithmeticCalculator, 즉 사칙연산 계산기는 양의 정수(0 포함)를 매개변수로 전달받아 연산을 수행
    • [ ] 피연산자를 여러 타입으로 받을 수 있도록 기능을 확장
      • [ ] ArithmeticCalculator 클래스의 연산 메서드(calculate)
    • [ ] 위 요구사항을 만족할 수 있도록 ArithmeticCalculator 클래스를 수정합니다. (제네릭)
      • [ ] 추가적으로 수정이 필요한 다른 클래스나 메서드가 있다면 같이 수정 해주세요.
  • [ ] 저장된 연산 결과들 중 Scanner로 입력받은 값보다 큰 결과값 들을 출력
    • [ ] ArithmeticCalculator 클래스에 위 요구사항을 만족하는 조회 메서드를 구현합니다.
    • [ ] 단, 해당 메서드를 구현할 때 Lambda & Stream을 활용하여 구현합니다.
      • [ ] Java 강의에서 람다 & 스트림을 학습 및 복습 하시고 적용 해보세요!
    • [ ] 추가) 람다 & 스트림 학습을 위해 여러 가지 조회 조건들을 추가하여 구현 해보시면 학습에 많은 도움이 되실 수 있습니다.

하고 싶은대로 가이드

3. 오버로드, 제네릭 + 정형 타입 매개변수 사용

  • 오버로드를 사용하여 여러 함수를 구현해본다.
  • 제네릭 타입을 포함한 다양한 매개변수를 하나의 메소드에서 사용해 본다
  • 특정 데이터의 추가, 삭제, 수정등의 추가 기능을 구현한다.
  • 예외처리

 

 

 

트러블 슈팅

배경 : 계산기 과제를 받고 위의 내용을 기준으로 프로젝트 제작을 진행했다.

발단 

  • 오랜만에 자바 프로젝트를 진행해보니 파이썬과 너무 많은 차이가 있었다.
  • 알고리즘 적인 요소는 잘 이해하고 있다보니 수월하게 해결할 수 있었다.
  • 클래스 2개를 제작하여 하나는 계산, 정보 관리를 구현하였다.
  • 다만 여기서 문제가 발생했는데
  • 제니릭과 추상화된 타입의 연산자간의 문제가 발생했다.

전개 

  • 이 과제의 해결법은 크게 2가지였다.
  • 하나, 제니릭 데이터의 반환값을 미리 정해두는 방식
  • 둘, 제니릭한 데이터 매개변수의 타입을 for문으로 하나씩 다 확인하는 방법
  • 이 과정에서 첫번째 방법을 택하게 되었는데 이유는 코드가 더 간결하게 짤 수 있어서였다.
  • 조금 더 자세히 설명하면, Number 클래스를 상속받으면 해당 클래스 메소드인 doubleValue()를 사용해 매개변수 타입을 임의로 지정할 수 있었기 때문이었다.
class Calculator {

    // 덧셈
    public <T extends Number> double add(T x, T y) {
        return x.doubleValue() + y.doubleValue();
    }

    // 뺄셈
    public <T extends Number> double subtract(T x, T y) {
        return x.doubleValue() - y.doubleValue();
    }

    // 곱셈
    public <T extends Number> double multiply(T x, T y) {
        return x.doubleValue() * y.doubleValue();
    }

    // 나눗셈
    public <T extends Number> double divide(T x, T y) {
        // 0으로 나누는 경우 예외 처리
        if (y.doubleValue() == 0) {
            throw new ArithmeticException("Cannot divide by zero");
        }
        return x.doubleValue() / y.doubleValue();
    }
}

 

위기 

  • 다만 코드를 진행하면서 좀더 오려운 방식으로 코드를 전개해 보고 싶어졌다.
  • 이번에는 제네릭을 사용하면서 모든 타입을 입력할 수 있도록하는 매개변수 타입을 만들어보고 싶어졌다.
  • 다만 위 방식은 구현이 애매해졌는데
  • public T void로 선언하면 확실히 모든 타입을 입력할 수 있게 되지만
  • 이 경우 클래스 내부의 연산자를 사용하는 방식이 너무 복잡하고 길어진다는 문제점이 있었다.

절정 : 근본적인 해결을 위해 이런 방법으로 접근하였다.

 

  • 그래서 아쉽지만 해당 방식은 구현을 포기하고 T와 정적 타입 매개변수를 같이 사용하는 것을 연습해 보기로 했다.
  • 이번에는 함수에 특정 데이터 삭제와 수정을 추가할 예정이라 오버로드를 사용할 수 있었다.
  • 코드는 단순히 double 데이터와 int 데이터를 받을 수 있도록 Number 클래스를 상속하는 타입을 만들었고
  • 문자열을 받을 수 있는 String 타입의 매개변수를 받는 class confirm을 제작하였다.
  • 그 외에는 다른 변수 없이 처리가 가능하였다.
  • 조금 의외였던 것은 this.dataList를 안해도 제대로 dataList의 내용을 수정할 수 있었다.
  • 사실 이 부분은 단순히 생각하면 되는데 객체의 dataList를 불러오는 dataList나 현재 객체라는 의미를 가진 this를 추가한 this.dataList나 다를게 없다..
  • 보통 this는 매개 변수와 메서드 내의 변수명이 같을 때 구분하라고 사용하는 건데 현재 코드에는 딱히 맴버,로컬,매개 변수 중 이름이 같은 변수는 없었다.
  • 사실 제네릭, 스트림, 람다 모두 기존의 파이썬에서는 사용하지 않은 기능이다보니 기능들이 필요한 적제 적소의 타이밍이 언제인지 감이 잘 오지 않았다.

 

결말 : 최종적으로 코드는 수월하게 완성이 되었다. 크게 막히는 부분은 없었지만, 몇번 gpt에게 물어보니 코드의 최적화와 클린 코드 작성에 있어서 큰 차이를 보였다. 기능을 구현하는 것도 문론 중요하지만, 어떠한 코드든 소프트웨어 생명주기 모델에 의거하여 유지보수가 거의 90%를 차지할 정도로 코드의 관리 용이성은 중요하다. 그 점에서 오늘 제작한 코드는 미흡한 점이 크다고 생각한다.

  • 변수명이 이해가 되지 않는 부분은 변수 명을 수정하여 가독성을 높였다.
  • 제네릭을 사용하여 다양한 형태의 데이터를 사용할 수 있게 수정했다.
  • 스트림, 람다를 이용해 코드의 가독성을 높였다.
  • 클래스를 나누어 적절히 기능별로 구현하였다.
  • 상속을 구현하기보단 단순히 표현하기 위해 따로 클래스를 구현하였다.
  • for, switch문을 적절히 사용하여 선택지를 구성할 수 있었다.

 

회고

  • 사실 위의 방식은 배운 부분을 유기적으로 연동한 것이 끝이라 어렵지 않았지만 아래의 내용이 앞으로의 숙제일 것 같다.
  • 코드의 변수, 객체, 생성자, 메서드, 매서드 내 변수, 함수 관리에 특정한 룰을 만드는 것이 좋아보인다.
  • 코드가 짧으면 무조건 좋다고 생각했지만 그건 또 아닌 듯 하다.
  • 대부분의 코드를 다른 팀원과 하게되면 항상 말하는 것이 "짜여있는 코드보다 차라리 처음부터 짜는게 빠르겠다" 라는 말을 많이한다. 그 점에 있어 타인과 함께 이해할 수 있는 코드 방식을 구사하는 것이 중요해 보인다.
  • 복잡한 코드에는 주석을 통해 설명을 꼭 해줘야한다는 점도 중요한 포인트라고 생각이 든다.
  • 특히 이번에 while, for, if, switch문을 보면서 생각나는게, 이 조건문 반복문은 꼭 필요하면서 코드를 길게 만드는 점이 좀 마음에 안든다...
  • Map을 사용하는 방식으로 수정해 보는 것이 더 효율적이라는 생각이 든다.
  • 다양한 기능을 배우고 구현을 해봤다고는 하지만, 실제로 정확히 어느 타이밍에 뭘 사용해야하는지, 이게 가장 어려운 것 같다. 적제 적소에 필요한 기능을 유지보수하기 쉽도록 개발하는 것의 어려움을 깨달았다.
  • 스트림을 사용하는 방식으로 간단히 표현한 점에서는 유지보수에 용히하게 개선이 되었지만, 제네릭은 너무 코드를 이해하기 힘들게 만드는 것 같아서 기본 타입으로 구성하는 것이 좋을 것 같다.
  • 상속의 개념은 최고 클래스에서 불러올 경우에는 많이 사용하는 느낌이지만, 실제로는 포함 관계를 더 많이 구현할 것 같다는 생각이 든다.
  • 이 코드를 작성하면서 가장 힘들었던 부분은 코드의 최적화보다도, 유지보수에 유리하게 만드는 것이 어려웠다고 생각이 든다. 사실 상속으로 여러 코드를 나눠볼까 라는 생각을 했지만, 유지보수를 현업에서 다양한 사람과 함께 하는 만큼 이해가 쉬운 코드를 우선순위 1번으로 생각하고 2번을 최적화로 생각하면서 코드를 진행했다.
  • 사실 어느쪽이 정답인지는 알 수 없으나, 가장 좋은 방법은 2가지를 다 챙기는 방법이라 생각한다.
  • 중요한 숙제는 프리셋을 구성하는 것이라 생각한다. 나 스스로 메인, 각 클래스, 메소드, 변수 선언에 일정한 규칙을 가질 수 있는 개발 뼈대를 만드는 것이 앞으로의 능력에 중요한 기준이 될 것이라 생각한다.
  • 다만, 그를 위해서는 깊은 수준의 CS와 JAVA 이해능력이 필요하다는 생각이 들었다. 추가적인 이론을 학습을 안할 수 는 없어보인다.

'Project > JAVA' 카테고리의 다른 글

[JAVA] SOLID 원칙  (1) 2025.01.23
[JAVA] 키오스크  (0) 2024.11.26

NULL

📌참조 변수가 아무 객체도 가리키지 않음을 나타내는 특수한 값입니다.

  • 특징:
    • 객체가 생성되지 않았거나, 특정 객체를 참조하지 않는 상태를 나타냅니다.
    • 참조 타입(객체 타입)에만 사용할 수 있습니다. (예: String, Integer, 사용자 정의 클래스 등)
    • 원시 타입(예: int, float)에는 사용할 수 없습니다.
    • null 값의 변수로 메서드를 호출하려고 하면 **NullPointerException**이 발생합니다.

빈 공간 (Empty String)

  • 의미: 길이가 0인 문자열로, 내용이 없는 문자열을 의미합니다.
  • 표현: 빈 문자열은 "" (따옴표만 있는 문자열)로 표현됩니다.
  • 특징:
    • 문자열 객체가 존재하지만, 내용이 없습니다.
    • 메모리에는 문자열 객체가 생성되며, 이는 null과 다릅니다.
    • isEmpty() 메서드로 확인할 수 있습니다.

 

 

 

 

  • NULL은 사용에 있어 주의가 필요한데 null이 반환되면 보통 nullPointerException이 발생하게 된다.
public class NullIsDanger {
    public static void main(String[] args) {

        SomeDBClient myDB = new SomeDBClient();
        
        String userId = myDB.findUserIdByUsername("HelloWorldMan");

        System.out.println("HelloWorldMan's user Id is : " + userId);
    }
}

class SomeDBClient {

    public String findUserIdByUsername(String username) {
        // ... db에서 찾아오는 로직
				String data = "DB Connection Result";

        if (data != null) {
            return data;
        } else {
            return null;
        } 
    }
    
}
  1. 논리적으로도, 환경적으로도 null이 반환될 여지가 있음에도, null이 반환될 수 있음을 명시하지 않았습니다.
  2. 메인 함수 쪽에서, 사용할 때 null 체크를 하지 않아, 만약 null이 반환 된다면, nullPointerException이 발생하게 됩니다.

 

  • 위 문제를 해결하는 방법은 크게 3가지인데
  • 1. null이 반환될 여지를 명시하고, 그 메서드를 사용하는 사람이 조심하기
  • 2. 결과값을 감싼 객체를 반환 = 결과값 + 성공 여부를 함께 반환 = 성공 못해도 값을 null로 반환하진 않는다.
  • 3. java.util.Optiona 객체 사용

+ 감싼 객체 : 보통 반환은 1개의 데이터만 반환하지만 객체의 형태로 반환하면 여러 데이터를 담아 반환이 가능하다.

 

 

java.util.Optional

📌 Java 8에서 도입된 클래스입니다. 널(null) 값을 명시적으로 처리하기 위해 사용됩니다. Optional은 값이 있을 수도 있고 없을 수도 있다는 개념을 명확하게 표현하고, 널 포인터 예외(NullPointerException)를 방지하는 데 도움을 줍니다.

 

Optional 기본 정리

  • Java8에서는 Optional<T> 클래스를 사용해 Null Pointer Exception을 방지할 수 있도록 도와줍니다.
  • Optional<T>는 null이 올 수 있는 값을 감싸는 Wrapper 클래스입니다.
  • Optional이 비어있더라도, 참조해도 Null Pointer Exception가 발생하지 않습니다.
  • Optional은 객체를 생성하는데 오버헤드가 있을 수 있기 때문에 성능이 중요한 경우 과도한 사용을 피하는 것이 좋습니다.
  • 모든 메서드에서 Optional을 사용해야 할 필요는 없으며, 주로 널 처리가 필요한 곳에서만 사용하는 것이 바람직합니다.

 

Optional 사용법

1. Optional 생성

  • Optional.of(): 값이 반드시 존재할 때 사용합니다. 만약 null을 넣으려 하면 NullPointerException이 발생합니다.
  • Optional.ofNullable(): 값이 null일 수도 있는 경우에 사용합니다.
  • Optional.empty(): 빈 Optional 객체를 생성합니다. (값이 없음을 나타냄)
Optional<String> presentValue = Optional.of("Hello");
Optional<String> nullableValue = Optional.ofNullable(null); // null이 될 수 있음
Optional<String> emptyValue = Optional.empty(); // 빈 Optional

 

2. 값 존재 여부 확인

  • isPresent(): 값이 존재하면 true, 그렇지 않으면 false를 반환합니다.
  • ifPresent(): 값이 존재하면 해당 값을 처리하는 람다를 실행합니다.
Optional<String> value = Optional.of("Hello");

// 값이 존재하는지 확인
if (value.isPresent()) {
    System.out.println(value.get()); // 값이 존재하면 출력
}

// 값이 존재하면 처리하는 방식
value.ifPresent(v -> System.out.println("Value: " + v));

 

3. 값을 안전하게 얻기

  • get(): Optional에 값이 존재하면 해당 값을 반환합니다. 값이 없으면 NoSuchElementException을 발생시킵니다.
  • orElse(): 값이 존재하면 해당 값을 반환하고, 없으면 기본값을 반환합니다.
  • orElseGet(): 값이 존재하면 해당 값을 반환하고, 없으면 제공된 Supplier로 기본값을 생성하여 반환합니다.
  • orElseThrow(): 값이 없으면 예외를 던집니다.
Optional<String> value = Optional.ofNullable("Hello");

// 값이 없으면 예외를 던짐
String result = value.orElseThrow(() -> new IllegalArgumentException("Value is missing"));

// 값이 없으면 기본값을 반환
String result2 = value.orElse("Default Value");

// 값이 없으면 Supplier로 기본값을 생성
String result3 = value.orElseGet(() -> "Generated Value");

 

4. 값 변환

  • map(): Optional에 값이 있을 때, 해당 값에 연산을 적용하여 새로운 Optional을 반환합니다.
  • flatMap(): map()과 유사하지만, 결과가 또 다른 Optional일 때 사용됩니다. (중첩된 Optional을 평평하게 만들어줍니다.)
Optional<String> value = Optional.of("Hello");

// 값이 있을 때 대문자로 변환
Optional<String> upperCaseValue = value.map(String::toUpperCase);
System.out.println(upperCaseValue.get()); // HELLO

// flatMap 예시
Optional<String> result = value.flatMap(v -> Optional.of(v + " World"));
System.out.println(result.get()); // Hello World

 

5. 필터링

  • filter(): Optional에 값이 있을 때, 특정 조건을 만족하는 값만 남기고 필터링합니다.
Optional<String> value = Optional.of("Hello");

// 값이 "Hello"인 경우에만 값 반환
Optional<String> filteredValue = value.filter(v -> v.equals("Hello"));
filteredValue.ifPresent(System.out::println); // Hello

 

< 예시 >

public class OptionalExample {
    public static void main(String[] args) {
        Optional<String> name = Optional.ofNullable(getName());

        // 값이 있으면 출력
        name.ifPresent(System.out::println);

        // 값이 없으면 기본값 반환
        String defaultName = name.orElse("Default Name");
        System.out.println("Name: " + defaultName);

        // 값이 없으면 예외 던지기
        String forcedName = name.orElseThrow(() -> new RuntimeException("Name is missing"));
    }

    public static String getName() {
        // 값이 없으면 null을 반환
        return null;
    }
}

'Back-End (Web) > JAVA' 카테고리의 다른 글

[JAVA] 자바의 정렬  (0) 2024.11.21
[JAVA] 응용 정리  (1) 2024.11.15
[JAVA] 쓰레드 & 람다 함수 & 스트림  (3) 2024.11.13
[JAVA] Generic  (3) 2024.11.12
[JAVA] 오류 및 예외에 대한 이해  (1) 2024.11.12

프로세스

📌 운영체제로부터 자원을 할당받는 작업의 단위

 

프로세스 작업 단위

  • 프로세스는 “실행 중인 프로그램”을 의미합니다.
  • 예를 들어 우리가 Java 프로그램을 실행시키면 이 프로그램은 프로세스라는 이름으로 운영체제 위에서 실행됩니다.
  • 즉, OS 위에서 실행되는 모든 프로그램은 OS가 만들어준 프로세스에서 실행됩니다.
    • 카카오톡, 브라우저, JAVA 프로그램 모두 프로세스로 실행되며
    • 크롬 브라우저를 2개 띄우면 크롬 브라우저 프로세스도 2개가 띄워진 것입니다.

 

 

프로세스 구조

📌 OS가 프로그램 실행을 위한 프로세스를 할당해 줄 때 프로세스 안에 프로그램 Code와 Data 그리고 메모리 영역(Stack, Heap)을 함께 할당해 줍니다.

  1. Code는 Java main 메소드와 같은 코드를 말합니다.
  2. Data는 프로그램이 실행 중 저장할 수 있는 저장 공간을 의미합니다.
    1. 전역변수, 정적 변수(static), 배열 등 초기화된 데이터를 저장하는 공간
  3. Memory (메모리 영역)
    • Stack : 지역변수, 매개변수 리턴 변수를 저장하는 공간
    • Heap : 프로그램이 동적으로 필요한 변수를 저장하는 공간 (new(), mallock())

 

 

 

 

쓰레드

📌 프로세스가 할당받은 자원을 이용하는 실행의 단위

https://madplay.github.io/post/process-vs-thread

 

  • 위 그림처럼 OS는 프로세스 마다 메모리와 CPU 사용량을 할당해 준다.
  • 쓰레드 1개 생성 = 프로세스 1개를 실행할 일꾼을 배치 한다는 의미이다.
  • 여기서 프로세스는 정해진 자원을 할당받으면 위 처럼 같은 프로세스를 2번 실행하면 자원을 반씩 할당 받는가? 라고 생각할 수 있는데, 프로세스가 할당받은 총 자원의 양은 변하지 않으며, 그 자원을 여러 쓰레드가 협업하며 사용하는 방식이다.
  • 흔히 쓰레드는  LIFO FIFO 방식으로 운영되다보니 확실히 쓰레드 1개인 프로세스와 2개인 프로세스의 처리 속도는 다를 수 밖에 없다.

 

쓰레드의 생성

  • 프로세스가 작업 중인 프로그램에서 실행 요청이 들어오면 쓰레드(일꾼)을 만들어 명령을 처리하도록 합니다.

쓰레드의 자원

  • 프로세스 안에는 여러 쓰레드(일꾼)들이 있고, 쓰레드들은 실행을 위한 프로세스 내 주소 공간이나 메모리 공간(Heap)을 공유 받습니다. (자원에 대한 정보는 프로세스에게 있고 쓰레드는 공유하는 것)
  • 추가로, 쓰레드(일꾼)들은 각각 명령 처리를 위한 자신만의 메모리 공간(Stack)도 할당받습니다.

 

 

Java 쓰레드

📌  일반 쓰레드와 동일하며 JVM 프로세스 안에서 실행되는 쓰레드를 말합니다.

  • Java 프로그램을 실행하면 앞서 배운 JVM 프로세스 위에서 실행됩니다.
  • Java 프로그램 쓰레드는 Java Main 쓰레드부터 실행되며 JVM에 의해 실행됩니다.
  • = Java는 메인 쓰레드가 main() 메서드를 실행시키면서 시작이 됩니다.

 

싱글 & 멀티 쓰레드

 

싱글 쓰레드

📌 프로세스 안에서 하나의 쓰레드만 실행되는 것을 말합니다.

  • Java 프로그램의 경우 main() 메서드만 실행시켰을 때 이것을 싱글 쓰레드 라고 합니다.
  • 지금까지 코드스니펫을 통해 실습한 모든 Java 프로그램들은 main() 메서드만 실행시켰기 때문에 모두 싱글 쓰레드로 실행되고 있었습니다.
    • Java 프로그램 main() 메서드의 쓰레드를 ‘메인 쓰레드’ 라고 부릅니다.
    • JVM의 메인 쓰레드가 종료되면, JVM도 같이 종료됩니다.

멀티 쓰레드

📌 프로세스 안에서 여러 개의 쓰레드가 실행되는 것을 말합니다.

  • 하나의 프로세스는 여러 개의 실행 단위(쓰레드)를 가질 수 있으며 이 쓰레드들은 프로세스의 자원을 공유합니다.
  • Java 프로그램은 메인 쓰레드 외에 다른 작업 쓰레드들을 생성하여 여러 개의 실행 흐름을 만들 수 있습니다.
  • 멀티 쓰레드 장점
    • 여러 개의 쓰레드(실행 흐름)을 통해 여러 개의 작업을 동시에 할 수 있어서 성능이 좋아집니다.
    • 스택을 제외한 모든 영역에서 메모리를 공유하기 때문에 자원을 보다 효율적으로 사용할 수 있습니다.
    • 응답 쓰레드와 작업 쓰레드를 분리하여 빠르게 응답을 줄 수 있습니다. (비동기)
  • 멀티 쓰레드 단점
    • 동기화 문제가 발생할 수 있습니다.
      • 프로세스의 자원을 공유하면서 작업을 처리하기 때문에 자원을 서로 사용하려고 하는 충돌이 발생하는 경우를 의미합니다.
      • A쓰레드는 자원이 10개 남았다고 들어서 10개 예약했는데, 지금 자원을 쓰고 있는 B가 써보니 자원이 8개 남았다 
      • 이 과정에서 자원이 얼마 남았는지 쓰레드끼리 서로 정확히 알고 있어야 하는데, 이 서로 알려주는 과정을 동기화라고 한다.
    • 교착 상태(데드락)이 발생할 수 있습니다.
      • 둘 이상의 쓰레드가 서로의 자원을 원하는 상태가 되었을 때 서로 작업이 종료되기만을 기다리며 작업을 더 이상 진행하지 못하게 되는 상태를 의미합니다.
      • A,B 쓰레드 둘다 동시에 자원에 접근하면 코드가 에러가 난다. 병렬처리라고 하지만, 실제로 PC는 아주 빠른 속도로 데이터를 번갈아 가면서 처리하는 것이라 자원은 무조건 하나의 쓰레드만 차지할 수 있다. 근데 A,B가 동시에 요청하면 OS상에서 쓰레드가 중지된다.

쓰레드의 구현

 

Thread (클래스)

public class Main {
    public static void main(String[] args) {
        TestThread thread = new TestThread();
        thread.start();
    }
}

class TestThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i <100; i++) {
            System.out.print("*");
        }
    }
}
  • Java에서 제공하는 Thread 클래스를 상속받아 쓰레드를 구현해 줍니다.
  • run() 메서드에 작성된 코드가 쓰레드가 수행할 작업입니다.

 

Runnable (인터페이스)

public class Main {
    public static void main(String[] args) {

        Runnable run = new TestRunnable();
        Thread thread = new Thread(run);

        thread.start();
    }
}

class TestRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i <100; i++) {
            System.out.print("$");
        }
    }
}
  • Java에서 제공하는 Runnable 인터페이스를 사용하여 쓰레드를 구현해 줍니다.
  • 여기서의 run() 메서드도 마찬가지로 쓰레드가 수행할 작업입니다.
  • 그렇다면 Thread를 직접 상속받아 사용하는 방법이 더 간단해 보이는데 왜? Runnable을 사용하여 쓰레드를 구현하는 방법을 소개해 드렸을까요?
  • 바로 클래스와 인터페이스 차이 때문입니다. Java는 다중 상속을 지원하지 않습니다.
  • 그렇기 때문에 Thread를 상속받아 처리하는 방법은 확장성이 매우 떨어집니다.
  • 반대로 Runnable은 인터페이스이기 때문에 다른 필요한 클래스를 상속받을 수 있습니다.
    • 따라서 확정성에 매우 유리합니다.

람다식

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            int sum = 0;
            for (int i = 0; i < 50; i++) {
                sum += i;
                System.out.println(sum);
            }
            System.out.println(Thread.currentThread().getName() + " 최종 합 : " + sum);
        };

        Thread thread1 = new Thread(task);
        thread1.setName("thread1");
        Thread thread2 = new Thread(task);
        thread2.setName("thread2");

        thread1.start();
        thread2.start();
    }
}
  • run() 메서드에 작성했던 쓰레드가 수행할 작업을 실행 블록 { } 안에 작성하시면 됩니다.
  • setName() 메서드는 쓰레드에 이름을 부여할 수 있습니다.
  • Thread.currentThread().getName() 은 현재 실행 중인 쓰레드의 이름을 반환합니다.

멀티 쓰레드의 처리

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                System.out.print("$");
            }
        };
        Runnable task2 = () -> {
            for (int i = 0; i < 100; i++) {
                System.out.print("*");
            }
        };


        Thread thread1 = new Thread(task);
        thread1.setName("thread1");
        Thread thread2 = new Thread(task2);
        thread2.setName("thread2");

        thread1.start();
        thread2.start();
    }
}


// 결과
$$$***$$$**$$$***$$***$$$***$$**$$**$$**$*$*$**$$**$**... (등등)
  • 메인 쓰레드에서 멀티 쓰레드로 여러 개의 작업을 실행해 봅시다.
  • $ 와 * 이 순서가 일정하지 않게 출력되는 모습을 볼 수 있습니다.
  • 여러 번 실행해서 보면 순서가 정해져 있지 않아 출력의 형태가 계속해서 변화하는 것을 확인할 수 있습니다.
    • 즉, 2개의 쓰레드는 서로 번갈아가면서 수행됩니다.
  • 여기서 이 두 쓰레드의 실행 순서나 걸리는 시간은 OS의 스케줄러가 처리하기 때문에 알 수 없습니다.
  • = 그래서 $$$***$$$**$$$***$$***$$$***$$**$$**$$**$*$*$**$$**$**... 이따구로 나옴...

 


 

 

함수형 인터페이스

📌 단 하나의 추상 메서드만 가지는 인터페이스를 말합니다. 보통 함수를 값으로 전달하기위해 사용됩니다.

  • 뒤의 람다식의 이해를 위해 필요한 챕터입니다.
// Car 클래스 내부에 두 메서드 구현

public static boolean hasTicket(Car car) {
        return car.hasParkingTicket;
}

public static boolean noTicketButMoney(Car car) {
        return !car.hasParkingTicket && car.getParkingMoney() > 1000;
}

함수형 인터페이스를 다음과 같이 정리합니다.
interface Predicate<T> {
    boolean test(T t);
}

이 두 함수를 리팩토링
public static List<Car> parkingCarWithTicket(List<Car> carsWantToPark) {
        ArrayList<Car> cars = new ArrayList<>();

        for (Car car : carsWantToPark) {
            if (car.hasParkingTicket()) {
                cars.add(car);
            }
        }

        return cars;
  }

public static List<Car> parkingCarWithMoney(List<Car> carsWantToPark) {
    ArrayList<Car> cars = new ArrayList<>();

    for (Car car : carsWantToPark) {
        if (!car.hasParkingTicket() && car.getParkingMoney() > 1000) {
            cars.add(car);
        }
    }

    return cars;
}
  • 우리는 함수를 값으로 처리할 수 있게 됐기 때문에, 거의 똑같은 두 개의 함수 따위는 필요가 없다.
  • 즉 아래와 같이 수정된다.
// 변경점 1 : Predicate<Car> 인터페이스를 타입 삼아 함수를 전달합니다!
public static List<Car> parkCars(List<Car> carsWantToPark, Predicate<Car> function) {
      List<Car> cars = new ArrayList<>();

      for (Car car : carsWantToPark) {
					// 변경점 2 : 전달된 함수는 다음과 같이 사용됩니다!
          if (function.test(car)) {
              cars.add(car);
          }
      }

      return cars;
  }

 

< 실제 사용 >

parkingLot.addAll(parkCars(carsWantToPark, Car::hasTicket));
parkingLot.addAll(parkCars(carsWantToPark, Car::noTicketButMoney));
  • 새로운 문법 Car::hasTicket 이 보이시나요?
  • 우리는 함수를 값으로 취급하기 때문에, 함수를 참조로 불러서 쓸 수 있습니다.
  • Car 클래스의 hasTicket 함수를 값으로 가져와서 전달하는 부분입니다!

 

< 예제 >

import java.util.ArrayList;
import java.util.List;

public class LambdaAndStream {
    public static void main(String[] args) {
        ArrayList<Car> carsWantToPark = new ArrayList<>();
        ArrayList<Car> parkingLot = new ArrayList<>();

        Car car1 = new Car("Benz", "Class E", true, 0);
        Car car2 = new Car("BMW", "Series 7", false, 100);
        Car car3 = new Car("BMW", "X9", false, 0);
        Car car4 = new Car("Audi", "A7", true, 0);
        Car car5 = new Car("Hyundai", "Ionic 6", false, 10000);

        carsWantToPark.add(car1);
        carsWantToPark.add(car2);
        carsWantToPark.add(car3);
        carsWantToPark.add(car4);
        carsWantToPark.add(car5);

        parkingLot.addAll(parkCars(carsWantToPark, Car::hasTicket));
        parkingLot.addAll(parkCars(carsWantToPark, Car::noTicketButMoney));


        for (Car car : parkingLot) {
            System.out.println("Parked Car : " + car.getCompany() + "-" + car.getModel());
        }


    }

    public static List<Car> parkCars(List<Car> carsWantToPark, Predicate<Car> function) {
        List<Car> cars = new ArrayList<>();

        for (Car car : carsWantToPark) {
            if (function.test(car)) {
                cars.add(car);
            }
        }

        return cars;
    }


}

Car 클래스 내부에 메서드 구현
class Car {
    private final String company; // 자동차 회사
    private final String model; // 자동차 모델

    private final boolean hasParkingTicket;
    private final int parkingMoney;

    public Car(String company, String model, boolean hasParkingTicket, int parkingMoney) {
        this.company = company;
        this.model = model;
        this.hasParkingTicket = hasParkingTicket;
        this.parkingMoney = parkingMoney;
    }

    public String getCompany() {
        return company;
    }

    public String getModel() {
        return model;
    }

    public boolean hasParkingTicket() {
        return hasParkingTicket;
    }

    public int getParkingMoney() {
        return parkingMoney;
    }

    public static boolean hasTicket(Car car) {
        return car.hasParkingTicket;
    }

    public static boolean noTicketButMoney(Car car) {
        return !car.hasParkingTicket && car.getParkingMoney() > 1000;
    }
}

함수형 인터페이스를 다음과 같이 정리합니다.
interface Predicate<T> {
    boolean test(T t);
}

 

 

람다식

📌 Java 8 (함수형 프로그래밍, 병렬의 시작 굉장히 중요한 버전이다.)부터 도입된 기능으로, 익명 함수(이름 없는 함수)를 간단하게 표현할 수 있는 문법입니다. 주로 코드를 간결하게 하고, 함수형 프로그래밍 스타일을 지원하기 위해 사용됩니다.

 

기존 방식 람다
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello, World!");
    }
};
() -> System.out.println("Hello, World!");


(매개변수) -> { 함수 본문 }

 

// 주말의 주차장 추가
ArrayList<Car> weekendParkingLot = new ArrayList<>();

weekendParkingLot
.addAll(parkCars(carsWantToPark, (Car car) -> car.hasParkingTicket() && car.getParkingMoney() > 1000));

 

  • 새로운 문법! (Car car) -> car.hasParkingTicket() && car.getParkingMoney() > 1000))
  • 함수를 값으로 전달하는데, 어딘가에 구현하지 않고 그냥 간단하게 구현해서 넘기고 싶으면 람다식을 이용하면 됩니다!
  • 람다식은 “함수 값”으로 평가되며, 한 번만 사용됩니다.

람다 함수의 장점

  1. 코드 간결성: 익명 클래스보다 간결하고 가독성이 좋음.
  2. 함수형 프로그래밍 지원: Stream API와 결합해 데이터 처리를 선언적으로 표현.
  3. 지연 평가 가능: 필요한 시점까지 계산을 미뤄 성능 최적화에 유리.
  4. 익명 클래스 중복 감소: 반복적인 익명 클래스 코드를 줄여 유지보수성 향상.
  5. 병렬 처리 지원: Stream API와 함께 멀티코어 병렬 처리에 유리.

람다 함수의 단점

  1. 디버깅 어려움: 스택 트레이스가 불명확해 오류 추적이 어려울 수 있음.
  2. 재사용성 제한: 복잡한 로직은 재사용이 어렵고 가독성 저하 가능.
  3. 가독성 저하 가능성: 다중 중첩된 람다식은 코드 흐름을 파악하기 어려움.
  • 한발쏘면 끝나는 샷건으로 간결하고 현재의 상황을 모두 깔끔하게 정리할 수 있지만, 다시 못쓴다...

 

< 람다 함수 문법 >

// 기본적으로 문법은 다음과 같습니다.
(파라미터 값, ...) -> { 함수 몸체 }

// 아래의 함수 두개는 같은 함수입니다.
// 이름 반환타입, return문 여부에 따라 {}까지도 생략이 가능합니다.
public int toLambdaMethod(int x, int y) {
	return x + y;
}

(x, y) -> x + y

// 이런 함수도 가능하겠죠?
public int toLambdaMethod2() {
	return 100;
}

() -> 100

// 모든 유형의 함수에 가능합니다.
public void toLambdaMethod3() {
	System.out.println("Hello World");
}

() -> System.out.println("Hello World")

 

 

스트림

📌  Java 8부터 제공되는 기술로 람다를 활용해컬렉션(또는 배열)을 함수형으로 간단하게 처리하기 위한 API로, 함수형 프로그래밍 스타일을 지원하는 매우 강력한 도구입니다. 스트림을 사용하면 데이터 처리를 선언적이고 간결하게 할 수 있으며, 병렬 처리와 같은 고급 기능도 지원합니다.

 

  • 스트림은 정확하게는 Java 8부터 제공되는, 한 번 더 추상화된 자료구조자주 사용하는 프로그래밍 API를 제공한 것입니다. 자료구조를 한 번 더 추상화했기 때문에, 자료구조의 종류에 상관없이 같은 방식으로 다룰 수 있습니다.
  • ( + 병렬 처리에 유리한 구조로 조건부로 성능도 챙길 수 있습니다.)
  • 조금 더 쉽게 “비유” 하자면, 자료구조의 “흐름”을 객체로 제공해 주고, 그 흐름 동안 사용할 수 있는 메서드들을 api로 제공해 주고 있는 것이죠 일단은 “자료구조” (리스트, 맵, 셋 등)의 흐름이라고 비유하면 이해가 조금 더 쉬울 것 같습니다.
  • 스트림 메서드는 collection에 정의되어 있기 때문에, 모든 컬렉션을 상속하는 구현체들은 스트림을 반환할 수 있습니다.

https://steady-coding.tistory.com/309

 

  • 말이 어려운데, 보통 우리가 데이터를 다룰때 각 자료구조마다 약간씩은 사용방식이 다른 경우가 많다.
  • 예를 들면, List를 정렬할 때는 Collections.sort()를 사용하고, 배열을 정렬할 때는 Arrays.sort()를 사용한다
  • 비슷한 기능들을 하나의 방식으로 통일해서 사용할 수 없을까? 에서 시작한 것이 스트림이다.
  • 스트림은 데이터 소스가 무엇이던 다 통일하여 같은 방식으로 다룰 수 있게 해준다.
  • 그래서 자료구조를 추상화 했다는 말이 나오는 것
  • 더 나아가 하나의 "흐름" 처럼 위 그림과 같이 과정을 연속적으로 처리한다.
        List<String> filteredList = namesList.stream() // List를 스트림으로 변환
            .filter(name -> name.startsWith("A") || name.startsWith("B")) // A 또는 B로 시작하는 이름 필터링
            .sorted() // 알파벳 순 정렬
            .map(String::toUpperCase) // 대문자로 변환
            .collect(Collectors.toList()); // List로 변환
  • 정말 그냥 대놓고 1열로 나열해 버린다.

 

< 예시 >

import java.util.*;
import java.util.stream.*;

public class WithoutStreamExample {
	// 스트림 적용 안한 상태
    public static void main(String[] args) {
        // 1. List를 필터링, 정렬, 변환하는 작업
        List<String> namesList = Arrays.asList("Alice", "Bob", "Charlie", "David");
        ♣////////////////////////////////////////////////////////////////////////////////
        List<String> filteredList = new ArrayList<>();
        
        for (String name : namesList) {
            if (name.startsWith("A") || name.startsWith("B")) { // A 또는 B로 시작하는 이름 필터링
                filteredList.add(name.toUpperCase()); // 대문자로 변환
            }
        }
        
        Collections.sort(filteredList); // 알파벳 순 정렬
        ////////////////////////////////////////////////////////////////////////////////
        System.out.println("Filtered List without Stream: " + filteredList);

    public static void main(String[] args) {
    	// 스트림 적용한 상태
        // 1. List를 스트림을 통해 필터링, 정렬, 변환하는 작업
        List<String> namesList = Arrays.asList("Alice", "Bob", "Charlie", "David");
		
        ★////////////////////////////////////////////////////////////////////////////////
        List<String> filteredList = namesList.stream() // List를 스트림으로 변환
            .filter(name -> name.startsWith("A") || name.startsWith("B")) // A 또는 B로 시작하는 이름 필터링
            .sorted() // 알파벳 순 정렬
            .map(String::toUpperCase) // 대문자로 변환
            .collect(Collectors.toList()); // List로 변환
		////////////////////////////////////////////////////////////////////////////////
        
        System.out.println("Filtered List with Stream: " + filteredList);


}}
  • 기존의 방식(♣)은 for, if, 외부 메서드 호출을 통해 여러 과정을 거쳐 filter(구분) 하거나 collect(수집) 해야했지만
  • 스트림(★)은 filter, map, collect와 같은 기능이 이미 구현이 되어있고, 1열로 그냥 나열해서 써도 되다보니 간결하고 처리가 빠르다.

 

더보기
  • 스트림을 사용하지 않은 방식에서는 for 루프와 조건문을 활용하여 데이터를 필터링하고, 별도의 메서드(Collections.sort)로 정렬을 수행하며, 최종적으로 변환을 진행했습니다.
    • 코드가 길어지고, 각 작업 단계가 명확하게 분리되지 않아 가독성이 떨어질 수 있습니다.
    • List와 배열을 다룰 때 처리 방식에 차이가 있으며, 필터링과 변환 작업을 추가할수록 코드 복잡도가 높아집니다.
  • 스트림을 사용한 방식에서는 filter, sorted, map, collect 등 스트림 메서드를 체인 형태로 연결하여 간결하고 일관된 방식으로 데이터를 처리했습니다.
    • List와 배열을 동일한 스트림 메서드로 처리하여 코드의 일관성이 높아집니다.
    • 각 작업이 명확하게 분리되고 체이닝으로 연결되어 있어 가독성과 유지보수성이 크게 향상됩니다.

+ 체인 형태  : 메서드를 연달아 호출하여 작업을 순차적으로 처리하는 방식

 

스트림의 특징

  1. 원본 데이터 불변성: 스트림은 데이터를 읽거나 쓰는 동안 원본 데이터를 변경하지 않습니다. 스트림이 데이터를 처리하는 방식은 원본 데이터의 복사본을 사용하여 전달하고 처리하는 방식이므로, 데이터가 변조되지 않습니다.
  2. 일회성(단일 소비 가능): 스트림은 한 번 사용되면 재사용할 수 없습니다. 데이터를 읽는 스트림은 데이터를 한 번 읽고 나면 다시 읽을 수 없으며, 새로운 스트림을 만들어야 합니다. 이런 특성은 자바의 java.util.stream 패키지의 스트림에도 적용되며, 데이터 처리 후 스트림이 자동으로 소멸됩니다.
  3. 단방향성: 스트림은 데이터를 한 방향으로만 전달합니다. 읽기와 쓰기는 각각 별도의 스트림을 사용해야 하며, 데이터를 주고받으려면 각각 입력 스트림과 출력 스트림을 따로 생성해야 합니다.
  4. 연속성: 스트림은 데이터를 끊김 없이 연속적으로 처리합니다. 이는 파일이나 네트워크 통신에서 특히 유용하며, 데이터를 필요한 순간에 조금씩 처리해 메모리 사용량을 줄입니다.
  5. 시간 지연 최소화: 스트림은 데이터를 즉시 처리하거나 대기하지 않고 바로 전달하므로, 실시간 처리가 중요한 작업에 적합합니다. 네트워크에서 데이터가 도착하는 즉시 처리할 수 있도록 설계되었습니다.
  6. 추상화된 데이터 처리: 스트림은 데이터 소스(파일, 네트워크, 메모리 등)와 데이터가 전달될 위치(출력 화면, 파일 등)를 추상화하여 개발자가 데이터의 출처에 관계없이 동일한 방식으로 처리할 수 있게 해줍니다.
  7. 버퍼링 지원: 스트림에는 버퍼를 사용하여 데이터 전송 속도를 최적화할 수 있는 클래스(BufferedInputStream, BufferedReader 등)가 있습니다. 버퍼링된 스트림은 데이터가 일정량 모였을 때 한 번에 읽거나 쓰는 방식으로 성능을 향상시킵니다.
  8. 다양한 데이터 타입 지원: 바이트 스트림과 문자 스트림처럼 서로 다른 데이터 타입을 처리할 수 있는 다양한 스트림이 제공됩니다. 바이트 스트림(InputStream, OutputStream)은 이진 데이터를, 문자 스트림(Reader, Writer)은 텍스트 데이터를 처리하는 데 사용됩니다.
  9. 필터링과 연결 가능: 필터 스트림(FilterInputStream, FilterOutputStream 등)은 스트림에 필터 기능을 추가할 수 있습니다. 예를 들어, BufferedInputStream을 사용해 버퍼링을 추가하거나, DataInputStream을 통해 기본 데이터 타입을 처리할 수 있습니다. 스트림을 체인처럼 연결하여 다단계로 처리가 가능합니다.

 

스트림은 왜 필요할까?

1. 효율적인 데이터 처리

  • 연속적 데이터 처리: 스트림은 데이터를 한 번에 모두 불러오지 않고, 필요할 때 필요한 만큼만 읽거나 씁니다. 대용량 파일, 실시간 네트워크 데이터, 영상 스트리밍 등을 처리할 때 메모리를 절약하고 성능을 높일 수 있습니다.
  • 버퍼링으로 성능 개선: 스트림의 버퍼링 기능(BufferedInputStream, BufferedReader 등)은 데이터를 임시로 저장해 두었다가 한 번에 처리함으로써 IO 성능을 향상시키고 대기 시간을 줄입니다.

2. 코드 간소화와 유지보수성 향상

  • 고수준 추상화 제공: 스트림을 사용하면 데이터 소스와 관계없이 일관된 방식으로 데이터를 처리할 수 있습니다. 예를 들어, 파일, 네트워크, 메모리에서 데이터를 읽거나 쓸 때 동일한 인터페이스를 사용하여 코드의 가독성이 높아지고 유지보수가 쉬워집니다.
  • 필터링과 변환 용이: 스트림은 체이닝을 통해 여러 필터와 변환 기능을 연달아 적용할 수 있습니다. 데이터 처리를 단일 흐름으로 작성할 수 있어 복잡한 로직을 간결하게 구현할 수 있습니다.

3. 원본 데이터 보호

  • 불변성 유지: 스트림을 통해 데이터를 처리하면 원본 데이터는 그대로 유지되고, 필요할 경우 스트림의 복사본을 사용하여 데이터를 읽고 씁니다. 이로 인해 데이터 무결성을 보호할 수 있습니다.

4. 병렬 처리와 실시간 데이터 처리에 유리

  • 실시간 처리: 네트워크 소켓에서 전송되는 데이터나 파일에서 실시간으로 읽어와야 하는 경우, 스트림을 사용하면 데이터가 도착할 때마다 바로 처리할 수 있습니다.
  • 병렬 처리: 자바 8의 Stream API는 대량 데이터를 병렬 처리할 수 있어 멀티코어 CPU 환경에서 성능을 최적화할 수 있습니다.

5. 다양한 데이터 타입 지원

  • 스트림은 바이트와 문자 기반으로 나뉘어 다양한 데이터 타입을 지원합니다. 이진 데이터(이미지, 영상 파일 등)나 텍스트 데이터 모두 쉽게 처리할 수 있는 클래스를 제공해 개발자가 데이터의 형태에 맞는 스트림을 선택해 사용할 수 있습니다.

스트림을 사용하는 방법

1. 스트림을 받아오기 (.stream())
carsWantToPark.stream()
2. 스트림 가공하기
.filter((Car car) -> car.getCompany().equals("Benz"))
3. 스트림 결과 만들기
.toList();

 

스트림 API

  • https://www.baeldung.com/java-8-streams
  • 위는 스트림에서 제공하는 사이트로, 스트림 api 종류에 관해 상세히 기술되어 있다.
  • 아래는 자주 사용하는 api 메서드들이다.
import java.util.*;
import java.util.stream.*;

public class StreamApiExample {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Edward", "Frank");

        // 1. filter: 특정 조건에 맞는 요소만 걸러냄
        List<String> filteredNames = names.stream()
            .filter(name -> name.startsWith("A") || name.startsWith("D")) // A나 D로 시작하는 이름
            .collect(Collectors.toList());
        System.out.println("Filtered Names: " + filteredNames);

        // 2. map: 각 요소를 다른 값으로 변환
        List<String> upperCaseNames = names.stream()
            .map(String::toUpperCase) // 대문자로 변환
            .collect(Collectors.toList());
        System.out.println("Upper Case Names: " + upperCaseNames);

        // 3. sorted: 정렬
        List<String> sortedNames = names.stream()
            .sorted() // 기본 알파벳 순 정렬
            .collect(Collectors.toList());
        System.out.println("Sorted Names: " + sortedNames);

        // 4. distinct: 중복 제거
        List<String> namesWithDuplicates = Arrays.asList("Alice", "Bob", "Alice", "Charlie", "Bob");
        List<String> distinctNames = namesWithDuplicates.stream()
            .distinct() // 중복 제거
            .collect(Collectors.toList());
        System.out.println("Distinct Names: " + distinctNames);

        // 5. limit: 앞에서부터 n개의 요소만 선택
        List<String> limitedNames = names.stream()
            .limit(3) // 처음 3개의 이름만 선택
            .collect(Collectors.toList());
        System.out.println("Limited Names: " + limitedNames);

        // 6. skip: 앞에서부터 n개의 요소 건너뛰기
        List<String> skippedNames = names.stream()
            .skip(2) // 처음 2개의 이름 건너뛰기
            .collect(Collectors.toList());
        System.out.println("Skipped Names: " + skippedNames);

        // 7. anyMatch: 조건을 만족하는 요소가 하나라도 있는지 확인
        boolean hasNameStartingWithC = names.stream()
            .anyMatch(name -> name.startsWith("C"));
        System.out.println("Any name starts with 'C': " + hasNameStartingWithC);

        // 8. allMatch: 모든 요소가 조건을 만족하는지 확인
        boolean allNamesStartWithA = names.stream()
            .allMatch(name -> name.startsWith("A"));
        System.out.println("All names start with 'A': " + allNamesStartWithA);

        // 9. noneMatch: 모든 요소가 조건을 만족하지 않는지 확인
        boolean noNameStartsWithZ = names.stream()
            .noneMatch(name -> name.startsWith("Z"));
        System.out.println("No name starts with 'Z': " + noNameStartsWithZ);

        // 10. reduce: 모든 요소를 하나의 값으로 합침
        Optional<String> concatenatedNames = names.stream()
            .reduce((name1, name2) -> name1 + ", " + name2); // 모든 이름을 콤마로 연결
        concatenatedNames.ifPresent(result -> System.out.println("Concatenated Names: " + result));

        // 11. count: 스트림의 총 요소 수 계산
        long nameCount = names.stream()
            .count();
        System.out.println("Count of Names: " + nameCount);

        // 12. collect: 결과를 특정 컬렉션으로 수집
        Set<String> nameSet = names.stream()
            .collect(Collectors.toSet()); // Set으로 수집
        System.out.println("Names as Set: " + nameSet);
    }
}

 

스트림에 대해 더 자세히 알고 싶다면 추천하는 사이트는 https://hstory0208.tistory.com/entry/Java%EC%9E%90%EB%B0%94-Stream%EC%8A%A4%ED%8A%B8%EB%A6%BC%EC%9D%B4%EB%9E%80 여기다.

 

 


 

 

데몬 쓰레드

📌 일반적인 사용자 쓰레드와 달리, 백그라운드에서 실행되는 쓰레드입니다. 이 쓰레드는 주 쓰레드가 종료되면 자동으로 종료되는 특징이 있습니다. 주로 백그라운드에서 실행되어 보조적인 작업을 수행하며, 주 쓰레드가 모든 작업을 끝내면 남아 있는 데몬 쓰레드도 종료됩니다.

 

+ 보조적인 역할을 담당하며 대표적인 데몬 쓰레드로는 메모리 영역을 정리해 주는 가비지 컬렉터(GC)가 있다.

 

public class Main {
    public static void main(String[] args) {
        Runnable demon = () -> {
            for (int i = 0; i < 1000000; i++) {
                System.out.println("demon");
            }
        };

        Thread thread = new Thread(demon);
        thread.setDaemon(true);

        thread.start();

        for (int i = 0; i < 100; i++) {
            System.out.println("task");
        }
    }
}

 

  • demon 쓰레드는 우선순위가 낮고 다른 쓰레드가 모두 종료되면 강제 종료 당하기 때문에 main() 쓰레드의 task가 100번이 먼저 찍히면 종료되어 1000000번 수행이 되지 않고 종료됩니다.

 

사용자 쓰레드

📌 일반적인 프로그램의 주 작업을 수행하는 쓰레드입니다. 자원을 활용해 특정 작업을 수행하며, 모든 사용자 쓰레드가 종료될 때까지 프로그램이 계속 실행됩니다. 이는 데몬 쓰레드와 달리, 사용자 쓰레드가 실행 중인 한 JVM이 종료되지 않는다는 특징이 있습니다.

 

+ 프로그램 기능을 담당하며 대표적인 사용자 쓰레드로는 메인 쓰레드가 있습니다.

 

사용자 쓰레드의 특징

  1. 주요 작업 담당
    • 프로그램의 핵심 작업을 담당하며, 직접적인 연산, 데이터 처리, 입출력 등 다양한 작업을 수행합니다.
  2. 모든 사용자 쓰레드가 종료되어야 JVM이 종료
    • 프로그램 내 모든 사용자 쓰레드가 종료되지 않으면 JVM이 계속 실행됩니다. 반면, 사용자 쓰레드가 모두 종료되면 데몬 쓰레드가 남아있어도 프로그램은 종료됩니다.
  3. 데몬 쓰레드와 구분
    • 기본적으로 생성되는 모든 쓰레드는 사용자 쓰레드로 설정되어 있으며, 명시적으로 setDaemon(true)를 호출하지 않는 한 사용자 쓰레드입니다.
public class Main {
    public static void main(String[] args) {
        Runnable userTask = () -> {
            try {
                for (int i = 0; i < 5; i++) {
                    System.out.println("사용자 쓰레드 작업 중...");
                    Thread.sleep(1000);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("사용자 쓰레드 종료");
        };

        Thread userThread = new Thread(userTask);  // 사용자 쓰레드로 설정
        userThread.start();

        System.out.println("메인 쓰레드 종료");
    }
}


메인 쓰레드 종료
사용자 쓰레드 작업 중...
사용자 쓰레드 작업 중...
사용자 쓰레드 작업 중...
사용자 쓰레드 작업 중...
사용자 쓰레드 작업 중...
사용자 쓰레드 종료

 

설명: 사용자 쓰레드가 모두 종료되기 전에는 JVM이 계속 실행되기 때문에, 메인 쓰레드가 종료된 이후에도 프로그램이 끝나지 않고 사용자 쓰레드의 작업이 끝날 때까지 실행됩니다.

 

  • 사실 데몬 선언 없이 만드는 코드는 전부 사용자 쓰레드로 실행된다.
  • 그냥 코드를 짜면 다 사용자 쓰레드라는 것

 

쓰레드 우선순위

📌쓰레드 작업의 중요도에 따라서 쓰레드의 우선순위를 부여할 수 있습니다.

  • 작업의 중요도가 높을 때 우선순위를 높게 지정하면 더 많은 작업시간을 부여받아 빠르게 처리될 수 있습니다. 

 

  • 쓰레드는 생성될 때 우선순위가 정해집니다.
    • 이 우선순위는 우리가 직접 지정하거나 JVM에 의해 지정될 수 있습니다.
  • 우선순위는 아래와 같이 3가지 (최대/최소/보통) 우선순위로 나뉩니다.
    • 최대 우선순위 (MAX_PRIORITY) = 10
    • 최소 우선순위 (MIN_PRIORITY) = 1
    • 보통 우선순위 (NROM_PRIORITY) = 5
      • 기본 값이 보통 우선순위입니다.
    • 더 자세하게 나눈다면 1~10 사이의 숫자로 지정 가능합니다.
    • 이 우선순위의 범위는 OS가 아니라 JVM에서 설정한 우선순위입니다.
  • 스레드 우선순위는 setPriority() 메서드로 설정할 수 있습니다.
  • getPriority()로 우선순위를 반환하여 확인할 수 있습니다.
public class Main {
    public static void main(String[] args) {
        Runnable task1 = () -> {
            for (int i = 0; i < 100; i++) {
                System.out.print("$");
            }
        };

        Runnable task2 = () -> {
            for (int i = 0; i < 100; i++) {
                System.out.print("*");
            }
        };

        Thread thread1 = new Thread(task1);
        thread1.setPriority(8);
        int threadPriority = thread1.getPriority();
        System.out.println("threadPriority = " + threadPriority);

        Thread thread2 = new Thread(task2);
        thread2.setPriority(2);

        thread1.start();
        thread2.start();
    }
}

 

 

 

쓰레드 그룹

📌 여러 개의 쓰레드를 그룹으로 묶어 관리하고 제어할 수 있도록 해주는 Java의 기능입니다. 이를 통해 관련된 쓰레드를 하나의 단위로 묶어 일괄 처리하거나, 특정 그룹에 속한 쓰레드들에 대해 동시에 작업을 수행하는 것이 가능합니다.

 

https://self-learning-java-tutorial.blogspot.com/2014/03/thread-groups.html

 

  • 쓰레드들은 기본적으로 그룹에 포함되어 있습니다.
    • JVM 이 시작되면 system 그룹이 생성되고 쓰레드들은 기본적으로 system 그룹에 포함됩니다.
  • 메인 쓰레드는 system 그룹 하위에 있는 main 그룹에 포함됩니다.
  • 모든 쓰레드들은 반드시 하나의 그룹에 포함되어 있어야 합니다.
    • 쓰레드 그룹을 지정받지 못한 쓰레드는 자신을 생성한 부모 쓰레드의 그룹과 우선순위를 상속받게 되는데 우리가 생성하는 쓰레드들은 main 쓰레드 하위에 포함됩니다.
    • 따라서 쓰레드 그룹을 지정하지 않으면 해당 쓰레드는 자동으로 main 그룹에 포함됩니다.

 

쓰레드 그룹 생성

  • ThreadGroup 클래스로 객체를 만들어서 Thread 객체 생성 시 첫 번째 매개변수로 넣어주면 됩니다.
// ThreadGroup 클래스로 객체를 만듭니다.
ThreadGroup group1 = new ThreadGroup("Group1");

// Thread 객체 생성시 첫번째 매개변수로 넣어줍니다.
// Thread(ThreadGroup group, Runnable target, String name)
Thread thread1 = new Thread(group1, task, "Thread 1");

// Thread에 ThreadGroup 이 할당된것을 확인할 수 있습니다.
System.out.println("Group of thread1 : " + thread1.getThreadGroup().getName());

 

쓰레드 그룹으로 묶어서 쓰레드 관리

  • ThreadGroup 객체의 interrupt() 메서드를 실행시키면 해당 그룹 쓰레드들이 실행 대기 상태로 변경됩니다.
// ThreadGroup 클래스로 객체를 만듭니다.
ThreadGroup group1 = new ThreadGroup("Group1");

// Thread 객체 생성시 첫번째 매개변수로 넣어줍니다.
// Thread(ThreadGroup group, Runnable target, String name)
Thread thread1 = new Thread(group1, task, "Thread 1");
Thread thread2 = new Thread(group1, task, "Thread 2");

// interrupt()는 일시정지 상태인 쓰레드를 실행대기 상태로 만듭니다.
group1.interrupt();

 

< 예제 >

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    break;
                }
            }
            System.out.println(Thread.currentThread().getName() + " Interrupted");
        };

        // ThreadGroup 클래스로 객체를 만듭니다.
        ThreadGroup group1 = new ThreadGroup("Group1");

        // Thread 객체 생성시 첫번째 매개변수로 넣어줍니다.
        // Thread(ThreadGroup group, Runnable target, String name)
        Thread thread1 = new Thread(group1, task, "Thread 1");
        Thread thread2 = new Thread(group1, task, "Thread 2");

        // Thread에 ThreadGroup 이 할당된것을 확인할 수 있습니다.
        System.out.println("Group of thread1 : " + thread1.getThreadGroup().getName());
        System.out.println("Group of thread2 : " + thread2.getThreadGroup().getName());

        thread1.start();
        thread2.start();

        try {
            // 현재 쓰레드를 지정된 시간동안 멈추게 합니다.
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // interrupt()는 일시정지 상태인 쓰레드를 실행대기 상태로 만듭니다.
        group1.interrupt();

    }
}

 

 

 

쓰레드 상태

📌 우리는 동영상을 보거나 음악을 듣고 있을 때 일시정지 후에 다시 보거나 듣기도 하고 중간에 종료 시키기도 합니다. 쓰레드도 마찬가지로 상태가 존재하고 이를 제어를 할 수 있습니다.

 

쓰레드 상태

 

  • 이처럼 쓰레드는 실행과 대기를 반복하며 run() 메서드를 수행합니다.
  • run() 메서드가 종료되면 실행이 멈추게 됩니다.

 

쓰레드 상태 종류

상태
Enum
설명
객체생성
NEW
쓰레드 객체 생성, 아직 start() 메서드 호출 전의 상태
실행대기
RUNNABLE
실행 상태로 언제든지 갈 수 있는 상태
일시정지
WAITING
다른 쓰레드가 통지(notify) 할 때까지 기다리는 상태
일시정지
TIMED_WAITING
주어진 시간 동안 기다리는 상태
일시정지
BLOCKED
사용하고자 하는 객체의 Lock이 풀릴 때까지 기다리는 상태
종료
TERMINATED
쓰레드의 작업이 종료된 상태

 

NEW (새 상태)

  • 설명: 쓰레드가 생성되었지만 아직 시작되지 않은 상태입니다.
  • 특징: new Thread()를 호출하여 객체를 생성했지만 start() 메서드를 호출하지 않은 상태입니다.

2. RUNNABLE (실행 가능 상태)

  • 설명: 실행 중이거나 실행 준비가 된 상태입니다.
  • 특징: start()가 호출되어 쓰레드가 스케줄러에 의해 실행될 준비가 되었을 때입니다. CPU가 사용 가능하면 실행됩니다.
  • 예시: RUNNABLE 상태는 대기 중이지만 곧 CPU에서 실행될 수 있는 상태도 포함됩니다.

3. BLOCKED (블로킹 상태)

  • 설명: 다른 쓰레드가 사용 중인 리소스(예: synchronized 블록)를 기다리는 상태입니다.
  • 특징: 락(lock)을 얻지 못해 일시적으로 실행할 수 없는 상태입니다. 예를 들어, synchronized 블록에 접근하려 했으나 이미 다른 쓰레드가 해당 블록에 들어가 있을 때 발생합니다.

4. WAITING (대기 상태)

  • 설명: 다른 쓰레드가 특정 작업을 완료할 때까지 대기하는 상태입니다.
  • 특징: wait() 메서드 호출, join() 메서드 호출 시 대기 상태가 됩니다. 지정된 조건이 충족되어야 다음 단계로 넘어갈 수 있습니다.
  • 예시: 특정 쓰레드가 종료될 때까지 join()을 호출해 기다리거나, Object.wait()를 호출한 경우.

5. TIMED_WAITING (시간 지정 대기 상태)

  • 설명: 지정된 시간 동안 기다리는 상태입니다.
  • 특징: 특정 시간 동안 기다리며, 시간이 지나면 자동으로 RUNNABLE 상태로 돌아갑니다. sleep(time), wait(time), join(time), LockSupport.parkNanos(), LockSupport.parkUntil() 등이 있습니다.
  • 예시: Thread.sleep(1000);는 1초간 TIMED_WAITING 상태가 됩니다.

6. TERMINATED (종료 상태)

  • 설명: 쓰레드가 종료된 상태입니다.
  • 특징: 쓰레드의 작업이 완료되거나, 예외로 인해 종료된 경우입니다. 이 상태에서는 다시 실행할 수 없습니다.

 

 

쓰레드 제어

📌 쓰레드의 실행을 관리하고, 여러 쓰레드 간의 작업을 조정하는 과정입니다.

 

쓰레드 제어

 

start() 

 

 

📌 쓰레드를 시작하는 메서드로, 쓰레드를 NEW 상태에서 RUNNABLE 상태로 전환시킵니다.

  • start() 메서드를 호출해야만 쓰레드가 실행을 시작할 수 있으며, 이를 호출하지 않으면 쓰레드는 시작되지 않습니다.

 

Thread thread = new Thread(task);
thread.start();  // 쓰레드 시작

 

 

sleep()

📌 현재 쓰레드를 지정된 시간 동안 멈추게 합니다.

  • sleep()은 쓰레드 자기 자신에 대해서만 멈추게 할 수 있습니다. 
public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("task : " + Thread.currentThread().getName());
        };

        Thread thread = new Thread(task, "Thread");
        thread.start();

        try {
            thread.sleep(1000);
            System.out.println("sleep(1000) : " + Thread.currentThread().getName());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

  • Thread.sleep(ms); ms(밀리초) 단위로 설정됩니다.
  • 예외 처리를 해야 합니다.
    • sleep 상태에 있는 동안 interrupt()를 만나면 다시 실행되기 때문에 InterruptedException이 발생할 수 있습니다.

interrupt()

📌 일시정지 상태인 쓰레드를 실행 대기 상태로 만듭니다.

public class Thread implements Runnable {
    /* Make sure registerNatives is the first thing <clinit> does. */
    private static native void registerNatives();
    static {
        registerNatives();
    }

    private volatile String name;
    private int priority;

    /* Whether or not the thread is a daemon thread. */
    private boolean daemon = false;

    /* Interrupt state of the thread - read/written directly by JVM */
    private volatile boolean interrupted;
	
		...

		public void interrupt() {
        if (this != Thread.currentThread()) {
            checkAccess();

            // thread may be blocked in an I/O operation
            synchronized (blockerLock) {
                Interruptible b = blocker;
                if (b != null) {
                    interrupted = true;
                    interrupt0();  // inform VM of interrupt
                    b.interrupt(this);
                    return;
                }
            }
        }
        interrupted = true;
        // inform VM of interrupt
        interrupt0();
    }

		...

		public boolean isInterrupted() {
        return interrupted;
    }
}
  • Thread 클래스 내부에 interrupted 되었는지를 체크하는 boolean 변수가 존재합니다.
  • 쓰레드가 start() 된 후 동작하다 interrupt()를 만나 실행하면 interrupted 상태가 true가 됩니다.
  • isInterrupted() 메서드를 사용하여 상태 값을 확인할 수 있습니다.
public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            while (!Thread.currentThread().isInterrupted()) {
                try {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    break;
                }
            }
            System.out.println("task : " + Thread.currentThread().getName());
        };

        Thread thread = new Thread(task, "Thread");
        thread.start();

        thread.interrupt();

        System.out.println("thread.isInterrupted() = " + thread.isInterrupted());
        
    }
}
  • !Thread.currentThread().isInterrupted()로 interrupted 상태를 체크해서 처리하면 오류를 방지할 수 있습니다.
  • [ sleep 상태에 있는 동안 interrupt()를 만나면 다시 실행되기 때문에 InterruptedException이 발생할 수 있습니다. ]

 

join() 

📌정해진 시간 동안 지정한 쓰레드가 작업하는 것을 기다립니다.

  • 시간을 지정하지 않았을 때는 지정한 쓰레드의 작업이 끝날 때까지 기다립니다.
public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                Thread.sleep(5000); // 5초
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        Thread thread = new Thread(task, "thread");

        thread.start();

        long start = System.currentTimeMillis();

        try {
            thread.join();

        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // thread 의 소요시간인 5000ms 동안 main 쓰레드가 기다렸기 때문에 5000이상이 출력됩니다.
        System.out.println("소요시간 = " + (System.currentTimeMillis() - start));
    }
}
  • Thread.sleep(ms); ms(밀리초) 단위로 설정됩니다.
  • 예외 처리를 해야 합니다.
    • interrupt()를 만나면 기다리는 것을 멈추기 때문에 InterruptedException이 발생할 수 있습니다.
  • 시간을 지정하지 않았기 때문에 thread가 작업을 끝낼 때까지 main 쓰레드는 기다리게 됩니다.

 

yield()

📌 남은 시간을 다음 쓰레드에게 양보하고 쓰레드 자신은 실행 대기 상태가 됩니다.

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                for (int i = 0; i < 10; i++) {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName());
                }
            } catch (InterruptedException e) {
                Thread.yield();
            }
        };

        Thread thread1 = new Thread(task, "thread1");
        Thread thread2 = new Thread(task, "thread2");

        thread1.start();
        thread2.start();

        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        thread1.interrupt();

    }
}

 

  • thread1과 thread2가 같이 1초에 한 번씩 출력
  • 5초 뒤에 thread1에서 InterruptedException이 발생
  • Thread.yield(); 이 실행
  • thread1은 실행 대기 상태로 변경
  • 남은 시간은 thread2에게 리소스가 양보됩니다.

 

synchronized

📌 멀티 쓰레드의 경우 여러 쓰레드가 한 프로세스의 자원을 공유해서 작업하기 때문에 서로에게 영향을 줄 수 있습니다. 이로 인해서 장애나 버그가 발생할 수 있습니다. 이러한 일을 방지하기 위해 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 침범하지 못하도록 막기 위해, 모든 쓰레드는 현재 자원의 상태와 현재 쓰레드가 실행되고 있는지 등의 여부를 동일하게 알고 있어야 합니다. 이 상태 정보를 모두 동일하게 알도록 각 쓰레드가 가진 정보를 통일시키는 것이 '쓰레드 동기화(Synchronization)'라고 합니다.

  • 동기화를 하려면 다른 쓰레드의 침범을 막아야 하는 코드들을 ‘임계 영역( 공유 자원에 접근하는 코드 부분)’으로 설정하면 됩니다.
  • 임계 영역에는 Lock을 가진 단 하나의 쓰레드만 출입이 가능합니다.
    • 즉, 임계 영역은 한 번에 한 쓰레드만 사용이 가능합니다.
1. 메서드 전체를 임계 영역으로 지정합니다.
public synchronized void asyncSum() {
	  ...침범을 막아야하는 코드...
}

2.특정 영역을 임계 영역으로 지정합니다.
synchronized(해당 객체의 참조변수) {
		...침범을 막아야하는 코드...
}

 

public class Main {
    public static void main(String[] args) {
        AppleStore appleStore = new AppleStore();

        Runnable task = () -> {
            while (appleStore.getStoredApple() > 0) {
                appleStore.eatApple();
                System.out.println("남은 사과의 수 = " + appleStore.getStoredApple());
            }

        };

        for (int i = 0; i < 3; i++) {
            new Thread(task).start();
        }
    }
}

class AppleStore {
    private int storedApple = 10;

    public int getStoredApple() {
        return storedApple;
    }

    public void eatApple() {
        synchronized (this) {
            if(storedApple > 0) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                storedApple -= 1;
            }
        }
    }
}
  • 위 코드는 동기화가 실행되어 있어, 순서대로 사과를 먹지만
  • 만약 동기화를 안했다면 남은 사과의 수가 뒤죽박죽 출력될 뿐만 아니라 없는 사과를 먹는 경우도 발생한다 = 오류

 

wait(), notify()

📌침범을 막은 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, wait() 을 호출하여 쓰레드가 Lock을 반납하고 기다리게 할 수 있습니다.

  • 그럼 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 되고,
  • 추후에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서,
  • 작업을 중단했던 쓰레드가 다시 Lock을 얻어 진행할 수 있게 됩니다. 

wait()

  • 실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다립니다.

notify()

  • 해당 객체의 대기실(waiting pool)에 있는 모든 쓰레드 중에서 임의의 쓰레드만 통지를 받습니다.
  • 말 그대로 '알리다' 라는 의미
public class Main {
    public static String[] itemList = {
            "MacBook", "IPhone", "AirPods", "iMac", "Mac mini"
    };
    public static AppleStore appleStore = new AppleStore();
    public static final int MAX_ITEM = 5;

    public static void main(String[] args) {

        // 가게 점원
        Runnable StoreClerk = () -> {
                while (true) {
                    int randomItem = (int) (Math.random() * MAX_ITEM);
                    appleStore.restock(itemList[randomItem]);
                    try {
                        Thread.sleep(50);
                    } catch (InterruptedException ignored) {
                    }
                }
        };

        // 고객
        Runnable Customer = () -> {
                while (true) {
                    try {
                        Thread.sleep(77);
                    } catch (InterruptedException ignored) {
                    }

                    int randomItem = (int) (Math.random() * MAX_ITEM);
                    appleStore.sale(itemList[randomItem]);
                    System.out.println(Thread.currentThread().getName() + " Purchase Item " + itemList[randomItem]);
                }
        };


        new Thread(StoreClerk, "StoreClerk").start();
        new Thread(Customer, "Customer1").start();
        new Thread(Customer, "Customer2").start();

    }
}

class AppleStore {
    private List<String> inventory = new ArrayList<>();

    public void restock(String item) {
        synchronized (this) {
            while (inventory.size() >= Main.MAX_ITEM) {
                System.out.println(Thread.currentThread().getName() + " Waiting!");
                try {
                    wait(); // 재고가 꽉 차있어서 재입고하지 않고 기다리는 중!
                    Thread.sleep(333);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            // 재입고
            inventory.add(item);
            notify(); // 재입고 되었음을 고객에게 알려주기
            System.out.println("Inventory 현황: " + inventory.toString());
        }
    }

    public synchronized void sale(String itemName) {
        while (inventory.size() == 0) {
            System.out.println(Thread.currentThread().getName() + " Waiting!");
            try {
                wait(); // 재고가 없기 때문에 고객 대기중
                Thread.sleep(333);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        while (true) {
            // 고객이 주문한 제품이 있는지 확인
            for (int i = 0; i < inventory.size(); i++) {
                if (itemName.equals(inventory.get(i))) {
                    inventory.remove(itemName);
                    notify(); // 제품 하나 팔렸으니 재입고 하라고 알려주기
                    return; // 메서드 종료
                }
            }

            // 고객이 찾는 제품이 없을 경우
            try {
                System.out.println(Thread.currentThread().getName() + " Waiting!");
                wait();
                Thread.sleep(333);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

    }
}

 

Lock

 

📌synchronized 블럭으로 동기화하면 자동적으로 Lock이 걸리고 풀리지만, 같은 메서드 내에서만 Lock을 걸 수 있다는 제약이 있습니다. 이런 제약을 해결하기 위해 Lock 클래스를 사용합니다.

 

  • ReentrantLock
    • 재진입 가능한 Lock, 가장 일반적인 배타 Lock
    • 특정 조건에서 Lock을 풀고, 나중에 다시 Lock을 얻어 임계 영역으로 진입이 가능합니다.
public class MyClass {
    private Object lock1 = new Object();
    private Object lock2 = new Object();
    
    public void methodA() {
        synchronized (lock1) {
            methodB();
        }
    }
    
    public void methodB() {
        synchronized (lock2) {
            // do something
            methodA();
        }
    }
}
  • methodA는 lock1을 가지고, methodB는 lock2를 가집니다.
  • methodB에서 methodA를 호출하고 있으므로, methodB에서 lock2를 가진 상태에서 methodA를 호출하면 lock1을 가지려고 할 것입니다.
  • 그러나 이때, methodA에서 이미 lock1을 가지고 있으므로 lock2를 기다리는 상태가 되어 데드락이 발생할 가능성이 있습니다.
  • 하지만 ReentrantLock을 사용하면, 같은 스레드가 이미 락을 가지고 있더라도 락을 유지하며 계속 실행할 수 있기 때문에 데드락이 발생하지 않습니다.
  • 즉, ReentrantLock을 사용하면 코드의 유연성을 높일 수 있습니다.
  • ReentrantReadWriteLock
    • 읽기를 위한 Lock과 쓰기를 위한 Lock을 따로 제공합니다.
    • 읽기에는 공유적이고, 쓰기에는 베타적인 Lock입니다.
    • 읽기 Lock이 걸려있으면 다른 쓰레드들도 읽기 Lock을 중복으로 걸고 읽기를 수행할 수 있습니다. (read-only)
    • 읽기 Lock이 걸려있는 상태에서 쓰기 Lock을 거는 것은 허용되지 않습니다. (데이터 변경 방지)
  • StampedLock
    • ReentrantReadWriteLock에 낙관적인 Lock의 기능을 추가했습니다.
      • 낙관적인 Lock : 데이터를 변경하기 전에 락을 걸지 않는 것을 말합니다. 낙관적인 락은 데이터 변경을 할 때 충돌이 일어날 가능성이 적은 상황에서 사용합니다.
      • 낙관적인 락을 사용하면 읽기와 쓰기 작업 모두가 빠르게 처리됩니다. 쓰기 작업이 발생했을 때 데이터가 이미 변경된 경우 다시 읽기 작업을 수행하여 새로운 값을 읽어들이고, 변경 작업을 다시 수행합니다. 이러한 방식으로 쓰기 작업이 빈번하지 않은 경우에는 낙관적인 락을 사용하여 더 빠른 처리가 가능합니다.
    • 낙관적인 읽기 Lock은 쓰기 Lock에 의해 바로 해제 가능합니다.
    • 무조건 읽기 Lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기 후 읽기 Lock을 겁니다.

 

Condition

📌 wait()와 notify()는 몇 가지 문제점을 가지고 있습니다. 그 중 하나가 **"대기 중인 쓰레드를 구분할 수 없다"**는 점입니다. 이를 해결한 것이 Condition 인터페이스입니다.

더보기

[추가 설명]

wait()과 notify()는 객체에 대한 모니터링 락(lock)을 이용하여 스레드를 대기시키고 깨웁니다. 그러나 wait()과 notify()는 waiting pool 내에 대기 중인 스레드를 구분하지 못하므로, 특정 조건을 만족하는 스레드만 깨우기가 어렵습니다.

이러한 문제를 해결하기 위해 JDK 5에서는 java.util.concurrent.locks 패키지에서 Condition 인터페이스를 제공합니다. Condition은 waiting pool 내의 스레드를 분리하여 특정 조건이 만족될 때만 깨우도록 할 수 있으며, ReentrantLock 클래스와 함께 사용됩니다. 따라서 Condition을 사용하면 wait()과 notify()의 문제점을 보완할 수 있습니다.

  • 좀 더 자세히 설명드리겠습니다.wait()와 notify()는 동기화된 객체의 모니터에서 사용되며, 주로 다음과 같은 동작을 합니다:
    • wait(): 쓰레드를 대기 상태로 전환하고, 다른 쓰레드가 notify()나 notifyAll()을 호출할 때까지 대기합니다.
    • notify(): 대기 중인 쓰레드 중 하나를 깨워서 실행하도록 합니다.
    • notifyAll(): 대기 중인 모든 쓰레드를 깨웁니다.
    하지만 이러한 방식에는 대기 중인 쓰레드를 구분할 수 없다는 문제가 있습니다. 예를 들어, 여러 쓰레드가 wait() 상태에 있을 때, notify()가 호출되면, 무작위로 하나의 쓰레드만 깨우게 되어 그 중에 원하는 쓰레드가 아닌 다른 쓰레드가 깨어날 수 있습니다.

2. Condition 인터페이스

  • **Condition**은 **Lock**과 함께 사용되며, Object 클래스의 메서드인 wait(), notify(), notifyAll() 대신에 사용할 수 있습니다.

3. Condition의 장점

  • 다양한 조건을 처리할 수 있다: 여러 조건을 갖는 대기 상태를 만들고, 조건에 맞는 쓰레드만 깨울 수 있습니다.
  • 대기 중인 쓰레드를 구분할 수 있다: Condition을 사용하면 notify()와 notifyAll() 대신에 조건을 명시적으로 확인하고, 원하는 쓰레드만 깨울 수 있습니다.

4. Condition 사용 예시

  • Condition을 사용하여 wait()와 notify()의 문제를 해결하는 방법은 다음과 같습니다:
  • Condition은 java.util.concurrent.locks 패키지에 있는 고급 동기화 기능입니다. Condition을 사용하면 wait()와 notify()에서 발생하는 문제를 해결할 수 있습니다. 특히, 대기 중인 쓰레드를 구분하고 특정 조건을 만족하는 쓰레드만 깨어나게 할 수 있는 기능을 제공합니다.
  • 이런 상황은 특정 조건에 맞는 쓰레드만 깨어나야 하는 경우에 문제가 됩니다.
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ConditionExample {
    private static int count = 0;
    private static Lock lock = new ReentrantLock();
    private static Condition condition = lock.newCondition();

    public static void main(String[] args) throws InterruptedException {
        // 쓰레드 1
        Thread thread1 = new Thread(() -> {
            lock.lock();
            try {
                while (count <= 0) {
                    condition.await();  // count가 0보다 클 때까지 대기
                }
                System.out.println("Thread 1: count is " + count);
                count--;
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        });

        // 쓰레드 2
        Thread thread2 = new Thread(() -> {
            lock.lock();
            try {
                System.out.println("Thread 2: incrementing count");
                count++;
                condition.signal();  // 대기 중인 쓰레드를 하나 깨운다.
            } finally {
                lock.unlock();
            }
        });

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();
    }
}

 

5. Condition 메서드

Condition은 다음과 같은 주요 메서드를 제공합니다:

  • await(): 쓰레드를 대기 상태로 전환합니다. wait()와 유사하지만 Condition에서는 대기 상태에 진입하기 전에 명시적으로 Lock을 획득해야 합니다.
  • signal(): 대기 중인 쓰레드 중 하나를 깨웁니다. notify()와 유사합니다.
  • signalAll(): 대기 중인 모든 쓰레드를 깨웁니다. notifyAll()과 유사합니다.

6. 결론

  • wait()와 notify()는 쓰레드 간 통신에 유용하지만, 대기 중인 쓰레드를 구분할 수 없다는 한계가 있습니다.
  • 이를 해결한 것이 **Condition**입니다. Condition을 사용하면 대기 중인 쓰레드를 구분하고 조건에 맞는 쓰레드만 깨어나게 할 수 있습니다.
  • **Condition**은 Lock 객체와 함께 사용되며, 복잡한 동기화 문제를 해결하는 데 강력한 도구가 됩니다.

'Back-End (Web) > JAVA' 카테고리의 다른 글

[JAVA] 응용 정리  (1) 2024.11.15
[JAVA] NULL  (0) 2024.11.13
[JAVA] Generic  (3) 2024.11.12
[JAVA] 오류 및 예외에 대한 이해  (1) 2024.11.12
[JAVA] 인터페이스  (2) 2024.11.12

+ Recent posts