- 클로드 코드 ; 에디터 없이 터미널 자체에서 작동하는 CLI 기반 도구
- 파일 읽기쓰기, 명령 실행, 깃 커밋 생성을 내장도구로 직접 수행하며 특정 에디터에 종속되지 않음
- 에이전틱 코딩
- 탐색>계획>구현>커밋
- 클로드 코드는
- 컨텍스트 수집 : 파일 탐색, 코드 검색, 메모리 로딩
- 계획 수립 및 작업 수행 : 실행 계획 생성, 도구 실행
- 결과 확인 : 테스트 실행, 린트 검사, 빌드 검사
- 탐색과 분석
- 특정 기능과 관련된 코드를 찾을 때 세 단계로 접근
- 관련 파일을 찾는다
- 파일 간 상호작용을 파악한다
- 실행 흐름을 추적한다
- 탐색 범위가 넓을 때는 단계적으로 좁혀가는 접근이 컨텍스를 절약한다.
- 특정 기능과 관련된 코드를 찾을 때 세 단계로 접근
- 점진적 개발 전략
- 작업 범위의 명확한 정의
- 복잡한 기능을 구현할 때는 전체 작업을 독립적으로 테스트 가능한 단위로 분해한다.
- 피드백 루프 활용
- 클로드 코드는 테스트 결과, 린터 출력, 빌드 로 그처럼 구조화된 피드백을 받을 때 오류를 정확히 교정한다.
- 컨텍스트 관리
- 개발 세션이 길어지면 컨텍스트 윈도우가 소진되면서 클로드 코드의 응답 품질이 저하된다.
- /compact명령으로 대화를 요약해 컨텍스트를 정리하거나
- /clear로 완전히 재설정하고 새작업에 집중한다.
- 작업 범위의 명확한 정의
- 팀 표준 설정 구축
- CLAUDE.md => 세션 시작 시 클로드 코드의 컨텍스트에 로드되는 메모리 파일. 프로젝트의 빌드 명령, 코드 스타일 가이드라인, 테스트 절차, 아키텍쳐 결정 사항
- CLAUDE.md (프로젝트 루트) : 팀 전체가 공유하는 지침
- CLAUDE.local.md (프로젝트 루트) : 개인용 지침. 클로드 코드가 깃 무시 대상으로 자동 설정한다. 팀 표준을 따르되 개인별 조정이 필요할 떄 사용한다
- ~/.claude/CLAUDE.md : 사용자 전역 지침. 모든 프로젝트에 적용
- CLAUDE.md => 세션 시작 시 클로드 코드의 컨텍스트에 로드되는 메모리 파일. 프로젝트의 빌드 명령, 코드 스타일 가이드라인, 테스트 절차, 아키텍쳐 결정 사항
전체 글
- Chatper 01 클로드 코드 시작하기 2026.06.17
- 외부 연동 장애 대처 2026.04.13
- 테스트코드 잘 작성하기.. 2026.02.04
- 사내 efk 서버 구축 - 2 2026.01.11
- 사내 efk 서버 구축 - 1 2026.01.11
- 임상 메타데이터 검색엔진 서비스 구축 2026.01.11
- kafka 기반 로그 스트리밍 시스템 구축하기 2026.01.04
- 대용량 출력 최적화 과정 2026.01.02
- 웹소켓 2024.07.24 1
- 다중 로그인 제어 적용하기 2024.06.29
- spring event로 이벤트 아키텍처 적용하기 2024.02.02
- 클린 아키텍처 적용하기 2024.01.16
Chatper 01 클로드 코드 시작하기
외부 연동 장애 대처
연동하는 서비스에 장애가 발생하면 우리 서비스도 영향을 받게된다
가장 중요할 설정
1. 타임아웃
연동 서비스에 대한 타임아웃을 설정하지 않으면 해당 서비스의 응답이 느릴때 처리량이 급격히 떨어짐 -> 해당 서비스의 문제가 다른 기능도 사용하지 못하게 만들 수 있음
- API 연동 통신과정 : 연결 시도 -> 요청 전송 -> 응답 전송 -> 연결 종료
- 연결 타임아웃 (3~5초), 응답을 받기까지 읽기 타임아웃 (5초~30초)
2. 재시도
네트워크 통신 과정에서 간헐적으로 연결에 실패하거나 일시적으로 응답이 느려지는 경우 존재
재시도를 통해 연동 실패를 성공으로 바꿀 수 있다.
- 단순 조회, 연결 타임아웃, 멱등성을 가진 변경 기능
3. 서킷 브레이커
연동 서비스가 장애가 발생했을 때, 해당 서비스에 요청을 보내지 않고 바로 에러를 응답하는 것이 낫다.
닫힘, 열림, 반열림
현재 서비스에서 개선 필요한 부분
- 결제 로직 PG 연결 -> 타임아웃 로직 작성 (현재까지 장애가 발생한 적은 없지만 대비 필요)
- 알림톡 연동 서비스 -> 외부 메세징 서비스 호출할때 타임아웃 + 서킷브레이커 로직 필요할듯
- Redis -> 서킷브레이커 로직 필수
'업무 기록' 카테고리의 다른 글
| 테스트코드 잘 작성하기.. (0) | 2026.02.04 |
|---|---|
| 사내 efk 서버 구축 - 1 (0) | 2026.01.11 |
| 임상 메타데이터 검색엔진 서비스 구축 (0) | 2026.01.11 |
| kafka 기반 로그 스트리밍 시스템 구축하기 (0) | 2026.01.04 |
| 대용량 출력 최적화 과정 (0) | 2026.01.02 |
테스트코드 잘 작성하기..
기본 규칙
- 비즈니스 규칙 -> 사람이 말로 설명할 수 있는 규칙
- 상태변화 -> 엔티티 상태가 바뀌는 모든 순간
- 정상적으로 변경 가능한지
- 불가능한 상태 전이는 막히는지?
- 경계값
- 예외
- 0,1,MAX
- null, empty, blank
- 오늘, 만료일, 하루 전
- 예외 & 실패 케이스
- 성공보다 실패를 더 많이 테스트하기
- 예외 타입
- 메시지
- 상태가 변경되지 않았는지?
- 성공보다 실패를 더 많이 테스트하기
레이어별
- 도메인
- 어플리케이션 / 서비스 -> mock 활용
- 컨트롤러
- 비즈니스 로직보다는 입출력 테스트 (http / req ,res 매핑 / validation)
테스트 이름
[상황]_[행위]_[결과]
도메인 중심 설계 -> 테스트의 대부분은 도메인에서 테스트 될 수 있게...
'업무 기록' 카테고리의 다른 글
| 외부 연동 장애 대처 (0) | 2026.04.13 |
|---|---|
| 사내 efk 서버 구축 - 1 (0) | 2026.01.11 |
| 임상 메타데이터 검색엔진 서비스 구축 (0) | 2026.01.11 |
| kafka 기반 로그 스트리밍 시스템 구축하기 (0) | 2026.01.04 |
| 대용량 출력 최적화 과정 (0) | 2026.01.02 |
사내 efk 서버 구축 - 2
docker 명령어
목적 명령어
| 로그확인 | docker inspect 97525f3ea763 |
| 컨테이너 정보 확인 | docker inspect [컨테이너 이름 또는 ID] |
Filebeat..
filebeat를 docker container로 올리기
- spring container에서 stdout하는걸
- filebeat.inputs: - type: container paths: - '/var/lib/docker/containers/*/*.log' processors: - drop_event: when: not: contains: docker.container.name: "determined_diffie" output.elasticsearch: hosts: ["inspiring_burnell:9200"]
dockercompose 파일 작성
- es
- docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:7.10.2
- kibana
- docker run --link 1a1775260754:elasticsearch -p 5601:5601 docker.elastic.co/kibana/kibana:7.10.1
- spring
- docker run -v /mnt/d/home/ubuntu/prop:/home/ubuntu/prop/ -p 9000:9000 spring
version: '3.7'
services:
es:
image: docker.elastic.co/elasticsearch/elasticsearch:7.10.2
container_name: es
ports:
- "9200:9200"
- "9300:9300"
environment:
- discovery.type=single-node
spring:
image: spring
ports:
- "9000:9000"
volumes:
- /mnt/d/home/ubuntu/prop:/home/ubuntu/prop/
- /mnt/d/log:/logs
kibana:
image: docker.elastic.co/kibana/kibana:7.10.2
container_name: kibana
ports:
- "5601:5601"
links:
- es:elasticsearch
filebeat:
build: ./filebeat
# image: docker.elastic.co/beats/filebeat:7.10.1
container_name: filebeat
volumes:
- ./filebeat.yml:/usr/share/filebeat/filebeat.yml:ro
# - /var/lib/docker/containers:/usr/share/dockerlogs/data:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock
- /mnt/d/log:/app/logs
# 다른 필요한 볼륨도 추가 가능
command: ["filebeat", "-e", "-strict.perms=false"]
docker에서 log 어떻게 찍히나
- 컨테이너 로그를 호스트의 파일 시스템에 json형식으로 저장
- docker log 위치 확인하기
- docker inspect 97525f3ea763 | grep LogPath
- 기본적으로 도커 로그는 /var/lib/docker/containers/{container-id}/{container-id}.log 여기에 쌓임..
https://devbksheen.tistory.com/entry/ELK-Filebeat로-실시간-로그-수집하기with-Spring-Boot
윈도우 로컬환경해서 해보려고 했는데
- Docker Desktop을 사용하는 Windows 환경에서는 /var/lib/docker/containers 경로가 호스트의 실제 경로가 아닙니다. 따라서 이 경로를 직접적으로 bind mount 하려고 해도 원하는 결과를 얻기 어렵습니다. 이는 WSL2와 Docker의 백엔드 구조 때문에 발생하는 문제입니다. 라고한다
- 최종 설정..
filebeat.inputs:
- type: log
paths:
# - '/var/lib/docker/containers/*/*.log'
# - '/mnt/logs/*.log'
- /app/logs/*.log
processors:
- add_docker_metadata: ~
- dissect:
tokenizer: "%{timestamp} | %{time} | %{thread} | %{log.level} | %{message}"
field: "message"
setup.ilm.rollover_alias: "bsadmin"
setup.ilm.pattern: "{now/d}-000001"
setup.ilm.max_age: "7d"
setup.ilm.max_size: "10GB"
# filebeat.inputs:
# - type: container
# paths:
# - "/usr/share/dockerlogs/data/*/*.log"
# stream: "stdout"
# - type: container
# stream: stdout
# paths:
# - '/var/lib/docker/containers/*/*.log'
# processors:
# - drop_event:
# when:
# not:
# contains:
# docker.container.name: "determined_diffie"
output.elasticsearch:
hosts: ["es:9200"]
indices:
index: "bsadmin-%{[agent.version]}-%{+yyyy.MM.dd}"
# - index: "bsadmin-error-%{[agent.version]}-%{+yyyy.MM.dd}"
# when.contains:
# log.level: "ERROR"
# - index: "bsadmin-info-%{[agent.version]}-%{+yyyy.MM.dd}"
# when.contains:
# log.level: "INFO"
# - index: "bsadmin-warn-%{[agent.version]}-%{+yyyy.MM.dd}"
# when.contains:
# log.level: "WARN"
노드 설계
- 단일노드로 구성 (추후 데이터 양이 늘어나거나 고가용성이 필요할 경우, 클러스터 확장)
- 메모리
- ES JVM 힙사이즈는 RAM의 50%로
- 16GB RAM -> 8GB를 넘지 않도록
- ES JVM 힙사이즈는 RAM의 50%로
- 디스크
- 모니터링 계속..
- EBS볼륨타입은 gp3 혹은io2
- 스냅샷 백업
- 보안
- 인증 및 인가를 구성한다
- Basic Authentication: Elasticsearch에는 사용자 이름과 비밀번호를 사용한 기본 인증 기능이 내장되어 있습니다. 이 기능을 활성화하려면 Elasticsearch의 x-pack 기능을 사용합니다.
- Role-Based Access Control (RBAC): 사용자 및 권한을 정의하여 특정 작업에 대한 액세스를 제한할 수 있습니다. 예를 들어, 특정 인덱스에 대한 읽기/쓰기 권한을 특정 사용자에게만 부여할 수 있습니다.
- 네트워크 통신에 TLS/SSL을 사용한다
- 노드 간 통신: Elasticsearch 클러스터 내의 노드 간 통신을 위해 TLS를 설정하여 데이터를 안전하게 전송합니다.
- HTTP 통신: 클라이언트 및 Elasticsearch 간의 통신에 TLS를 사용하여 API 호출을 보호합니다.
- 네트워크 보안:
- 방화벽: Elasticsearch를 실행하는 서버에 방화벽을 설정하여 불필요한 포트에 대한 액세스를 차단합니다. 특히 9200 및 9300 포트를 보호해야 합니다.
- IP Filtering: Elasticsearch 설정에서 허용되는 IP 주소나 IP 범위를 지정하여 액세스를 제한할 수 있습니다.
- Private Networking: 가능한 경우, Elasticsearch를 프라이빗 네트워크 내에서 실행하고, 클러스터 액세스를 위한 VPN 또는 VPC 피어링을 사용합니다.
- API Key & Tokens:
- API 키 및 토큰을 사용하여 상태를 유지하는 인증을 설정할 수 있습니다.
- 감사 로깅:
- Elasticsearch에 내장된 감사 로깅 기능을 활성화하여 중요한 이벤트나 보안 관련 작업을 로깅합니다.
- 기타:
- 자동화된 스냅샷: 데이터 복구를 위해 정기적으로 Elasticsearch 스냅샷을 생성합니다.
- 마스킹 및 암호화: 민감한 데이터를 Elasticsearch에 저장하기 전에 데이터 마스킹 또는 암호화를 고려합니다.
- Kibana 보안: Kibana에도 인증 및 인가를 설정합니다. 또한, Kibana 액세스를 HTTPS로 제한하도록 설정합니다.
- 인증 및 인가를 구성한다
m6g.xlarge + 100GB 78.52 USD 10만4천원
- 200GB 86.52 USD 11만4천원
01. 인증 설정
- 인증 아이디, 패스워드 설정
02. 디폴트 계정 및 패스워드 변경
03. IP 접근 제한
04. 설치 디렉터리 접근 권한
guide
- 기본 기능
- 사용자 역할 기반 접근 제어
- 노드 간 통신 암호화
- HTTP 통신 암호화
- 인증서 생성
- elasticsearc-certutil
- CA(Certificate Autority) 인증서 필요 -> es에서 발급
- 노드간 통신 암호화(TLS)
- 인증서를 사용해 전송 계층 보안 설정
- 클러스터 내 노드 간에 전송되는 데이터를 인증서 기반으로 암호화함으로써 통신 간 데이터 유출이나 클러스터에 임의의 노드가 가입되는 상황을 방지할 수 있다
- config y ml에 node별 path 설정.......
- 키스토어 생성...
- HTTP 클라이언트 통신 암호화
- https 활성화
- 클러스터 시작과 빌트인 사용자 설정
- 키바나와 엘라스틱서치간 통신 암호화
- kibana yml 수정 (es 설정 등록)
version: '3.7'
services:
es:
image: docker.elastic.co/elasticsearch/elasticsearch:7.10.2
container_name: es
ports:
- "9200:9200"
- "9300:9300"
environment:
- discovery.type=single-node
volumes:
- ./elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
spring:
image: spring
ports:
- "9000:9000"
volumes:
- /mnt/d/home/ubuntu/prop:/home/ubuntu/prop/
- /mnt/d/log:/logs
kibana:
image: docker.elastic.co/kibana/kibana:7.10.2
container_name: kibana
ports:
- "5601:5601"
links:
- es:elasticsearch
filebeat:
build: ./filebeat
# image: docker.elastic.co/beats/filebeat:7.10.1
container_name: filebeat
volumes:
- ./filebeat.yml:/usr/share/filebeat/filebeat.yml:ro
# - /var/lib/docker/containers:/usr/share/dockerlogs/data:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock
- /mnt/d/log:/app/logs
# 다른 필요한 볼륨도 추가 가능
command: ["filebeat", "-e", "-strict.perms=false"]
fluent-bit:
image: fluent/fluent-bit:latest
container_name: fluent-bit
ports:
- "24224:24224"
- "24224:24224/udp"
volumes:
- ./fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf
- /mnt/d/log:/var/log
- ./parsers.conf:/fluent-bit/etc/parsers.conf
fluent-bit.conf
[SERVICE]
Flush 1
Daemon Off
Log_Level debug
Parsers_File parsers.conf
[INPUT]
Name tail
Path /var/log/*.log
Parser custom_parser
Tag something
[OUTPUT]
Name es
Match *
Host es
Port 9200
Logstash_Format Off
Index fluentbit-%Y.%m.%d
parsers.conf
[PARSER]
Name custom_parser
Format regex
Regex ^(?<date>\\d{4}-\\d{2}-\\d{2}) \\| (?<time>\\d{2}:\\d{2}:\\d{2}.\\d{3}) \\| (?<thread>[^\\|]+) \\| (?<log_level>[^\\|]+) \\| (?<unique_key>[^\\|]+) \\| (?<request_response>[^\\|]+) \\| (?<api_status>[^\\|]+) \\| (?<log_message>.+)$
Time_Key date
Time_Format %Y-%m-%d %H:%M:%S.%L
filebeat.yml
filebeat.inputs:
- type: log
paths:
# - '/var/lib/docker/containers/*/*.log'
# - '/mnt/logs/*.log'
- /app/logs/*.log
processors:
- add_docker_metadata: ~
- dissect:
tokenizer: "%{timestamp} | %{time} | %{thread} | %{log.level} | %{message}"
field: "message"
setup.ilm.enabled: true
setup.ilm.rollover_alias: "bsadmin"
setup.ilm.pattern: "{now/d}-000001"
setup.ilm.max_age: "7d"
setup.ilm.max_size: "5GB"
setup.template.name: "bsadmin"
setup.template.pattern: "bsadmin-*"
# filebeat.inputs:
# - type: container
# paths:
# - "/usr/share/dockerlogs/data/*/*.log"
# stream: "stdout"
# - type: container
# stream: stdout
# paths:
# - '/var/lib/docker/containers/*/*.log'
# processors:
# - drop_event:
# when:
# not:
# contains:
# docker.container.name: "determined_diffie"
output.elasticsearch:
hosts: ["es:9200"]
index: "bsadmin-%{+yyyy.MM.dd}"
# indices:
# index: "bsadmin-%{[agent.version]}-%{+yyyy.MM.dd}"
# - index: "bsadmin-error-%{[agent.version]}-%{+yyyy.MM.dd}"
# when.contains:
# log.level: "ERROR"
# - index: "bsadmin-info-%{[agent.version]}-%{+yyyy.MM.dd}"
# when.contains:
# log.level: "INFO"
# - index: "bsadmin-warn-%{[agent.version]}-%{+yyyy.MM.dd}"
# when.contains:
# log.level: "WARN"
kibana.yml
#
# ** THIS IS AN AUTO-GENERATED FILE **
#
# Default Kibana configuration for docker target
server.name: kibana
server.host: "0"
elasticsearch.hosts: [ "<http://localhost:9200>" ]
monitoring.ui.container.elasticsearch.enabled: true
xpack.security.enabled: true
elasticsearch.ssl.verificationMode: full
elasticsearch.ssl.keystore.path: "/path/to/elastic-certificates.p12"
elasticserach.yml
# ======================== Elasticsearch Configuration =========================
#
# 클러스터 이름 설정
cluster.name: my-cluster
# 노드 이름 설정
node.name: node-1
# 데이터와 로그 파일을 저장할 경로
path.data: /path/to/data
path.logs: /path/to/logs
# 네트워크 설정
network.host: 192.168.1.100 # 서버의 IP 주소 또는 호스트명
http.port: 9200
# 디스커버리 설정
discovery.seed_hosts: ["host1", "host2"] # 클러스터의 다른 노드 주소
cluster.initial_master_nodes: ["node-1", "node-2"] # 초기 마스터 노드 목록
# X-Pack 보안 기능 (7.x 이상에서는 기본적으로 활성화)
xpack.security.enabled: true
xpack.security.transport.ssl.enabled: true
xpack.security.transport.ssl.verification_mode: certificate
xpack.security.transport.ssl.keystore.path: elastic-certificates.p12
xpack.security.transport.ssl.truststore.path: elastic-certificates.p12
xpack.security.http.ssl.enabled: true
xpack.security.http.ssl.keystore.path: elastic-certificates.p12
xpack.security.http.ssl.truststore.path: elastic-certificates.p12
# 메모리 설정 (JVM 옵션)
#
# Elasticsearch 실행에 필요한 JVM 설정은 jvm.options 파일에서 관리합니다.
# 이 파일에서 -Xms (초기 힙 사이즈) 및 -Xmx (최대 힙 사이즈) 설정을 조정할 수 있습니다.
사내 efk 서버 구축 - 1
로그 수집도구 도입
- 도입 사유
- 로그 관리 중앙 집중화
- 모니터링 시각화 및 이상 감지 및 문제 해결
- log를 통해 복잡한 데이터처리를 하지 않는 이상 filebeat -> es 가좋지않을까

