지금까지 사용하던 tistory를 벗어나 github pages를 이용해보기로 하였습니다.
앞으로의 블로그는 아래 블로그에서 작성을 이어나갈 생각입니다.
아래 블로그도 많은 방문 부탁드립니다.
Geon Lee - Tech Blog | Full Stack Developer
보다 넓은 시야를 가지고 싶은 개발자의 기술 블로그
krongdev.github.io
지금까지 사용하던 tistory를 벗어나 github pages를 이용해보기로 하였습니다.
앞으로의 블로그는 아래 블로그에서 작성을 이어나갈 생각입니다.
아래 블로그도 많은 방문 부탁드립니다.
Geon Lee - Tech Blog | Full Stack Developer
보다 넓은 시야를 가지고 싶은 개발자의 기술 블로그
krongdev.github.io
DP는 어렵게 접근할 필요가 없이, 전체 문제 내에 작은 규칙들 확인하여 작은 부분 문제들을 해결하고, 이를 통해 전체 문제를 해결하는 방법이다.
이때, 동적 계획법을 적용하였을 때 좋은 상황은 아래와 같이 정의할 수 있다.
이와 같이 전체를 보면 해답이 안 보이는 문제 혹은 너무 복잡한 문제를 효율적으로 풀기 위한 방법이라고 보면 될 것 같다.
문제를 해결하는 관점에서 보았을 때 두 가지 관점으로 바라볼 수 있다.
DP에서 작은 문제를 해결하기 위해 식을 세우게된다.
이때, 작은 문제들의 상황에 맞춰 아래 식들을 정의하여 사용할 수 있다.
동일한 규칙을 가진 작은 문제들을 해결하고자 할 때 재귀식을 작성하여 효율적으로 풀어낼 수 있다.
이때 재귀식은 아래와 같은 규칙을 정의해야 한다.
이와 같은 조건으로 재귀식을 세운다면 문제없이 작은 문제들을 해결하여 큰 문제를 해결해 낼 수 있다.
다만, 재귀함수를 작성할 경우 스택 메모리 자원을 크게 사용할 수 있다는 문제점을 가지고 있다.
이를 해결하기 위해서는 아래와 같은 방법들을 고려해 볼 수 있다.
이때, 반복문은 말 그대로 재귀함수를 통해 반복되는 것을 반복문을 활용하는 것으로 변경하여, 추가적인 메모리 활용을 최대한 방지하는 방법이다.
그럼 재귀식에서의 메모이제이션은 무엇일까?
우리가 재귀식을 활용하여 함수를 작성하면, 해당 재귀가 몇 번 반복되는지 알 수 있는 경우가 존재한다.
예를 들어보자면 팩토리얼을 예로 들어볼 수 있다.
만약 팩토리얼 5와 팩토리얼 10을 구하고자 할 때, 메모이제이션을 하지 않는다면, 팩토리얼 10을 구하고자 할 때 5에 대한 연산이 재귀식을 통해 타고 들어가게 될 것이다.
하지만, 팩토리얼 5의 연산값을 저장하고 있다면, 이미 연산이 되어있어 팩토리얼 5를 연산할 재귀함수의 메모리를 절약할 수 있다.
| [연습문제] 배열 회전시키기 코드 개선 (2) | 2025.02.15 |
|---|---|
| [연습문제] 진료순서 정하기 코드 개선 (1) | 2025.02.15 |
| [ Tree ] 레드-블랙 트리 (0) | 2024.11.23 |
| [ 백준 - 1068 ] 트리 (0) | 2024.11.14 |
| [ 백준 - 9372 ]상준이의 여행 (2) | 2024.11.13 |
이전 직장에서 퇴사하고 거의 1년이란 시간이 지나가고 있다.
이 짧다면 짧고, 길다면 긴 시간 동안 블로그도 안 쓰고 뭘 하고 살았고, 어떤 고민을 하였고, 어떤 마인드셋을 가지게 되었는지 기록하고자 한다.
바쁜 일상을 보내다 퇴사를 하니 이제 개인 시간을 가질 수 있다는 기쁨이 존재하였지만, 바쁘게 꽉 찬 3년을 보내고, 오랜만에 찾아온 개인시간은 생각보다 마냥 개운하지만은 않았다.
우선, 쉬는 방법을 까먹은 기분이었다.
바쁜 일상에 몸이 적응해 버려 아무것도 안 해도 되는 일상은 참 익숙해보려 해도 익숙해지지 않는 생활이었다.
그리하여, 목표! 건강 회복하기.
바쁜 일상, 과도한 책임으로 인한 스트레스 때문일까 내가 나를 보살피지 못하는 사이 살이 쪄버렸다.
이로 인해 나의 일상은 운동을 진행하여 자기 관리를 시작하고, 평소 하고 싶던 개발공부를 진행하기 시작했다.
하지만, 이직을 하지 않고 백수가 된 대가는 가혹했다.
나는 본가에 들어가게 되면 편하게 늘어져버릴까 봐 자취를 유지하며, 보유한 자산이 없어지기 전 입사하는 것을 목표로 생활을 해왔다.
이때만 해도 알지 못했다. 보유한 자산이 줄어들어가면서 생기는 조급함의 위험성을...
사실 그동안 모아둔 돈도 있고, 실력에도 자신 있었기 때문에 빠르게 취업이 될 줄 알았다.
하여, 퇴사하고 3개월 정도는 해보고 싶은 공부하고, 운동하며 지낼 수 있었다.
점차 시간이 지날수록 줄어드는 통장 잔고를 보며, 슬슬 일을 해야 한다는 현실을 깨달았고, 이로 인해 취업준비에 들어가게 된다.
생각보다 최종합격까지 가기는 어렵지 않았다.
평소 관심 많았던 WMS를 다뤄볼 수 있는 기회가 찾아왔고, 입사일이 다가온 그 순간.
입사예정일이 한 주 미뤄지는 일이 생겼다.
그제야 회사에 무슨 일이 생겼나 확인하니 이게 웬걸 기업들이 파산하기 시작하면서 그 영향이 미친 것일까 안타까운 일이 벌어진 것을 확인했다.
세상이 나를 싫어하는 게 분명했다.
무언가를 하려 할 때마다 코로나에, 파산에 아이고...
이때부터 마음속에 꽃피기 시작한 감정 "조급함" 이러다 돈 못 벌고 굶겠구나 싶은 마음이 들며, 조급해지기 시작하는 것이었다.
조급해지기 시작하면서 점차 생활이 꼬이기 시작했다.
걱정에 잠을 못 이루기 시작하면서 점차 퇴사할 때 가졌던 "난 이것을 하고 말 것이다!"라고 다짐했던 것이 뒤로 가고, 어찌 되었든 돈을 벌자는 마음이 앞서기 시작하는 것이었다.
그렇게 조급함을 가진상태로 하루하루 지나갈 무렵 이전에 미팅했던 프리랜서건으로 연락이 왔다.
퇴사를 하면서 다짐했던 것 중 하나인 "최대한 많은 경험을 해서 무엇을 하고 싶은지 명확하게 정리하자!".
마침 프리랜서로서 활동할 수 있는 기회가 주어졌고, 해당 프로젝트로 투입되기 전 시간이 남아 대표님 회사 프로젝트를 진행하기로 하였다.
교내 방송 TTS프로그램과, 성XX쪽에 납품할 프로그램 이렇게 두 가지 프로젝트를 개발하게 되었다.
이전 소규모 리딩했던 경험이 있어서 그럴까, 단순 개발이 아닌 기획, 설계, 개발, 리딩을 전부 해주기를 원하였고 이 또한 스타트업에서의 경험을 해볼 수 있다는 생각에 수행하게 되었다.
해당 프로젝트들을 수행하면서 기술적으로 성장했다고 보기는 어려운 것 같다.
이전 직장에서 워낙 많은 기술들을 다뤄보기도 했고, 개인적으로 공부하면서 기술적인 스킬들을 기본기부터 다지기 위해 노력했기 때문이라 생각한다.
다만, A - Z까지 경험을 할 수 있는 좋은 경험이었다고 생각한다.
연차가 오르면서 기술적인 실력도 중요하다 생각하지만, 그보다 중요하다 생각하는 것은 프로젝트를 바라보는 시각의 넓이와 깊이 그리고 마음가짐에 달려있다고 생각하기 때문이다.
해당 프로젝트를 진행하며 대표님과 사용할 기술스택에 대해 논의를 거쳐가며 스택들을 정하였고,
고객 요청을 수용하여 비즈니스 프로세스를 정의하였으며, 이를 팀원에 공유하기 위해 문서들을 작성하였다.
개발 시에는 테스트코드를 작성하고 개발을 진행해 보면서 평소 관심 있던 TDD를 경험해보기도 하였다.
가장 마음에 들었던 것은 "책임감"이었다.
단순 업무에 대한 책임감이 아닌 프로덕트 상품에 대한 책임감을 가지며, 프로젝트를 완성하기 위해 책임을 가지고 수행하는 마음가짐을 배워보고, 경험할 수 있었다.
이전 직장에서 개발할 때도 물론 책임감 있게 개발했다 생각한다.
다만, 더 많은 권한을 보유한 프로젝트에서의 책임감은 아무리 소규모 프로젝트라 하더라도 또 다른 책임감을 느낄 수 있는 알찬 경험을 제공해 주었다.
4개월간의 프로젝트 개발을 완료하고, 고객사로부터 내가 제안한 기능의 반응이 좋았다는 소식을 전해 들었을 때의 기분은 지금까지 개발을 하며 이처럼 기분 좋았던 적은 많지 않았던 거 같다.
이후 계약상의 문제로 인해 아쉽게 2025년 9월 1일 프리생활을 종료하게 되었다.
매우 재밌는 새로운 경험이었다.
다만, 지금이 20대라 도전을 해봤지만, 30대 40대였다면 이처럼 도전해 보기는 쉽지 않았을 것 같다.
왜 그런가?
내가 경험한 프리랜서는 해줄 거 해주고 돈 받는 말 그대로 개인 사업자였다.
그러다 보니 "말한 거 해줬으면 되었지" 같은 분위기가 존재했고, 더 나은 방향성에 대한 논의가 활발하게 이어지진 않았다.
"굳이 일 벌일 필요 있나..."라고 느껴지는 분위기였다.
계속해서 고민하고 고도화하고, 더 나은 방향성을 제시하여 프로젝트의 완성도를 높이고 싶은 나와는 조금 맞지 않는다 느꼈다.
다른 말로는 개발을 "개발자"로써 하기보다 "직장인"으로 돈 벌는데 목표가 있다면 프리랜서와 잘 맞을 거라 생각한다.
이게 나쁘다는 의미는 아니고, 이것이 흔히 말하는 "컬처핏"이라는 것 같다.
유니콘이나 대기업 가서 돈 많이 벌거 아니면 돈을 위해서는 프리랜서 괜찮은 거 같다.
물론 고연차에 따라 더 많은 권한이 주어지기도 하고, 실력이 뛰어나신 프리랜서분들도 당연히 많다.
다만 내가 경험한 것은 그랬다.
음... 우선 최근 개최 된 Amazon Q Developemt 해커톤에 팀으로 참석한 활동이 있었다.
수상하지 못해 매우 아쉽긴 했지만, AWS의 다양한 서비스들을 마음껏 무제한으로 사용해 보며 하나의 완성된 프로덕트를 개발할 수 있어 매우 좋은 기회 었다.
이와 관련해서 아래 다른 글로 작성하였다.
또한, 팀을 빌딩 하여 개발하던 사이드프로젝트가 오픈되었다는 점이다.
6월 27일부터 시작한 이 프로젝트는 5명이서 시작하여 현재 8명이 기획 및 설계 개발에 참석하는 실제 운영 중인 사이트이다.
이 또한 프로젝트의 1차 오픈이 마무리되면서 정리하여 글로 작성해서 공유할 예정이다.
그동안 개발을 하면서 어떤 개발을 하고 싶은지, 내가 뭘 하고 싶은지 확실하게 말할 수 없었다.
개발자체는 즐거웠지만, 뭘 하고 싶은지는 잘 몰랐고, 이번 기회에 이에 대해 깊은 고민을 할 수 있었다.
또한, 시간이 많은 만큼 많은 개발자들을 만나볼 수 있었다.
이로 인한 결론은.
나는 "개발자로서 끝을 보기 위해 노력할 것이고, 좋은 프로젝트를 만들기 위해서는 그만큼 미쳐 몰입해야 한다."이다.
즉, 한번 미쳐보려 한다.
내가 개발이라는 영역을 좋아하고, 이를 잘한다는 소리를 들어왔으니.
어디까지 해볼 수 있는지 개발에 미쳐서 덕질을 해봐야겠다.
여기까지가 지난 1년에 대한 내용들이다.
한 글에 다 담기 어려워 세세하게 소개하고 싶은 내용들은 따로 글을 작성하여 올릴 예정이다.
취업이 빠르게 될지는 잘 모르겠다.
다만 이전처럼 조급함을 가지고 성급하게 행동하지 않고, 개발에 미쳐보며 소중한 기회들을 잡아보기 위해 노력할 예정이다.
마이크로서비스 아키텍처(MSA)에서 각 서비스는 독립적으로 운영되지만, 사용자에게 일관된 알림 경험을 제공해야 합니다. 주문 완료, 결제 승인, 배송 시작 등 다양한 이벤트가 발생할 때마다 이메일, SMS, 푸시 알림을 통해 사용자에게 적절한 정보를 전달해야 하죠.
이번 글에서는 Spring Boot와 MySQL을 사용하여 MSA 환경에서 효율적인 알림 서비스를 설계하고 구현하는 방법을 살펴보겠습니다.
온라인 쇼핑몰의 다음 서비스들이 알림을 발송해야 하는 상황을 가정하겠습니다:
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Order Service │ │ Payment Service │ │Shipping Service │
│ │ │ │ │ │
│ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │
│ │Event Pub │ │ │ │Event Pub │ │ │ │Event Pub │ │
│ └──────────┘ │ │ └──────────┘ │ │ └──────────┘ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
└───────────────────────┼───────────────────────┘
│
┌─────────────────────────────┐
│ Message Queue │
│ (RabbitMQ) │
└─────────────────────────────┘
│
┌─────────────────────────────┐
│ Notification Service │
│ │
│ ┌─────────────────────┐ │
│ │ Event Processor │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ Template Engine │ │
│ └─────────────────────┘ │
│ ┌─────────────────────┐ │
│ │ Channel Router │ │
│ └─────────────────────┘ │
└─────────────────────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ Email Service │ │ SMS Service │ │ Push Service │
│ (SMTP) │ │ (Twilio) │ │ (FCM) │
└───────────────┘ └───────────────┘ └───────────────┘
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│Order Service│ │Message Queue│ │Notification │ │ Template │ │Email Service│
│ │ │ │ │ Service │ │ Engine │ │ │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │ │ │ │
│ 1. Publish │ │ │ │
│ OrderComplete │ │ │ │
│ Event │ │ │ │
│──────────────→│ │ │ │
│ │ │ │ │
│ │ 2. Consume │ │ │
│ │ Event │ │ │
│ │──────────────→│ │ │
│ │ │ │ │
│ │ │ 3. Load │ │
│ │ │ Template │ │
│ │ │──────────────→│ │
│ │ │ │ │
│ │ │ 4. Generate │ │
│ │ │ Content │ │
│ │ │←──────────────│ │
│ │ │ │ │
│ │ │ 5. Send │ │
│ │ │ Notification │ │
│ │ │──────────────────────────────→│
│ │ │ │ │
│ │ │ 6. Update │ │
│ │ │ Status │ │
│ │ │──────────────→│ │
-- 알림 템플릿 테이블
CREATE TABLE notification_template (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
template_code VARCHAR(50) NOT NULL UNIQUE,
template_name VARCHAR(100) NOT NULL,
channel_type ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
subject VARCHAR(200),
content TEXT NOT NULL,
variables JSON,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 알림 요청 테이블
CREATE TABLE notification_request (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
event_id VARCHAR(100) NOT NULL,
event_type VARCHAR(50) NOT NULL,
user_id BIGINT NOT NULL,
template_code VARCHAR(50) NOT NULL,
channel_type ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
recipient VARCHAR(200) NOT NULL,
variables JSON,
status ENUM('PENDING', 'PROCESSING', 'SENT', 'FAILED') DEFAULT 'PENDING',
retry_count INT DEFAULT 0,
error_message TEXT,
sent_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status_created (status, created_at),
INDEX idx_user_id (user_id),
INDEX idx_event_type (event_type)
);
-- 알림 발송 이력 테이블
CREATE TABLE notification_history (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
request_id BIGINT NOT NULL,
channel_type ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
recipient VARCHAR(200) NOT NULL,
subject VARCHAR(200),
content TEXT,
status ENUM('SENT', 'FAILED') NOT NULL,
provider VARCHAR(50),
external_id VARCHAR(100),
error_message TEXT,
sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (request_id) REFERENCES notification_request(id),
INDEX idx_request_id (request_id),
INDEX idx_sent_at (sent_at)
);
-- 사용자 알림 설정 테이블
CREATE TABLE user_notification_preference (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
event_type VARCHAR(50) NOT NULL,
channel_type ENUM('EMAIL', 'SMS', 'PUSH') NOT NULL,
is_enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_event_channel (user_id, event_type, channel_type),
INDEX idx_user_id (user_id)
);
// 알림 요청 엔티티
@Entity
@Table(name = "notification_request")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotificationRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "event_id", nullable = false)
private String eventId;
@Column(name = "event_type", nullable = false)
private String eventType;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "template_code", nullable = false)
private String templateCode;
@Enumerated(EnumType.STRING)
@Column(name = "channel_type", nullable = false)
private ChannelType channelType;
@Column(name = "recipient", nullable = false)
private String recipient;
@Convert(converter = JpaConverterJson.class)
@Column(name = "variables", columnDefinition = "JSON")
private Map<String, Object> variables;
@Enumerated(EnumType.STRING)
@Column(name = "status")
private NotificationStatus status = NotificationStatus.PENDING;
@Column(name = "retry_count")
private Integer retryCount = 0;
@Column(name = "error_message")
private String errorMessage;
@Column(name = "sent_at")
private LocalDateTime sentAt;
@CreatedDate
@Column(name = "created_at")
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
// 알림 템플릿 엔티티
@Entity
@Table(name = "notification_template")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotificationTemplate {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "template_code", unique = true, nullable = false)
private String templateCode;
@Column(name = "template_name", nullable = false)
private String templateName;
@Enumerated(EnumType.STRING)
@Column(name = "channel_type", nullable = false)
private ChannelType channelType;
@Column(name = "subject")
private String subject;
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
@Convert(converter = JpaConverterJson.class)
@Column(name = "variables", columnDefinition = "JSON")
private List<String> variables;
@Column(name = "is_active")
private Boolean isActive = true;
@CreatedDate
@Column(name = "created_at")
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
// 열거형 정의
public enum ChannelType {
EMAIL, SMS, PUSH
}
public enum NotificationStatus {
PENDING, PROCESSING, SENT, FAILED
}
// 기본 알림 이벤트
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotificationEvent {
private String eventId;
private String eventType;
private Long userId;
private String userEmail;
private String userPhone;
private Map<String, Object> data;
private List<ChannelType> channels;
private LocalDateTime occurredAt;
}
// 주문 완료 이벤트
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderCompletedEvent {
private String orderId;
private Long userId;
private String userEmail;
private String customerName;
private String productName;
private BigDecimal totalAmount;
private String orderDate;
private String deliveryAddress;
private LocalDateTime occurredAt;
public NotificationEvent toNotificationEvent() {
Map<String, Object> data = Map.of(
"orderId", orderId,
"customerName", customerName,
"productName", productName,
"totalAmount", totalAmount,
"orderDate", orderDate,
"deliveryAddress", deliveryAddress
);
return NotificationEvent.builder()
.eventId(orderId)
.eventType("ORDER_COMPLETED")
.userId(userId)
.userEmail(userEmail)
.data(data)
.channels(Arrays.asList(ChannelType.EMAIL, ChannelType.SMS))
.occurredAt(occurredAt)
.build();
}
}
@Configuration
@EnableRabbit
public class RabbitMQConfig {
public static final String NOTIFICATION_EXCHANGE = "notification.exchange";
public static final String NOTIFICATION_QUEUE = "notification.queue";
public static final String NOTIFICATION_ROUTING_KEY = "notification.event";
@Bean
public TopicExchange notificationExchange() {
return new TopicExchange(NOTIFICATION_EXCHANGE);
}
@Bean
public Queue notificationQueue() {
return QueueBuilder.durable(NOTIFICATION_QUEUE)
.withArgument("x-dead-letter-exchange", NOTIFICATION_EXCHANGE + ".dlx")
.withArgument("x-dead-letter-routing-key", "notification.failed")
.build();
}
@Bean
public Binding notificationBinding() {
return BindingBuilder
.bind(notificationQueue())
.to(notificationExchange())
.with(NOTIFICATION_ROUTING_KEY);
}
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
template.setMessageConverter(new Jackson2JsonMessageConverter());
template.setExchange(NOTIFICATION_EXCHANGE);
return template;
}
}
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationEventPublisher {
private final RabbitTemplate rabbitTemplate;
public void publishOrderCompleted(OrderCompletedEvent event) {
try {
NotificationEvent notificationEvent = event.toNotificationEvent();
rabbitTemplate.convertAndSend(
RabbitMQConfig.NOTIFICATION_ROUTING_KEY,
notificationEvent
);
log.info("Order completed event published: {}", event.getOrderId());
} catch (Exception e) {
log.error("Failed to publish order completed event: {}", event.getOrderId(), e);
}
}
public void publishPaymentCompleted(PaymentCompletedEvent event) {
try {
NotificationEvent notificationEvent = event.toNotificationEvent();
rabbitTemplate.convertAndSend(
RabbitMQConfig.NOTIFICATION_ROUTING_KEY,
notificationEvent
);
log.info("Payment completed event published: {}", event.getPaymentId());
} catch (Exception e) {
log.error("Failed to publish payment completed event: {}", event.getPaymentId(), e);
}
}
}
// 알림 이벤트 리스너
@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationEventListener {
private final NotificationProcessingService notificationProcessingService;
@RabbitListener(queues = RabbitMQConfig.NOTIFICATION_QUEUE)
public void handleNotificationEvent(NotificationEvent event) {
try {
log.info("Received notification event: {} for user: {}",
event.getEventType(), event.getUserId());
notificationProcessingService.processNotification(event);
} catch (Exception e) {
log.error("Failed to process notification event: {}", event.getEventId(), e);
throw new AmqpRejectAndDontRequeueException("Failed to process notification", e);
}
}
}
// 알림 처리 서비스
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationProcessingService {
private final NotificationRequestRepository notificationRequestRepository;
private final NotificationTemplateRepository templateRepository;
private final UserNotificationPreferenceRepository preferenceRepository;
private final NotificationChannelRouter channelRouter;
@Transactional
public void processNotification(NotificationEvent event) {
// 1. 사용자 알림 설정 확인
List<UserNotificationPreference> preferences =
preferenceRepository.findByUserIdAndEventType(event.getUserId(), event.getEventType());
Map<ChannelType, Boolean> enabledChannels = preferences.stream()
.collect(Collectors.toMap(
UserNotificationPreference::getChannelType,
UserNotificationPreference::getIsEnabled
));
// 2. 활성화된 채널별로 알림 요청 생성
for (ChannelType channel : event.getChannels()) {
if (enabledChannels.getOrDefault(channel, true)) {
createNotificationRequest(event, channel);
}
}
}
private void createNotificationRequest(NotificationEvent event, ChannelType channel) {
try {
String templateCode = generateTemplateCode(event.getEventType(), channel);
String recipient = getRecipient(event, channel);
NotificationRequest request = NotificationRequest.builder()
.eventId(event.getEventId())
.eventType(event.getEventType())
.userId(event.getUserId())
.templateCode(templateCode)
.channelType(channel)
.recipient(recipient)
.variables(event.getData())
.status(NotificationStatus.PENDING)
.build();
notificationRequestRepository.save(request);
// 비동기로 알림 발송 처리
channelRouter.routeNotification(request);
} catch (Exception e) {
log.error("Failed to create notification request: {} for channel: {}",
event.getEventId(), channel, e);
}
}
private String generateTemplateCode(String eventType, ChannelType channel) {
return eventType + "_" + channel.name();
}
private String getRecipient(NotificationEvent event, ChannelType channel) {
switch (channel) {
case EMAIL:
return event.getUserEmail();
case SMS:
return event.getUserPhone();
case PUSH:
return event.getUserId().toString();
default:
throw new IllegalArgumentException("Unsupported channel type: " + channel);
}
}
}
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationTemplateEngine {
private final NotificationTemplateRepository templateRepository;
public NotificationContent generateContent(String templateCode, Map<String, Object> variables) {
NotificationTemplate template = templateRepository.findByTemplateCodeAndIsActiveTrue(templateCode)
.orElseThrow(() -> new NotificationTemplateNotFoundException(templateCode));
String processedSubject = processTemplate(template.getSubject(), variables);
String processedContent = processTemplate(template.getContent(), variables);
return NotificationContent.builder()
.subject(processedSubject)
.content(processedContent)
.channelType(template.getChannelType())
.build();
}
private String processTemplate(String template, Map<String, Object> variables) {
if (template == null) {
return null;
}
String result = template;
for (Map.Entry<String, Object> entry : variables.entrySet()) {
String placeholder = "{{" + entry.getKey() + "}}";
String value = entry.getValue() != null ? entry.getValue().toString() : "";
result = result.replace(placeholder, value);
}
return result;
}
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class NotificationContent {
private String subject;
private String content;
private ChannelType channelType;
}
// 채널 라우터
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationChannelRouter {
private final Map<ChannelType, NotificationChannelService> channelServices;
private final NotificationRequestRepository requestRepository;
@Async
public void routeNotification(NotificationRequest request) {
try {
// 상태를 PROCESSING으로 업데이트
request.setStatus(NotificationStatus.PROCESSING);
requestRepository.save(request);
NotificationChannelService channelService = channelServices.get(request.getChannelType());
if (channelService == null) {
throw new UnsupportedChannelException(request.getChannelType());
}
channelService.sendNotification(request);
} catch (Exception e) {
log.error("Failed to route notification: {}", request.getId(), e);
handleFailure(request, e);
}
}
private void handleFailure(NotificationRequest request, Exception e) {
request.setStatus(NotificationStatus.FAILED);
request.setErrorMessage(e.getMessage());
request.setRetryCount(request.getRetryCount() + 1);
requestRepository.save(request);
// 재시도 로직 (간단한 구현)
if (request.getRetryCount() < 3) {
// 지연 후 재시도 (실제로는 더 정교한 재시도 전략 필요)
CompletableFuture.delayedExecutor(Duration.ofMinutes(5))
.execute(() -> routeNotification(request));
}
}
}
// 이메일 발송 서비스
@Service
@RequiredArgsConstructor
@Slf4j
public class EmailNotificationService implements NotificationChannelService {
private final JavaMailSender mailSender;
private final NotificationTemplateEngine templateEngine;
private final NotificationRequestRepository requestRepository;
private final NotificationHistoryRepository historyRepository;
@Override
public void sendNotification(NotificationRequest request) {
try {
NotificationContent content = templateEngine.generateContent(
request.getTemplateCode(), request.getVariables());
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(request.getRecipient());
helper.setSubject(content.getSubject());
helper.setText(content.getContent(), true);
helper.setFrom("noreply@example.com");
mailSender.send(message);
// 성공 처리
request.setStatus(NotificationStatus.SENT);
request.setSentAt(LocalDateTime.now());
requestRepository.save(request);
// 발송 이력 저장
saveHistory(request, content, NotificationStatus.SENT, null);
log.info("Email notification sent successfully: {}", request.getId());
} catch (Exception e) {
log.error("Failed to send email notification: {}", request.getId(), e);
// 실패 처리
request.setStatus(NotificationStatus.FAILED);
request.setErrorMessage(e.getMessage());
requestRepository.save(request);
saveHistory(request, null, NotificationStatus.FAILED, e.getMessage());
throw new NotificationSendException("Failed to send email", e);
}
}
private void saveHistory(NotificationRequest request, NotificationContent content,
NotificationStatus status, String errorMessage) {
NotificationHistory history = NotificationHistory.builder()
.requestId(request.getId())
.channelType(request.getChannelType())
.recipient(request.getRecipient())
.subject(content != null ? content.getSubject() : null)
.content(content != null ? content.getContent() : null)
.status(status)
.provider("SMTP")
.errorMessage(errorMessage)
.sentAt(LocalDateTime.now())
.build();
historyRepository.save(history);
}
}
// SMS 발송 서비스 (Twilio 예제)
@Service
@RequiredArgsConstructor
@Slf4j
public class SmsNotificationService implements NotificationChannelService {
private final NotificationTemplateEngine templateEngine;
private final NotificationRequestRepository requestRepository;
private final NotificationHistoryRepository historyRepository;
@Value("${twilio.account.sid}")
private String accountSid;
@Value("${twilio.auth.token}")
private String authToken;
@Value("${twilio.phone.number}")
private String fromPhoneNumber;
@Override
public void sendNotification(NotificationRequest request) {
try {
Twilio.init(accountSid, authToken);
NotificationContent content = templateEngine.generateContent(
request.getTemplateCode(), request.getVariables());
Message message = Message.creator(
new PhoneNumber(request.getRecipient()),
new PhoneNumber(fromPhoneNumber),
content.getContent())
.create();
// 성공 처리
request.setStatus(NotificationStatus.SENT);
request.setSentAt(LocalDateTime.now());
requestRepository.save(request);
// 발송 이력 저장
saveHistory(request, content, NotificationStatus.SENT, null, message.getSid());
log.info("SMS notification sent successfully: {}", request.getId());
} catch (Exception e) {
log.error("Failed to send SMS notification: {}", request.getId(), e);
// 실패 처리
request.setStatus(NotificationStatus.FAILED);
request.setErrorMessage(e.getMessage());
requestRepository.save(request);
saveHistory(request, null, NotificationStatus.FAILED, e.getMessage(), null);
throw new NotificationSendException("Failed to send SMS", e);
}
}
private void saveHistory(NotificationRequest request, NotificationContent content,
NotificationStatus status, String errorMessage, String externalId) {
NotificationHistory history = NotificationHistory.builder()
.requestId(request.getId())
.channelType(request.getChannelType())
.recipient(request.getRecipient())
.content(content != null ? content.getContent() : null)
.status(status)
.provider("TWILIO")
.externalId(externalId)
.errorMessage(errorMessage)
.sentAt(LocalDateTime.now())
.build();
historyRepository.save(history);
}
}
// 주문 서비스에서 알림 발송
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {
private final NotificationEventPublisher notificationPublisher;
private final OrderRepository orderRepository;
@Transactional
public void completeOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
// 주문 상태 업데이트
order.setStatus(OrderStatus.COMPLETED);
order.setCompletedAt(LocalDateTime.now());
orderRepository.save(order);
// 알림 이벤트 발행
OrderCompletedEvent event = OrderCompletedEvent.builder()
.orderId(order.getId().toString())
.userId(order.getUserId())
.userEmail(order.getUserEmail())
.customerName(order.getCustomerName())
.productName(order.getProductName())
.totalAmount(order.getTotalAmount())
.orderDate(order.getOrderDate().toString())
.deliveryAddress(order.getDeliveryAddress())
.occurredAt(LocalDateTime.now())
.build();
notificationPublisher.publishOrderCompleted(event);
log.info("Order completed and notification event published: {}", orderId);
}
}
// 알림 템플릿 등록 예제
@Component
@RequiredArgsConstructor
@Slf4j
public class NotificationTemplateInitializer {
private final NotificationTemplateRepository templateRepository;
@PostConstruct
public void initializeTemplates() {
createOrderCompletedEmailTemplate();
createOrderCompletedSmsTemplate();
}
private void createOrderCompletedEmailTemplate() {
String emailContent = """
<html>
<body>
<h2>주문이 완료되었습니다!</h2>
<p>안녕하세요, {{customerName}}님!</p>
<p>주문해주신 상품의 주문이 성공적으로 완료되었습니다.</p>
<div style="border: 1px solid #ddd; padding: 20px; margin: 20px 0;">
<h3>주문 정보</h3>
<p><strong>주문 번호:</strong> {{orderId}}</p>
<p><strong>상품명:</strong> {{productName}}</p>
<p><strong>결제 금액:</strong> {{totalAmount}}원</p>
<p><strong>주문 날짜:</strong> {{orderDate}}</p>
<p><strong>배송 주소:</strong> {{deliveryAddress}}</p>
</div>
<p>빠른 시일 내에 배송 준비를 완료하겠습니다.</p>
<p>감사합니다.</p>
</body>
</html>
""";
NotificationTemplate template = NotificationTemplate.builder()
.templateCode("ORDER_COMPLETED_EMAIL")
.templateName("주문 완료 이메일")
.channelType(ChannelType.EMAIL)
.subject("주문이 완료되었습니다 - 주문번호: {{orderId}}")
.content(emailContent)
.variables(Arrays.asList("customerName", "orderId", "productName",
"totalAmount", "orderDate", "deliveryAddress"))
.isActive(true)
.build();
templateRepository.save(template);
log.info("Order completed email template created");
}
private void createOrderCompletedSmsTemplate() {
String smsContent = """
[쇼핑몰] {{customerName}}님, 주문이 완료되었습니다.
주문번호: {{orderId}}
상품: {{productName}}
금액: {{totalAmount}}원
배송지: {{deliveryAddress}}
감사합니다.
""";
NotificationTemplate template = NotificationTemplate.builder()
.templateCode("ORDER_COMPLETED_SMS")
.templateName("주문 완료 SMS")
.channelType(ChannelType.SMS)
.content(smsContent)
.variables(Arrays.asList("customerName", "orderId", "productName",
"totalAmount", "deliveryAddress"))
.isActive(true)
.build();
templateRepository.save(template);
log.info("Order completed SMS template created");
}
}
// 배치 처리를 통한 성능 개선
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationBatchProcessor {
private final NotificationRequestRepository requestRepository;
private final NotificationChannelRouter channelRouter;
@Scheduled(fixedDelay = 5000) // 5초마다 실행
public void processPendingNotifications() {
List<NotificationRequest> pendingRequests =
requestRepository.findByStatusOrderByCreatedAtAsc(
NotificationStatus.PENDING, PageRequest.of(0, 100));
if (!pendingRequests.isEmpty()) {
log.info("Processing {} pending notifications", pendingRequests.size());
// 채널별로 그룹화하여 병렬 처리
Map<ChannelType, List<NotificationRequest>> groupedRequests =
pendingRequests.stream()
.collect(Collectors.groupingBy(NotificationRequest::getChannelType));
groupedRequests.forEach((channelType, requests) -> {
CompletableFuture.runAsync(() -> {
requests.forEach(channelRouter::routeNotification);
});
});
}
}
}
// 알림 서비스 모니터링
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationMonitoringService {
private final NotificationRequestRepository requestRepository;
private final MeterRegistry meterRegistry;
@EventListener
public void handleNotificationSent(NotificationSentEvent event) {
// 메트릭 수집
Counter.builder("notification.sent")
.tag("channel", event.getChannelType().name())
.tag("event_type", event.getEventType())
.register(meterRegistry)
.increment();
}
@EventListener
public void handleNotificationFailed(NotificationFailedEvent event) {
// 실패 메트릭 수집
Counter.builder("notification.failed")
.tag("channel", event.getChannelType().name())
.tag("event_type", event.getEventType())
.tag("error_type", event.getErrorType())
.register(meterRegistry)
.increment();
}
@Scheduled(fixedRate = 60000) // 1분마다 체크
public void checkNotificationHealth() {
long failedCount = requestRepository.countByStatusAndCreatedAtAfter(
NotificationStatus.FAILED,
LocalDateTime.now().minusMinutes(5)
);
if (failedCount > 10) {
log.warn("High notification failure rate detected: {} failures in last 5 minutes",
failedCount);
// 알림 또는 대시보드에 경고 전송
}
}
}
// 개인정보 보호를 위한 데이터 마스킹
@Service
@RequiredArgsConstructor
public class NotificationSecurityService {
public String maskPersonalInfo(String data, String type) {
if (data == null) return null;
switch (type) {
case "email":
return maskEmail(data);
case "phone":
return maskPhone(data);
default:
return data;
}
}
private String maskEmail(String email) {
if (email == null || !email.contains("@")) return email;
String[] parts = email.split("@");
String localPart = parts[0];
String domain = parts[1];
if (localPart.length() <= 2) return email;
return localPart.charAt(0) + "***" + localPart.charAt(localPart.length() - 1) + "@" + domain;
}
private String maskPhone(String phone) {
if (phone == null || phone.length() < 8) return phone;
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
}
// 대용량 알림 처리를 위한 샤딩 전략
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationShardingService {
private final List<NotificationChannelService> emailServices;
private final List<NotificationChannelService> smsServices;
public void sendBulkNotifications(List<NotificationRequest> requests) {
// 사용자 ID 기반 샤딩
Map<Integer, List<NotificationRequest>> shardedRequests =
requests.stream()
.collect(Collectors.groupingBy(
req -> Math.abs(req.getUserId().hashCode() % 4)));
shardedRequests.forEach((shardId, shardRequests) -> {
CompletableFuture.runAsync(() -> {
processShardRequests(shardId, shardRequests);
});
});
}
private void processShardRequests(int shardId, List<NotificationRequest> requests) {
log.info("Processing {} requests in shard {}", requests.size(), shardId);
for (NotificationRequest request : requests) {
try {
NotificationChannelService service = getServiceForShard(shardId, request.getChannelType());
service.sendNotification(request);
} catch (Exception e) {
log.error("Failed to send notification in shard {}: {}", shardId, request.getId(), e);
}
}
}
private NotificationChannelService getServiceForShard(int shardId, ChannelType channelType) {
switch (channelType) {
case EMAIL:
return emailServices.get(shardId % emailServices.size());
case SMS:
return smsServices.get(shardId % smsServices.size());
default:
throw new IllegalArgumentException("Unsupported channel type: " + channelType);
}
}
}
// A/B 테스트를 위한 알림 템플릿 관리
@Service
@RequiredArgsConstructor
@Slf4j
public class NotificationABTestService {
private final NotificationTemplateRepository templateRepository;
private final Random random = new Random();
public String selectTemplate(String baseTemplateCode, Long userId) {
// 사용자 ID 기반으로 A/B 테스트 그룹 결정
boolean isTestGroup = (userId % 100) < 50; // 50% 사용자를 테스트 그룹으로
if (isTestGroup) {
String testTemplateCode = baseTemplateCode + "_TEST";
boolean testTemplateExists = templateRepository.existsByTemplateCodeAndIsActiveTrue(testTemplateCode);
if (testTemplateExists) {
log.debug("Using test template for user {}: {}", userId, testTemplateCode);
return testTemplateCode;
}
}
log.debug("Using default template for user {}: {}", userId, baseTemplateCode);
return baseTemplateCode;
}
}
| 항목 | 기존 방식 (각 서비스에서 직접 발송) | 알림 서비스 방식 |
|---|---|---|
| 결합도 | 높음 (각 서비스가 알림 로직 포함) | 낮음 (이벤트 기반 분리) |
| 코드 중복 | 많음 (각 서비스마다 알림 코드) | 없음 (중앙 집중화) |
| 확장성 | 제한적 (각 서비스 개별 확장) | 높음 (독립적 확장) |
| 알림 통합 관리 | 어려움 | 쉬움 |
| 장애 격리 | 낮음 (알림 실패 시 메인 로직 영향) | 높음 (완전 분리) |
| 성능 | 동기 처리로 인한 지연 | 비동기 처리로 빠른 응답 |
| 모니터링 | 분산되어 어려움 | 중앙 집중 모니터링 |
| 템플릿 관리 | 코드 변경 필요 | 운영자 직접 수정 가능 |
MSA 환경에서 알림 서비스는 시스템의 확장성, 안정성, 운영 효율성을 크게 향상시키는 핵심 컴포넌트입니다.
알림 서비스의 핵심 가치는 다음과 같습니다:
확장성: 각 서비스는 비즈니스 로직에만 집중하고, 알림 서비스는 독립적으로 확장하여 대용량 알림 처리가 가능합니다.
안정성: 메시지 큐를 통한 비동기 처리와 재시도 메커니즘으로 높은 안정성을 제공하며, 알림 실패가 메인 서비스에 영향을 주지 않습니다.
운영 효율성: 중앙 집중화된 템플릿 관리와 발송 이력 추적으로 운영 효율성을 대폭 향상시킵니다.
다만 메시지 큐 관리의 복잡성, 템플릿 관리 오버헤드, 초기 구현 복잡성 등을 고려해야 합니다.
실제 도입 시에는 서비스의 규모, 알림 발송량, 개발팀의 역량을 종합적으로 고려하여 단계적으로 적용하는 것이 중요합니다. 초기에는 핵심 알림부터 시작하여 점진적으로 확장하는 전략을 추천합니다.
| 파티션과 샤딩에 관하여 (2) | 2025.06.30 |
|---|---|
| [Monorepo] 모노레포 그것이 답인가? (2) | 2025.05.22 |
| [해결 과제] 인증 서비스 왜 Refactoring 하였을까? (0) | 2025.04.04 |
| [EDA] EDA는 왜 적용하게 된걸까? (0) | 2025.04.03 |
| [MSA] 왜 MSA로 가야하나요? (0) | 2025.04.03 |
데이터베이스가 성장하면서 단일 테이블로는 더 이상 효율적인 데이터 관리가 어려워지는 시점이 옵니다. 특히 대용량 데이터를 다룰 때 성능 병목이 발생하게 되는데, 이를 해결하기 위한 대표적인 방법이 파티셔닝(Partitioning)과 샤딩(Sharding)입니다.
이번 글에서는 Spring Boot와 MySQL을 사용하여 Product 테이블을 예제로 두 방법의 차이점과 장단점을 살펴보겠습니다.
우리가 다룰 Product는 다음과 같은 구조를 가집니다:
@Entity
@Table(name = "product")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String type; // "상의", "하의", "모자", "신발"
private BigDecimal price;
private Integer stock;
private LocalDateTime createdAt;
// 생성자, getter, setter 생략
}
파티셔닝은 하나의 데이터베이스 내에서 테이블을 논리적으로 분할하는 기법입니다. 물리적으로는 여전히 같은 데이터베이스에 존재하지만, 데이터를 여러 파티션으로 나누어 관리합니다.
┌─────────────────────────────────────────┐
│ MySQL Database │
├─────────────────────────────────────────┤
│ Product Table (Partitioned by type) │
├─────────────────────────────────────────┤
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Partition 1 │ │ Partition 2 │ │
│ │ (상의) │ │ (하의) │ │
│ └─────────────┘ └─────────────┘ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Partition 3 │ │ Partition 4 │ │
│ │ (모자) │ │ (신발) │ │
│ └─────────────┘ └─────────────┘ │
└─────────────────────────────────────────┘
-- 파티션 테이블 생성
CREATE TABLE product (
id BIGINT AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
price DECIMAL(10,2) NOT NULL,
stock INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, type)
)
PARTITION BY LIST COLUMNS(type) (
PARTITION p_top VALUES IN ('상의'),
PARTITION p_bottom VALUES IN ('하의'),
PARTITION p_hat VALUES IN ('모자'),
PARTITION p_shoes VALUES IN ('신발')
);
@Repository
public class ProductRepository {
@Autowired
private JdbcTemplate jdbcTemplate;
// 특정 타입의 상품 조회 (파티션 프루닝 활용)
public List<Product> findByType(String type) {
String sql = "SELECT * FROM product WHERE type = ?";
return jdbcTemplate.query(sql, new Object[]{type},
(rs, rowNum) -> mapRowToProduct(rs));
}
// 전체 상품 조회 (모든 파티션 스캔)
public List<Product> findAll() {
String sql = "SELECT * FROM product";
return jdbcTemplate.query(sql, (rs, rowNum) -> mapRowToProduct(rs));
}
private Product mapRowToProduct(ResultSet rs) throws SQLException {
Product product = new Product();
product.setId(rs.getLong("id"));
product.setName(rs.getString("name"));
product.setType(rs.getString("type"));
product.setPrice(rs.getBigDecimal("price"));
product.setStock(rs.getInt("stock"));
product.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
return product;
}
}
샤딩은 동일한 스키마를 가진 데이터를 여러 데이터베이스에 수평적으로 분산하는 기법입니다. 각 샤드는 독립적인 데이터베이스 서버에서 실행됩니다.
┌─────────────────┐ ┌─────────────────┐
│ MySQL DB 1 │ │ MySQL DB 2 │
│ (상의 샤드) │ │ (하의 샤드) │
├─────────────────┤ ├─────────────────┤
│ Product Table │ │ Product Table │
│ - 상의 데이터 │ │ - 하의 데이터 │
└─────────────────┘ └─────────────────┘
┌─────────────────┐ ┌─────────────────┐
│ MySQL DB 3 │ │ MySQL DB 4 │
│ (모자 샤드) │ │ (신발 샤드) │
├─────────────────┤ ├─────────────────┤
│ Product Table │ │ Product Table │
│ - 모자 데이터 │ │ - 신발 데이터 │
└─────────────────┘ └─────────────────┘
@Configuration
public class ShardingConfig {
@Bean
@Primary
public DataSource topDataSource() {
return DataSourceBuilder.create()
.url("jdbc:mysql://localhost:3306/product_top")
.username("user")
.password("password")
.build();
}
@Bean
public DataSource bottomDataSource() {
return DataSourceBuilder.create()
.url("jdbc:mysql://localhost:3307/product_bottom")
.username("user")
.password("password")
.build();
}
@Bean
public DataSource hatDataSource() {
return DataSourceBuilder.create()
.url("jdbc:mysql://localhost:3308/product_hat")
.username("user")
.password("password")
.build();
}
@Bean
public DataSource shoesDataSource() {
return DataSourceBuilder.create()
.url("jdbc:mysql://localhost:3309/product_shoes")
.username("user")
.password("password")
.build();
}
}
@Service
public class ShardingService {
private final Map<String, JdbcTemplate> shardMap;
public ShardingService(
@Qualifier("topDataSource") DataSource topDs,
@Qualifier("bottomDataSource") DataSource bottomDs,
@Qualifier("hatDataSource") DataSource hatDs,
@Qualifier("shoesDataSource") DataSource shoesDs) {
this.shardMap = Map.of(
"상의", new JdbcTemplate(topDs),
"하의", new JdbcTemplate(bottomDs),
"모자", new JdbcTemplate(hatDs),
"신발", new JdbcTemplate(shoesDs)
);
}
public JdbcTemplate getShardByType(String type) {
JdbcTemplate shard = shardMap.get(type);
if (shard == null) {
throw new IllegalArgumentException("Unknown product type: " + type);
}
return shard;
}
public Collection<JdbcTemplate> getAllShards() {
return shardMap.values();
}
}
@Repository
public class ShardedProductRepository {
@Autowired
private ShardingService shardingService;
// 특정 타입의 상품 조회 (단일 샤드 접근)
public List<Product> findByType(String type) {
JdbcTemplate shard = shardingService.getShardByType(type);
String sql = "SELECT * FROM product WHERE type = ?";
return shard.query(sql, new Object[]{type}, this::mapRowToProduct);
}
// 상품 저장 (적절한 샤드로 라우팅)
public void save(Product product) {
JdbcTemplate shard = shardingService.getShardByType(product.getType());
String sql = "INSERT INTO product (name, type, price, stock) VALUES (?, ?, ?, ?)";
shard.update(sql, product.getName(), product.getType(),
product.getPrice(), product.getStock());
}
// 전체 상품 조회 (모든 샤드 접근 - 성능 주의!)
public List<Product> findAll() {
List<Product> allProducts = new ArrayList<>();
// 병렬 처리로 성능 개선
List<CompletableFuture<List<Product>>> futures =
shardingService.getAllShards().stream()
.map(shard -> CompletableFuture.supplyAsync(() ->
shard.query("SELECT * FROM product", this::mapRowToProduct)))
.collect(Collectors.toList());
futures.forEach(future -> {
try {
allProducts.addAll(future.get());
} catch (Exception e) {
throw new RuntimeException("Failed to fetch from shard", e);
}
});
return allProducts;
}
// 범위 검색 (가격 기준 - 모든 샤드 접근 필요)
public List<Product> findByPriceRange(BigDecimal minPrice, BigDecimal maxPrice) {
List<Product> results = new ArrayList<>();
String sql = "SELECT * FROM product WHERE price BETWEEN ? AND ?";
for (JdbcTemplate shard : shardingService.getAllShards()) {
List<Product> shardResults = shard.query(sql,
new Object[]{minPrice, maxPrice}, this::mapRowToProduct);
results.addAll(shardResults);
}
return results.stream()
.sorted(Comparator.comparing(Product::getPrice))
.collect(Collectors.toList());
}
private Product mapRowToProduct(ResultSet rs, int rowNum) throws SQLException {
Product product = new Product();
product.setId(rs.getLong("id"));
product.setName(rs.getString("name"));
product.setType(rs.getString("type"));
product.setPrice(rs.getBigDecimal("price"));
product.setStock(rs.getInt("stock"));
product.setCreatedAt(rs.getTimestamp("created_at").toLocalDateTime());
return product;
}
}
// 문제점: 모든 샤드를 조회해야 하므로 성능 저하
public List<Product> findExpensiveProducts(BigDecimal threshold) {
List<Product> results = new ArrayList<>();
// 모든 샤드에서 데이터 수집
for (JdbcTemplate shard : shardingService.getAllShards()) {
String sql = "SELECT * FROM product WHERE price > ?";
results.addAll(shard.query(sql, new Object[]{threshold},
this::mapRowToProduct));
}
return results.stream()
.sorted(Comparator.comparing(Product::getPrice).reversed())
.collect(Collectors.toList());
}
// 문제점: 여러 샤드에 걸친 트랜잭션 처리
@Transactional
public void transferStock(Long fromProductId, String fromType,
Long toProductId, String toType, Integer quantity) {
if (!fromType.equals(toType)) {
// 서로 다른 샤드 간의 트랜잭션 - 복잡한 2PC 필요
throw new UnsupportedOperationException(
"Cross-shard transactions not supported");
}
JdbcTemplate shard = shardingService.getShardByType(fromType);
// 같은 샤드 내에서만 트랜잭션 보장 가능
shard.update("UPDATE product SET stock = stock - ? WHERE id = ?",
quantity, fromProductId);
shard.update("UPDATE product SET stock = stock + ? WHERE id = ?",
quantity, toProductId);
}
// 문제점: 서로 다른 샤드의 데이터를 조인할 수 없음
public class OrderService {
// 주문과 상품이 다른 샤드에 있을 경우 애플리케이션 레벨에서 조인
public OrderDetailDTO getOrderWithProducts(Long orderId) {
Order order = orderRepository.findById(orderId);
List<Product> products = new ArrayList<>();
for (OrderItem item : order.getItems()) {
// 각 상품을 개별적으로 조회 (N+1 문제 발생 가능)
Product product = shardedProductRepository
.findByType(item.getProductType())
.stream()
.filter(p -> p.getId().equals(item.getProductId()))
.findFirst()
.orElse(null);
if (product != null) {
products.add(product);
}
}
return new OrderDetailDTO(order, products);
}
}
| 항목 | 파티셔닝 | 샤딩 |
|---|---|---|
| 확장성 | 수직 확장만 가능 | 수평 확장 가능 |
| 메모리 | 단일 DB 메모리 한계 | 각 샤드별 독립적 메모리 |
| QPS | 단일 DB 서버 한계 | 여러 서버로 분산 처리 |
| 트랜잭션 | ACID 보장 | 크로스 샤드 트랜잭션 복잡 |
| 조인 | 일반적인 조인 가능 | 크로스 샤드 조인 불가 |
| 범위 검색 | 효율적 | 모든 샤드 접근 필요 |
| 복잡성 | 상대적으로 단순 | 애플리케이션 레벨 복잡성 증가 |
| 장애 격리 | 전체 DB 영향 | 샤드별 독립적 |
@Service
public class AnalyticsService {
// 특정 타입의 상품 분석 - 파티션 프루닝 활용
public ProductAnalytics analyzeByType(String type) {
List<Product> products = productRepository.findByType(type);
return ProductAnalytics.builder()
.totalCount(products.size())
.averagePrice(calculateAveragePrice(products))
.topSellingProducts(findTopSelling(products))
.build();
}
}
@Service
public class ProductService {
// 대용량 데이터 처리 - 각 샤드에서 병렬 처리
public void updatePricesForType(String type, BigDecimal multiplier) {
JdbcTemplate shard = shardingService.getShardByType(type);
String sql = "UPDATE product SET price = price * ? WHERE type = ?";
int updatedRows = shard.update(sql, multiplier, type);
log.info("Updated {} products in {} shard", updatedRows, type);
}
// 높은 QPS 처리 - 각 샤드가 독립적으로 처리
@Async
public CompletableFuture<List<Product>> findTopProductsByType(String type) {
JdbcTemplate shard = shardingService.getShardByType(type);
String sql = "SELECT * FROM product WHERE type = ? ORDER BY price DESC LIMIT 10";
List<Product> products = shard.query(sql, new Object[]{type},
this::mapRowToProduct);
return CompletableFuture.completedFuture(products);
}
}
파티셔닝과 샤딩은 각각 다른 상황에서 유용한 데이터베이스 확장 기법입니다.
파티셔닝은 비교적 단순한 구현으로 쿼리 성능을 향상시킬 수 있지만, 근본적인 확장성 문제는 해결하지 못합니다. 중간 규모의 데이터에서 성능 개선이 필요한 경우 적합합니다.
샤딩은 더 복잡한 구현이 필요하지만, 진정한 수평 확장을 통해 대용량 데이터와 높은 QPS를 처리할 수 있습니다. 다만 크로스 샤드 쿼리, 트랜잭션 관리 등의 복잡성을 감수해야 합니다.
실제 서비스에서는 데이터의 성격, 트래픽 패턴, 확장 계획을 종합적으로 고려하여 적절한 전략을 선택하는 것이 중요합니다. 때로는 두 방법을 조합하여 사용하는 것도 좋은 선택이 될 수 있습니다.
| MSA 환경에서의 알림 서비스 설계와 구현 (2) | 2025.07.13 |
|---|---|
| [Monorepo] 모노레포 그것이 답인가? (2) | 2025.05.22 |
| [해결 과제] 인증 서비스 왜 Refactoring 하였을까? (0) | 2025.04.04 |
| [EDA] EDA는 왜 적용하게 된걸까? (0) | 2025.04.03 |
| [MSA] 왜 MSA로 가야하나요? (0) | 2025.04.03 |
본 보고서는 전통적 자본 시장의 핵심인 '주식'과 새로운 디지털 경제의 상징인 '암호화폐'에 대한 포괄적이고 심층적인 분석을 제공하는 것을 목표로 합니다. 현대 투자 환경은 이 두 자산 클래스의 공존과 상호작용으로 인해 그 어느 때보다 복잡하고 역동적으로 변화하고 있습니다. 따라서 투자자가 각 시장의 본질적인 특성, 역사적 배경, 작동 메커니즘, 그리고 가치 평가의 근본적인 차이를 이해하는 것은 성공적인 자산 배분을 위한 필수 전제 조건이 되었습니다.
본 보고서는 다음과 같은 핵심 질문에 대한 명확하고 체계적인 답변을 제시할 것입니다: 주식과 암호화폐는 근본적으로 무엇이 다른가? 각 시장의 가치는 어디에서 비롯되는가? 두 시장은 어떻게 운영되며 어떤 규제를 받는가? 투자자가 활용할 수 있는 핵심 용어, 분석 도구, 그리고 투자 전략에는 무엇이 있는가? 마지막으로, 이 두 시장의 미래는 어떻게 전개될 것인가?
이 보고서를 통해 투자자들은 단편적인 정보를 넘어 두 자산 시장을 관통하는 거시적인 흐름을 파악하고, 정보에 입각한 합리적인 투자 의사결정을 내리는 데 필요한 지적 토대를 구축할 수 있을 것입니다. 기초 개념부터 역사, 작동 원리, 투자 전략, 그리고 미래 전망에 이르기까지 모든 것을 망라하여 현명한 투자자의 길을 안내하고자 합니다.
주식 시장은 흔히 '자본주의의 꽃'이라 불립니다.1 이는 기업이 성장에 필요한 자금을 조달하고, 투자자는 그 성장의 과실을 공유하는 현대 자본주의 경제의 가장 핵심적인 메커니즘이기 때문입니다. 이 장에서는 주식의 근본적인 개념과 역사적 배경을 살펴보고, 주식 시장이 어떻게 작동하는지를 심도 있게 분석합니다.
주식이란 주식회사의 자본을 구성하는 최소 단위를 의미하며, 동시에 해당 회사의 소유권 일부를 나타내는 증서입니다.2 주식을 매수한다는 것은 단순히 특정 가격에 자산을 사는 행위를 넘어, 그 기업의 일부를 소유하는 '주주(株主)'가 되는 것을 의미합니다.6 이 지위는 기업에 돈을 빌려주고 이자를 받는 채권자와는 근본적으로 구별됩니다. 대한민국 상법에서는 주주로서의 권리를 나타내는 이 지위를 '주식'으로, 그리고 그 지위를 증권 형태로 만든 것을 '주권(株券)'으로 명확히 구분하여 표현하고 있습니다.7
주식이라는 혁신적인 개념은 17세기 초, 대항해시대의 상업적 필요성에서 탄생했습니다. 세계 최초의 주식회사로 인정받는 기업은 1602년 네덜란드에서 설립된 동인도 회사(Vereenigde Oost-Indische Compagnie, VOC)입니다.1 당시 유럽과 아시아를 잇는 향신료 무역은 막대한 이익을 가져다주었지만, 동시에 긴 항해 기간과 예측 불가능한 위험(해적, 난파 등)으로 인해 엄청난 자본과 위험 감수를 요구했습니다.9
이러한 거대한 규모의 사업을 소수의 부유한 상인이나 귀족의 자본만으로는 감당하기 어려웠습니다. 네덜란드 동인도 회사는 이 문제를 해결하기 위해 사업의 소유권을 잘게 쪼개어 '주식'이라는 형태로 만들고, 이를 일반 대중에게 판매하여 자금을 조달하는 방식을 고안했습니다.6 이는 특정 계층에 국한되지 않고 하인, 상인, 인부 등 다양한 사람들이 투자에 참여할 수 있는 길을 열었으며 11, 고위험-고수익 사업의 리스크를 다수의 투자자에게 분산시키고 거대한 자본을 효율적으로 집적하는 최초의 금융 혁신이었습니다. 이처럼 주식의 기원은 '자본 조달'이라는 명확한 상업적 목적에 있으며, 이는 '중앙화된 시스템에 대한 이념적 반발'에서 태동한 암호화폐와 근본적인 차이를 보입니다. 이 본질적인 탄생 배경의 차이가 두 자산의 가치 평가 방식, 규제 체계, 시장 참여자의 행동 양식 등 모든 면에서 뚜렷한 차이를 만들어내는 근원이라 할 수 있습니다.
주식의 확산에 결정적인 기여를 한 또 다른 혁신은 '유한책임(Limited Liability)' 제도의 도입입니다.1 유한책임이란 투자자가 자신이 투자한 금액, 즉 보유한 주식의 가치를 한도로만 책임을 지는 것을 의미합니다.12 만약 회사가 파산하더라도 주주는 투자 원금을 잃을 뿐, 회사의 채무를 개인적으로 변제할 의무가 없습니다.5
이전의 '무한책임' 구조에서는 사업 실패가 곧 개인의 파산으로 이어졌기 때문에 투자는 매우 위험한 행위였습니다. 유한책임 제도는 이러한 투자의 심리적, 재정적 장벽을 극적으로 낮추었고, 더 많은 사람들이 안심하고 기업에 자본을 공급할 수 있도록 만들었습니다. 이는 자본의 축적과 재투자를 촉진하여 현대 자본주의 경제가 발전하는 견고한 토대가 되었습니다.5
주주는 회사의 주인으로서 다음과 같은 권리와 책임을 가집니다.
주식 시장은 크게 주식이 처음 만들어져 투자자에게 판매되는 '발행시장'과, 이미 발행된 주식이 투자자들 사이에서 거래되는 '유통시장'으로 나뉩니다.6
상장 기업은 IPO 이후에도 다양한 방법을 통해 유통시장에서 추가적인 자금을 조달할 수 있습니다.
주가는 수많은 요인이 복합적으로 작용하여 결정됩니다. 이를 크게 기업의 내재적 요인과 시장의 외부적 요인으로 나눌 수 있습니다.
주식은 부여된 권리의 내용이나 투자 전략의 관점에 따라 다양하게 분류될 수 있습니다.
투자자들은 자신의 투자 목표와 위험 감수 수준에 따라 다양한 성격의 주식에 투자합니다.
21세기에 들어 금융 시장은 새로운 차원의 혁명을 맞이했습니다. 바로 암호화폐의 등장입니다. 중앙 기관의 통제 없이 개인 간의 가치 전송을 가능하게 한 이 디지털 자산은 기존 금융 시스템에 대한 근본적인 질문을 던지며 새로운 투자 지평을 열었습니다. 이 장에서는 암호화폐의 개념과 역사, 그리고 그 기반 기술인 블록체인과 시장의 작동 원리를 탐구합니다.
암호화폐(Cryptocurrency)는 '암호(Crypto)'와 '통화(Currency)'의 합성어로, 분산원장 기술인 **블록체인(Blockchain)**을 기반으로 하여 암호화 기술을 통해 보안이 유지되는 디지털 자산 또는 가상 자산을 의미합니다.20 가장 핵심적인 특징은 정부, 중앙은행, 금융 회사와 같은 중앙화된 중개 기관 없이 개인과 개인이 직접(P2P, Peer-to-Peer) 가치를 주고받을 수 있는 탈중앙화된 시스템이라는 점입니다.21 거래 기록은 네트워크에 참여하는 다수의 컴퓨터에 분산되어 저장되므로, 위·변조가 사실상 불가능에 가깝습니다.22
암호화폐의 역사는 2008년 10월, '사토시 나카모토(Satoshi Nakamoto)'라는 가명을 쓰는 정체불명의 개인 혹은 집단이 "비트코인: 개인 간 전자화폐 시스템(Bitcoin: A Peer-to-Peer Electronic Cash System)"이라는 제목의 논문을 온라인에 공개하면서 시작되었습니다.22 이 논문은 은행과 같은 신뢰할 수 있는 제3자 없이도 이중 지불 문제를 해결하며 안전하게 온라인 결제를 할 수 있는 방법을 제시했습니다. 그리고 2009년 1월 3일, 첫 번째 블록인 '제네시스 블록'이 생성되면서 최초의 탈중앙화 암호화폐, 비트코인이 세상에 등장했습니다.20
비트코인의 탄생은 2008년 글로벌 금융위기 이후 기존 중앙집권적 금융 시스템에 대한 깊은 불신과 회의감 속에서 이루어졌습니다. 이는 단순히 새로운 기술의 등장을 넘어, 중개자를 배제하고 개인에게 금융 주권을 돌려주려는 철학적, 이념적 목표를 가진 사회적 운동의 성격을 띠었습니다.23 이러한 '반-체제적' DNA는 암호화폐가 상업적 필요에 의해 탄생한 주식과 근본적으로 다른 길을 걷게 만든 원동력입니다. 이 차이를 이해하는 것은 암호화폐 커뮤니티의 독특한 문화, 규제에 대한 강한 저항, 그리고 시장의 극단적인 변동성을 이해하는 핵심 열쇠가 됩니다.
비트코인의 성공 이후, 비트코인의 기술을 개선하거나 다른 목적을 가진 수많은 새로운 암호화폐가 등장하기 시작했습니다. 이들을 비트코인의 '대안(Alternative)'이라는 의미에서 **알트코인(Altcoin)**이라고 통칭합니다.20 초창기 알트코인으로는 비트코인보다 빠른 거래 속도를 목표로 한 라이트코인(Litecoin), 작업증명과 지분증명을 혼합한 피어코인(Peercoin) 등이 있었습니다.20 현재는 수만 종류가 넘는 알트코인이 각자의 목표와 기술을 가지고 경쟁하고 있으며, 이 중에는 혁신적인 기술을 가진 프로젝트도 있지만, 투자자를 현혹하기 위한 스캠(사기) 코인도 다수 존재하여 투자자의 각별한 주의가 요구됩니다.26
알트코인 중 가장 중요한 혁신을 이룬 것은 비탈릭 부테린이 개발한 이더리움(Ethereum)입니다. 비트코인이 'P2P 전자화폐' 즉, 가치 저장과 전송 기능에 집중했다면, 이더리움은 **스마트 컨트랙트(Smart Contract)**라는 개념을 블록체인에 구현하여 그 활용 가능성을 무한히 확장시켰습니다.22
스마트 컨트랙트란 특정 계약 조건이 충족되었을 때, 사전에 프로그래밍된 내용이 제3자의 개입 없이 자동으로 실행되도록 하는 컴퓨터 프로토콜입니다.28 예를 들어, 'A가 B에게 10 이더(ETH)를 보내면, B가 소유한 부동산 등기 정보가 A에게 자동으로 이전된다'와 같은 계약을 코드로 구현할 수 있습니다. 이는 변호사나 등기소 같은 전통적인 중개 기관 없이도 신뢰 기반의 복잡한 계약을 체결할 수 있게 만들었으며, 오늘날
탈중앙화 금융(DeFi), 대체 불가능 토큰(NFT), 탈중앙화 자율 조직(DAO) 등 수많은 블록체인 혁신의 기술적 토대가 되었습니다.27
암호화폐 시장은 전통 금융 시장과는 다른 독특한 기술과 원리에 의해 움직입니다.
암호화폐는 주로 거래소를 통해 매매됩니다. 거래소는 운영 주체에 따라 크게 두 종류로 나뉩니다.
암호화폐의 가격 역시 기본적으로는 수요와 공급의 원리에 따라 결정되지만, 그 가격에 영향을 미치는 동인은 주식과 매우 다릅니다.32
주식과 암호화폐는 모두 '투자 자산'이라는 공통점을 갖지만, 그 본질과 특성은 근본적으로 다릅니다. 성공적인 투자를 위해서는 두 자산 클래스 간의 핵심적인 차이점을 명확히 인지하는 것이 중요합니다. 이 장에서는 내재가치, 시장 운영 방식, 규제 환경 등 다양한 측면에서 두 시장을 심층적으로 비교 분석합니다.
자산의 가격을 결정하는 가장 근본적인 요소는 내재가치(Intrinsic Value)입니다. 주식과 암호화폐는 이 내재가치의 원천에서부터 극명한 차이를 보입니다.
이처럼 주식의 가치 평가가 '소유'의 패러다임에 기반한다면, 암호화폐의 가치 평가는 '참여'의 패러다임에 가깝습니다. "가상자산 보유가 발행 기업의 지분 소유를 의미하지는 않는다"는 점은 36, 전통적인 가치 평가 모델이 암호화폐에 그대로 적용될 수 없음을 명확히 보여줍니다.34 투자자는 주식을 매수할 때 '이 회사는 미래에 돈을 얼마나 잘 벌 것인가?'를 질문하지만, 암호화폐를 매수할 때는 '이 네트워크는 미래에 얼마나 많은 사람에게, 얼마나 유용하게 사용될 것인가?'를 질문해야 합니다. 이 패러다임의 전환을 이해하는 것이 두 자산을 올바르게 평가하는 첫걸음입니다.
두 시장은 거래가 이루어지는 방식과 이를 감독하는 규제 체계에서도 현격한 차이를 보입니다.
두 시장의 복잡한 특성을 강점(Strength), 약점(Weakness), 기회(Opportunity), 위협(Threat)이라는 네 가지 기준으로 정리하면, 투자자가 각 시장의 전략적 환경을 한눈에 파악하고 정보에 입각한 의사결정을 내리는 데 도움이 됩니다. 다음 표는 관련 연구 자료들을 종합하여 두 시장을 SWOT 관점에서 분석한 결과입니다.40
Table 1: SWOT 분석: 주식 시장 vs. 암호화폐 시장
| 구분 (Category) | 강점 (Strength) | 약점 (Weakness) | 기회 (Opportunity) | 위협 (Threat) | ||||||||||
| 주식 시장 (Stock Market) | - 기업 실적 기반의 명확한 가치 평가 3 | - 강력한 규제 및 투자자 보호 체계 40 |
- 배당 등 안정적 현금흐름 창출 가능 6 |
- 경제 성장과 함께 장기적 우상향 기대 17 |
- 제한된 거래 시간 및 낮은 유동성 40 | - 상대적으로 낮은 변동성 및 수익률 기대치 40 |
- 정보 비대칭성 및 전문 지식 요구 3 |
- 거시 경제 변수(금리, 인플레이션)에 취약 40 |
- 기업 밸류업 프로그램 등 주주환원 정책 강화 17 | - AI, 바이오 등 고부가가치 신산업 기업의 상장 17 |
- 글로벌 경제 성장에 따른 동반 성장 |
- 경기 침체 시 전반적인 시장 하락 위험 40 | - 지정학적 리스크 및 무역 분쟁 - 급격한 금리 인상 및 통화 긴축 |
|
| 암호화폐 시장 (Crypto Market) | - 365일 24시간 글로벌 거래 가능 41 | - 높은 변동성으로 인한 초고수익 잠재력 40 |
- 탈중앙화 및 검열 저항성 21 |
- 기술 혁신에 따른 높은 성장 잠재력 40 |
- 극심한 가격 변동성 및 높은 위험 20 | - 불분명한 가치 평가 기준 및 투기적 성격 34 |
- 규제 미비 및 투자자 보호 장치 부족 33 |
- 해킹, 사기, 시장 조작 등 보안 위험 노출 44 |
- 기관 투자자 참여 증가 및 제도권 편입 40 | - DeFi, NFT, RWA 등 생태계 확장 30 |
- 기술 발전을 통한 새로운 금융 시스템으로의 전환 가능성 56 |
- 각국 정부의 예측 불가능한 강력한 규제 도입 33 | - 시장 조작 및 시세 조종의 용이성 44 |
- 중앙은행 디지털화폐(CBDC) 도입에 따른 경쟁 및 대체 가능성 25 |
성공적인 투자는 시장을 정확히 이해하고 분석하는 능력에서 비롯됩니다. 이를 위해서는 시장에서 통용되는 언어, 즉 핵심 용어와 데이터를 해석하는 도구인 분석 지표에 대한 깊이 있는 이해가 필수적입니다. 이 장에서는 주식과 암호화폐 시장에서 반드시 알아야 할 핵심 용어와 기술적 분석 도구들을 총정리하여 투자자의 무기고를 채워드립니다.
암호화폐 시장은 기술과 문화가 결합된 독특한 생태계를 가지고 있어, 고유한 용어들이 많이 사용됩니다.
기술적 분석은 과거의 가격 움직임과 거래량 데이터를 분석하여 미래의 가격 방향을 예측하려는 시도입니다.73 이는 주식과 암호화폐 시장 모두에서 널리 사용되는 분석 방법입니다.
| 지표명 (Indicator) | 주요 목적 (Primary Purpose) | 핵심 해석 및 활용법 (Key Interpretation & Application) | 관련 자료 (Source) |
| 이동평균선 (Moving Average, MA) | 추세의 방향성 확인 및 지지/저항선 파악 | - 정배열/역배열: 단기 이평선이 장기 이평선 위에 있으면 상승 추세(정배열), 아래에 있으면 하락 추세(역배열)로 판단합니다. - 골든크로스/데드크로스: 단기 이평선이 장기 이평선을 위로 뚫고 올라가는 '골든크로스'는 강력한 매수 신호로, 반대로 아래로 뚫고 내려가는 '데드크로스'는 강력한 매도 신호로 해석됩니다. |
72 |
| 볼린저 밴드 (Bollinger Bands) | 가격의 변동성 및 상대적 과매수/과매도 구간 파악 | - 밴드 폭: 밴드의 폭이 좁아지는 '수축(Squeeze)' 구간은 에너지가 응축되는 시기로, 이후 큰 가격 변동이 나타날 것을 예고합니다. 폭이 넓어지는 '확장'은 변동성이 크다는 의미입니다. - 매매 전략: 가격이 상단 밴드에 닿거나 넘어서면 과매수(매도 고려), 하단 밴드에 닿거나 이탈하면 과매도(매수 고려) 상태로 해석하는 역추세 전략에 주로 활용됩니다. |
78 |
| 상대강도지수 (RSI, Relative Strength Index) | 가격 상승 압력과 하락 압력 간의 상대적 강도를 측정하여 과매수/과매도 수준을 판단하는 모멘텀 지표 | - 기준선: 일반적으로 RSI 값이 70을 넘으면 과매수 구간으로, 향후 가격 하락 가능성을 시사합니다. 반대로 30 미만이면 과매도 구간으로, 가격 반등 가능성을 시사합니다. - 다이버전스(Divergence): 가격은 신고점을 경신하는데 RSI의 고점은 낮아지는 '하락 다이버전스'는 추세 약화 및 하락 반전의 강력한 신호입니다. |
72 |
| 이동평균수렴확산 (MACD) | 두 이동평균선 간의 관계를 통해 추세의 방향, 강도, 그리고 전환 시점을 종합적으로 파악 | - MACD선과 시그널선 교차: MACD선이 시그널선을 상향 돌파(골든크로스)하면 매수 신호, 하향 돌파(데드크로스)하면 매도 신호로 해석합니다. - 오실레이터(히스토그램): 막대그래프가 0선을 기준으로 위에 있으면 상승 모멘텀이 강함을, 아래에 있으면 하락 모멘텀이 강함을 의미합니다. |
78 |
| 일목균형표 (Ichimoku Cloud) | 추세, 지지/저항, 모멘텀, 매매 신호를 하나의 차트에서 종합적으로 보여주는 선행성 지표 | - 구름대(Cloud): 가격이 구름대 위에 위치하면 상승 추세, 아래에 위치하면 하락 추세로 판단합니다. 구름대는 그 자체로 강력한 지지선 또는 저항선 역할을 합니다. - 전환선/기준선 교차: 단기 추세선인 전환선이 중기 추세선인 기준선을 상향 돌파하면 매수 신호(호전), 하향 돌파하면 매도 신호(역전)로 봅니다. |
78 |
시장에 대한 이해와 분석 도구를 갖추었다면, 다음 단계는 이를 바탕으로 자신만의 견고한 투자 전략을 수립하는 것입니다. 어떤 자산에, 어떤 방식으로, 어떤 원칙을 가지고 투자할 것인지를 정하는 것은 장기적인 성공의 핵심입니다. 이 장에서는 수십 년간 검증된 주식 투자 전략과 새롭게 부상하는 암호화폐 투자 전략, 그리고 모든 투자의 기본이 되는 포트폴리오 구성 및 위험 관리 방안을 제시합니다.
주식 시장의 오랜 역사만큼이나 다양한 투자 전략이 존재하지만, 크게 세 가지 접근법으로 분류할 수 있습니다.
암호화폐 시장은 역사가 짧고 변동성이 극심하여 아직 정형화된 투자 전략이 많지는 않지만, 시장의 특성을 반영한 몇 가지 대표적인 전략들이 존재합니다.
여기서 중요한 점은 암호화폐에서 얻는 '이자'나 '수익'이 주식의 '배당'과는 그 원천이 근본적으로 다르다는 것입니다. 주식의 배당은 기업이 실제 영업 활동을 통해 창출한 '이익'을 소유주인 주주에게 분배하는, 경제적 가치 창출의 결과물입니다.6 반면, 스테이킹 보상은 네트워크 보안 유지라는 '서비스 제공'에 대한 대가로, 그 재원은 주로 새로 발행되는 토큰(즉, 인플레이션)이나 네트워크 사용자들이 지불하는 거래 수수료에서 나옵니다.67 이는 이익 분배라기보다는 네트워크 운영 비용 지급에 가깝습니다. 이자 농사의 보상 역시 유동성 공급이라는 '위험 감수'에 대한 인센티브로, 주로 해당 프로토콜의 거버넌스 토큰으로 지급됩니다.70 이는 현재의 이익 분배가 아닌, '프로토콜의 미래 가치에 대한 약속'에 가깝습니다. 따라서 투자자는 암호화폐의 '고수익률'이라는 용어 이면에 있는 수익의 원천이 무엇인지 명확히 파악해야 하며, 이는 수익의 질과 지속 가능성, 그리고 내재된 위험을 평가하는 데 결정적인 차이를 만듭니다.
어떤 투자 전략을 선택하든, 모든 투자의 성공은 결국 위험 관리에 달려있습니다. 각 자산 클래스가 가진 고유한 위험을 이해하고 이를 관리하기 위한 체계적인 계획을 세워야 합니다.
투자 시장은 정체되어 있지 않고 끊임없이 진화합니다. 특히 기술 발전과 규제 환경의 변화는 주식과 암호화폐 시장의 미래를 결정짓는 가장 중요한 변수입니다. 이 장에서는 두 시장을 둘러싼 규제의 현재와 미래를 살펴보고, 기관 투자자의 진입, 중앙은행 디지털화폐(CBDC)의 등장 등 미래 시장의 판도를 바꿀 핵심 트렌드를 전망합니다.
규제는 투자자를 보호하고 시장의 건전성을 유지하는 필수적인 장치입니다. 주식 시장은 성숙한 규제 체계를 갖춘 반면, 암호화폐 시장은 이제 막 규제의 틀이 만들어지는 과정에 있습니다.
기술 발전과 새로운 참여자들의 등장은 두 시장의 미래를 더욱 예측하기 어렵게, 그러나 한편으로는 더욱 흥미롭게 만들고 있습니다.
본 보고서는 주식과 암호화폐라는 두 거대한 자산 시장의 기초부터 미래 전망까지를 포괄적으로 분석했습니다. 두 시장은 표면적으로는 유사해 보이지만, 그 탄생 배경, 내재가치의 원천, 작동 방식, 그리고 위험의 종류에 있어 근본적인 차이를 지니고 있습니다. 이 모든 분석을 종합하여, 현명한 투자자가 되기 위한 최종적인 제언을 다음과 같이 제시합니다.
이 보고서가 독자 여러분의 성공적인 투자 여정에 든든하고 신뢰할 수 있는 초석이 되기를 바랍니다.
개발을 하다 보면 가끔 "모노레포"라는 단어를 들어본 적이 있을 것이다.
프로젝트를 관리할 때 모노레포로 구성하면 관리하기 편하다고 하지만, 경험상 모노레포 구조를 가진 프로젝트들은 결국 확장됨에 따라 분리하는 절차를 밟게 되었었다.
오늘은 모노레포가 탄생한 배경, 그리고 어떨 때 적용하라고 만든 구조인지 알아보도록 하겠다.
Monorepo는 말 그대로 Mono(하나의) + Repo(Repository), 즉 여러 애플리케이션이나 라이브러리를 하나의 저장소에서 관리하는 구조이다.
그럼 지금까지 사용해 왔던 Polyrepo의 어떤 점을 보완하고자 나온 구조일까?
우선 Polyrepo의 장단점에 대해 알아보자.
Polyrepo는 다중 저장소를 이용하는 구조로, 프로젝트마다 별도의 저장소를 이용하는 구조이다.
즉, 프로젝트들이 물리적으로 분리되어 있어 연관되지 않는 Merge 충돌과 같은 상황을 방지할 수 있는 등 물리적인 분리에서 오는 장점들이 존재한다.
반대로 이러한 특징은 단점으로 적용되기도 한다.
이런 단점들을 개선하고자 등장한 구조가 바로 Monorepo다.
Monorepo는 여러 프로젝트를 하나의 저장소에서 관리한다.
이는 특히 다음과 같은 상황에서 큰 장점을 가진다.
utils나 domain 모듈을 하나의 저장소에서 직접 수정 및 반영 가능물론 Monorepo도 단점이 있다.
가장 대표적인 문제는 스케일에 따른 복잡성 증가다.
다음과 같은 경우에 Monorepo 구조는 적합하다.
반면, 대규모 서비스나 팀에서는 신중한 검토가 필요하다.
Google도 Monorepo를 사용하고 있지만, 이를 위해 독자적인 빌드 시스템과 버전 관리 시스템을 따로 구축한 사례다.
MultiModule은 주로 하나의 프로젝트 안에서 여러 기능을 모듈화해 관리하는 방식이다.
Java 기반의 프로젝트(Spring 등)에서 자주 볼 수 있다.
이 구조는 물리적으로는 Monorepo, 논리적으로는 Polyrepo와 유사한 형태를 갖는다.
core, common, domain 같은 공통 모듈을 다른 모듈들이 참조| 항목 | Monorepo | MultiModule |
|---|---|---|
| 저장소 구조 | 여러 프로젝트를 하나의 저장소에서 관리 | 하나의 프로젝트 안에서 모듈로 분리 |
| 독립성 | 높은 유연성 (서비스 단위 프로젝트 존재 가능) | 하나의 애플리케이션 중심 (빌드 및 배포 단위가 동일) |
| 공통 코드 관리 | 공통 라이브러리를 독립 패키지로도 구성 가능 | 대부분 동일한 버전을 공유 |
| 빌드 관리 | 고급 도구 필요 (Bazel, Nx 등) | Gradle, Maven 기반 관리 |
즉, MultiModule은 단일 서비스 또는 애플리케이션의 모듈화를 위한 설계,
Monorepo는 복수의 애플리케이션 또는 서비스 단위까지 고려한 구조라는 차이가 있다.
정리하자면, Monorepo는 통합성과 효율성에서 큰 장점을 가지지만, 이를 유지하려면 명확한 설계 기준과 도구의 도움이 필요하다.
처음에는 편리하지만, 규모가 커지면 관리 비용도 함께 커진다는 점을 명심해야 한다.
Monorepo의 경우 Bazel과 같은 고급 도구가 필요한 만큼 팀과 프로젝트의 성격에 따라 Polyrepo, Monorepo, MultiModule 중 어떤 구조를 적용할지 신중하게 고민하여 적용하는 것이 필요하다 본다.
| MSA 환경에서의 알림 서비스 설계와 구현 (2) | 2025.07.13 |
|---|---|
| 파티션과 샤딩에 관하여 (2) | 2025.06.30 |
| [해결 과제] 인증 서비스 왜 Refactoring 하였을까? (0) | 2025.04.04 |
| [EDA] EDA는 왜 적용하게 된걸까? (0) | 2025.04.03 |
| [MSA] 왜 MSA로 가야하나요? (0) | 2025.04.03 |
로컬에서 어떤 서비스가 특정 포트를 리스닝 중인데도 외부나 내부에서 접근이 안 되는 상황이 있었다.
예를 들어, API 서버가 6443 포트를 열고 있지만 nc, curl 등으로 접속이 실패하는 경우다.
# 테스트 커맨드
nc 127.0.0.1 6443 -zv -w 2
# 결과
nc: connect to 127.0.0.1 port 6443 (tcp) failed: Connection refused
이럴 때는 보통 3가지를 의심해 볼 수 있다.
이번 글에서는 방화벽, iptables 때문에 막힌 경우 어떻게 해결할 수 있는지를 다루고자 한다.
iptables는 리눅스에서 네트워크 패킷을 필터링하거나 포트 포워딩, NAT 등을 설정할 수 있는 방화벽 도구다.
우리가 설정하지 않아도, 클라우드 이미지나 OS 배포판이 기본 규칙을 넣어두는 경우가 있다.
sudo iptables -L -n -v
위 커멘드를 통해 iptable 정보들을 조회할 수 있다.
결과는 아래와 같은 형식으로 출력된다.
Chain INPUT (policy DROP 120 packets, 9600 bytes)
pkts bytes target prot opt in out source destination
10 800 ACCEPT tcp -- * * 0.0.0.0/0 0.0.0.0/0 tcp dpt:22
예를 들어 Kubernetes API Server나 커스텀 서버가 6443 포트를 사용하고 있다면 다음과 같이 열 수 있다.
# TCP 6443 포트를 허용
sudo iptables -A INPUT -p tcp --dport 6443 -j ACCEPT
이후 해당 설정을 적용하기 위해서는 OS마다 조금 다를 수 있다.
이 글은 Ubuntu를 기준으로 작성하도록 하겠다.
sudo apt install iptables-persistent
sudo netfilter-persistent save
만약 작업 중인 환경이 테스트 환경이고, 방화벽 내 설정을 비우고 싶다면 아래와 같이 설정해 주면 된다.
# 모든 규칙 초기화
sudo iptables -F
# 기본 정책 ACCEPT로 변경
sudo iptables -P INPUT ACCEPT
sudo iptables -P FORWARD ACCEPT
sudo iptables -P OUTPUT ACCEPT
내부에서 정상적으로 호출되지만 외부에서의 접속이 어렵다면, 이는 다른 레벨의 방화벽이 막고 있을 수 있다.
흔히 AWS의 InBound 규칙과 같은 규칙들을 생각할 수 있고, ufw 때문일 수도 있다.
ufw는 iptables를 기반으로 손쉽게 방화벽 설정을 할 수 있는 인터페이스이다.
그럼 iptables의 설정을 열었는데 왜 그 기반으로 동작하는 인터페이스인 ufw의 영향을 받게 되는가?
설정 자체는 iptables에 명시되어 있습니다. 다만 iptables의 규칙들은 서로 chain을 하고 있고, 설정 중에는 ufw에 대한 설정이 존재하게 된다.
즉, iptables에는 ACCEPT로 설정되어 있고, ufw에는 DROP으로 설정되어 있다면 해당 Connect 요청은 iptables까지 도달하지 못하고 ufw에서 막히는 상황이 발생할 수 있다.
정리하자면 iptables는 방화벽의 엔진, ufw는 그 위에 올라간 인터페이스일 뿐이지만,
ufw는 자신만의 규칙 체인을 만들기 때문에 차단되는 문제가 생길 수 있는 것이다.
개인 미니 PC에 Kubernetes를 설치하는 와중 특정 포트가 닫혀있는 것을 보게 되었고,
이를 해결하는 과정에서 왜 해당 설정이 필요한지, 어떤 내용들이 존재하는지를 기록하고자 글로 남기게 되었다.
가장 기본적인 부분이지만, 왜 방화벽 때문에 호출이 막히는 현상이 발생하는지가 궁금하였다면, 이 글이 기본적인 개념을 잡는 데에 도움이 될 것 같다.