ELK
https://backtony.github.io/elk/2021-09-08-elk-1/
https://blog.dramancompany.com/2015/12/안정적인-서비스-운영을-위한-서버-모니터링-2/
- ELK 사용 용도
- 검색 엔진 (ES)
- 로깅 시스템 (Logstash)
- 시각화 (kibana)
- ElasticSearch
- 도입효과
- 도입 배경
- 현재 중첩적으로 이루어진 복잡한 관계의 데이터들로 구성됨
- 데이터를 가져오려면 전체에 가까운 테이블들을 join, 그룹핑해야하는 작업 발생
- 현 데이터 구조로 RDB는 부적합하다고 판단
- 대상자 상세 데이터의 경우 정의된 데이터베이스 형식으로 적재하기 모호한 부분이 있음
- 검색쿼리(OR, LIKE %%)에서 인덱스를 안탐(full scan) -> 데이터가 많이 적재될수록 성능 감소
- 복잡한 쿼리로 인한 개발 생산성 하락
- nosql이나 검색엔진 도입 필요 판단
- 효과
- 생산성 증대
- 리스트 출력/검색만을 위한 데이터가 중첩적으로 적재되므로 join, grouping 작업의 필요가 사라짐
- 검색 성능 향상
- '검색엔진' 으로서의 역할로 추후 대량의 데이터가 적재되더라도 성능 보장
- 복잡한 요구사항도 수용 가능
- es 특징
- 중첩객체 지원
- 풀텍스트 검색
- vs mongodb?
- es의 강점 -> 풀텍스트 검색, 집계, 정렬, 필터링 등 검색으로 특화되어있음
- 검색에 관련된 요구사항들이 점진적으로 확대될것으로 예상
- => es 채택
- 요금
- 오픈 소스
- EC2 서버에 설치 (별도 서버 구축 필요)
- es 최저 사양
- CPU: 2개 이상의 가상 CPU 또는 물리 CPU (추천: 4개 이상)
- RAM: 4GB 이상 (추천: 8GB 이상)
- 디스크: 50GB 이상의 가용한 디스크 공간 (Elasticsearch 데이터와 인덱스를 저장하기 위해 필요)
- https://zigzec.tistory.com/11
- ES, Kibana
- 노드
- 검색엔진용 : 데이터 색인/검색
- 대용량 데이터가 아니다보니 단일 노드로 하는게 좋을것같다...
- AWS EC2 - CentOS 7.6 (t2.medium ) storage : 40GB t4g.medium t2.medium
- es 최저 사양
- 도입효과
- Logstash(FileBeats)
- 로그 관리 중앙 집중화
- 모니터링 시각화 및 이상 감지 및 문제 해결
요금
- 오픈 소스 (Elastic Stack Basic)
- EC2 서버에 설치 (별도 서버 구축 필요할듯)
- es, kibana 최저 사양
- CPU: 2개 이상의 가상 CPU 또는 물리 CPU (추천: 4개 이상)
- RAM: 4GB 이상 (추천: 8GB 이상)
- 디스크: 50GB 이상의 가용한 디스크 공간 (Elasticsearch 데이터와 인덱스를 저장하기 위해 필요)
- https://zigzec.tistory.com/11
- AWS EC2 - CentOS 7.6 (t2.medium ) storage : 40GB t4g.medium
- es, kibana 최저 사양
- ES, Kibana
- 노드
- 검색엔진용 : 데이터 색인/검색
- 로그수집용 : FluentBit 껴서?
- 대용량 데이터가 아니다보니 단일 노드로 하는게 좋을것같다...

EFK(ELK) 노드 설계 개요
전체 설계 방향 요약
- 대용량 로그 시스템이 아님
- 목적은:
- 로그 중앙 집중 관리
- 검색 & 시각화
- 장애 분석 및 문제 추적
- 따라서 고가용성/분산 클러스터보다는 단순·안정성 우선
- 단일 노드 기반 EFK 구조 채택
노드 구성 설계
Elasticsearch Node (Single Node)
역할
- 로그 데이터 색인 및 검색
- 집계, 필터링, 풀텍스트 검색 수행
설계 이유
- 로그 데이터 규모가 크지 않음
- 샤딩/레플리카가 필요 없는 단계
- 운영 복잡도 최소화가 목적
설정 개념
- node.roles: [ master, data ]
- 단일 노드 → split brain 이슈 없음
- 장애 발생 시 즉각 원인 파악 가능
🔸 Kibana Node
역할
- Elasticsearch 데이터 시각화
- 로그 검색, 대시보드 구성
- 이상 징후 모니터링
설계 이유
- ES와 1:1 연동
- 별도 확장 필요 없음
- 운영자가 바로 접근 가능한 UI 제공
🔸 Logstash vs Filebeat 판단
| 리소스 | 무거움 | 매우 경량 |
| 역할 | 복잡한 변환/가공 | 로그 전달 전용 |
| 설정 | 복잡 | 단순 |
| 사용 판단 | ❌ | ✅ 채택 |
결론
로그를 통해 복잡한 데이터 가공을 하지 않는 이상
Filebeat → Elasticsearch 직접 전송 구조가 가장 합리적
🔸 Elasticsearch + Kibana 서버
| Instance | t2.medium / t4g.medium |
| CPU | 2 Core |
| RAM | 4GB (최소) / 8GB 권장 |
| Storage | 40~50GB |
| OS | CentOS 7.6 |
판단 근거
- Elastic Stack Basic (오픈소스)
- 단일 노드 운영 가능
- 초기 비용 최소화 + 추후 확장 가능
'업무 기록' 카테고리의 다른 글
| 외부 연동 장애 대처 (0) | 2026.04.13 |
|---|---|
| 테스트코드 잘 작성하기.. (0) | 2026.02.04 |
| 임상 메타데이터 검색엔진 서비스 구축 (0) | 2026.01.11 |
| kafka 기반 로그 스트리밍 시스템 구축하기 (0) | 2026.01.04 |
| 대용량 출력 최적화 과정 (0) | 2026.01.02 |
임상 메타데이터 검색엔진 서비스 구축
사내 임상 연구팀을 위한 임상 메타데이터 관리 시스템을 구축하는 과정에서,
검색 필터에 대한 요구사항은 점점 증가하게 될거라는 기획팀의 첨언을 받았고..
임상 데이터의 특성상 다음과 같은 제약이 존재했다.
- 질환군, 대상자, 샘플 등 도메인 구조가 매우 복잡
- 데이터 형식이 표준화되어 있지 않고 비정형 요소가 많음
- 연구원이 직접 검색 조건을 조합해야 하는 요구사항
- 데이터는 Write/Update가 거의 없고 Read 비중이 매우 높음
초기 시스템은 당연히 일반적인 RDB 기반 모델로 설계되어있었고.. 기본 메타 테이블은 다음과 같았다
STUDY (연구)
| study_id | BIGINT | 연구(임상) ID |
| study_code | VARCHAR(50) | 화면/외부 식별자 |
| study_name | NVARCHAR(200) | 연구명 |
| status | VARCHAR(20) | ACTIVE / CLOSED 등 |
SITE (기관)
| site_id | BIGINT | 기관/사이트 ID |
| study_id | BIGINT | 소속 연구 |
| site_code | VARCHAR(50) | 외부 식별자 |
| site_name | NVARCHAR(200) | 기관명 |
| region | NVARCHAR(100) | 지역 |
SUBJECT (대상자)
| subject_id | BIGINT | PK | 내부 PK |
| study_id | BIGINT | FK(STUDY) | 연구 |
| site_id | BIGINT | FK(SITE) | 기관 |
| subject_code | VARCHAR(50) | UNIQUE | 예: SUBJ-000124 |
| status | VARCHAR(20) | NOT NULL | ACTIVE / DROPPED 등 |
| sex | VARCHAR(10) | NULL | M/F/UNKNOWN |
| birth_year | INT | NULL | 출생년도 |
SAMPLE (샘플)
| sample_id | BIGINT | PK | 내부 PK |
| study_id | BIGINT | FK(STUDY) | 연구 |
| site_id | BIGINT | FK(SITE) | 기관 |
| subject_id | BIGINT | FK(SUBJECT) | 대상자 |
| sample_code | VARCHAR(50) | UNIQUE | 예: SMP-99101 |
| sample_type | VARCHAR(30) | NOT NULL | blood/tissue/urine 등 |
| collected_at | DATETIME2 | NULL | 채취일 |
고정 속성의 테이블은 이런식으로 되어있었고, 문제는 가변속성의 EAV 구조의 테이블이였는데
CLINICAL_ATTR (통합 속성 / EAV 단일 테이블)
| attr_row_id | BIGINT | PK | 속성 row PK |
| study_id | BIGINT | FK(STUDY) | 연구 범위 |
| entity_type | VARCHAR(20) | NOT NULL | SUBJECT / SAMPLE / COHORT 등 |
| entity_id | BIGINT | NOT NULL | 대상 엔티티 PK |
| attr_key | VARCHAR(100) | NOT NULL | 속성 키 (예: hba1c, smoker) |
| value_type | VARCHAR(20) | NOT NULL | STRING/NUMBER/DATE/BOOL |
| value_string | NVARCHAR(1000) | NULL | 문자열 값 |
| value_number | FLOAT | NULL | 숫자 값 |
| value_date | DATETIME2 | NULL | 날짜 값 |
| value_bool | BIT | NULL | 불리언 값 |
| unit | VARCHAR(30) | NULL | 단위 (mg/dL 등) |
| source | VARCHAR(30) | NULL | 입력원(장비/수기 등) |
| updated_at | DATETIME2 | NOT NULL | 수정일 |
데이터를 입력할때는 제법 유연한 구조였지만 필터링이 들어가는 경우 (요구사항이 추가됐을 때..) 무한 join을 맺어야 하는.. 복합적인 조인 지옥을 맞이했다
예를 들면 '대상자'에
- 흡연자가 아님 (smoker = false)
- HbA1c >= 6.5
- 약물 metormin 복용
이라는 요구사항이 붙었을 때
SELECT s.subject_id, s.subject_code, s.sex, s.birth_year
FROM SUBJECT s
JOIN CLINICAL_ATTR a_smoker
ON a_smoker.entity_type = 'SUBJECT'
AND a_smoker.entity_id = s.subject_id
AND a_smoker.attr_key = 'smoker'
AND a_smoker.value_bool = 0
JOIN CLINICAL_ATTR a_hba1c
ON a_hba1c.entity_type = 'SUBJECT'
AND a_hba1c.entity_id = s.subject_id
AND a_hba1c.attr_key = 'hba1c'
AND a_hba1c.value_number >= 6.5
JOIN CLINICAL_ATTR a_med
ON a_med.entity_type = 'SUBJECT'
AND a_med.entity_id = s.subject_id
AND a_med.attr_key = 'medication'
AND a_med.value_string = 'metformin'
WHERE s.status = 'ACTIVE';
의 쿼리가 작성되며, 필터 1개가 추가할 떄마다 CLINICAL_ATTR < 테이블에 join 1개가 추가되는 기본 패턴이 되고만다..
여기에
- blood 샘플 존재
- blood 샘플의 tube = EDTA
- QC PASS
- vloume_ml >= 5
와 같은.. 필터링이 추가로 들어갔을 때는
SELECT DISTINCT s.subject_id, s.subject_code
FROM SUBJECT s
JOIN SAMPLE sp
ON sp.subject_id = s.subject_id
AND sp.sample_type = 'blood'
AND sp.qc_status = 'PASS'
JOIN CLINICAL_ATTR a_smoker
ON a_smoker.entity_type='SUBJECT' AND a_smoker.entity_id=s.subject_id
AND a_smoker.attr_key='smoker' AND a_smoker.value_bool=0
JOIN CLINICAL_ATTR a_hba1c
ON a_hba1c.entity_type='SUBJECT' AND a_hba1c.entity_id=s.subject_id
AND a_hba1c.attr_key='hba1c' AND a_hba1c.value_number>=6.5
JOIN CLINICAL_ATTR a_med
ON a_med.entity_type='SUBJECT' AND a_med.entity_id=s.subject_id
AND a_med.attr_key='medication' AND a_med.value_string='metformin'
-- 샘플에 대한 필터링 조건 또 추가
JOIN CLINICAL_ATTR a_tube
ON a_tube.entity_type='SAMPLE' AND a_tube.entity_id=sp.sample_id
AND a_tube.attr_key='tube' AND a_tube.value_string='EDTA'
JOIN CLINICAL_ATTR a_vol
ON a_vol.entity_type='SAMPLE' AND a_vol.entity_id=sp.sample_id
AND a_vol.attr_key='volume_ml' AND a_vol.value_number>=5;
필터가 subject에 3개, sample에 2개 붙으면 → attr 조인만 5개 붙어야했고, 여기서 만약 SAMLE 조건이 붙는다면 결과 중복때문에 GROUP BY 가 붙어 성능과 가독성이 악화되는 사태가 발생하게 되는데..
이 RDB의 문제점들을 정리해보자면
1) 검색 쿼리 작성 난이도 증가
- 질환군 ↔ 대상자 ↔ 샘플 간 N:M 관계
- 하나의 검색 조건을 만족시키기 위해 다수 테이블 조인 필수
- 연구원이 요구하는 조건이 늘어날수록 쿼리는 기하급수적으로 복잡해짐
2) 성능 및 확장성 문제
- 조건이 늘어날수록 조인 수 증가 → 성능 저하
- 검색 조건 변경 시마다 SQL 수정 필요
- 비정형 데이터 처리에 적합하지 않음
데이터는 많아지는데 검색은 점점 느려지고 어려워짐에 따라 구조 자체가 검색에 적합하지 않다는 판단에 도달하였다.
특히 데이터 특성상 update는 거의 없고 (임상 실험 결과가 변경되는 경우는 매우 드뭄) 비정형 구조에 도메인 다양성이 큰데다가 무엇보다 개발하는 플랫폼의 요구사항 자체가 '필터링' 에 집중되어있기 떄문에, 검색 조건이 지속적으로 확장될 것을 고려해 근본적인 데이터 구조를 수정해보자는 의견이 나오게 되었고..
비정형 데이터임을 고려하여 유연한 스키마에 JSON 구조의 MongoDB도 함꼐 고려해보였으나
검색/필터링 < 쿼리에 한계가 있을 것을 고려해 ElasticSEarch를 선택하게 되었다.
- 검색과 필터링에 특화된 엔진
- 다양한 조건 조합(Query DSL)에 강점
- 인덱스 기반 구조로 성능 예측 가능
- 향후 검색 요구사항 확장에 유리
ES 도입의 목적은.. '조인 없는 검색' 즉 데이터 자체를 flat한 구조로 변환함에 있었는데 인덱스 설계 구조는 다음과 같았다.
- 도메인 단위별 인덱스 설계
- clinical_subject : 대상자 (검색화면)
- clinical_sample : 샘플 (샘플 목록/필품질/보관조건 등)
- clinical_cohort: 질환군/코호트 (코포트/ 대상자 집합 등)
변환 원칙
원칙 1) “조회 단위(화면 단위)”로 문서를 만든다
- 대상자 검색 화면 → subject 문서 1장
- 샘플 검색 화면 → sample 문서 1장
- 코호트 관리 화면 → cohort 문서 1장
원칙 2) 속성은 “타입별 맵(Map)”으로 flat하게 만든다
EAV의 가장 큰 문제는 “키가 유동적”이라 매핑이 불안정해지는 것인데,
ES에서는 아래처럼 정리해서 해결한다.
- attrsKw : 문자열/카테고리/enum → keyword
- attrsNum : 숫자 → double
- attrsDate : 날짜 → date
- attrsBool : bool → boolean
즉, DB row 단위 속성들을 문서 내부의 typed map으로 접어 넣는다.
원칙 3) join 제거를 위해 필요한 스냅샷을 문서에 포함한다
- sample 문서에 subjectSnapshot(성별/나이/중요 임상수치)을 함께 넣어
“샘플 검색 → 대상자 조건 필터”가 조인 없이 가능하도록 한다.
Subject 문서 생성 로직 (clinical_subject_v1)
입력 데이터 소스
- SUBJECT 고정 필드
- CLINICAL_ATTR where entity_type='SUBJECT' and entity_id=subject_id
- SAMPLE 목록 (subject_id로)
- CLINICAL_ATTR where entity_type='SAMPLE' and entity_id in (sample_ids) (옵션: subject 문서에 샘플을 nested로 넣는 경우)
변환 과정 (단계별)
Step A) subject 고정 필드 매핑
- subjectId, studyId, siteId, sex, birthYear, enrolledAt, status …
Step B) subject 속성(EAV) → typed map 으로 변환
예)
- (SUBJECT, 124, "hba1c", NUMBER, 7.2)
→ attrsNum["hba1c"] = 7.2 - (SUBJECT, 124, "medication", STRING, "metformin")
→ attrsKw["medication"] = "metformin" - (SUBJECT, 124, "smoker", BOOL, false)
→ attrsBool["smoker"] = false - (SUBJECT, 124, "last_visit", DATE, 2025-11-28)
→ attrsDate["last_visit"] = "2025-11-28"
Step C) 샘플 요약값 생성(검색/정렬 최적화)
- sampleSummary.totalCount
- sampleSummary.types (distinct)
- sampleSummary.latestAt (MAX(collected_at))
Step D) (선택) subject 문서에 samples를 nested로 포함
- 대상자 페이지에서 “샘플 조건”으로 필터링 해야 하면 nested가 유용
- 샘플이 너무 많으면 subject 문서가 비대해지므로 sample 인덱스를 분리해서 해결하기도 함
(너가 이미 둘 다 운영하는 구조로 잡았으니 상황에 따라 선택)
Sample 문서 생성 로직 (clinical_sample_v1)
입력 데이터 소스
- SAMPLE 고정 필드
- CLINICAL_ATTR where entity_type='SAMPLE' and entity_id=sample_id
- SUBJECT 고정 필드(스냅샷)
- CLINICAL_ATTR where entity_type='SUBJECT' and entity_id=subject_id (스냅샷에 필요한 일부만)
변환 과정
Step A) sample 고정 필드 매핑
sampleId, sampleType, collectedAt, storage_temp_c, qc_status…
Step B) sample 속성 → typed map
- tube(STRING) → attrsKw.tube
- volume_ml(NUMBER) → attrsNum.volume_ml
- expired_at(DATE) → attrsDate.expired_at
Step C) subjectSnapshot 생성
샘플 검색 화면에서 자주 걸리는 대상자 조건만 “스냅샷”으로 포함
- sex, birthYear
- hba1c, bmi 같은 주요 수치
- medication 같은 주요 키워드
이렇게 하면:
- “QC PASS + blood 샘플 + 대상자 hba1c>=6.5” 같은 검색을 sample 인덱스 단독으로 처리 가능해짐.
PUT clinical_subject_v1
{
"mappings": {
"dynamic": false,
"properties": {
"subjectId": {"type":"keyword"},
"studyId": {"type":"keyword"},
"siteId": {"type":"keyword"},
"cohortIds": {"type":"keyword"},
"status": {"type":"keyword"},
"sex": {"type":"keyword"},
"birthYear": {"type":"integer"},
"enrolledAt": {"type":"date"},
"updatedAt": {"type":"date"},
"attrsKw": {"type":"object","dynamic":true},
"attrsNum": {"type":"object","dynamic":true},
"attrsDate":{"type":"object","dynamic":true},
"attrsBool":{"type":"object","dynamic":true},
"sampleSummary": {
"properties": {
"totalCount": {"type":"integer"},
"types": {"type":"keyword"},
"latestAt": {"type":"date"},
"qcPassCount": {"type":"integer"}
}
},
"sampleSignals": {
"properties": {
"hasBlood": {"type":"boolean"},
"hasEdtaTube": {"type":"boolean"},
"maxVolumeMl": {"type":"double"}
}
}
}
}
}
PUT clinical_sample_v1
{
"mappings": {
"dynamic": false,
"properties": {
"sampleId": {"type":"keyword"},
"studyId": {"type":"keyword"},
"siteId": {"type":"keyword"},
"subjectId": {"type":"keyword"},
"cohortIds": {"type":"keyword"},
"sampleType": {"type":"keyword"},
"collectedAt": {"type":"date"},
"qcStatus": {"type":"keyword"},
"qcScore": {"type":"double"},
"storageTempC": {"type":"double"},
"storageLocation": {"type":"keyword"},
"attrsKw": {"type":"object","dynamic":true},
"attrsNum": {"type":"object","dynamic":true},
"attrsDate":{"type":"object","dynamic":true},
"attrsBool":{"type":"object","dynamic":true},
"subjectSnapshot": {
"properties": {
"sex": {"type":"keyword"},
"birthYear": {"type":"integer"},
"status": {"type":"keyword"},
"attrsKw": {"type":"object","dynamic":true},
"attrsNum": {"type":"object","dynamic":true},
"attrsDate":{"type":"object","dynamic":true},
"attrsBool":{"type":"object","dynamic":true}
}
},
"updatedAt": {"type":"date"}
}
}
}
PUT clinical_cohort_v1
{
"mappings": {
"dynamic": false,
"properties": {
"cohortId": {"type":"keyword"},
"studyId": {"type":"keyword"},
"name": {"type":"keyword"},
"description": {"type":"text"},
"version": {"type":"integer"},
"status": {"type":"keyword"},
"criteria": {
"properties": {
"humanText": {"type":"text"},
"dsl": {"type":"object","dynamic":true}
}
},
"stats": {
"properties": {
"subjectCount": {"type":"integer"},
"sampleCount": {"type":"integer"},
"updatedAt": {"type":"date"}
}
},
"createdAt": {"type":"date"},
"updatedAt": {"type":"date"}
}
}
}
아까 RDB에서 self-join 5개였던 요구사항:
- smoker=false
- hba1c>=6.5
- medication=metformin
- blood sample
- tube=EDTA
- qc=PASS
- volume>=5
ES에서 처리할 때
GET clinical_sample_v1/_search
{
"query": {
"bool": {
"filter": [
{"term": {"sampleType": "blood"}},
{"term": {"qcStatus": "PASS"}},
{"term": {"attrsKw.tube": "EDTA"}},
{"range": {"attrsNum.volume_ml": {"gte": 5}}},
{"term": {"subjectSnapshot.attrsBool.smoker": false}},
{"range": {"subjectSnapshot.attrsNum.hba1c": {"gte": 6.5}}},
{"term": {"subjectSnapshot.attrsKw.medication": "metformin"}}
]
}
}
}'업무 기록' 카테고리의 다른 글
| 테스트코드 잘 작성하기.. (0) | 2026.02.04 |
|---|---|
| 사내 efk 서버 구축 - 1 (0) | 2026.01.11 |
| kafka 기반 로그 스트리밍 시스템 구축하기 (0) | 2026.01.04 |
| 대용량 출력 최적화 과정 (0) | 2026.01.02 |
| 다중 로그인 제어 적용하기 (0) | 2024.06.29 |
kafka 기반 로그 스트리밍 시스템 구축하기
서비스가 급하게 만들어진 것도 있었고.. 오픈 초기에는 cs문의나 시스템 에러도 별로 안 터지는 상황이였어서 로그 시스템 구축을 살짝 미루고 있었다.. 하지만 (비즈니스에서 제일 중요한) 결제 관련 cs가 올라오니 허겁지겁 로그를 뒤지게 되느 상황이 발생했고... 로그 파일을 하나씩 열어보며 grep 명령어를 치는 재앙을 맞이한 후에야
로그를 손봐야겠다...
라는 생각이 제대로 들어서
사실 이전 회사에서 es를 구축해본 경험이 있었고 클라우드 환경이라면 가장 간편하게 aws cloudwatch를 사용하면 됐지만
온프레미스 환경이였고.. 인프라팀에서 그라파나를 구축해놨긴 했지만 로그 검색하는 방법이 통 맘에 들지 않았음 (관리자 권한이 없어서 어떻게 커스텀해보지 못했던게 컸다)
먼거 가장 먼저 한 일은 로그를 어떻게 다룰것인가.. 였는데
팀이 원하는 로그 시스템의 기준은 명확했다
- 로그는 중앙에서 한 번에 볼 수 있어야 한다
- 에러 로그는 즉시 감지 가능해야 한다
- 로그는 실시간으로 흘러야 한다
- 애플리케이션 로직과 강하게 결합되지 않아야 한다
이 기준을 놓고 봤을 때 로그를 파일 단위로 수집하는 방식보다는 이벤트 스트림 기반 구조가 더 적합하다고 판단했다.
그래서 api 통계 수집용으로 깔아놓은 카프카를 연동해서
로그서비스를 구축해보자! < 까지 오게됐다는
Kafka 토픽 설계
Kafka를 도입하면서 가장 먼저 고민한 건 토픽 설계였다.
- ebook_store_trace
- 일반 요청 흐름 추적용 로그
- 파라미터, 처리 시간, 비즈니스 흐름
- ebook_store_critical
- 에러 및 장애 대응용 로그
- 즉각적인 확인이 필요한 이벤트
이렇게 분리함으로써
- Critical 로그만 별도 Consumer로 즉시 감지 가능
- 장애 대응과 일반 분석 로그를 명확히 분리
- 로그 소비 전략을 로그 성격에 맞게 가져갈 수 있음
애플리케이션 코드와 로그 수집을 분리
Kafka 연동에서 가장 중요하게 본 포인트는 애플리케이션 코드 침투를 최소화하는 것이었다.
비즈니스 로직에서 직접 Kafka Producer를 호출하는 방식은 곧바로 제외했다.
- 로그와 로직이 강하게 결합됨
- 유지보수 난이도 증가
- 로깅 정책 변경 시 코드 수정 필요
대신 선택한 방식이 Custom Logback Appender였다.
public class KafkaLogAppender extends AppenderBase<ILoggingEvent> {
private KafkaProducer<String, String> producer;
@Override
protected void append(ILoggingEvent event) {
String message = convertToJson(event);
producer.send(new ProducerRecord<>(topic, message));
}
}
이 방식을 채택함으로서 Slf4j의 log.info,. log.error() 코드는 그대로 사용할 수 있었고,
로그 전송 정책은 이 Appender에서만 관리 할 수 있다는 분리성을 가질 수 있었다
로그가 모이자, 컨텍스트가 보이지 않았다
로그를 Kafka로 모으기 시작하자 곧바로 다음 문제가 드러났다.
이 로그가 어떤 요청에서 나온 로그인지 알 수 없다.
서버가 여러 대이고, 동시에 수많은 요청이 처리되는 환경에서
단순한 로그 메시지만으로는 흐름을 추적하기 어려웠다.
그래서 MDC(Mapped Diagnostic Context)를 적극 활용했다.
MDC 기반 요청 단위 로그 추적
요청 진입 시점에 컨텍스트 정보를 MDC에 설정했다.
MDC.put("traceId", UUID.randomUUID().toString()); MDC.put("userId", userId);
그리고 로그 포맷에 MDC 값을 포함시켰다.
[%X{traceId}] [%X{userId}] %msg
이 구조를 통해
- 하나의 요청에서 발생한 로그를 traceId로 묶을 수 있었고
- 서버가 달라도 동일한 요청 흐름을 추적할 수 있었고
- Kafka 이후 단계에서도 일관된 분석이 가능해졌다
AOP 기반 자동 로깅 도입
로그 시스템을 구축하면서 request log 나 시스템 에러로그같은 공통적인 요청들은 자동으로 로깅되므로 Controller 전반에 AOP 기반 자동 로깅을 적용했다.
@Around("execution(* com.xxx.controller..*(..))")
public Object logController(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
log.info("Request success");
return result;
} catch (Exception e) {
log.error("Critical error", e);
throw e;
} finally {
log.info("Elapsed time: {}ms", System.currentTimeMillis() - start);
}
}
이 구조의 효과는 명확했다.
- 모든 요청이 자동으로 로깅됨
- 예외 발생 시 Critical 로그가 즉시 Kafka로 전송
- 장애 발생을 “로그 발생 순간”에 인지 가능
로그 포맷 표준화: 마지막으로 남은 문제
Kafka에 로그가 쌓이기 시작하자 서버마다, 서비스마다 로그 포맷이 다르다는 문제가 드러났다.
이 상태에서는 검색이 어렵고 분석은 거의 불가능하다는 결론으로 이어져 로그 포맷을 구조적으로 표준화하기로 했음
{
"timestamp": "...",
"level": "ERROR",
"service": "ebook-store",
"traceId": "...",
"message": "...",
"stackTrace": "..."
}
- JSON 기반 포맷 통일
- MDC 필드 강제 포함
- 이후 Consumer, 분석 파이프라인에서 일관성 확보
정리하며
이 로그 시스템 구축은 단순히 Kafka를 도입한 작업이 아니었다.
- 로그를 파일이 아닌 이벤트로 바라보는 관점
- 장애 대응을 사후 분석에서 실시간 감지로 전환
- 운영과 개발 모두의 피로도를 줄인 구조 개선
무엇보다 가장 큰 변화는 이거였다.
장애가 발생하면 서버 접속부터 하던 흐름이 로그 스트림과 대시보드부터 보는 흐름으로 바뀌었다.
로그는 남기는 것보다 어떻게 흐르게 할 것인가가 훨씬 중요하다는 걸 이 작업을 통해 확실히 체감했다.
'업무 기록' 카테고리의 다른 글
| 사내 efk 서버 구축 - 1 (0) | 2026.01.11 |
|---|---|
| 임상 메타데이터 검색엔진 서비스 구축 (0) | 2026.01.11 |
| 대용량 출력 최적화 과정 (0) | 2026.01.02 |
| 다중 로그인 제어 적용하기 (0) | 2024.06.29 |
| spring event로 이벤트 아키텍처 적용하기 (0) | 2024.02.02 |
대용량 출력 최적화 과정
내가 개발한 모의고사 성적표 웹뷰 출력< 기능 관련해
학원기획팀에서 들어온 요구사항이 있었다.
> 학생 전체 성적표를 한 번에 출력할 수 있게 해주세요.<
학원 한 곳당 약 200~300명, 많은곳은 500명까지 요구사항이 도착하였는데
기존 구현했던 성적표 서비스는 HTML 기반 단 1개의 출력물이었다.
문제는 이 요청을 기존 구조 그대로 확장할 수 없었다는 점이였고..
(현재 프로젝트에서 구현하고 있는 스펙은 java springboot + thymeleaft 기반으로 구현 되어 있음)
대용량 처리로 인해 발생한 문제점들
성적표 HTML의 평균 크기는 약 2MB...
- 1명: 약 2MB
- 300명: 약 600MB
- 서버 Heap 메모리: 512MB
처음 접근한 방식은
300명의 성적표 HTML을 한 번에 생성 → 메모리에 적재 → 응답 으로
단순하게 기존 성적표 1개를 생성하는 로직에서 반복문을 돌리는.. (제일 공수가 적게 들것이므로..) 방식이였는데 사실 말이 안되긴 했다;;
- 학생별 성적 데이터 조회
- 템플릿에 모델로 전달
- TemplateEngine.process();
- 이걸 반복문으로 돌려 HTML을 생성해서 response함
실제로 구현 시 OutOfMemoryError가 발생....
이를 해결하기 위해서 여러가지 방법을 생각해봤는데
① 배치 처리로 부하를 쪼개기
일단 처음 접근한 방법은 서버 부하 관리에 있어서 매우 기본적이고 단순한 방법으로,
기존 요청수에 맞게 반복문을 돌리는 방식에서 수를 분할해 부하를 쪼개는 방식을 사용했다.
(전체 데이터를 한 번에 처리하는 대신, 예측 가능한 단위로 나누어 처리하는 방식을 선택)
- 전체 학생 300명
- 10명 단위로 분할 → 30개 배치
- 배치 간: 순차 처리
- 배치 내: 병렬 처리
List batches = Lists.partition(students, 10);
for (List batch : batches) {
processBatchAsync(batch).join(); // 배치 단위 순차 처리
}
②: CompletableFuture 기반 병렬 처리
배치 내부에서는 CompletableFuture를 활용해 병렬로 성적표를 생성했다.
Executor executor = threadPoolTaskExecutor;
List futures = batch.stream()
.map(student -> CompletableFuture.runAsync(() -> generateReport(student), executor)
)
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
서버가 감당 가능한 수준에서 동시에 여러 작업을 처리하되 리소스를 예측 가능하게 사용하기 위해 비동기 전략을 사용했는데,
사실 비동기 처리를 하지 않아도 OutOfMemory는 피할 수 있었고 시스템 안정성도 충분히 확보할 수 있었다
그럼에도 비동기 처리 로직을 추가한건... 처리시간 감축을 위해서였는데
배치 크기를 10명으로 설정했을 때, 한 배치 처리시간은 3초, 100배치가 돈다면 300초 => 5분까지 확장되므로,
출력 버튼을 누르고 5분 가까이 기다려야 결과가 나오는 구조였다.
거기다가 출력 요청 중에는 서버 리소스가 그 작업에 계속 묶여있으므로 요청 스레드가 불필요하게 오래 점유되지 않고, 서버 응답성 유지 기대효과를 위해 비동기 로직까지 추가하기로 했다.
결과적으로 전체 처리 시간은 100명 학생의 성적표 화면을 출력할 때, 약 300초 → 30초로 감소할 수 있었다
③: StreamingResponseBody로 메모리 사용 최소화
배치 + 비동기를 적용했음에도 여전히 메모리 사용량이 부담스러웠는데,
모든 HTML을 다 만든 뒤 응답하는 구조이다보니 HTML 렌더링 결과가 생각보다 오래 메모리에 남아 있을 수 밖에 없었다.
처음 구조는 단순하게..
1. 학생 전체 성적표 HTML 생성
2. List<String>에 저장
3. 컨트롤러에서 한 번에 Response로 반환
구조였는데 이거의 문제는 HTML 용량이 커질수록 메모리 점유가 증가하고, GC가 돌떄까지 해제가 되지 않아 보관시간이 선형적으로 증가한다는 것에 있었다
그래서 저장하는 게 아니라 응답을 바로 보내는 스트리밍 방식을 생각해보았다
@GetMapping("/print/reports")
public StreamingResponseBody printReports(HttpServletResponse response) {
response.setContentType("text/html;charset=UTF-8");
return outputStream -> {
for (List<Student> batch : batches) {
processBatchAndStream(batch, outputStream);
outputStream.flush();
}
};
}
private void processBatchAndStream(List<Student> batch, OutputStream os) {
batch.parallelStream().forEach(student -> {
String html = generateReportHtml(student);
os.write(html.getBytes(StandardCharsets.UTF_8));
});
}
이 방식의 핵심은 다음과 같다.
- Thymeleaf는 한 명씩 렌더링
- 렌더링 결과를 즉시 OutputStream으로 전송
- HTML을 리스트나 컬렉션에 쌓아두지 않음
사실 스트리밍 처리까지 할 필요가 있을까?싶긴 했는데 메모리 모니터링 해보면서 코드를 간소화하든가 해야겠음
무튼 이 작업을 통해 얻은 결론은 다음과 같다
- 배치처리는 당연히
- 대용량 처리 문제는 대부분 처리 흐름 설계의 문제
- OOM은 메모리 증설로 해결할 대상이 아님
- 한 번에 처리해야 하는가? < 를 먼저 의심
비슷한 문제를 겪고 있다면, 배치 + 스트리밍 구조는 충분히 고려해볼 만한 선택지라고 생각한다.
'업무 기록' 카테고리의 다른 글
| 임상 메타데이터 검색엔진 서비스 구축 (0) | 2026.01.11 |
|---|---|
| kafka 기반 로그 스트리밍 시스템 구축하기 (0) | 2026.01.04 |
| 다중 로그인 제어 적용하기 (0) | 2024.06.29 |
| spring event로 이벤트 아키텍처 적용하기 (0) | 2024.02.02 |
| 클린 아키텍처 적용하기 (0) | 2024.01.16 |
웹소켓
웹소켓에 대해서는 국비학원 다닐 때 채팅기능 구현해보기 <로 3시간정도? 배우고 사용해 본 적이 없었다..
실무에 들어가서도 거의 HTTP 통신만 다루며 api 서버 개발만을 맡게 되어... 이거 좀 알아봐야겠다 < 라는 생각이 아예 없었는데
기회가 생겨 웹소켓에 대해 뜯어볼 건덕지가 생김
먼저 웹소켓이란..?
'사용자의 브라우저와 서버 사이의 인터액티브 통신 세션을 설정할 수 있게 하는 고급 기술으로 개발자는 웹 소켓 API를 통해 서버로 메시지를 보내고 서버의 응답을 위해 서버를 폴링하지 않고도 이벤트 중심 응답을 받는 것이 가능하다'
라고 mdn에선 정의한다...
그러니깐 웹 브라우저와 웹 서버가 실시간으로 대화할 수 있게 하는 통신 방식인데 한 번 서버를 연결하면 계속해서 유지가 되기 때문에, 매번 연결을 하지 않아도 된다는 특징이 있다.. 따라서 채팅서버를 구현할 때 많이 사용하는 것으로 알공 ㅣㅆ음
작동 방식은 간단하게..
1. request 요청을 보냄
- 여기에 'Upgrade' 헤더를 포함해야한다..
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
이런식으로 HTTP 요청을 보내게 되는데.. 클라이언트단에서는 javascript를 통해 요청해보겠다
const socket = new SockJS('/ws'); //
const stompClient = Stomp.over(socket);
stompClient.connect({}, function(frame) {
console.log('Connected: ' + frame);
});
SockJS는 브라우저와 서버간의 실시간연결을 설정하는 라이브러리로, /ws라는 웹소켓 엔드포인트로 연결을 시도한다.
여기에서 Upgrade: websocket, Connection:Upgrade 헤더가 추가된다..

이런식으로~ 서버는 이 리퀘스트를 수신하고, 웹소켓 프로토콜로 업그레이드한다.
2. reponse를 받음

정상적으로 요청이 왔으면, 101 Switching Protocols를 보내 웹소켓 연결을 설정한다.
이렇게 되면 정상적으로 요청이 완료된다!
나는 백엔드 서버를 스프링 서버로 구현했는데..
2024-07-29 00:08:31 2024-07-28T15:08:31.568Z INFO 1 --- [MessageBroker-1] o.s.w.s.c.WebSocketMessageBrokerStats : WebSocketSession[1 current WS(1)-HttpStream(0)-HttpPoll(0), 1 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(1)-CONNECTED(1)-DISCONNECT(0)], stompBrokerRelay[null], inboundChannel[pool size = 4, active threads = 0, queued tasks = 0, completed tasks = 6], outboundChannel[pool size = 1, active threads = 0, queued tasks = 0, completed tasks = 1], sockJsScheduler[pool size = 2, active threads = 1, queued tasks = 2, completed tasks = 14]
서버에 웹소켓이 연결된 경우 이런식으로 로그가 찍힌다.
'프로젝트' 카테고리의 다른 글
| 사내 efk 서버 구축 - 2 (0) | 2026.01.11 |
|---|
다중 로그인 제어 적용하기
신규 서비스 개발 중... 회사 내규 보안 규칙을 적용해야 한다는 사항을 전달 받았다..
1. 어드민 서비스에 sms 2차 인증 추가 -> sms api는 이미 구현 되어있었고, redis에 2차 인증이 되었는지 여부만 저장하여 최초 로그인 filter에 해당 로직만 추가함으로써 간단하게 구현할 수 있었다..
2. 비밀번호 n회 이상 틀렸을 때 락걸기 -> redis로 할까 했는데.. db에 로그인 실패 카운트 횟수, 락 여부 컬럼 추가해서 구현 함
3. 다중 로그인 제어 -> 이게 좀? 머리를 써야할 것 같았는데
A PC로 접근 중, B PC로 접근했을 때 A PC의 로그인을 풀리게 하는 것이 요구사항이였고
단순하게 아~ 토큰에 ip주소를 저장하고 접근할 때 이 ip주소가 다르다면 토큰 만료시키면 되겠다~~~
라고 생각했는데 (접근은 맞음..)
생각해보니 액세스 토큰 < 이 녀석이 걸리는 거였다...
얘를 어떻게 처리해야하나... GPT한테도 물어보고 여기저기 물어봤지만
결론적으로는 Redis로 Black list를 만들어서 request할 때마다 블랙리스트에 해당 토큰이 저장되어있는지 확인하고, 블랙리스트에 있다면 로그아웃 로직과 동일하게 쿠키 제거, Spring Security 세션을 해제시키는 방식..을 사용하기로 했다
사실 request 할 때마다 redis를 컨택해야 하는 것이 좀 맘에 안들었지만...
이거 말고는 방법이 없어보였다... 액세스 토큰을 푸는 방법 있다면 알려주세요
정리하자면
로그인 로직 처리
- 로그인 요청 시 사용자 인증 정보(아이디, 비밀번호 등)를 확인
- 인증이 성공하면 해당 사용자의 기존 Refresh Token을 조회
- Redis에서 해당 사용자의 Refresh Token을 조회.
- Redis에 Refresh Token이 없는 경우 데이터베이스에서 조회.
- 기존 Refresh Token이 존재하는 경우:
- 토큰에 포함된 IP 주소와 현재 로그인 요청의 IP 주소를 비교
- IP 주소가 다르다면 기존 Refresh Token을 무효화
- Refresh Token 무효화
- Redis에서 해당 Refresh Token을 삭제
- DB에서 만료 처리
- Access Token 무효화
- 블랙리스트 - 만료된 토큰을 블랙리스트에 추가하여 관리
- 액세스토큰의 만료시간과 동일하게 설정
- Refresh Token 무효화
- 새로운 Access Token과 Refresh Token을 생성
- 생성된 토큰에는 현재 로그인 요청의 IP 주소를 포함
- 새로운 Refresh Token을 저장
- Redis에 새로운 Refresh Token을 저장
- 데이터베이스에 새로운 Refresh Token을 저장하거나 업데이트합니다.
- 생성된 Access Token과 Refresh Token을 쿠키에 저장하여 클라이언트에게 전달
Request 로직 처리
JWT 토큰 검사하는 로직 (spring이면 거의 OncePerRequestFilter를 상속받아 쓰고 있을 것이다..)에다가
redis black list에 있는지 확인하는 로직을 넣으면 된다..
나같은 경우에는 key 값은 간단하게 blacklist.{service-name}.{access-token} 이렇게 부여하고 value는 true로 넣었다..
값이 존재한다면 로그아웃 로직을 동일하게 넣어주면 된다 (내가 구현한 프로젝트는 템플릿 엔진을 사용하고 있는 어드민 서비스라서 스프링 시큐리티 세션 해제, 쿠키 제거, 리프레시 토큰 만료처리 장도 넣어둠)
'업무 기록' 카테고리의 다른 글
| kafka 기반 로그 스트리밍 시스템 구축하기 (0) | 2026.01.04 |
|---|---|
| 대용량 출력 최적화 과정 (0) | 2026.01.02 |
| spring event로 이벤트 아키텍처 적용하기 (0) | 2024.02.02 |
| 클린 아키텍처 적용하기 (0) | 2024.01.16 |
| Spring 로그 관리하기 (0) | 2023.12.12 |
spring event로 이벤트 아키텍처 적용하기
담당하게 된 회사 프로젝트의 코드를 살펴보면서, 결제 기능과 이메일 발송 로직에서 개선할 수 있는 부분이.... 보였다. (웬만한 실무 코드가 그렇듯이) 하나의 비즈니스 로직이 nn줄로 거대하게 존재하고 있는 상태였다......
이런 중요한 기능들을 더 효율적이고 확장 가능하게 만들 수 있는 방법을 모색하는 과정에서, 몇 가지 중요한 개선 포인트를 발견했다..
결제 기능의 확장성을 높이고, 공통 로직을 더 잘 관리하며, 이메일 발송을 비동기로 처리하는 방법을 고민하다 보니, 이벤트 아키텍처 도입입을 통해 시스템의 전반적인 성능도 개선할수 있어 좋은 해결책이 될 거라고 생각했다.
프로젝트의 현재 규모와 팀의 상황을 고려했을 때, RabbitMQ, Kafka와 같은 메시징 시스템을 도입하기보다는 Spring에서 지원하는 Spring Event를 사용하는 것이 더 적합하다고 판단했다. Spring Event는 간편하게 사용할 수 있고, Spring 애플리케이션에 자연스럽게 통합되어 필요한 기능을 제공한다는 장점이 있다.
이벤트 아키텍처란?
이벤트 아키텍처는 소프트웨어 설계에서 이벤트를 중심으로 시스템을 구축하는 방식을 말한다. 이 아키텍처는 시스템 내의 다양한 이벤트(사용자의 액션, 시스템 상태 변경 등)를 감지하고, 이에 대응하는 행동을 비동기적으로 처리하는 구조로 설계된다. 이벤트 아키텍처의 핵심은 시스템의 결합도를 낮추고, 확장성과 유연성을 높이며, 동시에 다양한 시스템 구성 요소 간의 상호 작용을 효율적으로 관리하는 데 있다.
이벤트 아키텍처의 특징
- 비동기 처리: 이벤트 아키텍처는 이벤트를 생성하고 이에 반응하는 작업을 비동기적으로 처리한다. 이는 시스템의 전반적인 성능을 개선하고 사용자 경험을 향상시키는 데 기여한다.
- 결합도 감소: 시스템의 각 부분이 이벤트를 통해서만 상호 작용하므로, 결합도가 낮아진다. 이로 인해 시스템의 유지 관리가 쉬워지고, 개별 컴포넌트를 독립적으로 개발하거나 수정할 수 있다.
- 확장성: 새로운 이벤트 처리기를 추가하거나 기존의 것을 수정하는 것이 비교적 간단하므로, 시스템의 확장성이 크게 향상된다.
- 유연성: 다양한 이벤트와 반응 로직을 손쉽게 추가하거나 변경할 수 있어, 시스템을 빠르게 적응시키고 새로운 요구사항에 대응할 수 있다.
Spring Event를 통한 구현
Spring Framework에서 제공하는 Spring Event는 자바 애플리케이션에서 이벤트 아키텍처를 쉽게 구현할 수 있도록 돕는다. 개발자는 이벤트 발행, 리스닝, 처리를 간단한 어노테이션과 몇 줄의 코드로 구현할 수 있으며, Spring의 Dependency Injection(DI) 기능과 함께 사용하여 더욱 강력한 이벤트 기반 애플리케이션을 만들 수 있다.
이벤트 아키텍처의 도입은 프로젝트의 코드 구조를 개선하고, 시스템의 전반적인 성능 및 개발 효율성을 향상시키는 중요한 전략이다. Spring Event와 같은 도구를 활용하면, 이벤트 아키텍처의 장점을 최대한 활용하며 프로젝트의 발전 가능성을 넓힐 수 있다.
Spring Event를 활용한 비동기 이메일 발송 기능 구현
import org.springframework.context.ApplicationEvent;
public class RegistrationCompleteEvent extends ApplicationEvent {
private String userEmail;
public RegistrationCompleteEvent(Object source, String userEmail) {
super(source);
this.userEmail = userEmail;
}
public String getUserEmail() {
return userEmail;
}
}
이 코드는 사용자가 회원가입을 완료했을 때 발생하는 이벤트를 정의한다. ApplicationEvent 클래스를 상속받아 사용자의 이메일 정보를 포함하는 RegistrationCompleteEvent 클래스를 생성한다.
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
@Component
public class RegistrationEventListener {
@Async
@EventListener
public void handleRegistrationCompleteEvent(RegistrationCompleteEvent event) {
sendEmail(event.getUserEmail());
}
private void sendEmail(String userEmail) {
// 실제 이메일 발송 로직 구현
System.out.println("이메일 발송: " + userEmail);
}
}
이 리스너는 RegistrationCompleteEvent가 발생하면 이메일을 비동기적으로 발송한다. @Async 어노테이션을 사용해 메서드가 비동기적으로 실행됨을 명시한다.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class RegistrationService {
@Autowired
private ApplicationEventPublisher eventPublisher;
public void registerUser(String userEmail) {
RegistrationCompleteEvent completeEvent = new RegistrationCompleteEvent(this, userEmail);
eventPublisher.publishEvent(completeEvent);
}
}
ApplicationEventPublisher를 이용해 이벤트를 발행한다. 사용자가 회원가입을 완료하면 RegistrationCompleteEvent를 생성하고 발행한다.
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig {
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("EmailSend-");
executor.initialize();
return executor;
}
}
@EnableAsync 어노테이션을 사용해 비동기 처리를 활성화한다. taskExecutor 메서드는 비동기 작업을 위한 Executor를 설정한다. 이 설정을 통해, 비동기 작업을 수행할 스레드 풀의 크기와 이름, 대기열 용량 등을 세부적으로 조정할 수 있다.
이 구성을 통해 Spring Event를 활용하여 비동기적으로 이메일을 발송하는 기능을 구현할 수 있다.
Spring Event와 JPA Entity Lifecycle Events 비교
Spring Event와 JPA Entity Lifecycle Events는 이벤트 기반 프로그래밍을 지원하지만 각각의 적용 범위와 목적에 차이가 있다.
Spring Event
- 애플리케이션 내의 다양한 이벤트를 처리하기 위해 사용된다.
- 사용자 정의 이벤트를 만들어 애플리케이션의 다른 부분에서 처리할 수 있게 한다.
- 주로 애플리케이션 레벨의 비즈니스 로직 처리에 사용되며, 이벤트를 통해 서비스나 컴포넌트 간 결합도를 낮추는 데 유리하다.
- 예를 들어 사용자가 회원가입을 완료했을 때 이메일을 발송하는 작업에 적합하다.
JPA Entity Lifecycle Events
- 엔티티의 생명 주기와 관련된 이벤트를 처리하기 위해 사용된다.
- @PostPersist, @PostUpdate, @PostRemove 같은 어노테이션을 사용해 엔티티 객체가 데이터베이스에 저장, 업데이트, 삭제될 때 실행될 로직을 정의한다.
- 데이터 무결성을 유지하거나 엔티티의 변경 사항을 감사하는 등의 목적으로 사용된다.
- 엔티티가 저장되거나 업데이트될 때 자동으로 타임스탬프를 업데이트하거나 변경 내용을 로깅하는 데 적합하다.
비교
- Spring Event는 애플리케이션의 비즈니스 로직에 초점을 맞추고, JPA Entity Lifecycle Events는 데이터베이스 엔티티의 생명 주기와 직접적으로 관련된 로직에 초점을 맞춘다.
- Spring Event는 애플리케이션 전반에 걸쳐 유연한 이벤트 처리를 가능하게 하는 반면, JPA Entity Lifecycle Events는 엔티티의 특정 생명 주기 이벤트에 반응하는 좀 더 구체적인 로직을 구현하는 데 사용된다.
- 두 시스템은 각각의 목적과 사용 사례에 따라 선택하여 사용되며, 애플리케이션의 요구 사항을 충족시키는 데 서로 보완적인 역할을 할 수 있다.
'업무 기록' 카테고리의 다른 글
| 대용량 출력 최적화 과정 (0) | 2026.01.02 |
|---|---|
| 다중 로그인 제어 적용하기 (0) | 2024.06.29 |
| 클린 아키텍처 적용하기 (0) | 2024.01.16 |
| Spring 로그 관리하기 (0) | 2023.12.12 |
| 현업에서 Spring Framework -> Spring Boot로 마이그레이션 하기 - 1 (0) | 2023.09.27 |
클린 아키텍처 적용하기
백엔드 개발에서 아키텍처는 마치 큰 건물의 기초와 같다. 잘 짜여진 아키텍처는 개발의 효율성을 높이고, 유지보수를 쉽게 해준다. 특히 Java Spring 프로젝트에서 클린 아키텍처를 적용하면 더욱 강력한 구조를 만들 수 있다. 이번 글에서는 내가 직접 경험한 클린 아키텍처의 적용 과정과 그 이점에 대해 소개하려 한다.
문제점: 컨벤션이 없는 아키텍처의 혼란
내가 처음 입사하여 프로젝트를 시작했을 때, 맡게 된 프로젝트를 포함하여 백엔드 파트 내부에선 아키텍처에 대한 명확한 컨벤션이 없었다. 이런 상태에서 개발을 진행하다 보니 코드의 일관성이 떨어지고, 새로운 기능을 추가하거나 수정하는 데 시간이 많이 걸렸다. 또한, 다른 개발자가 작성한 코드를 이해하기 어려워 협업에도 문제가 있었다.
접근 방식: 클린 아키텍처를 통한 구조 개선
이러한 문제를 해결하기 위해, 우리 팀은 '만들면서 배우는 클린 아키텍처'라는 책을 참고하여 클린 아키텍처를 적용하기로 결정했다. 클린 아키텍처는 도메인 로직과 인프라스트럭처를 분리하여, 코드의 유연성과 가독성을 높이는 아키텍처 패턴이다. 이를 통해 코드의 의존성을 관리하기 쉽고, 테스트와 유지보수가 용이해진다.
클린 아키텍처 적용 후: 구조적 변화와 각 계층의 역할
클린 아키텍처를 적용한 후의 프로젝트 구조는 다음과 같이 변화했다.
│
└── {domain-name}
├── domain (도메인 계층)
│ ├── entity (엔티티 클래스)
│ └── repository (도메인 레포지토리 인터페이스)
│
├── application (응용 계층)
│ ├── service (서비스 인터페이스 및 구현)
│ └── dto (데이터 전송 객체)
│
└── adapter (인프라스트럭처 계층)
├── controller (웹 컨트롤러)
└── persistence (JPA 리포지토리 구현)
도메인 계층 (Domain)
- 엔티티 클래스 (Entity): 비즈니스 로직의 핵심을 담고 있는 클래스들. 이들은 비즈니스 규칙을 캡슐화하고, 애플리케이션의 상태를 유지한다.
- 도메인 레포지토리 인터페이스 (Repository): 엔티티 객체에 대한 저장소와의 상호 작용을 추상화하는 인터페이스. 실제 데이터베이스와의 연결은 인프라스트럭처 계층에서 처리한다.
응용 계층 (Application)
- 서비스 인터페이스 및 구현 (Service): 사용자의 요구 사항에 따라 도메인 계층을 활용하는 서비스 로직이 위치한다. 비즈니스 로직의 실행 순서나 트랜잭션 관리 등을 담당한다.
- 데이터 전송 객체 (DTO, Data Transfer Object): 계층 간 데이터 교환을 위한 객체. 주로 간단한 데이터 구조를 가지며, 복잡한 도메인 로직은 포함하지 않는다.
인프라스트럭처 계층 (Adapter)
- 웹 컨트롤러 (Controller): 사용자의 요청을 받아 서비스 계층에 전달하고, 결과를 사용자에게 반환한다. MVC 패턴의 컨트롤러와 유사한 역할을 수행한다.
- JPA 리포지토리 구현 (Persistence): 실제 데이터베이스와의 연결 및 데이터 조작을 담당하는 클래스들로 JPA를 사용하여 데이터베이스와의 CRUD 작업을 처리한다.
이러한 구조 변경을 통해, 각 계층은 자신의 역할에만 집중할 수 있게 되었으며, 시스템 전체의 유연성과 확장성이 크게 향상되었다. 또한, 각 계층 간의 의존성이 줄어들어, 코드의 가독성과 유지보수성이 높아졌다.
클린 아키텍처 적용의 실무적 이점
1. 개발 속도 향상
클린 아키텍처는 각 기능이 명확한 계층에 속해 있기 때문에, 새로운 기능 추가나 기존 기능 수정 시 해당 계층만 집중적으로 다룰 수 있다. 이는 개발자가 빠르게 문제를 파악하고 해결할 수 있게 해주며, 전체적인 개발 속도를 높인다.
2. 협업의 용이성
팀원들이 각자 다루는 코드 영역이 명확해지기 때문에, 협업 시 코드 충돌이 줄어든다. 또한, 새로운 팀원이 프로젝트에 참여할 때 시스템의 구조를 빠르게 이해하고 기여할 수 있게 된다.
주의해야 할 실무적 측면
1. 과도한 분리 주의
각 계층을 너무 세세하게 분리하면, 오히려 코드 관리가 복잡해질 수 있다. 필요 이상의 추상화나 분리는 개발 과정을 느리게 하고 혼란을 야기할 수 있다.
2. 팀원 간의 의사소통
클린 아키텍처는 팀원 간의 지속적인 의사소통을 필요로 한다. 아키텍처를 적용하며 발생하는 변경 사항이나 결정 사항을 팀 내에서 공유하고 이해하는 것이 중요하다.
결론: 클린 아키텍처, 장기적인 효과를 누리자
클린 아키텍처를 적용함으로써 우리 팀은 단순히 코드의 구조를 개선하는 것을 넘어, 개발 과정 전체의 질을 높일 수 있었다. 해당 아키텍처를 적용함으로 팀원 간의 협업을 강화하고, 유지보수 및 확장성을 용이하게 만들어, 장기적으로 프로젝트의 성공에 기여할 수 있다. 이는 단순한 코드 작성 방법론을 넘어, 팀이 더 나은 소프트웨어를 만드는 방식을 재정립하는 계기가 된다. 이는 프로젝트의 성공뿐만 아니라, 팀의 문화와 작업 방식에도 긍정적인 변화를 가져온다. (사실 이 부분이 가장 크게 느껴졌다..)
클린 아키텍처의 도입은 시작이 어려울 수 있지만, 이를 통해 얻을 수 있는 이점은 분명하고, 장기적인 관점에서 봤을 때 그 가치는 더욱 크다고 본다.
'업무 기록' 카테고리의 다른 글
| 대용량 출력 최적화 과정 (0) | 2026.01.02 |
|---|---|
| 다중 로그인 제어 적용하기 (0) | 2024.06.29 |
| spring event로 이벤트 아키텍처 적용하기 (0) | 2024.02.02 |
| Spring 로그 관리하기 (0) | 2023.12.12 |
| 현업에서 Spring Framework -> Spring Boot로 마이그레이션 하기 - 1 (0) | 2023.09.27 |