vault backup: 2026-05-05 21:27:37

This commit is contained in:
son
2026-05-05 21:27:37 +09:00
parent dc1b910d36
commit 957d24aa9f
39 changed files with 42394 additions and 1 deletions

3
.gitignore vendored
View File

@@ -12,4 +12,5 @@ Thumbs.db
.idea/
# Ignore the obsidian-git plugin's internal data file
.obsidian-git-data
.obsidian-git-data
/copilot/

4
.obsidian/appearance.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"cssTheme": "Blue Topaz",
"theme": "moonstone"
}

4
.obsidian/community-plugins.json vendored Normal file
View File

@@ -0,0 +1,4 @@
[
"copilot",
"obsidian-git"
]

33
.obsidian/core-plugins.json vendored Normal file
View File

@@ -0,0 +1,33 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}

464
.obsidian/plugins/copilot/data.json vendored Normal file
View File

@@ -0,0 +1,464 @@
{
"userId": "82e491f6-82e0-4355-8fb7-3e9828e68e57",
"isPlusUser": false,
"plusLicenseKey": "",
"openAIApiKey": "",
"openAIOrgId": "",
"huggingfaceApiKey": "",
"cohereApiKey": "",
"anthropicApiKey": "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",
"azureOpenAIApiKey": "",
"azureOpenAIApiInstanceName": "",
"azureOpenAIApiDeploymentName": "",
"azureOpenAIApiVersion": "",
"azureOpenAIApiEmbeddingDeploymentName": "",
"googleApiKey": "",
"openRouterAiApiKey": "",
"xaiApiKey": "",
"mistralApiKey": "",
"deepseekApiKey": "",
"amazonBedrockApiKey": "",
"amazonBedrockRegion": "",
"siliconflowApiKey": "",
"githubCopilotAccessToken": "",
"githubCopilotToken": "",
"githubCopilotTokenExpiresAt": 0,
"defaultChainType": "llm_chain",
"defaultModelKey": "claude-sonnet-4-6|anthropic",
"embeddingModelKey": "openai/text-embedding-3-small|openrouterai",
"temperature": 0.1,
"maxTokens": 6000,
"contextTurns": 15,
"userSystemPrompt": "",
"openAIProxyBaseUrl": "",
"openAIEmbeddingProxyBaseUrl": "",
"stream": true,
"defaultSaveFolder": "copilot/copilot-conversations",
"defaultConversationTag": "copilot-conversation",
"autosaveChat": true,
"generateAIChatTitleOnSave": true,
"autoAddActiveContentToContext": true,
"defaultOpenArea": "view",
"defaultSendShortcut": "enter",
"customPromptsFolder": "copilot/copilot-custom-prompts",
"indexVaultToVectorStore": "ON MODE SWITCH",
"qaExclusions": "copilot",
"qaInclusions": "",
"chatNoteContextPath": "",
"chatNoteContextTags": [],
"enableIndexSync": true,
"debug": false,
"enableEncryption": false,
"maxSourceChunks": 30,
"enableInlineCitations": true,
"groqApiKey": "",
"activeModels": [
{
"name": "copilot-plus-flash",
"provider": "copilot-plus",
"enabled": true,
"isBuiltIn": true,
"core": true,
"plusExclusive": true,
"projectEnabled": false,
"capabilities": [
"vision"
]
},
{
"name": "google/gemini-2.5-flash",
"provider": "openrouterai",
"enabled": true,
"isBuiltIn": true,
"core": true,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "gpt-5.4",
"provider": "openai",
"enabled": true,
"isBuiltIn": true,
"core": true,
"capabilities": [
"vision"
]
},
{
"name": "gpt-5-mini",
"provider": "openai",
"enabled": true,
"isBuiltIn": true,
"core": true,
"capabilities": [
"vision"
]
},
{
"name": "claude-sonnet-4-6",
"provider": "anthropic",
"enabled": true,
"isBuiltIn": true,
"capabilities": [
"reasoning",
"vision"
]
},
{
"name": "gemini-3.1-flash-lite-preview",
"provider": "google",
"enabled": true,
"isBuiltIn": true,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "gemini-2.5-flash",
"provider": "google",
"enabled": true,
"isBuiltIn": true,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "google/gemini-3-flash-preview",
"provider": "openrouterai",
"enabled": false,
"isBuiltIn": true,
"capabilities": [
"vision",
"reasoning"
]
},
{
"name": "google/gemini-3.1-pro-preview",
"provider": "openrouterai",
"enabled": false,
"isBuiltIn": true,
"capabilities": [
"vision",
"reasoning"
]
},
{
"name": "google/gemini-2.5-pro",
"provider": "openrouterai",
"enabled": false,
"isBuiltIn": true,
"core": false,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "openai/gpt-5.4",
"provider": "openrouterai",
"enabled": false,
"isBuiltIn": true,
"core": false,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "openai/gpt-5-mini",
"provider": "openrouterai",
"enabled": false,
"isBuiltIn": true,
"core": false,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "grok-4-1-fast",
"provider": "xai",
"enabled": false,
"isBuiltIn": true,
"core": false,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "x-ai/grok-4.1-fast",
"provider": "openrouterai",
"enabled": false,
"isBuiltIn": true,
"core": false,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "gpt-4.1",
"provider": "openai",
"enabled": false,
"isBuiltIn": true,
"core": false,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "gpt-4.1-mini",
"provider": "openai",
"enabled": false,
"isBuiltIn": true,
"core": false,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "claude-opus-4-6",
"provider": "anthropic",
"enabled": false,
"isBuiltIn": true,
"capabilities": [
"vision",
"reasoning"
]
},
{
"name": "gemini-3-flash-preview",
"provider": "google",
"enabled": false,
"isBuiltIn": true,
"capabilities": [
"vision",
"reasoning"
]
},
{
"name": "gemini-3.1-pro-preview",
"provider": "google",
"enabled": false,
"isBuiltIn": true,
"capabilities": [
"vision",
"reasoning"
]
},
{
"name": "gemini-2.5-pro",
"provider": "google",
"enabled": false,
"isBuiltIn": true,
"projectEnabled": true,
"capabilities": [
"vision"
]
},
{
"name": "deepseek-chat",
"provider": "deepseek",
"enabled": false,
"isBuiltIn": true
},
{
"name": "deepseek-reasoner",
"provider": "deepseek",
"enabled": false,
"isBuiltIn": true,
"capabilities": [
"reasoning"
]
},
{
"name": "deepseek-ai/DeepSeek-V3",
"provider": "siliconflow",
"enabled": false,
"isBuiltIn": false,
"baseUrl": "https://api.siliconflow.com/v1"
},
{
"name": "deepseek-ai/DeepSeek-R1",
"provider": "siliconflow",
"enabled": false,
"isBuiltIn": false,
"baseUrl": "https://api.siliconflow.com/v1",
"capabilities": [
"reasoning"
]
},
{
"name": "claude-opus-4-7",
"provider": "anthropic",
"enabled": true
}
],
"activeEmbeddingModels": [
{
"name": "copilot-plus-small",
"provider": "copilot-plus",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true,
"plusExclusive": true
},
{
"name": "copilot-plus-large",
"provider": "copilot-plus-jina",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true,
"plusExclusive": true,
"believerExclusive": true,
"dimensions": 1024
},
{
"name": "copilot-plus-multilingual",
"provider": "copilot-plus-jina",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true,
"plusExclusive": true,
"dimensions": 512
},
{
"name": "openai/text-embedding-3-small",
"provider": "openrouterai",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true
},
{
"name": "text-embedding-3-small",
"provider": "openai",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true
},
{
"name": "gemini-embedding-001",
"provider": "google",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true
},
{
"name": "gemini-embedding-2-preview",
"provider": "google",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true
},
{
"name": "Qwen/Qwen3-Embedding-0.6B",
"provider": "siliconflow",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true,
"core": true,
"baseUrl": "https://api.siliconflow.com/v1"
},
{
"name": "text-embedding-3-large",
"provider": "openai",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true
},
{
"name": "embed-multilingual-light-v3.0",
"provider": "cohereai",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true
},
{
"name": "text-embedding-004",
"provider": "google",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true
},
{
"name": "azure-openai",
"provider": "azure openai",
"enabled": true,
"isBuiltIn": true,
"isEmbeddingModel": true
}
],
"embeddingRequestsPerMin": 60,
"embeddingBatchSize": 16,
"disableIndexOnMobile": true,
"showSuggestedPrompts": true,
"showRelevantNotes": true,
"numPartitions": 1,
"lexicalSearchRamLimit": 100,
"promptUsageTimestamps": {},
"promptSortStrategy": "timestamp",
"chatHistorySortStrategy": "recent",
"projectListSortStrategy": "recent",
"defaultConversationNoteName": "{$topic}@{$date}_{$time}",
"inlineEditCommands": [],
"projectList": [],
"lastDismissedVersion": null,
"passMarkdownImages": true,
"enableAutonomousAgent": true,
"enableCustomPromptTemplating": true,
"enableSemanticSearchV3": true,
"enableSelfHostMode": false,
"enableMiyo": false,
"miyoSearchAll": false,
"selfHostModeValidatedAt": null,
"selfHostValidationCount": 0,
"selfHostUrl": "",
"selfHostApiKey": "",
"miyoServerUrl": "",
"selfHostSearchProvider": "firecrawl",
"firecrawlApiKey": "",
"perplexityApiKey": "",
"supadataApiKey": "",
"enableLexicalBoosts": true,
"suggestedDefaultCommands": true,
"autonomousAgentMaxIterations": 4,
"autonomousAgentEnabledToolIds": [
"localSearch",
"readNote",
"webSearch",
"pomodoro",
"youtubeTranscription",
"writeFile",
"editFile",
"updateMemory"
],
"reasoningEffort": "low",
"verbosity": "medium",
"memoryFolderName": "copilot/memory",
"enableRecentConversations": true,
"maxRecentConversations": 30,
"enableSavedMemory": true,
"quickCommandIncludeNoteContext": true,
"autoIncludeTextSelection": false,
"autoAddSelectionToContext": false,
"autoAcceptEdits": false,
"diffViewMode": "split",
"userSystemPromptsFolder": "copilot/system-prompts",
"defaultSystemPromptTitle": "",
"autoCompactThreshold": 128000,
"convertedDocOutputFolder": ""
}

3444
.obsidian/plugins/copilot/main.js vendored Normal file

File diff suppressed because one or more lines are too long

13
.obsidian/plugins/copilot/manifest.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"id": "copilot",
"name": "Copilot",
"version": "3.2.8",
"minAppVersion": "0.15.0",
"description": "Your AI Copilot: Chat with Your Second Brain, Learn Faster, Work Smarter.",
"author": "Logan Yang",
"authorUrl": "https://twitter.com/logancyang",
"fundingUrl": {
"Buy Me a Coffee": "https://www.buymeacoffee.com/logancyang",
"GitHub Sponsor": "https://github.com/sponsors/logancyang"
}
}

1
.obsidian/plugins/copilot/styles.css vendored Normal file

File diff suppressed because one or more lines are too long

452
.obsidian/plugins/obsidian-git/main.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
{
"author": "Vinzent",
"authorUrl": "https://github.com/Vinzent03",
"id": "obsidian-git",
"name": "Git",
"description": "Integrate Git version control with automatic backup and other advanced features.",
"isDesktopOnly": false,
"fundingUrl": "https://ko-fi.com/vinzent",
"version": "2.38.2"
}

View File

@@ -0,0 +1,710 @@
@keyframes loading {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.git-signs-gutter {
.cm-gutterElement {
/* Needed to align the sign properly for different line heigts. Such as
* when having a heading or list item.
*/
padding-top: 0 !important;
}
}
.workspace-leaf-content[data-type="git-view"] .button-border {
border: 2px solid var(--interactive-accent);
border-radius: var(--radius-s);
}
.workspace-leaf-content[data-type="git-view"] .view-content {
padding-left: 0;
padding-top: 0;
padding-right: 0;
}
.workspace-leaf-content[data-type="git-history-view"] .view-content {
padding-left: 0;
padding-top: 0;
padding-right: 0;
}
.loading {
overflow: hidden;
}
.loading > svg {
animation: 2s linear infinite loading;
transform-origin: 50% 50%;
display: inline-block;
}
.obsidian-git-center {
margin: auto;
text-align: center;
width: 50%;
}
.obsidian-git-textarea {
display: block;
margin-left: auto;
margin-right: auto;
}
.obsidian-git-disabled {
opacity: 0.5;
}
.obsidian-git-center-button {
display: block;
margin: 20px auto;
}
.tooltip.mod-left {
overflow-wrap: break-word;
}
.tooltip.mod-right {
overflow-wrap: break-word;
}
/* Limits the scrollbar to the view body */
.git-view {
display: flex;
flex-direction: column;
position: relative;
height: 100%;
}
/* Re-enable wrapping of nav buttns to prevent overflow on smaller screens #*/
.workspace-drawer .git-view .nav-buttons-container {
flex-wrap: wrap;
}
.git-tools {
display: flex;
margin-left: auto;
}
.git-tools .type {
padding-left: var(--size-2-1);
display: flex;
align-items: center;
justify-content: center;
width: 11px;
}
.git-tools .type[data-type="M"] {
color: orange;
}
.git-tools .type[data-type="D"] {
color: red;
}
.git-tools .buttons {
display: flex;
}
.git-tools .buttons > * {
padding: 0 0;
height: auto;
}
.workspace-leaf-content[data-type="git-view"] .tree-item-self,
.workspace-leaf-content[data-type="git-history-view"] .tree-item-self {
align-items: center;
}
.workspace-leaf-content[data-type="git-view"]
.tree-item-self:hover
.clickable-icon,
.workspace-leaf-content[data-type="git-history-view"]
.tree-item-self:hover
.clickable-icon {
color: var(--icon-color-hover);
}
/* Highlight an item as active if it's diff is currently opened */
.is-active .git-tools .buttons > * {
color: var(--nav-item-color-active);
}
.git-author {
color: var(--text-accent);
}
.git-date {
color: var(--text-accent);
}
.git-ref {
color: var(--text-accent);
}
/* ====== diff2html ======
The following styles are adapted from the obsidian-version-history plugin by
@kometenstaub https://github.com/kometenstaub/obsidian-version-history-diff/blob/main/src/styles.scss
which itself is adapted from the diff2html library with the following original license:
https://github.com/rtfpessoa/diff2html/blob/master/LICENSE.md
Copyright 2014-2016 Rodrigo Fernandes https://rtfpessoa.github.io/
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.theme-dark,
.theme-light {
--git-delete-bg: #ff475040;
--git-delete-hl: #96050a75;
--git-insert-bg: #68d36840;
--git-insert-hl: #23c02350;
--git-change-bg: #ffd55840;
--git-selected: #3572b0;
--git-delete: #c33;
--git-insert: #399839;
--git-change: #d0b44c;
--git-move: #3572b0;
}
.git-diff {
.d2h-d-none {
display: none;
}
.d2h-wrapper {
text-align: left;
border-radius: 0.25em;
overflow: auto;
}
.d2h-file-header.d2h-file-header {
background-color: var(--background-secondary);
border-bottom: 1px solid var(--background-modifier-border);
font-family:
Source Sans Pro,
Helvetica Neue,
Helvetica,
Arial,
sans-serif;
height: 35px;
padding: 5px 10px;
}
.d2h-file-header,
.d2h-file-stats {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
}
.d2h-file-header {
display: none;
}
.d2h-file-stats {
font-size: 14px;
margin-left: auto;
}
.d2h-lines-added {
border: 1px solid var(--color-green);
border-radius: 5px 0 0 5px;
color: var(--color-green);
padding: 2px;
text-align: right;
vertical-align: middle;
}
.d2h-lines-deleted {
border: 1px solid var(--color-red);
border-radius: 0 5px 5px 0;
color: var(--color-red);
margin-left: 1px;
padding: 2px;
text-align: left;
vertical-align: middle;
}
.d2h-file-name-wrapper {
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
display: -webkit-box;
display: -ms-flexbox;
display: flex;
font-size: 15px;
width: 100%;
}
.d2h-file-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-normal);
font-size: var(--h5-size);
}
.d2h-file-wrapper {
border: 1px solid var(--background-secondary-alt);
border-radius: 3px;
margin-bottom: 1em;
max-height: 100%;
}
.d2h-file-collapse {
-webkit-box-pack: end;
-ms-flex-pack: end;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
border: 1px solid var(--background-secondary-alt);
border-radius: 3px;
cursor: pointer;
display: none;
font-size: 12px;
justify-content: flex-end;
padding: 4px 8px;
}
.d2h-file-collapse.d2h-selected {
background-color: var(--git-selected);
}
.d2h-file-collapse-input {
margin: 0 4px 0 0;
}
.d2h-diff-table {
border-collapse: collapse;
font-family: var(--font-monospace);
font-size: var(--code-size);
width: 100%;
}
.d2h-files-diff {
width: 100%;
}
.d2h-file-diff {
/*
overflow-y: scroll;
*/
border-radius: 5px;
font-size: var(--font-text-size);
line-height: var(--line-height-normal);
}
.d2h-file-side-diff {
display: inline-block;
margin-bottom: -8px;
margin-right: -4px;
overflow-x: scroll;
overflow-y: hidden;
width: 50%;
}
.d2h-code-line {
padding-left: 6em;
padding-right: 1.5em;
}
.d2h-code-line,
.d2h-code-side-line {
display: inline-block;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
white-space: nowrap;
width: 100%;
}
.d2h-code-side-line {
/* needed to be changed */
padding-left: 0.5em;
padding-right: 0.5em;
}
.d2h-code-line-ctn {
word-wrap: normal;
background: none;
display: inline-block;
padding: 0;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
user-select: text;
vertical-align: middle;
width: 100%;
/* only works for line-by-line */
white-space: pre-wrap;
}
.d2h-code-line del,
.d2h-code-side-line del {
background-color: var(--git-delete-hl);
color: var(--text-normal);
}
.d2h-code-line del,
.d2h-code-line ins,
.d2h-code-side-line del,
.d2h-code-side-line ins {
border-radius: 0.2em;
display: inline-block;
margin-top: -1px;
text-decoration: none;
vertical-align: middle;
}
.d2h-code-line ins,
.d2h-code-side-line ins {
background-color: var(--git-insert-hl);
text-align: left;
}
.d2h-code-line-prefix {
word-wrap: normal;
background: none;
display: inline;
padding: 0;
white-space: pre;
}
.line-num1 {
float: left;
}
.line-num1,
.line-num2 {
-webkit-box-sizing: border-box;
box-sizing: border-box;
overflow: hidden;
/*
padding: 0 0.5em;
*/
text-overflow: ellipsis;
width: 2.5em;
padding-left: 0;
}
.line-num2 {
float: right;
}
.d2h-code-linenumber {
background-color: var(--background-primary);
border: solid var(--background-modifier-border);
border-width: 0 1px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: var(--text-faint);
cursor: pointer;
display: inline-block;
position: absolute;
text-align: right;
width: 5.5em;
}
.d2h-code-linenumber:after {
content: "\200b";
}
.d2h-code-side-linenumber {
background-color: var(--background-primary);
border: solid var(--background-modifier-border);
border-width: 0 1px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: var(--text-faint);
cursor: pointer;
overflow: hidden;
padding: 0 0.5em;
text-align: right;
text-overflow: ellipsis;
width: 4em;
/* needed to be changed */
display: table-cell;
position: relative;
}
.d2h-code-side-linenumber:after {
content: "\200b";
}
.d2h-code-side-emptyplaceholder,
.d2h-emptyplaceholder {
background-color: var(--background-primary);
border-color: var(--background-modifier-border);
}
.d2h-code-line-prefix,
.d2h-code-linenumber,
.d2h-code-side-linenumber,
.d2h-emptyplaceholder {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.d2h-code-linenumber,
.d2h-code-side-linenumber {
direction: rtl;
}
.d2h-del {
background-color: var(--git-delete-bg);
border-color: var(--git-delete-hl);
}
.d2h-ins {
background-color: var(--git-insert-bg);
border-color: var(--git-insert-hl);
}
.d2h-info {
background-color: var(--background-primary);
border-color: var(--background-modifier-border);
color: var(--text-faint);
}
.d2h-del,
.d2h-ins,
.d2h-file-diff .d2h-change {
color: var(--text-normal);
}
.d2h-file-diff .d2h-del.d2h-change {
background-color: var(--git-change-bg);
}
.d2h-file-diff .d2h-ins.d2h-change {
background-color: var(--git-insert-bg);
}
.d2h-file-list-wrapper {
a {
text-decoration: none;
cursor: default;
-webkit-user-drag: none;
}
svg {
display: none;
}
}
.d2h-file-list-header {
text-align: left;
}
.d2h-file-list-title {
display: none;
}
.d2h-file-list-line {
display: -webkit-box;
display: -ms-flexbox;
display: flex;
text-align: left;
}
.d2h-file-list {
}
.d2h-file-list > li {
border-bottom: 1px solid var(--background-modifier-border);
margin: 0;
padding: 5px 10px;
}
.d2h-file-list > li:last-child {
border-bottom: none;
}
.d2h-file-switch {
cursor: pointer;
display: none;
font-size: 10px;
}
.d2h-icon {
fill: currentColor;
margin-right: 10px;
vertical-align: middle;
}
.d2h-deleted {
color: var(--git-delete);
}
.d2h-added {
color: var(--git-insert);
}
.d2h-changed {
color: var(--git-change);
}
.d2h-moved {
color: var(--git-move);
}
.d2h-tag {
background-color: var(--background-secondary);
display: -webkit-box;
display: -ms-flexbox;
display: flex;
font-size: 10px;
margin-left: 5px;
padding: 0 2px;
}
.d2h-deleted-tag {
border: 1px solid var(--git-delete);
}
.d2h-added-tag {
border: 1px solid var(--git-insert);
}
.d2h-changed-tag {
border: 1px solid var(--git-change);
}
.d2h-moved-tag {
border: 1px solid var(--git-move);
}
/* needed for line-by-line*/
.d2h-diff-tbody {
position: relative;
}
}
/* ====================== Line Authoring Information ====================== */
.cm-gutterElement.obs-git-blame-gutter {
/* Add background color to spacing inbetween and around the gutter for better aesthetics */
border-width: 0px 2px 0.2px 2px;
border-style: solid;
border-color: var(--background-secondary);
background-color: var(--background-secondary);
}
.cm-gutterElement.obs-git-blame-gutter > div,
.line-author-settings-preview {
/* delegate text color to settings */
color: var(--obs-git-gutter-text);
font-family: monospace;
height: 100%; /* ensure, that age-based background color occupies entire parent */
text-align: right;
padding: 0px 6px 0px 6px;
white-space: pre; /* Keep spaces and do not collapse them. */
}
@media (max-width: 800px) {
/* hide git blame gutter not to superpose text */
.cm-gutterElement.obs-git-blame-gutter {
display: none;
}
}
.git-unified-diff-view,
.git-split-diff-view .cm-deletedLine .cm-changedText {
background-color: #ee443330;
}
.git-unified-diff-view,
.git-split-diff-view .cm-insertedLine .cm-changedText {
background-color: #22bb2230;
}
.git-obscure-prompt[git-is-obscured="true"] #git-show-password:after {
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-eye"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"></path><circle cx="12" cy="12" r="3"></circle></svg>');
}
.git-obscure-prompt[git-is-obscured="false"] #git-show-password:after {
-webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="svg-icon lucide-eye-off"><path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"></path><path d="M14.084 14.158a3 3 0 0 1-4.242-4.242"></path><path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"></path><path d="m2 2 20 20"></path></svg>');
}
/* Override styling of Codemirror merge view "collapsed lines" indicator */
.git-split-diff-view .ͼ2 .cm-collapsedLines {
background: var(--interactive-normal);
border-radius: var(--radius-m);
color: var(--text-accent);
font-size: var(--font-small);
padding: var(--size-4-1) var(--size-4-1);
}
.git-split-diff-view .ͼ2 .cm-collapsedLines:hover {
background: var(--interactive-hover);
color: var(--text-accent-hover);
}
.git-signs-gutter {
.cm-gutterElement {
display: grid;
}
}
.git-gutter-marker:hover {
border-radius: 2px;
}
.git-gutter-marker.git-add {
background-color: var(--color-green);
justify-self: center;
height: inherit;
width: 0.2rem;
}
.git-gutter-marker.git-change {
background-color: var(--color-yellow);
justify-self: center;
height: inherit;
width: 0.2rem;
}
.git-gutter-marker.git-changedelete {
color: var(--color-yellow);
font-weight: var(--font-bold);
font-size: 1rem;
justify-self: center;
height: inherit;
}
.git-gutter-marker.git-delete {
background-color: var(--color-red);
height: 0.2rem;
width: 0.8rem;
align-self: end;
}
.git-gutter-marker.git-topdelete {
background-color: var(--color-red);
height: 0.2rem;
width: 0.8rem;
align-self: start;
}
div:hover > .git-gutter-marker.git-change {
width: 0.6rem;
}
div:hover > .git-gutter-marker.git-add {
width: 0.6rem;
}
div:hover > .git-gutter-marker.git-delete {
height: 0.6rem;
}
div:hover > .git-gutter-marker.git-topdelete {
height: 0.6rem;
}
div:hover > .git-gutter-marker.git-changedelete {
font-weight: var(--font-bold);
}
.git-gutter-marker.staged {
opacity: 0.5;
}
.git-diff {
.cm-merge-revert {
width: 4em;
}
/* Ensure that merge revert markers are positioned correctly */
.cm-merge-revert > * {
position: absolute;
background-color: var(--background-secondary);
display: flex;
}
}
/* Prevent shifting of the editor when git signs gutter is the only gutter present */
.cm-gutters.cm-gutters-before:has(> .git-signs-gutter:only-child) {
margin-inline-end: 0;
.git-signs-gutter {
margin-inline-start: -1rem;
}
}
.git-changes-status-bar-colored {
.git-add {
color: var(--color-green);
}
.git-change {
color: var(--color-yellow);
}
.git-delete {
color: var(--color-red);
}
}
.git-changes-status-bar .git-add {
margin-right: 0.3em;
}
.git-changes-status-bar .git-change {
margin-right: 0.3em;
}

View File

@@ -0,0 +1,7 @@
{
"name": "Blue Topaz",
"version": "2026011402",
"minAppVersion": "1.0.0",
"author": "WhyI & Pkmer",
"authorUrl": "https://github.com/whyt-byte"
}

29671
.obsidian/themes/Blue Topaz/theme.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

918
projects/Didit 분석.md Normal file
View File

@@ -0,0 +1,918 @@
# Didit
> 본 문서는 Write-docs.md 템플릿에 따라 실제 코드에서 확인된 내용만을 바탕으로 작성되었다.
> 확인되지 않는 내용은 "확인 필요"로 표기한다.
---
## 1. 프로젝트 목적
### 해결하려는 문제
- 분산된 팀이 GitHub 이슈 기반으로 협업할 때, **이슈 우선순위 판단이 수동적**이고 **회의 내용이 체계적으로 기록/공유되지 않는 문제**
- 실시간 화상회의 + 채팅 + GitHub 이슈 관리가 각각 분리되어 **워크플로우 단절**이 발생하는 문제
### 주요 사용자
- GitHub 저장소를 사용하는 개발 팀
- 실시간 화상회의로 소통하면서 이슈 트래킹이 필요한 애자일/스크럼 팀
### 프로젝트 목표
- 하나의 플랫폼에서 **화상회의 + 채팅 + GitHub 이슈 관리 + AI 기반 요약/우선순위 분석**을 통합 제공
### 백엔드가 담당하는 핵심 책임
- RESTful API 제공 (프로젝트 CRUD, 이슈 관리, 회의 관리, 채팅)
- GitHub OAuth2 인증/인가 처리
- OpenVidu WebRTC 미디어 서버 연동 (세션/커넥션/녹화 관리)
- Redis 기반 비동기 작업 큐잉 (AI 분석 요청 → 응답 수신)
- SSE(Server-Sent Events) 기반 실시간 이벤트 스트리밍
- Flyway 기반 DB 마이그레이션 관리
### 인프라 구성이 필요한 이유
- 5개 이상의 서비스(서버, DB, Redis, OpenVidu, AI Worker, 클라이언트)가 **유기적으로 연동**되어야 하므로 컨테이너 오케스트레이션이 필요
- 내부 네트워크 분리로 서비스 간 보안 격리 필요
- Reverse Proxy를 통한 도메인 라우팅 및 HTTPS 처리 필요
### 한 줄 설명
**"GitHub OAuth 기반의 실시간 팀 협업 플랫폼으로, AI가 이슈 우선순위를 분석하고 회의 내용을 자동 요약한다."**
---
## 2. 전체 아키텍처
### 전체 구성 요소
| 계층 | 기술 스택 | 역할 |
|------|-----------|------|
| **Reverse Proxy** | Traefik | 도메인 라우팅, HTTPS 종단 |
| **Frontend** | React | SPA 클라이언트 |
| **Backend** | Java 21 + Spring Boot 4.0 | REST API, 인증/인가, SSE |
| **Media Server** | OpenVidu 2.32.1 | WebRTC 화상회의 (SFU) |
| **AI Worker** | Python (FastAPI) + HuggingFace | 이슈 우선순위 분석, 회의 요약 (Whisper STT + Claude API) |
| **Message Broker** | Redis 7.2 | 작업 큐, Pub/Sub, SSE client key 저장 |
| **Database** | MySQL 8.0 | Source of Truth |
| **검색 엔진** | Elasticsearch | (의존성만 존재, 실제 사용 확인 필요) |
### 프론트엔드/백엔드/DB/Redis/Storage/Worker/Reverse Proxy 관계
```
외부 요청 (HTTPS)
Traefik (Reverse Proxy)
├─► didit-client (React 정적 서빙)
├─► didit-server (Spring Boot :8080)
│ ├─► MySQL 8.0 (:3306, internal 네트워크만)
│ ├─► Redis 7.2 (:6379)
│ ├─► OpenVidu (:4443, internal 네트워크만)
│ └─► SSE Emitter → 클라이언트 직접 연결
└─► (AI Worker는 HTTP 외부 노출 없음, Redis 큐로만 통신)
```
### 외부 요청이 내부 서비스로 전달되는 흐름
1. 클라이언트 → `https://did-it.xyz` → Traefik → `didit-client` (정적 파일) 또는 `didit-server` (API)
2. `didit-server``internal` 네트워크를 통해 `db:3306`, `redis:6379`, `openvidu:4443`와 통신
3. AI 분석 요청: 서버가 Redis List(`queue:issue:priority:single`)에 작업 Push → AI Worker가 polling → 결과 Redis Pub/Sub 발행 → 서버 Listener가 수신 → SSE로 클라이언트 전달
### 서비스 간 책임 분리
- **didit-server**: 모든 비즈니스 로직, 인증, API, SSE, 외부 연동 조율
- **AI Worker**: 순수 ML 추론 작업만 수행, HTTP 엔드포인트 없이 Redis로만 통신
- **OpenVidu**: WebRTC 미디어 스트리밍만 담당, 시그널링/녹화는 서버가 조율
- **MySQL**: 모든 영속 데이터 저장
- **Redis**: 캐시/세션보다 **비동기 작업 큐 + Pub/Sub 메시징**이 주 용도
### 외부 공개 서비스와 내부 서비스 구분
| 공개 (외부) | 비공개 (내부) |
|-------------|---------------|
| Traefik (443) | MySQL (3306) |
| didit-server (8080, Caddy 네트워크 경유) | Redis (6379, 포트는 외부 노출되어 있음 - 개선 여지 있음) |
| didit-client | OpenVidu (4443) |
| | AI Worker |
### 아키텍처 유형
**모듈러 모놀리스**에 가깝다. 하나의 Spring Boot 애플리케이션이 API + SSE + 보안을 모두 담당하며, AI 분석만 별도 Python Worker로 분리되어 있다. 패키지 구조는 전통적인 3계층(api/service/data)으로 설계되어 있다.
### 아키텍처 설계 의도
- **SSE + Redis Pub/Sub**으로 실시간성을 확보하면서도 WebSocket보다 가벼운 인프라 구성
- AI Worker를 별도 프로세스로 분리해 **ML 추론 부하가 API 응답성에 영향을 주지 않도록** 설계
- GitHub OAuth2 + Session Cookie 방식으로 **SPA에서도 안전한 인증** 구현
---
## 3. API 설계
### API 버전 관리
- URL 경로 기반 버저닝: `/api/v1/`
- Base URL: `https://api.did-it.xyz/api/v1`
### Swagger/OpenAPI 문서화
- SpringDoc OpenAPI 3.0.1 (Swagger UI 연동)
- `openapi.yaml` 파일 제공 (`/openapi.yaml`, `/v3/api-docs/swagger-config`)
- 운영 환경(`prod`)에서는 Swagger UI / API Docs 비활성화
### 주요 API 목록 (총 30개+ 엔드포인트)
#### 인증 (Auth)
| Method | Endpoint | 인증 | Request DTO | Response DTO | 성공 | 실패 |
|--------|----------|------|-------------|-------------|------|------|
| GET | `/api/v1/auth/login` | 불필요 | - | Redirect | 302 | - |
| POST | `/api/v1/auth/logout` | 불필요 | - | - | 200 | - |
| GET | `/api/v1/auth/me` | 필요 | - | UserResponse | 200 | 401 |
#### 프로젝트 (Projects)
| Method | Endpoint | 인증 | 권한 | Request DTO | Response DTO |
|--------|----------|------|------|-------------|-------------|
| GET | `/api/v1/projects?page={n}` | 필요 | 멤버 | - | ProjectResponse[] |
| POST | `/api/v1/projects` | 필요 | - | AddProjectRequest | 200 empty |
| GET | `/api/v1/projects/{projectId}` | 필요 | 멤버 | - | ProjectResponse |
| DELETE | `/api/v1/projects/{projectId}` | 필요 | OWNER | - | 200 empty |
| DELETE | `/api/v1/projects/{projectId}/leave` | 필요 | 멤버 | - | 200 empty |
| GET | `/api/v1/projects/recents` | 필요 | - | - | ProjectRecentResponse[] |
| GET | `/api/v1/projects/{projectId}/participants` | 필요 | - | - | UserResponse[] |
| DELETE | `/api/v1/projects/{projectId}/participants/{userId}` | 필요 | OWNER | - | 200 empty |
| PATCH | `/api/v1/projects/{projectId}` | 필요 | OWNER | UpdateRepoRequest | 200 empty |
| PATCH | `/api/v1/projects/{projectId}/owner` | 필요 | OWNER | TransferOwnerRequest | 200 empty |
| PATCH | `/api/v1/projects/{projectId}/name` | 필요 | OWNER | UpdateProjectNameRequest | 200 empty |
| POST | `/api/v1/projects/{projectId}/github/validate` | 필요 | OWNER | ValidateGithubRequest | GithubRepoResponse |
#### 초대 (Invites)
| Method | Endpoint | 인증 | 권한 | Request DTO | Response DTO |
|--------|----------|------|------|-------------|-------------|
| POST | `/api/v1/projects/invites` | 필요 | ADMIN | AddProjectInviteRequest | UUID String |
| GET | `/api/v1/projects/invites/{inviteCode}` | 필요 | - | - | ProjectResponse |
| POST | `/api/v1/projects/invites/{inviteCode}` | 필요 | - | - | 200 empty |
#### 회의 (Meetings/Channels)
| Method | Endpoint | 인증 | 권한 | Request DTO | Response DTO |
|--------|----------|------|------|-------------|-------------|
| POST | `/api/v1/projects/{projectId}/add-channel` | 필요 | ADMIN | CreateMeetingRequest | Long (meetingId) |
| POST | `/api/v1/projects/{projectId}/book-channel` | 필요 | ADMIN | BookMeetingRequest | Long (meetingId) |
| GET | `/api/v1/projects/{projectId}/channels?status&cursor` | 필요 | - | - | MeetingResponse[] |
| GET | `/api/v1/projects/{projectId}/channels/date?start&end` | 필요 | 멤버 | - | MeetingResponse[] |
| GET | `/api/v1/channels/{channelId}` | 필요 | 멤버 | - | MeetingResponse |
| PATCH | `/api/v1/channels/{channelId}?title&start&due` | 필요 | 멤버 | - | 200 empty |
| DELETE | `/api/v1/channels/{channelId}` | 필요 | 멤버 | - | 200 empty |
| POST | `/api/v1/channels/{channelId}/webrtc` | 필요 | 멤버(VOICE) | - | String (token) |
| DELETE | `/api/v1/channels/{channelId}/webrtc` | 필요 | 멤버(VOICE) | - | 200 empty |
| GET | `/api/v1/channels/{channelId}/webrtc/users` | 필요 | - | - | UserResponse[] |
| POST | `/api/v1/channels/{channelId}/recording/start` | 필요 | 멤버(VOICE) | - | Long (recordId) |
| POST | `/api/v1/channels/{channelId}/recording/stop/{recordId}` | 필요 | - | - | 200 empty |
#### 채팅 (Chat)
| Method | Endpoint | 인증 | 권한 | Request DTO | Response DTO |
|--------|----------|------|------|-------------|-------------|
| POST | `/api/v1/channels/{channelId}/chats` | 필요 | 멤버 | SendMessageRequest | 200 empty |
| GET | `/api/v1/channels/{channelId}/chats?lastId` | 필요 | 멤버 | - | ChatResponse[] |
| PATCH | `/api/v1/channels/chats/{chatId}` | 필요 | 작성자 | UpdateChatMessage | 200 empty |
| DELETE | `/api/v1/channels/chats/{chatId}` | 필요 | 작성자 | - | 200 empty |
#### SSE (Server-Sent Events)
| Method | Endpoint | 인증 | 권한 | 설명 |
|--------|----------|------|------|------|
| GET | `/api/v1/channels/{channelId}/stream` | 필요 | 멤버 | 채널 단위 실시간 이벤트 |
| GET | `/api/v1/projects/{projectId}/stream` | 필요 | 멤버 | 프로젝트 단위 실시간 이벤트 |
#### 이슈 (Issues)
| Method | Endpoint | 인증 | 권한 | Request DTO | Response DTO |
|--------|----------|------|------|-------------|-------------|
| GET | `/api/v1/projects/{projectId}/issues/active` | 필요 | 멤버 | - | IssueResponse[] |
| GET | `/api/v1/projects/{projectId}/issues` | 필요 | 멤버 | - | IssueResponse[] |
| POST | `/api/v1/projects/{projectId}/issue/analyze` | 필요 | 멤버 | AnalyzeIssueRequest | AiResponse |
| POST | `/api/v1/projects/{projectId}/issues` | 필요 | 작성자 | CreateIssueRequest | IssueResponse[] |
| PATCH | `/api/v1/projects/{projectId}/issues/{issueId}` | 필요 | 작성자 | UpdateIssueRequest | 200 empty |
| DELETE | `/api/v1/projects/{projectId}/issues/{issueId}` | 필요 | 작성자 | - | 200 empty |
| GET | `/api/v1/projects/{projectId}/github/issues` | 필요 | 멤버 | - | IssueResponse[] |
#### 웹훅 (Webhooks)
| Method | Endpoint | 인증 | 설명 |
|--------|----------|------|------|
| POST | `/api/v1/webhooks/openvidu` | Token 검증 | OpenVidu 이벤트 수신 (세션/참가자/녹화 이벤트) |
#### 회의 요약 (Meeting Summary)
| Method | Endpoint | 인증 | 권한 |
|--------|----------|------|------|
| GET | `/api/v1/projects/{projectId}/summary?page` | 필요 | 멤버 |
| PATCH | `/api/v1/projects/{projectId}/summary/{summaryId}` | 필요 | 멤버 |
| DELETE | `/api/v1/projects/{projectId}/summary/{summaryId}` | 필요 | ADMIN |
### 페이징 방식
- **Spring Pageable** (`page`, `size`, `sort` 쿼리 파라미터) - 회의 목록, 채팅 목록
- **커서 기반** (`cursor`/`lastId`) - 일부 엔드포인트에서 사용
### 공통 응답 패턴
- 성공: `200 OK` + DTO (빈 body인 경우도 있음)
- 실패: `ErrorResponse(statusCode, message)` + 해당 HTTP Status
---
## 4. 인증/인가 설계
### 로그인 방식
- **GitHub OAuth2** (Authorization Code Grant)
- Spring Security의 `oauth2Login()` 사용
- 요청 권한 범위(scope): `read:user`, `user:email`, `repo`, `read:org`
### 세션 관리
- **Session Cookie** (`JSESSIONID`) 기반
- Cookie 설정: `SameSite=None`, `Secure=True`, `HttpOnly=True`
- 로그아웃 시 세션 무효화 + 쿠키 삭제
### 현재 사용자 식별 방식
- `@AuthenticationPrincipal CustomOAuth2User` 어노테이션으로 Controller에서 접근
- `CustomOAuth2User`는 Spring의 `DefaultOAuth2User`를 확장하여 내부 DB PK(`id`)와 GitHub `accessToken`을 포함
- OAuth2 로그인 성공 시 `CustomOAuth2UserService`에서 사용자 정보를 DB에 저장/갱신 (joinOrUpdate)
### Role/Permission 구조
| Role | 범위 | 설명 |
|------|------|------|
| `ADMIN` | 프로젝트 | 프로젝트 소유자(최초 생성자), 모든 관리 권한 보유 |
| `MEMBER` | 프로젝트 | 일반 참여자, 읽기/쓰기 가능하나 관리 기능 제한 |
| User Status | 설명 |
|-------------|------|
| `PENDING` | 초대 수락 전 (사용하지 않는 것으로 보임) |
| `ACTIVE` | 활성 멤버 |
| `LEFT` | 탈퇴/추방된 멤버 (soft 상태 변경) |
### 리소스 소유자 검증 방식
- **프로젝트 소유자(ADMIN) 검증**: `project.getOwner().getId().equals(userId)` → 실패 시 `ForbiddenError("권한이 부족합니다.")`
- **이슈 작성자 검증**: `entity.getAuthor().getId() != userId` → 실패 시 `ForbiddenError("권한이 없습니다.")`
- **프로젝트 멤버십 검증**: 모든 요청 전 `FindProjectUser(userId, projectId)` 호출 → 실패 시 `NotFoundError` 또는 `ForbiddenError`
### 권한 검증 위치
- **Controller 계층**: `@AuthenticationPrincipal`으로 사용자 식별
- **Service 계층**: 모든 권한 검증이 Service 구현체 내에서 수행됨 (Global Filter/AOP 미사용)
### 인증 실패/권한 실패 응답 방식
- 인증 실패: Spring Security 기본 동작 (401 Unauthorized 또는 로그인 페이지로 리다이렉트)
- 권한 실패: `Result.fail(new ForbiddenError("메시지"))` → Controller에서 `ErrorResponse`로 변환 (HTTP 403)
### 인증/인가 설계 판단
**장점**: GitHub OAuth2로 별도 회원가입 절차 불필요, 개발자 타겟에 적합
**개선 여지**:
- Role이 ADMIN/MEMBER 2개로 단순하나, **요약 삭제(DELETE summary)만 ADMIN 전용**이고 대부분 MEMBER에게 개방되어 있어 세분화 부족
- `@PreAuthorize` 등 선언적 인가보다 Service 코드 내 if문 검증이 많아 **보안 정책이 코드에 흩어져 있음**
- OpenVidu Webhook 엔드포인트만 Token 헤더 검증 (공개 URL + Secret Token)
---
## 5. 데이터베이스 설계
### 주요 Entity 목록 (총 14개 테이블)
| Entity | Table | 설명 |
|--------|-------|------|
| UserEntity | `users` | GitHub OAuth 사용자 |
| UserGithubAuthEntity | `user_github_auth` | GitHub Access Token 저장 (1:1 users) |
| ProjectEntity | `projects` | 프로젝트/워크스페이스 |
| ProjectUserEntity | `project_users` | 프로젝트-사용자 멤버십 (N:M) |
| ProjectInviteEntity | `project_invites` | UUID 기반 초대 링크 |
| ProjectRecentEntity | `project_recents` | 최근 조회 프로젝트 (최대 4개 노출) |
| MeetingEntity | `meetings` | 회의/채널 |
| MeetingUserEntity | `meeting_users` | 회의 참여자 |
| MeetingRecordEntity | `meeting_records` | OpenVidu 녹화 기록 |
| MeetingSummaryEntity | `meeting_summary` | AI 회의 요약 (버전 관리) |
| IssueEntity | `issues` | GitHub 연동 이슈 |
| IssueAssigneeEntity | `issue_assignees` | 이슈 담당자 (N:M) |
| ChatEntity | `chats` | 채팅 메시지 (Soft Delete) |
| ProjectUserReadEntity | `project_user_reads` | 사용자별 읽음 상태 |
### 테이블 간 관계 (주요 FK)
```
users (1) ────── (1) user_github_auth [FK: user_id, ON DELETE CASCADE]
users (1) ────── (N) projects [FK: owner_id, ON DELETE RESTRICT]
users (1) ────── (N) project_users [FK: user_id, ON DELETE RESTRICT]
projects (1) ──── (N) project_users [FK: project_id, ON DELETE CASCADE]
projects (1) ──── (N) project_invites [FK: project_id, ON DELETE CASCADE]
projects (1) ──── (N) meetings [FK: project_id, ON DELETE CASCADE]
users (1) ──────── (N) meetings [FK: created_by, ON DELETE RESTRICT]
meetings (1) ──── (N) meeting_users [FK: meeting_id, ON DELETE CASCADE]
users (1) ──────── (N) meeting_users [FK: user_id, ON DELETE RESTRICT]
meetings (1) ──── (N) meeting_records [FK: meeting_id, ON DELETE CASCADE] (추정)
meetings (1) ──── (N) meeting_summary [FK: meeting_id, ON DELETE CASCADE]
projects (1) ──── (N) issues [FK: project_id, ON DELETE CASCADE]
users (1) ──────── (N) issues [FK: author_id, ON DELETE RESTRICT]
issues (1) ────── (N) issue_assignees [FK: issue_id, ON DELETE CASCADE]
users (1) ──────── (N) issue_assignees [FK: user_id, ON DELETE RESTRICT]
projects (1) ──── (N) chats [FK: project_id, ON DELETE CASCADE]
users (1) ──────── (N) chats [FK: user_id, ON DELETE RESTRICT]
meetings (1) ──── (N) chats [FK: meeting_id, ON DELETE SET NULL]
```
### 주요 FK 제약 특징
- `ON DELETE RESTRICT`: 사용자(users) 삭제 방지 (관련 엔티티가 있으면 삭제 불가)
- `ON DELETE CASCADE`: 프로젝트/회의/이슈 삭제 시 하위 데이터 자동 삭제
- `ON DELETE SET NULL`: 채팅의 meeting_id, 요약의 meeting_id/edited_by → 참조 레코드 삭제 시 null
### 주요 Unique / Index 제약
| 테이블 | Type | 컬럼 | 설명 |
|--------|------|------|------|
| users | UK | github_id | GitHub 계정 중복 방지 |
| users | UK | github_login | GitHub 로그인 중복 방지 |
| projects | UK | repo_full_name, active_key | 동일 레포지토리 중복 등록 방지 (Soft Delete 고려) |
| project_users | UK | (project_id, user_id) | 중복 멤버십 방지 |
| issues | UK | (project_id, github_issue_id) | 프로젝트 내 동일 GitHub 이슈 중복 방지 |
| issue_assignees | UK | (issue_id, user_id) | 이슈별 중복 담당자 방지 |
| meeting_summary | UK | (meeting_id, version) | 동일 회의 동일 버전 요약 중복 방지 |
| meeting_records | UK | recording_session_id | 녹화 세션 중복 방지 |
| project_recents | UK | (user_id, project_id) | 최근 조회 중복 방지 |
| project_user_reads | UK | (project_id, user_id) | 읽음 상태 중복 방지 |
### Soft Delete 여부
- `projects`: **Soft Delete** (`deleted_at` 컬럼 존재, 그러나 `ProjectRepository.delete()`가 실제 삭제하는 것으로 보임 - 확인 필요)
- `chats`: **Soft Delete** (`deleted_at` 컬럼 존재, 실제 deleteMessage() 동작은 확인 필요)
- `project_users`: **상태 변경 방식** (`status = LEFT`, `left_at` 기록) - 실제 삭제 없이 상태만 변경
### CreatedAt/UpdatedAt 관리 방식
- JPA Entity에서 `@CreationTimestamp`, `@UpdateTimestamp` 사용
- DB 레벨: `DEFAULT CURRENT_TIMESTAMP`, `ON UPDATE CURRENT_TIMESTAMP`
### Migration 관리 방식
- **Flyway** 사용
- 마이그레이션 파일: `apps/server/src/main/resources/db/migration/V{번호}__{설명}.sql`
- 총 12개 마이그레이션 파일 (V1~V12)
- `clean-disabled: true` (운영에서 실수로 clean 방지)
- JPA: `ddl-auto: validate` (Entity-DB 불일치 시 즉시 오류)
### 데이터 정합성 설계
- Unique Constraint + FK 제약으로 **애플리케이션 레벨 + DB 레벨 이중 방어**
- `@Transactional`로 서비스 단위 작업 원자성 보장
---
## 6. 비즈니스 로직 / UseCase 구조
### 주요 UseCase 목록
**프로젝트 관리**
- `AddProject`: 프로젝트 생성 + 생성자 ADMIN 등록 + 최근 조회 upsert
- `AddInviteCode`: UUID 초대 코드 생성 (ADMIN 전용, 만료일 설정)
- `AddProjectUser`: 초대 코드로 프로젝트 참여 (LEFT 상태였으면 ACTIVE로 복원)
- `DeleteProject`: 프로젝트 삭제 (OWNER 전용)
- `LeaveProject`: 프로젝트 탈퇴 (OWNER 불가), 상태 LEFT로 변경
- `transferOwnership`: OWNER 권한 이전 (ADMIN↔MEMBER 역할 교환)
- `removeParticipant`: 참여자 추방 (OWNER 전용)
**회의 관리**
- `createMeeting`: 회의 생성 (ADMIN 전용, RUNNING 상태)
- `bookMeeting`: 회의 예약 (ADMIN 전용, SCHEDULED 상태, 시간 검증 포함)
- `UpdateMeeting`: 회의명/시간 수정
- `DeleteMeeting`: 회의 삭제
**미디어 관리 (OpenVidu 연동)**
- `createConnection`: OpenVidu 세션/커넥션 생성 → 토큰 발급
- `closeConnection`: 커넥션 종료 + 사용자 left 처리
- `StartRecording`: 녹화 시작
- `StopRecording`: 녹화 중단
**이슈 관리 (GitHub 연동)**
- `AnalyzeIssue`: 이슈 초안 생성 → Redis 큐에 AI 분석 요청
- `CreateIssue`: AI 분석 완료된 초안 → GitHub 실제 이슈 생성 + 우선순위 설정
- `updateIssue`: 이슈 수정 (작성자만, GitHub 동기화 포함)
- `deleteIssue`: 이슈 삭제 (작성자만, GitHub 이슈는 CLOSE)
- `getGithubIssues`: GitHub에서 이슈 동기화 (신규 등록 + 상태 업데이트)
### Command/Query 구조
- `service/command/`: `AddProjectCommand`, `BookMeetingCommand`, `UpdateRecordingCommand`, `UpdateSummaryCommand`
- 요청 DTO → Controller에서 Command 변환 → Service 호출
- CQS(Command-Query Separation)가 엄격하게 적용되지는 않음 (대부분 Service가 CRUD 혼합)
### 요청 처리 순서 (예: CreateIssue)
1. Controller: `@AuthenticationPrincipal`으로 userId 추출, `@RequestBody CreateIssueRequest` 바인딩
2. Service: `issueRepository.findById(issueId)` → 드래프트 이슈 존재 확인
3. 작성자 검증 (`entity.getAuthor().getId() != userId`)
4. GitHub 인증 정보 조회 (`userGithubAuthRepository`)
5. GitHub API 호출: `githubService.createIssue(accessToken, ...)`
6. 로컬 Entity 업데이트 (title, body, priority, githubIssueId, issueNo, assignees)
7. `issueRepository.save(entity)`
### 검증 로직
- Bean Validation: `@Valid` + Jakarta Validation (`@NotBlank`, `@Size`, `@URL`)
- Service 내 비즈니스 검증: 존재 확인 → `NotFoundError`, 권한 확인 → `ForbiddenError`, 중복 확인 → `ConflictError`
### 성공/실패 반환 방식
- 모든 Service 메서드가 `Result<T>` 반환
- Controller는 `result.isFailure()``ResultError.getResponse()` (ErrorResponse DTO + HTTP Status)
- 성공 시 `ResponseEntity.ok(data)` 또는 `.ok().build()` (empty body)
### API 계층과 UseCase 계층의 책임 분리
- **Controller**: URL 라우팅, `@Valid` 검증, `@AuthenticationPrincipal` 추출, Result→HTTP 변환, DTO 변환
- **Service**: 모든 비즈니스 로직, 권한 검증, 트랜잭션 관리, 외부 API 호출
---
## 7. 트랜잭션과 동시성
### 트랜잭션이 필요한 기능
- `AddProject`: 프로젝트 생성 + ADMIN 등록 (2개 Entity)
- `CreateIssue`: 이슈 Entity 업데이트 + Assignee 교체 + GitHub API 호출
- `transferOwnership`: OWNER/MEMBER 역할 교환 (3개 Entity 동시 변경)
- `LeaveProject`: 상태 변경 (UPDATE only)
- `updateProjectRepo`: 프로젝트 레포지토리 정보 변경
### 트랜잭션 범위
- `@Transactional`이 Service 구현체 메서드 단위로 적용
- `ProjectServiceImpl`: `@Transactional(readOnly = true)` (조회성), `@Transactional` (쓰기)
- `IssueServiceImpl`: `@jakarta.transaction.Transactional` (Jakarta EE, Spring과 혼용 - 개선 여지)
### 동시 요청 시 발생할 수 있는 문제
**중복 참여 (AddProjectUser)**
```java
// LEFT 상태 유저 → ACTIVE로 복원, 이미 ACTIVE → ConflictError
if (entity.getStatus() == ProjectUserStatus.ACTIVE) {
return Result.fail(new ConflictError("이미 존재하는 유저입니다."));
}
```
- 동시에 2개 요청이 들어오면 둘 다 LEFT 상태를 읽고 ACTIVE로 변경 → **2개 ACTIVE 레코드 가능성 없음** (UK 제약 `(project_id, user_id)`이 방어)
**중복 초대 코드 사용**
- `POST /api/v1/projects/invites/{inviteCode}`: 경쟁 상태 가능성 낮음 (멱등성 - ACTIVE로 상태 변경만 수행)
### 중복 생성 방지 방식
- **UK Constraint**: DB에서 중복 생성 방지
- `uk_project_users_project_user`: 동일 프로젝트 중복 멤버십
- `uk_issues_project_github_issue_id`: 동일 GitHub 이슈 중복
- `uk_issue_assignees_issue_user`: 동일 이슈 중복 담당자
- **사전 중복 체크**: `existsByRepoFullName()`, `existsByProject_IdAndUser_IdAndStatus()`
### Lock / CAS / Optimistic Concurrency
- 명시적 Lock 사용: **코드에서 확인되지 않음**
- Optimistic Concurrency: `MeetingSummaryEntity``@Version`과 혼동될 수 있는 `version` 컬럼이 있으나 JPA의 `@Version`은 아님 (단순 데이터 컬럼)
### Idempotency 고려
- Redis 큐(`leftPush`)로 AI 작업을 중복 적재할 가능성 있음 (이슈 조회 시 항상 큐에 push)
- Idempotency key 미사용, 별도 중복 방지 메커니즘 없음 → **개선 여지**
---
## 8. 예외 처리와 응답 구조
### Result 패턴
- **자체 구현 `Result<T>`** 클래스 사용 (C#의 Result 패턴 차용)
- 예외를 던지지 않고 **성공/실패를 값으로 반환**
- `Result.ok(value)` / `Result.fail(error)` / `Result.fail(errors)`
### ResultError 계층
```
ResultError (인터페이스)
├── SimpleError (단순 code + message)
├── NotFoundError (404)
├── ForbiddenError (403)
├── ConflictError (409)
├── BadRequestError (400)
├── GoneError (410)
└── ServerError (500)
```
- `getCode()` = HTTP Status Code (예: 404, 403, 409)
- `getStatus()` = `HttpStatus.valueOf(code)`
- `getResponse()` = `new ErrorResponse(status, message)`
### Global Exception Middleware
- **코드에서 확인되지 않음** (`@ControllerAdvice` 없음)
- Controller가 직접 `result.isFailure()`를 체크하고 `ErrorResponse`로 변환
- `ResultException`(runtime)은 존재하나 이를 처리하는 Global Handler 미확인
### 공통 ErrorResponse 구조
```json
{
"StatusCode": "NOT_FOUND", // HttpStatus enum name
"Message": "..."
}
```
- `StatusCode` 필드: PascalCase 사용 (일관성 이슈)
- `Message` 필드: 한글/영문 혼용
### 에러 코드 체계
- HTTP Status를 에러 코드로 사용
- Custom 에러 코드 체계 없음 (예: `ERR-001` 같은 비즈니스 에러 코드)
### 로그 레벨 구분
- `log.info`: 정상 흐름 (깃허브 데이터, AI 요청 페이로드)
- `log.warn`: OpenVidu webhook 파싱 실패
- `log.error`: Redis 메시지 처리 실패, 엔티티 미발견, 작업 처리 실패
### 프론트엔드 일관 처리
- 모든 실패가 `ErrorResponse(statusCode, message)` + HTTP Status로 통일 → **일관된 처리 가능**
- SSE Error 이벤트에도 동일한 `ErrorResponse` 구조 사용
---
## 9. 인프라 구성
### Dockerfile 존재 여부
- **didit-server**: 있음 (`apps/server/Dockerfile`) - Multi-stage build (Build: `eclipse-temurin:21-jdk`, Runtime: `eclipse-temurin:21-jre`)
- **didit-client**: 있음 (`apps/client/Dockerfile`) - (내용 확인 필요)
- **AI Worker**: **확인 필요** (Dockerfile 미발견)
### Docker Compose 구성
#### 로컬 개발용 (`apps/server/docker-compose.yml`)
```yaml
services:
db: MySQL 8.0 (3306 노출)
openvidu: OpenVidu dev 2.32.1 (4443 노출)
redis: Redis 7.2-alpine (6379 노출)
```
- 백엔드 서버 자체는 로컬 IDE에서 실행
- `healthcheck`: mysqladmin ping (MySQL only)
#### 배포용 (`infra/docker-compose/docker-compose.yml`)
| 서비스 | 이미지 | 네트워크 | 포트 | 설명 |
|--------|--------|----------|------|------|
| db | mysql:8.0 | internal only | expose 없음 | MySQL, healthcheck 적용 |
| server | ghcr.io/pjtdidit/server-image:latest | internal + caddy_default | expose 8080 | Spring Boot |
| client | ghcr.io/pjtdidit/client-image:latest | internal + caddy_default | - | React 정적 서빙 |
| openvidu | openvidu/openvidu-dev:2.32.1 | internal only | expose 없음 | WebRTC SFU |
| redis | redis:7.2-alpine | internal only | 6379 (외부 노출) | Message Broker |
### 서비스별 컨테이너 역할
- **db**: 영속 데이터 저장 (MySQL 8.0, utf8mb4, Asia/Seoul timezone)
- **server**: API 서버 (HTTP API, SSE, Webhook 수신)
- **client**: 정적 파일 서빙 (React SPA)
- **openvidu**: WebRTC 미디어 서버 (SFU, recording)
- **redis**: 작업 큐, Pub/Sub, client key 저장소
### 내부 네트워크/외부 네트워크 구성
- `internal` (bridge): db, server, client, openvidu, redis 간 통신
- `caddy_default` (external): server ↔ client ↔ Reverse Proxy 통신용
### Volume 마운트 구조
- `mysql-data:/var/lib/mysql` (MySQL 데이터 영속화)
- `redis_data:/data` (Redis AOF 데이터 영속화)
### Healthcheck
- **MySQL**: `mysqladmin ping` (10s interval, 5s timeout, 10 retries)
- **Server**: depends_on db(healthy), 자체 healthcheck 없음 → **개선 여지**
- **Redis**: 별도 healthcheck 없음
### depends_on 조건
- `server` depends_on `db` with `condition: service_healthy`
- 나머지 서비스 간 의존성 명시 안 됨
### 포트 노출 정책
- 배포 환경: 모든 DB/서버 포트가 **주석 처리** (외부 직접 접근 불가)
- 단, **Redis 6379가 환경변수로 외부 노출** 가능 → 보안상 개선 여지 있음
### 인프라 구성 설계 의도
- 단일 서버에 Docker Compose로 올인원 배포 (소규모 프로젝트에 적합)
- `internal` 네트워크로 서비스 간 통신 격리
- `caddy_default` external 네트워크로 Reverse Proxy와만 API 통신 허용
---
## 10. Reverse Proxy / 도메인 / HTTPS
### Caddy/Nginx 사용 여부
- **Traefik** 사용 (설계 문서상 명시)
- 실제 배포 docker-compose에는 `caddy_default` external 네트워크로 연결 → **Caddy 사용 가능성**도 있음 (네트워크명이 caddy_default)
### 도메인 라우팅 구조
- Frontend: `https://did-it.xyz`
- API: `https://api.did-it.xyz/api/v1`
- CORS 허용 Origin: `https://did-it.xyz`
### subdomain 구성
- `did-it.xyz` → 클라이언트
- `api.did-it.xyz` → 백엔드 API 서버
### HTTPS 적용 방식
- Traefik/Caddy에서 HTTPS 종단 처리
- Spring Boot 자체는 HTTP(8080), Cookie `Secure=true` → Reverse Proxy가 HTTPS 처리하고 있음을 전제
### reverse_proxy 대상
- `/` 및 정적 리소스 → `didit-client`
- `/api/v1/*``didit-server:8080`
- `/oauth2/*``didit-server:8080`
### 외부 포트 노출 최소화
- Docker Compose에서 MySQL, OpenVidu, Server의 포트가 **명시적으로 주석 처리**되어 외부에서 직접 접근 불가
- Redis 포트(`6379`)는 환경변수로 노출되도록 열려 있음 → **개선 필요**
---
## 11. 환경변수와 설정 관리
### .env 구조
- `.env.example` 파일 존재 (14개 변수)
- 주요 변수: `MYSQL_*`, `GITHUB_*`, `OPENVIDU_*`, `REDIS_*`
### 개발/운영 설정 분리
- `application.yml` (공통)
- `application-prod.yml` (운영 전용: Swagger 비활성화, HikariCP Pool 튜닝, Tomcat Thread 설정)
- `SPRING_PROFILES_ACTIVE=prod` 환경변수로 활성화
### DB 연결 문자열 구성 방식
```yaml
url: jdbc:mysql://${MYSQL_HOST:db}:${MYSQL_PORT:3306}/${MYSQL_DATABASE:didit}?serverTimezone=Asia/Seoul&characterEncoding=utf8
```
- 환경변수 우선, 기본값 fallback (`MYSQL_HOST:db`)
- `MYSQL_USER`, `MYSQL_PASSWORD`도 환경변수에서 주입
### Redis 연결 설정
- 환경변수: `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`
- timeout: 2s
- Password 기반 인증 (`requirepass`)
### Secret 관리 방식
- GitHub Client ID/Secret, OpenVidu Secret: 환경변수로 주입
- `.env.example`은 Git에 커밋되어 있으나, 실제 값은 배포 환경에서 별도 관리
- **Vault/Secret Manager 미사용** → 개선 여지
### ASP.NET Options 바인딩
- 해당 없음 (Java / Spring Boot 프로젝트)
- Spring Boot `@ConfigurationProperties` 사용 (`OpenViduProperties`, `OpenViduWebhookProperties`)
### 설정 검증
- Bean Validation을 통한 설정 검증 미확인
- `@Validated` + `@ConfigurationProperties` 조합 사용 가능하나 구현되지 않음
---
## 12. 로그 / 모니터링 / 운영
### 로깅 구조
- **SLF4J + Logback** (Spring Boot 기본)
- `@Slf4j` (Lombok)으로 Logger 주입
### 구조화 로그 사용 여부
- JSON 구조화 로그: **미사용** (일반 텍스트 로그)
- `log.info("깃허브 데이터: {}", attributes)`
### 요청/응답 로그
- Spring MVC 요청 로깅 **확인 필요** (Access Log 미구현 가능성)
### 예외 로그
- `log.error("Redis 메시지 처리 중 에러 발생: {}", e.getMessage())`
- `log.error("워커 실행 중 오류 발생: %s", str(e), exc_info=True)` (Python)
### 로그 레벨 기준
- INFO: 정상 비즈니스 흐름
- WARN: 복구 가능한 오류 (webhook 파싱 실패)
- ERROR: 실패한 작업 처리
### 운영 도구 사용 여부
- **Dozzle**: 미확인
- **Grafana/Loki**: 미확인
- **Portainer**: 배포 트리거로 사용 (`PORTAINER_WEBHOOK_URL`)
- AI Worker: `/health`, `/health/redis` Healthcheck 엔드포인트 존재
### Healthcheck
- AI Worker: `GET /health`, `GET /health/redis` → FastAPI 제공
- Server: Spring Boot Actuator Healthcheck **확인 필요** (의존성 없음)
- DB: docker-compose healthcheck (mysqladmin ping)
- Redis: 별도 healthcheck 없음
---
## 13. CI/CD / 배포 자동화
### GitLab CI/CD 구성
**`.gitlab-ci.yml` (Root Pipeline)**
- Trigger: Merge Request Event + Master Push
- Stages: `test``build``deploy`
- Docker BuildKit 활성화
**Server Pipeline (`.gitlab/ci/server.yml`)**
| Job | Stage | Trigger | Actions |
|-----|-------|---------|---------|
| `test_server` | test | MR (apps/server 변경 시) | `./gradlew --no-daemon test` → JUnit report artifact |
| `build_push_server` | build | Master Push (apps/server 변경 시) | Docker build + push to `ghcr.io/pjtdidit/server-image:latest` |
**Deploy Pipeline (`.gitlab/deploy.yml`)**
| Job | Stage | Trigger | Actions |
|-----|-------|---------|---------|
| `deploy_portainer` | deploy | Master Push (변경 시) | POST to Portainer Webhook → Redeploy |
### Docker image build
- Multi-stage Dockerfile (BuildKit 캐시 마운트 활용)
- Build Stage: Gradle wrapper → bootJar
- Runtime Stage: `eclipse-temurin:21-jre`, non-root user (`appuser`)
- JVM 옵션: `-XX:MaxRAMPercentage=75`
### Migration 실행 방식
- Flyway 자동 실행 (애플리케이션 시작 시)
- CI/CD에서 별도 migration step 없음
### 브랜치 전략
- `master` = 기본 브랜치 (production)
- Merge Request 기반 코드 리뷰 흐름
- Revert 이력 존재 (`revert-b0a13fa0`)
### 배포 실패 시 대응 방식
- Portainer Webhook 호출 실패 시 `set -eu`로 Pipeline 중단
- **Rollback 전략 확인 필요**
### 향후 개선할 배포 구조
- AI Worker의 CI/CD **미구성** (`.gitlab/ci/ai.yml` 주석 처리)
- Client의 CI/CD 존재 (`.gitlab/ci/client.yml`)
- Test 단계에서 Testcontainers 사용 → 실제 DB 의존성 없는 테스트 가능
---
## 14. 성능 최적화
### Pagination 적용 여부
- **적용**: `PageRequest.of(page, 10)` (프로젝트 목록)
- **Spring Pageable**: size 기본값 20
### Projection DTO 조회 여부
- **미적용**: Entity 전체 조회 후 `stream().map(Response::fromEntity)`로 변환
- `SELECT *` 형태로 모든 컬럼을 읽어옴 → **개선 여지**
### AsNoTracking 사용 여부
- JPA `@Transactional(readOnly = true)` → Hibernate가 flush 모드를 MANUAL로 설정하나 영속성 컨텍스트는 사용
- `AsNoTracking`에 해당하는 명시적 설정 없음
### Include/N+1 문제 처리
- `FetchType.LAZY` 사용 (기본 전략)
- `findTop5ByProject_IdAndStatusOrderByUpdatedAtDesc`: ProjectEntity, AuthorEntity lazy → 응답 변환 시 N+1 가능성 있음
- `findAllByProject_Id`: Assignee 컬렉션 @OneToMany → 응답 변환 시 N+1 가능성 있음
- `@EntityGraph` 또는 JOIN FETCH **미사용****개선 여지**
### Index 적용 여부
- 외래키 컬럼 전반에 인덱스 적용: `idx_projects_repo_id`, `idx_issues_project_id`, `idx_meetings_project_id`, `idx_chats_project_created_at`
- 만료 시간 조회: `idx_project_invites_expires_at`
- 복합 인덱스: `(project_id, created_at)`, `(meeting_id, created_at)`
### Cache 적용 여부
- 명시적 캐시: **미적용** (Spring Cache, @Cacheable 없음)
- Redis는 작업 큐/PubSub 용도로만 사용
### 응답 시간 측정 또는 개선 수치
- **확인 필요** (APM 도구 없음)
---
## 15. 설계 문서화
### README 구조
- `readme.md`: 저장소 구조, 디렉토리 가이드
- `exec/readme.md`: 실행 관련 (빌드/설정/시나리오)
### API 문서
- `apps/server/docs/API.md`: 상세 API 문서 (엔드포인트, DTO, SSE 이벤트)
- `apps/server/docs/openapi.yaml`: OpenAPI 3.0.3 명세
- `docs/API/`: Swagger/Redoc 관련 파일
### ERD
- `docs/기획/ERD/`: Logical ERD Model (Mermaid, SVG)
- `docs/설계/Physical ERD Model.md`: Mermaid ERD 다이어그램 + 테이블 상세 명세
### 아키텍처 다이어그램
- `docs/설계/Architecture.md`: 기술 스택 구성도 (SVG)
- `docs/설계/svg/architecture.svg`: 전체 구성도
- `docs/설계/svg/PERD.svg`: 물리 ERD
### 시퀀스 다이어그램
- **확인 필요** (별도 시퀀스 다이어그램 미발견)
- SSE 이벤트 흐름: `docs/컨벤션/svg/sse_arch.svg`, `sse_flow.svg`
### 기술 선택 이유 문서 / ADR
- **미발견** (ADR 디렉토리 없음)
### Mermaid 다이어그램 사용 여부
- **사용**: Physical ERD Model, SSE 문서 등에서 Mermaid 사용
---
## 16. 문제 해결 사례 후보
### 사례 1: AI 작업 결과의 실시간 클라이언트 전달
**문제 상황**: AI Worker가 이슈 우선순위 분석 완료 후, **특정 사용자에게만** 결과를 전달해야 함 (다중 사용자 환경)
**원인 분석**: SSE broadcasting만으로는 모든 구독자에게 전파되므로, 특정 사용자에게만 선택적 전달이 필요
**해결 방법**:
- Redis에 `sse:client_key:{userId}` 형태로 clientKey 저장
- AI 결과 수신 시 `RedisSubscribeListener`가 Redis에서 clientKey 조회
- `SseHub.broadcastToClient(projectId, clientKey, "ai_analysis_result", ...)`**사용자 타겟팅**
**선택 이유**: 동일 유저의 다중 탭/디바이스를 구분하면서도, Redis를 활용해 무상태(stateless) 서버 간 확장 가능
**결과**: AI 분석 결과가 요청한 사용자에게만 실시간 전달, 불필요한 브로드캐스트 방지
---
### 사례 2: LEFT 상태 사용자 재참여 처리
**문제 상황**: 탈퇴했던 사용자가 초대 링크로 재참여 시, **UK 제약 위반** 없이 기존 레코드를 재활용해야 함
**원인 분석**: `project_users``UK(project_id, user_id)`가 있어 새 레코드 INSERT 불가
**해결 방법**: `AddProjectUser` 메서드에서:
1. 기존 레코드 존재 확인 (`findByProject_IdAndUser_Id`)
2. `ACTIVE` 상태면 `ConflictError`, `LEFT` 상태면 상태를 `ACTIVE`로 복원 + `role=MEMBER` + `leftAt=null`
3. 레코드가 없으면 신규 생성
**선택 이유**: DB 레벨 UK 제약을 유지하면서도, Soft한 상태 변경으로 사용자 이력을 보존 (감사 추적 가능)
**결과**: 탈퇴했던 사용자도 초대 코드로 원활히 재참여 가능, 중복 참여 방지
---
### 사례 3: AI 분석 워크플로우의 비동기 처리
**문제 상황**: HuggingFace 모델 추론이 수 초~수십 초 소요되어, REST API 요청-응답 사이클 안에서 처리 불가
**원인 분석**: ML 추론은 HTTP 요청 타임아웃을 초과하는 장기 작업
**해결 방법**:
- **Redis List**를 작업 큐로 사용 (`queue:issue:priority:single`, `queue:issue:priority:sort`)
- 서버는 `leftPush`로 작업 등록 후 즉시 응답
- Python AI Worker가 0.5s polling으로 큐 소비
- 완료 시 **Redis Pub/Sub**으로 결과 발행 → 서버의 `RedisMessageListener`가 수신 → DB 업데이트 + SSE 전송
**선택 이유**: 추가 인프라(RabbitMQ/Kafka) 없이 기존 Redis로 큐/메시징 통합 구현, 운영 단순화
**결과**: ML 추론 시간과 API 응답성이 완전히 분리됨
---
### 사례 4: GitHub 이슈와 로컬 DB의 양방향 동기화
**문제 상황**: GitHub에서 이슈를 가져올 때, 기존 로컬 DB에 있는 이슈는 업데이트하고 없는 이슈는 새로 생성해야 함
**원인 분석**: GitHub이 Source of Truth지만, AI 우선순위 등 로컬 메타데이터를 추가로 관리해야 함
**해결 방법**: `getGithubIssues()` 메서드에서:
1. GitHub API로 레포지토리 이슈 일괄 조회
2. `existsByGithubIssueId(ghIssue.getId())` → 존재하면 title/body/status 업데이트
3. 미존재 시 신규 Entity 생성 (priority 기본값 MEDIUM)
4. Assignee 정보 GitHub→로컬 UserEntity mapping
5. 모든 이슈에 대해 AI 단일 분석 + 배치 정렬 큐 추가
**선택 이유**: Upsert 패턴으로 데이터 중복 없이 동기화, `github_issue_id`를 natural key로 사용
**결과**: GitHub과 로컬 DB 간 일관성 유지, 신규 이슈 자동 등록, AI 분석 파이프라인 연계
---
## 17. 포트폴리오 문장 초안
### 프로젝트 개요
"Didit은 GitHub 생태계에 특화된 실시간 팀 협업 플랫폼입니다. GitHub OAuth2 로그인, OpenVidu 기반 화상회의, Redis Pub/Sub 기반 AI 비동기 분석, SSE 실시간 이벤트 스트리밍을 하나의 Spring Boot 서버로 통합한 B2B SaaS 프로젝트입니다."
### 담당 역할
"백엔드 개발자로서 Spring Boot 4.0 서버의 전체 API 설계 및 구현, 인증/인가 체계 수립, 데이터베이스 스키마 설계(Flyway), Docker Compose 기반 인프라 구성, GitLab CI/CD 파이프라인 구축을 담당했습니다."
### 주요 기여
- 30개 이상의 RESTful API 엔드포인트 설계 및 구현
- GitHub OAuth2 + Session Cookie 인증 체계 구축
- Redis 기반 AI 작업 큐 + Pub/Sub 메시징 아키텍처 설계
- SSE(Server-Sent Events) 채널/프로젝트 단위 실시간 이벤트 시스템 구현
- Flyway + JPA validate 기반 데이터베이스 마이그레이션 관리
### 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
|------|-----------|
| **Java 21 + Spring Boot 4.0** | 최신 LTS 버전, Virtual Threads 등 최신 기능 활용, 강력한 생태계 |
| **MySQL 8.0** | FK 제약, UK 제약으로 데이터 정합성 보장, utf8mb4 지원 |
| **Redis 7.2** | 빠른 메모리 기반 작업 큐, Pub/Sub 메시징, 경량 인프라 |
| **Flyway** | 선언적 DB 마이그레이션, 모든 변경 이력 추적, Clean 비활성화로 안전 |
| **GitHub OAuth2** | 개발자 타겟, 별도 회원가입 불필요, repo/org 권한 자연스러운 연계 |
| **SSE + Redis Pub/Sub** | WebSocket보다 가벼운 실시간 통신, Redis로 확장 가능한 이벤트 브로커 역할 |
| **Docker Compose** | 5개 서비스의 로컬/배포 환경 일관성 확보, 단일 서버 올인원 배포 |
| **Portainer + GitLab CI/CD** | Docker 이미지 자동 빌드 → Webhook 기반 배포 트리거, 운영 단순화 |
### 아키텍처 설계
"전통적인 3계층(api/service/data) 구조를 채택하여 HTTP/비즈니스/데이터 책임을 명확히 분리했습니다. AI 분석 작업은 별도 Python Worker로 분리하고 Redis를 중간 메시지 브로커로 사용하여, ML 추론 부하가 API 응답성에 영향을 주지 않도록 설계했습니다. 모든 서비스는 Docker Compose로 오케스트레이션되며, internal 네트워크로 보안 격리하고 Traefik Reverse Proxy를 통해 HTTPS를 종단 처리합니다."
### API 설계
"RESTful 원칙에 따라 `/api/v1/` 기반의 URL 버저닝을 적용하고, Spring Pageable과 커서 기반의 이중 페이징 전략을 사용했습니다. Java의 Result 패턴을 도입하여 성공/실패를 값으로 반환하며, NotFound/Forbidden/Conflict/Gone/ServerError 등 의미 있는 에러 타입을 체계화했습니다. OpenAPI 3.0.3 명세를 Swagger UI와 연동하여 API 문서화를 자동화했습니다."
### 인프라 구성
"MySQL 8.0(utf8mb4), Redis 7.2(AOF), OpenVidu 2.32.1, Spring Boot Server, React Client 등 5개 서비스를 Docker Compose로 구성했습니다. internal bridge 네트워크로 내부 서비스를 격리하고, MySQL healthcheck에 대한 depends_on 조건을 설정하여 서비스 시작 순서를 보장했습니다. Multi-stage Dockerfile로 이미지 크기를 최적화하고, BuildKit 캐시 마운트로 빌드 시간을 단축했습니다."
### 문제 해결 사례
1. **AI 결과의 사용자 타겟팅 전달**: Redis에 clientKey를 저장하고, SSE Hub에서 대상 사용자에게만 broadcastToClient() 하는 방식으로 해결했습니다.
2. **탈퇴 사용자 재참여 처리**: UK 제약을 유지하면서 LEFT → ACTIVE 상태 변경으로 재참여를 처리하여 데이터 일관성을 보장했습니다.
3. **ML 추론-API 응답 분리**: Redis List/ Pub/Sub을 활용한 비동기 작업 큐 패턴으로, 장시간 ML 추론이 API 응답에 영향을 주지 않도록 설계했습니다.
4. **GitHub-로컬DB 이슈 동기화**: `github_issue_id`를 natural key로 Upsert 패턴을 구현하여 GitHub↔로컬 데이터 일관성을 유지했습니다.
### 프로젝트 성과
- 단일 서버에서 화상회의 + 채팅 + GitHub 이슈 관리 + AI 분석 통합 제공
- Flyway로 12회 DB 마이그레이션을 무중단 적용
- Docker Compose 기반 원커맨드 배포 체계 구축 (CI/CD + Portainer)
- Spring Boot, Python, MySQL, Redis, OpenVidu 등 5+ 기술 스택 통합 경험
### 회고
"이번 프로젝트를 통해 단일 백엔드 서버가 API, SSE, Webhook 수신, 외부 API 연동 등 다양한 역할을 수행할 때의 설계적 고려사항을 깊이 있게 경험했습니다. 특히 Redis를 단순 캐시가 아닌 작업 큐+Pub/Sub 메시지 브로커로 활용한 점이 인상적이었습니다. 다만, Global Exception Handler나 선언적 인가(@PreAuthorize), API 응답 시간 모니터링 등 프로덕션 레벨에서 필요한 부가적인 부분들을 더 보강하고 싶습니다."
---
> **부록**: 본 문서는 `apps/server/`, `apps/ai/`, `infra/`, `.gitlab/`, `docs/` 디렉토리의 실제 코드 및 설정 파일을 분석하여 2026-05-05 기준으로 작성되었습니다.

View File

@@ -0,0 +1,579 @@
# TusBlazorClient
## 1. 프로젝트 목적
### 해결하려는 문제
.NET Blazor WebAssembly 환경에서 **대용량 파일 업로드**를 안정적으로 처리하는 것. Blazor WASM의 순수 C# 파일 I/O는 속도가 느리고 전송 가능한 파일 크기에 제한이 있어, 대용량 파일 전송이 실질적으로 어렵다.
### 기존 방식의 불편함
- Blazor WASM에서 순수 C# 코드로 대용량 파일을 전송할 경우, 브라우저의 메모리 제약과 느린 I/O 속도로 인해 전송이 실패하거나 브라우저가 멈추는 현상이 발생한다.
- 네트워크 중단 시 처음부터 다시 업로드해야 하므로, 대용량 파일일수록 실패 확률이 높아진다.
- 기존 `tus-js-client`는 JavaScript 라이브러리이므로, Blazor C# 환경에서 직접 사용하기 어렵다.
### 라이브러리 사용 시 단순해지는 부분
- JavaScript 라이브러리를 직접 다루지 않고, **C# 네이티브 API**로 tus 프로토콜 업로드를 사용할 수 있다.
- DI 컨테이너에 `AddTusBlazorClient()` 한 줄로 등록 후 `TusClient`를 주입받아 즉시 사용 가능하다.
- 파일 선택, 업로드 생성, 진행률 추적, 재개, 중단, 옵션 변경까지 모두 C#에서 타입 세이프하게 처리된다.
### 주요 사용자
.NET Blazor WebAssembly 개발자. 특히 대용량 파일 업로드(동영상, 이미지, 문서 등)가 필요한 웹 애플리케이션을 구축하는 개발자.
### 한 줄 설명
"Blazor WebAssembly에서 tus 프로토콜 기반의 재개 가능한 대용량 파일 업로드를 C# API로 제공하는 래퍼 라이브러리"
---
## 2. Public API 설계
### 주요 클래스/인터페이스/메서드
| 클래스 | 역할 | 주요 멤버 |
|--------|------|----------|
| `TusClient` | 진입점 (Singleton) | `Upload()`, `IsSupported()`, `CanStoreUrls()`, `GetFileInputElement()` |
| `TusUpload` | 단일 업로드 작업 | `Start()`, `Abort()`, `Terminate()`, `GetUrl()`, `GetFileInfo()`, `GetOptions()`, `SetOtions()`, `FindPreviousUpload()`, `ResumeFromPreviousUpload()` |
| `TusOptions` | 설정 클래스 | `Endpoint`, `ChunkSize`, `OnProgress`, `OnError`, `OnSuccess`, `OnShouldRetry`, `Headers`, `Metadata` 등 18개 프로퍼티 |
| `FileInputElement` | HTML input[type=file] 래퍼 | `GetFiles()`, `Length()` |
| `JsFile` | 선택된 단일 파일 | `ToJsObjectReference()`, `GetFileInfo()` |
| `JsFileInfo` | 파일 메타데이터 | `Name`, `Size`, `LastModified` |
| `SetupExtension` | DI 등록 | `AddTusBlazorClient()` |
### 기본 사용 흐름
```
1. DI 등록: builder.Services.AddTusBlazorClient()
2. Razor 컴포넌트에서 TusClient 주입
3. FileInputElement.GetFiles() 로 파일 선택
4. TusOptions 구성 (Endpoint, 콜백 등)
5. TusClient.Upload(file, options) 으로 TusUpload 생성
6. (선택) FindPreviousUpload() + ResumeFromPreviousUpload() 로 이어 올리기
7. TusUpload.Start() 호출
```
### Options/Config 구조
`TusOptions`는 **클래스**로 설계되어, 프로퍼티에 기본값이 지정되어 있다. 콜백 프로퍼티는 `[JsonIgnore]`로 마킹되어 JSON 직렬화 대상에서 제외된다 (JS로 전달 불가능한 .NET 델리게이트이기 때문).
### 비동기 API 설계
모든 I/O 관련 메서드는 `Task` 또는 `ValueTask`를 반환한다. JavaScript interop 호출이 비동기이기 때문에 전체 API가 비동기로 설계되어 있다.
### 사용 예시 코드
```csharp
@inject TusClient TusClient
<input type="file" @ref="_fileElement" />
<button onclick="@Upload">upload</button>
@code {
private ElementReference _fileElement;
private TusUpload? _tusUpload;
private async Task Upload()
{
var file = (await TusClient.GetFileInputElement(_fileElement).GetFiles()).First();
var fileInfo = await file.GetFileInfo();
var opt = new TusOptions
{
Endpoint = new Uri("http://localhost:1080/files"),
Metadata = new Dictionary<string, string> { { "filename", fileInfo.Name } },
OnError = (err) => Console.WriteLine($"Failed: {err.ErrorMessage}"),
OnProgress = (uploaded, total) => Console.WriteLine($"{(double)uploaded / total:P}"),
OnSuccess = async () => {
var url = await _tusUpload!.GetUrl();
Console.WriteLine($"Uploaded to {url}");
},
};
_tusUpload = await TusClient.Upload(file, opt);
var previousUploads = await _tusUpload.FindPreviousUpload();
if (previousUploads.Count > 0)
await _tusUpload.ResumeFromPreviousUpload(previousUploads.First());
await _tusUpload.Start();
}
}
```
### API 설계 의도
- **TusClient를 Singleton**으로 등록해 JS 모듈을 한 번만 로드하고 모든 업로드가 공유한다.
- **TusUpload는 내부 생성자**로 제한하여, 반드시 `TusClient.Upload()`를 통해서만 생성하도록 강제한다. 이로 인해 JS 콜백 브릿지(`DotNetObjectReference`)가 올바르게 연결되는 것을 보장한다.
- **Fluent API가 아닌 명령형 API**를 채택했다. 옵션 객체를 구성하고, 업로드 생성 후 명령을 내리는 직관적인 구조다.
---
## 3. 내부 구조와 책임 분리
### 주요 폴더 구조
```
TusBlazorClient/
├── TusClient.cs # Public API 진입점
├── TusOptions.cs # 설정 모델
├── TusUpload.cs # 단일 업로드 작업
├── TusError.cs # 오류 모델
├── TusHttpRequest.cs # HTTP 요청 DTO
├── TusHttpResponse.cs # HTTP 응답 DTO
├── TusPreviousUpload.cs # 이전 업로드 DTO
├── TusJsInterop.cs # .NET ↔ JS 브릿지 계층
├── TusOptionJsInvoke.cs # JS → .NET 콜백 수신기
├── TusOptionNullCheck.cs # Null 콜백 최적화 정보
├── FileInputElement.cs # File input 래퍼 + JsFile, JsFileInfo
├── SetupExtension.cs # DI 등록 확장 메서드
└── wwwroot/
└── tusBlazorClient.js # JS ↔ tus-js-client 브릿지
```
### 주요 클래스별 책임
| 클래스 | 책임 |
|--------|------|
| `TusClient` | 외부 API 진입점, 업로드 생성 팩토리 |
| `TusJsInterop` | `IJSRuntime`을 통한 JavaScript ES 모듈 호출 관리, Lazy 초기화 |
| `tusBlazorClient.js` | `tus-js-client`의 옵션 변환, 콜백 null 체크, HTTP 헤더 파싱 |
| `TusUpload` | 단일 업로드의 생명주기 관리, 옵션 동적 변경 |
| `TusOptionJsInvoke` | `[JSInvokable]` 메서드로 JS에서 .NET 델리게이트 호출 중계 |
| `TusOptionNullCheck` | JS 측에서 불필요한 콜백 호출을 방지하는 정보 제공 |
### 인터페이스와 구현체 분리
**인터페이스가 별도로 추출되어 있지 않다.** `TusClient``TusJsInterop` 모두 구체 클래스만 존재한다. 이는 라이브러리 규모가 작은 점을 고려한 선택으로 보인다. 테스트는 E2E 테스트(Selenium)로 커버하고 있어 Mocking이 필요하지 않다.
### 내부 구현과 외부 API 분리
- `TusUpload` 생성자는 **internal**로 제한되어 있다. 외부 사용자는 반드시 `TusClient.Upload()`를 통해서만 인스턴스를 얻을 수 있다.
- `TusJsInterop`는 **internal 멤버**를 통해 `TusUpload`, `FileInputElement` 등 내부 클래스만 접근 가능한 메서드를 노출한다.
- `TusOptionJsInvoke`, `TusOptionNullCheck`는 외부에 노출되지 않고 `TusClient` 내부에서만 생성된다.
### 확장 가능한 구조
`TusOptions`에 새로운 프로퍼티를 추가하는 방식으로 기능 확장이 이루어지며, `tusBlazorClient.js``GetUploadOption`에서도 동일한 옵션을 매핑해야 한다. 현재 확장 포인트가 인터페이스로 정형화되어 있지 않다.
### 디자인 패턴 사용
| 패턴 | 적용 위치 |
|------|----------|
| **Singleton** | `TusClient`를 DI Singleton으로 등록 |
| **Adapter (래퍼)** | `tus-js-client`를 C#에 맞게 감싼 전체 구조가 Adapter 패턴 |
| **Builder-like** | `TusOptions` 객체를 구성한 후 업로드 생성 → Builder 패턴의 변형 |
| **IDisposable/AsyncDisposable** | `TusClient`, `TusUpload`, `TusJsInterop` 모두 구현 |
| **Extension Method** | `SetupExtension.AddTusBlazorClient()` |
---
## 4. 설정과 확장성
### 설정 가능한 옵션 목록 (TusOptions)
| 옵션 | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `Endpoint` | `Uri` | **(필수)** | 업로드 생성 URL |
| `ChunkSize` | `long?` | `null` (Infinity) | PATCH 요청 본문 최대 크기 (byte) |
| `OnProgress` | `Action<long, long>?` | `null` | 진행률 콜백 (bytesSent, bytesTotal) |
| `OnChunkComplete` | `Action<long, long, long>?` | `null` | 청크 완료 콜백 |
| `OnSuccess` | `Action?` | `null` | 업로드 성공 콜백 |
| `OnError` | `Action<TusError>?` | `null` | 오류 발생 콜백 |
| `OnShouldRetry` | `Func<TusError, long, bool>?` | `null` | 재시도 결정 콜백 |
| `Headers` | `Dictionary<string, string>` | `new()` | 커스텀 HTTP 헤더 |
| `Metadata` | `Dictionary<string, string>` | `new()` | 업로드 생성 시 메타데이터 |
| `UploadUrl` | `Uri?` | `null` | 직접 재개용 URL |
| `RetryDelays` | `List<int>` | `[0, 1000, 3000, 5000]` | 재시도 대기 시간 (ms) |
| `StoreFingerprintForResuming` | `bool` | `true` | URL 저장소에 fingerprint 저장 |
| `RemoveFingerprintOnSuccess` | `bool` | `false` | 업로드 완료 시 fingerprint 제거 |
| `UploadLengthDeferred` | `bool` | `false` | Upload-Defer-Length 헤더 사용 |
| `UploadDataDuringCreation` | `bool` | `false` | 생성-with-upload 확장 사용 |
| `AddRequestId` | `bool` | `false` | 랜덤 Request ID 추가 |
| `ParallelUploads` | `int` | `1` | 병렬 업로드 수 |
| `ParallelUploadBoundaries` | `List<(int, int)>?` | `null` | 병렬 업로드 파트 경계 |
### 기본값 제공 방식
`TusOptions` 클래스에서 프로퍼티 초기화로 기본값을 제공한다. `new TusOptions()`만 생성해도 `RetryDelays`, `Headers`, `Metadata`, `StoreFingerprintForResuming`, `ParallelUploads` 등이 유효한 기본값을 가진다.
### 설정 검증 방식
명시적인 설정 검증 로직은 발견되지 않는다. JavaScript 레벨에서 `tus-js-client`가 자체적으로 검증할 것으로 추정된다. **(확인 필요)**
### 확장 지점
- 콜백: `OnBeforeRequest`, `OnAfterResponse`를 통해 모든 HTTP 요청/응답을 가로채고 분석할 수 있다.
- `OnShouldRetry`로 재시도 로직을 사용자가 완전히 제어할 수 있다.
- `Headers`로 인증 토큰 등 커스텀 헤더 주입이 가능하다.
- 인터페이스 기반 확장 포인트는 존재하지 않으며, 상속을 통한 교체도 지원되지 않는다.
### API 호환성 설계
`TusOptions`는 클래스이므로 새 프로퍼티 추가 시 기존 코드가 깨지지 않는다. `TusUpload.SetOtions()` API가 있어 업로드 실행 중에도 옵션 변경이 가능하다.
---
## 5. 오류 처리
### 예외 처리 방식
- `TusClient`에서 `IsSupported()`, `CanStoreUrls()` 등 간단한 조회 메서드는 `try-catch`로 감싸 실패 시 `false`를 반환한다.
- 그 외 직접적인 예외 throw/catch 패턴보다는 **콜백 기반 오류 전달**을 주로 사용한다.
### Result/Error 타입 사용
별도의 Result 타입은 없지만, `TusError`라는 전용 오류 클래스가 존재한다. `TusError`는 오류 메시지와 함께 실패한 요청/응답의 상세 정보를 포함한다.
### 사용자 입력 오류와 시스템 오류 구분
코드에서 명시적으로 구분하고 있지 않다. 모든 오류는 `OnError` 콜백을 통해 `TusError`로 전달된다. 네트워크 오류, HTTP 4xx/5xx 오류 모두 동일 콜백으로 수신된다.
### 커스텀 예외/에러 코드
별도 정의되어 있지 않다. `tus-js-client`의 오류 문자열을 그대로 `ErrorMessage`에 담아 전달한다.
### 실패 원인 전달 방식
`TusError`는 다음 정보를 외부 사용자에게 전달한다:
- `ErrorMessage` (string): 오류 설명
- `OriginalHttpRequest` (TusHttpRequest?): 실패한 요청의 HTTP 메서드, URL
- `OriginalHttpResponse` (TusHttpResponse?): 응답의 StatusCode, Body, Headers
이 구조를 통해 사용자는 네트워크 레벨에서 무슨 일이 일어났는지 상세히 파악할 수 있다.
### 재시도 가능/불가능 오류 구분
`OnShouldRetry` 콜백에서 `TusError``retryAttempt`(long)을 받아 사용자가 직접 재시도 여부를 결정한다. 콜백을 지정하지 않으면 tus-js-client의 기본 재시도 로직(409, 423, 4XX 이외 상태코드 재시도)이 적용된다.
---
## 6. 상태 관리
### 상태 enum/객체
별도의 상태 enum은 존재하지 않는다. 업로드 상태는 tus-js-client 내부에서 관리되며, 콜백(`OnProgress`, `OnSuccess`, `OnError`)을 통해 상태 변화가 외부에 통지된다.
### 상태 전이 흐름
```
[생성: TusClient.Upload()]
├── FindPreviousUpload() → ResumeFromPreviousUpload() (선택적)
├── Start()
│ │
│ ├── OnProgress → OnChunkComplete → ... (진행 중)
│ │ │
│ │ ├── Abort(true/false) → 중단
│ │ │ └── Start() → 재개
│ │ │
│ │ ├── OnError → (OnShouldRetry) → 자동 재시도 or 중단
│ │ │
│ │ └── OnSuccess → 완료
│ │
│ └── Terminate(url) → 서버에서 완전히 제거
└── DisposeAsync() → 리소스 정리
```
### 완료/실패/취소 처리
- **완료**: `OnSuccess` 콜백 → 업로드 URL을 `GetUrl()`로 획득 가능
- **실패**: `OnError` 콜백 → `TusError`로 상세 정보 확인
- **취소**: `Abort(bool shouldTerminate)``shouldTerminate=true`면 서버에서도 삭제
### 중복 실행 방지
`TusClient.Upload()`는 상태를 확인하지 않고 항상 새 `TusUpload`를 생성한다. `TusUpload.Start()`를 중복 호출하는 것에 대한 방어 로직은 확인되지 않는다.
### 동시성 / Thread-Safe
Blazor WASM은 단일 스레드에서 실행되므로, 별도의 동시성 제어가 필요하지 않다. `lock`, `SemaphoreSlim` 등은 사용되지 않는다.
---
## 7. 사용성
### README 구조
README.md는 다음과 같은 구조로 잘 정리되어 있다:
1. 프로젝트 소개 (tus 프로토콜 설명)
2. 사용 동기 ("Why do I use this?")
3. 설치 방법 (CDN + NuGet + DI 등록)
4. 완전한 예제 코드
5. Wiki 링크 (상세 API 문서)
### Quick Start
README에 포함된 예제 코드가 Quick Start 역할을 동시에 수행한다. 별도의 튜토리얼 페이지는 없다.
### 샘플 프로젝트
`TusBlazorClient.Demo` 프로젝트가 존재하며, 13개의 페이지로 주요 기능을 모두 시연한다:
| 페이지 | 시연 기능 |
|--------|----------|
| Index | `IsSupported()`, `CanStoreUrls()` |
| Upload | 기본 업로드 |
| UploadByJsObjectReference | IJSObjectReference 직접 사용 |
| UploadResume | 업로드 중단 후 재개 |
| ResumeFromPreviousUpload | 이전 업로드 찾아 재개 |
| ShouldRetry | 무한 재시도 |
| ShouldNoRetry | 재시도 없음 |
| OnRequest | OnBeforeRequest/OnAfterResponse |
| SetOption | 업로드 중 옵션 변경 |
| GetOption | 옵션 조회 |
| GetFileInfo | 파일 메타데이터 |
### 최소 사용 코드
```csharp
@inject TusClient TusClient
<input type="file" @ref="_el" />
<button onclick="@Upload">upload</button>
@code {
private ElementReference _el;
private async Task Upload() {
var file = (await TusClient.GetFileInputElement(_el).GetFiles()).First();
var upload = await TusClient.Upload(file,
new TusOptions { Endpoint = new Uri("https://example.com/files") });
await upload.Start();
}
}
```
### XML 주석/문서화 수준
`TusOptions`의 모든 프로퍼티에 XML 문서 주석(`/// <summary>`)이 작성되어 있으며, `TusUpload.GetUrl()` 등 주요 메서드에도 주석이 있다. 단, `TusClient`, `TusJsInterop`, `FileInputElement`에는 주석이 거의 없다.
### 처음 사용자가 헷갈릴 수 있는 부분
- `TusOptions`의 콜백은 `[JsonIgnore]`이므로, `GetOptions()`으로 받은 옵션 객체를 그대로 `Upload()`에 재사용하면 콜백이 유실된다. `GetOptions()` 내부에서 수동으로 콜백을 merge하는 이유가 여기에 있다.
- `tus-js-client` CDN 로드를 잊으면 라이브러리가 동작하지 않는다.
- `Upload()`을 호출할 때마다 새 `DotNetObjectReference<TusOptionJsInvoke>`가 생성되므로, 이전 upload 객체를 `Dispose`하지 않으면 리소스 누수가 발생할 수 있다.
---
## 8. 테스트와 검증
### 테스트 프로젝트
`TusBlazorClient.Test` 프로젝트가 존재한다. NUnit 3.13.3 + Selenium WebDriver 4.12.4 (Firefox) 조합으로 E2E 테스트를 수행한다.
### 단위 테스트
**존재하지 않음.** 별도의 단위 테스트는 없다.
### 통합 테스트 (E2E)
8개의 Selenium E2E 테스트 케이스:
| 테스트 | 검증 내용 |
|--------|----------|
| `Upload()` | 기본 업로드 성공, 진행률 콜백, 청크 완료 콜백, 파일 이름 일치, 유효한 업로드 URL |
| `UploadByJsObj()` | `ToJsObjectReference()` 경로 업로드 성공 |
| `UploadResume()` | 중단 후 `Start()` 재개, 재개 시 첫 progress가 0이 아님 |
| `ResumeFromPreviousUpload()` | `FindPreviousUpload()` + `ResumeFromPreviousUpload()` 성공 |
| `ShouldRetry()` | `OnShouldRetry`가 true 반환 시 5회 이상 재시도 발생 |
| `ShouldNotRetry()` | `OnShouldRetry`가 false 반환 시 1회 이하 재시도 |
| `OnRequest()` | `OnBeforeRequest`, `OnAfterResponse` 발생, 응답에 `tus-resumable` 헤더 존재 |
| `SetOption()` | 업로드 중 `SetOtions()`으로 ChunkSize 변경, 변경된 청크 크기로 전송됨 |
| `GetOption()` | 업로드 성공 후 `GetOptions()`로 옵션 조회 가능 |
### 실패 케이스 테스트
`ShouldRetry()``ShouldNotRetry()` 두 테스트가 오류 시나리오를 커버한다. 의도적으로 틀린 URL로 업로드를 시도하여 오류 콜백과 재시도 동작을 검증한다.
### Mock/Fake
사용되지 않음. 모든 테스트는 실제 tus 서버(Docker로 구동된 `tusd`, `172.17.0.3:8080`)와 실제 Firefox 브라우저를 사용한다.
### 테스트가 부족한 부분
- **단위 테스트 부재**: `TusOptionJsInvoke`, `TusOptions`, `TusOptionNullCheck` 등 순수 C# 로직에 대한 단위 테스트가 없다.
- **병렬 업로드 테스트 부재**: `ParallelUploads` > 1 시나리오가 없음.
- **다양한 오류 시나리오 부족**: 네트워크 중단, 타임아웃, 서버 오류 등 세분화된 오류 케이스가 부재.
- **Chrome/Edge 브라우저 테스트 부재**: Firefox만 사용.
---
## 9. 패키징과 배포
### 패키지 배포 구조
- NuGet.org에 `TusBlazorClient` 패키지로 배포되어 있다.
- `GeneratePackageOnBuild=true`로 빌드 시 자동 패키징된다.
- `.sln` 파일과 세 개의 프로젝트로 구성된 표준 .NET 솔루션 구조다.
### 버전 관리
- Semantic Versioning 사용. 현재 버전 `1.0.1`.
- csproj에 `<Version>1.0.1</Version>`로 직접 명시.
- Git 히스토리 기준 약 20개의 커밋으로 진화됨.
### 패키지 메타데이터
```
Title: TusBlazorClient
Description: tus-blazor-client is a wrapper library project for tus-js-client
that can be used in .NET Blazor.
Copyright: MIT
PackageProjectUrl: https://github.com/thsdmfwns/tus-blazor-client
PackageLicenseUrl: (LICENSE 파일)
PackageTags: tus, blazor, wrapper, js, browser
PackageReleaseNotes: 1.0.0
Version: 1.0.1
```
### CI/CD
코드 저장소에서 GitHub Actions나 다른 CI/CD workflow 파일은 **존재하지 않는다**. **(확인 필요)**
### 빌드/테스트/패키징 명령어
- 빌드: `dotnet build`
- 테스트: `dotnet test` (단, Selenium E2E 테스트는 Firefox + Docker tusd 서버 필요)
- 패키징: 빌드 시 `GeneratePackageOnBuild=true`로 자동 생성
### 외부 프로젝트 사용 가능 상태
NuGet.org에 배포된 버전 1.0.1이 있고, 정상적으로 설치하여 사용할 수 있는 상태다. 단, 아래 선행 조건이 필요하다:
1. 대상 프로젝트가 Blazor WebAssembly여야 함
2. `index.html``tus-js-client` CDN 스크립트 추가 필요
3. DI에 `AddTusBlazorClient()` 등록 필요
---
## 10. 성능과 리소스 고려
### async/await 사용 방식
- 모든 JS interop 메서드는 `async`로 선언되어 있으며, `ValueTask`를 반환하여 불필요한 Task 할당을 방지한다.
- `IAsyncDisposable`을 구현하여 JS 리소스를 비동기적으로 정리한다.
### Stream/Buffer 처리
.NET 측에서는 Stream이나 buffer를 직접 다루지 않는다. 실제 파일 전송은 브라우저의 `tus-js-client`가 처리하며, .NET 측은 설정값을 전달하고 콜백을 수신하는 역할만 한다.
### 메모리 사용량을 줄이기 위한 구조
- **Lazy 모듈 로딩**: `TusJsInterop`는 JavaScript ES module을 최초 사용 시점까지 지연 로딩한다 (`InitializeAsync()`). 사용하지 않으면 모듈이 로드되지 않는다.
- **TusOptionNullCheck**: 콜백이 null인 경우 JavaScript에서 `invokeMethodAsync` 호출을 아예 건너뛰도록 하여, .NET↔JS 간 불필요한 마샬링을 제거한다.
- **onBeforeRequest / onAfterResponse 동기화**: 이 두 콜백은 내부 상태를 변경하지 않으므로 `invokeMethodAsync` 대신 `invokeMethod`(동기 호출)를 사용한다.
### 반복 객체 생성 최소화
- `TusClient`는 Singleton으로 등록되어 JS 모듈을 재사용한다.
- `TusOptionJsInvoke`, `TusOptionNullCheck`는 upload 생성 시마다 새로 생성된다 (각 업로드가 다른 콜백을 가질 수 있으므로 불가피).
### 성능 측정/벤치마크
**존재하지 않는다.** 코드 내 성능 측정이나 벤치마크 코드는 발견되지 않았다.
---
## 11. 포트폴리오용 문제 해결 사례 후보
---
### 사례 1: Blazor ↔ JavaScript 콜백 브릿지 설계
#### 문제 상황
`tus-js-client`는 업로드 진행 상황, 오류, 성공 등을 JavaScript 콜백으로 통지한다. 이 콜백을 C# Blazor에서 받아 개발자가 C# 델리게이트로 처리할 수 있도록 해야 했다.
#### 원인 분석
Blazor와 JavaScript 간 데이터 전달은 JSON 직렬화 기반으로 이루어지지만, C# 델리게이트(`Action`, `Func`)는 직렬화할 수 없다. 또한 JavaScript→.NET 호출은 `invokeMethodAsync`를 통해 이루어지는데, 콜백이 설정되지 않은 경우에도 호출이 발생하면 불필요한 오버헤드가 발생한다.
#### 해결 방법
1. **TusOptionJsInvoke** 클래스를 도입하여 모든 `[JSInvokable]` 메서드를 한 객체에 모았다. 이 객체를 `DotNetObjectReference`로 감싸 JavaScript에 전달했다.
2. 콜백 프로퍼티는 `[JsonIgnore]`로 직렬화에서 제외하고, JavaScript 옵션 구성용 실제 값만 전달했다.
3. **TusOptionNullCheck**를 별도로 생성하여 각 콜백의 null 여부를 JavaScript 측에 알려주고, JS 측에서 `if (optNullCheck.isNullOnError) return;` 과 같이 early return 하도록 했다.
4. `OnBeforeRequest`, `OnAfterResponse`처럼 내부 상태를 변경하지 않는 콜백은 `invokeMethod`(동기)로 호출하여 오버헤드를 더 줄였다.
#### 선택 이유
- `DotNetObjectReference`는 Blazor가 공식 제공하는 JS→.NET 호출 메커니즘으로, 가장 안정적인 방법이다.
- 별도의 인터페이스를 정의하지 않고 하나의 `TusOptionJsInvoke` 클래스에 모든 콜백을 통합한 이유는 JS 모듈에 전달할 .NET 참조를 단일 객체로 유지하기 위해서다.
#### 결과
- C# 개발자는 JavaScript를 한 줄도 작성하지 않고 순수 C# 델리게이트로 모든 이벤트를 처리할 수 있게 되었다.
- null 체크 최적화로 불필요한 JS→.NET 호출이 제거되어, 콜백을 일부만 사용하는 시나리오에서 성능이 개선되었다.
---
### 사례 2: 업로드 중 옵션 동적 변경 (SetOtions)
#### 문제 상황
업로드 진행 중에 ChunkSize를 변경하거나 새로운 콜백을 등록해야 하는 사용 사례가 있었다. tus-js-client는 생성 시점에 옵션을 설정하는 구조이지만, 업로드 옵션 객체를 직접 수정하면 반영될 수 있었다.
#### 원인 분석
`TusUpload` 생성 시 전달된 `DotNetObjectReference``TusOptionJsInvoke`는 옵션 변경 후에도 기존 콜백을 참조하고 있어, 옵션을 그냥 변경하면 콜백이 동기화되지 않는 문제가 있었다. 또한 JavaScript 측의 `tus.Upload.options` 객체의 개별 프로퍼티에 새 값을 할당해야 했다.
#### 해결 방법
1. `TusUpload.SetOtions(Action<TusOptions> setOption)` API를 설계했다.
2. 내부적으로 `GetOptions()` → callback merge → `setOption` 호출 → 새 `DotNetObjectReference` 생성 → `SetTusUploadOption` JS 호출의 파이프라인을 구현했다.
3. 기존 `_optionJsInvokeReference`를 dispose하고 새로 생성하여 콜백 참조를 갱신했다.
4. JS 측에서는 `upload.options.endpoint = opt.endpoint` 패턴으로 개별 프로퍼티를 in-place 업데이트했다.
#### 선택 이유
- 액션 기반 API (`Action<TusOptions>`)는 사용자가 변경하고 싶은 옵션만 선택적으로 수정할 수 있도록 해준다.
- `TusUpload` 인스턴스를 폐기하고 새로 생성하는 것보다 효율적이다.
#### 결과
- `SetOption` E2E 테스트로 검증되어 있다. 업로드 50% 지점에서 ChunkSize를 50000에서 15000으로 변경한 후 정상 재개되었다.
---
### 사례 3: 이전 업로드 탐지 및 재개 (FindPreviousUpload / ResumeFromPreviousUpload)
#### 문제 상황
브라우저를 종료했다 다시 열거나, 페이지를 실수로 새로고침한 경우, 진행 중이던 업로드를 이어서 진행할 수 있도록 하는 기능이 필요했다. tus-js-client는 `findPreviousUploads()` API를 제공하지만, 이 기능을 C#으로 노출해야 했다.
#### 원인 분석
`findPreviousUploads()`는 브라우저 URL 저장소에 저장된 이전 업로드 목록을 JavaScript 객체 배열로 반환한다. 이 정보를 C# DTO로 변환해야 하고, 사용자가 특정 업로드를 선택해 재개할 수 있도록 해야 했다.
#### 해결 방법
1. `findPreviousUploads()` 호출 결과를 `List<TusPreviousUpload>`로 역직렬화하는 `InvokeAsync` 호출을 구현했다.
2. 단순히 DTO만 반환하지 않고, `TusPreviousUploadRef` 래퍼를 도입해 `Index`를 함께 보관하도록 했다. 이 Index가 `resumeFromPreviousUpload(pres[index])` 호출에 필요하기 때문이다.
3. `TusUpload.FindPreviousUpload()``TusUpload.ResumeFromPreviousUpload(TusPreviousUploadRef)` API를 제공하여 사용자가 목록을 조회하고 특정 항목을 골라 재개할 수 있게 했다.
#### 선택 이유
- JavaScript 배열 인덱스는 JSON 역직렬화 과정에서 소실되므로, C# 측에서 명시적으로 Index를 추적하는 래퍼 클래스를 도입했다.
- 복잡한 상태 관리 없이, "목록 조회 → 선택 → 재개" 라는 단순한 흐름으로 구현했다.
#### 결과
- `ResumeFromPreviousUpload` E2E 테스트에서 검증되었다. 중단 후 `FindPreviousUpload()` + `ResumeFromPreviousUpload()`로 정상 재개된다.
- README 예제 코드에도 이 기능이 포함되어 있어 주요 사용 사례로 강조된다.
---
### 사례 4: JS ES 모듈의 Lazy 로딩 및 생명주기 관리
#### 문제 상황
`tusBlazorClient.js`는 Blazor의 JavaScript ES 모듈로 `import`를 통해 로드된다. 모듈을 사용하지 않을 때도 불필요하게 로드되거나, 싱글톤 `TusClient`의 생명주기에 맞춰 적절히 해제되어야 했다.
#### 원인 분석
Blazor에서 `IJSRuntime.InvokeAsync<IJSObjectReference>("import", ...)` 로 로드된 ES 모듈은 `IJSObjectReference`로 관리되며, `DisposeAsync()`로 해제할 수 있다. 로드가 비동기이므로 최초 호출 시점까지 지연시킬 수 있다.
#### 해결 방법
1. `TusJsInterop`에서 `_script`를 nullable로 선언하고, 모든 public/internal 메서드에서 `await InitializeAsync()`를 먼저 호출하도록 했다.
2. `InitializeAsync()``_script != null`인 경우 early return 하여 **한 번만 모듈을 로드**한다.
3. `IAsyncDisposable`을 구현하여 `_script.DisposeAsync()`로 모듈을 정리한다.
4. `TusClient`가 Singleton이고, `TusClient.DisposeAsync()`에서 `_tusJsInterop.DisposeAsync()`를 호출한다.
#### 선택 이유
- Lazy 로딩은 라이브러리를 사용하지 않는 페이지에서는 JS 모듈 로드 비용이 발생하지 않도록 한다.
- null 체크 early return은 복잡한 `Lazy<T>` 패턴 없이도 스레드 안전하다 (Blazor WASM은 싱글 스레드).
#### 결과
- `TusClient`를 DI로 주입만 받고 실제 업로드를 사용하지 않는 경우에도 불필요한 JS 모듈 로딩이 발생하지 않는다.
- `DisposeAsync()` 체인이 명확하게 구성되어 있다 (`TusClient``TusJsInterop` → JS module).
---
## 12. 최종 포트폴리오 문장 초안
### 프로젝트 개요
Blazor WebAssembly 환경에서 tus 프로토콜 기반의 재개 가능한(resumable) 대용량 파일 업로드를 지원하는 .NET 라이브러리. 오픈소스 JavaScript 라이브러리인 `tus-js-client`를 C# API로 래핑하여, Blazor 개발자가 JavaScript 코드 없이도 대용량 파일의 청크 업로드, 중단 후 재개, 병렬 업로드를 구현할 수 있도록 했다.
### 담당 역할
- **단독 개발** (코드 저장소 전체의 설계, 구현, 테스트, 문서화, NuGet 배포까지 단독 수행)
### 주요 기여
- Blazor ↔ `tus-js-client` 간의 완전한 콜백 브릿지 설계 및 구현 (7종 콜백 지원)
- `TusOptions` 기반 설정 모델로 tus-js-client의 모든 옵션(18개)을 C#으로 노출
- 업로드 중 동적 옵션 변경(`SetOtions`), 이전 업로드 탐지/재개(`FindPreviousUpload`/`ResumeFromPreviousUpload`) API 설계
- `IAsyncDisposable` 기반의 JS 리소스 생명주기 관리
- Blazor WASM Demo 앱 (13개 페이지) 및 Selenium E2E 테스트 (9개 테스트 케이스) 구현
### 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
|------|----------|
| .NET 7 Blazor (Razor SDK) | 라이브러리가 `tusBlazorClient.js`를 번들로 포함해야 하므로 |
| `DotNetObjectReference` + `[JSInvokable]` | Blazor 프레임워크가 공식 제공하는 JS→.NET 호출 방식이므로 |
| `[JsonIgnore]` 콜백 분리 | C# 델리게이트는 직렬화 불가 → JS 전달 데이터와 콜백을 분리 |
| `ValueTask` 반환 | 불필요한 Task 할당을 방지하여 성능 최적화 |
| Singleton DI 등록 + Lazy JS 모듈 로딩 | 모듈 로딩 비용 최소화 및 리소스 재사용 |
| Selenium WebDriver (Firefox) | Blazor WASM은 브라우저 환경에서만 동작 → E2E 테스트로 검증 |
| tus-js-client (CDN) | 이미 검증된 오픈소스 라이브러리를 재사용하여 안정성 확보 |
### 구현 사항
- **Public API**: `TusClient` (진입점), `TusUpload` (업로드 작업), `TusOptions` (설정), `FileInputElement`/`JsFile`/`JsFileInfo` (파일 처리)
- **내부 구조**: `TusJsInterop` (JS 브릿지), `TusOptionJsInvoke` (콜백 수신), `TusOptionNullCheck` (null 콜백 최적화), `tusBlazorClient.js` (246라인, tus-js-client 어댑터)
- **DI**: `SetupExtension.AddTusBlazorClient()` 확장 메서드
- **리소스 관리**: 모든 주요 클래스가 `IAsyncDisposable` 구현, JS 모듈의 Lazy 로딩 및 생명주기 관리
- **오류 모델**: `TusError` + `TusHttpRequest`/`TusHttpResponse`로 네트워크 레벨 상세 정보 전달
- **테스트**: 9개의 Selenium E2E 테스트 (성공, 실패, 재개, 옵션 변경, 재시도 등)
- **데모 앱**: 13개 Razor 페이지로 모든 기능 시연
- **문서화**: README (설치/Quick Start), `TusOptions` XML 문서 주석, GitHub Wiki 링크
### 문제 해결 사례
위 11번 항목의 4가지 사례 참조:
1. Blazor ↔ JavaScript 콜백 브릿지 설계 (델리게이트 직렬화 불가 문제 해결)
2. 업로드 중 옵션 동적 변경 (옵션 변경 시 콜백 동기화 문제 해결)
3. 이전 업로드 탐지/재개 (JS 배열 인덱스 소실 문제를 래퍼 클래스로 해결)
4. JS 모듈 Lazy 로딩 + 생명주기 관리 (불필요한 모듈 로드 방지)
### 프로젝트 성과
- **NuGet 패키지**로 배포되어 (`TusBlazorClient` v1.0.1) 외부 프로젝트에서 `dotnet add package`로 즉시 설치 가능
- README 예제 코드 복사 → 붙여넣기 수준의 간결한 Quick Start 제공
- E2E 테스트로 주요 시나리오 검증 완료 (업로드, 재개, 오류 복구, 옵션 변경)
- tus-js-client의 모든 옵션(18개)과 콜백(7종)을 C#에 완전히 매핑
### 회고
- **잘한 점**: 싱글톤 + Lazy 로딩 구조는 라이브러리형 프로젝트에 적합한 선택이었다. `TusOptionNullCheck` 최적화처럼 작지만 실용적인 설계 선택이 실제 성능에 도움이 되었다. Blazor WASM 환경의 한계(느린 C# I/O)를 인지하고 검증된 JS 라이브러리를 래핑하는 전략도 실용적이었다.
- **아쉬운 점**: 단위 테스트가 없어 리팩토링 시 C# 로직의 회귀를 잡기 어렵다. 상속/인터페이스 기반 확장 포인트가 없어 외부 사용자가 내부 동작을 교체할 수 없다. CI/CD 파이프라인이 없어 테스트 자동화와 NuGet 자동 배포가 되어 있지 않다.
- **다음 개선 방향**: `CancellationToken` 지원 추가, IUploadStrategy 인터페이스 도입으로 확장성 확보, GitHub Actions CI/CD 구축, 단위 테스트 추가.

View File

@@ -0,0 +1,259 @@
# 술통여지도 (Sulmap)
## 1. 프로젝트 개요
한국의 다회차 음주 문화(1차·2차·3차)에서 다음 장소를 고를 때, 단순한 거리·평점 정렬로는 "비 오는 날 운치 있는 이자카야", "데이트에 어울리는 조용한 분위기" 같은 상황 맞춤 조건을 반영하기 어렵습니다. 저는 이 문제를 해결하기 위해 사용자의 위치·날씨·시간대·성별·나이·요청사항을 GPT-5.2에 전달하고, 반경 내 최대 200개 후보 중 개인화된 Top 10을 자연어 추천 이유와 함께 제공하는 **AI 기반 술집 추천 플랫폼**을 설계하고 구현했습니다.
---
## 2. 담당 역할
- Spring Boot 3계층 Clean Architecture 설계 및 구현
- GPT-5.2 Structured Output 기반 2단계 Cascade Ranking 추천 엔진 구현
- MyBatis 기반 DB 모델링 (16개 테이블) 및 REST API 설계 (12개 엔드포인트)
- Result\<T\> Monad 패턴 기반 전역 에러 처리 체계 구축
- Vue 3 + TypeScript 기반 지도 UI 및 AI 추천 UX 구현
- .NET 9 기반 공공데이터 ETL 파이프라인 (Qdrant + OpenAI Embedding) 구현
---
## 3. 주요 기여
### 3.1 200개 후보를 GPT에 한 번에 보낼 수 없는 문제 — 2단계 Cascade Ranking 설계
반경 내 술집이 최대 200개일 때, 전부를 한 번에 GPT에 전달하면 200개 × 약 80토큰 ≈ 16,000토큰 이상의 입력이 발생하고, 컨텍스트가 길어질수록 GPT의 attention 품질이 저하되는 문제가 있었습니다.
저는 이를 해결하기 위해 **2단계 Cascade Ranking** 구조를 설계했습니다.
- **1단계 (GptMinorRecommendClient)**: 200개를 100개씩 배치로 나눠 각 배치에서 top 5를 선별합니다. 출력은 `{ "selected": [id, id, ...] }` 형태의 ID 목록만 반환하여 토큰을 최소화합니다.
- **2단계 (GptRecommendClient)**: 1단계에서 선별된 최대 40개를 정밀 랭킹하여 Top 10과 자연어 추천 이유를 생성합니다.
```
200개 후보
└─► 배치 분할 (100개씩)
└─► GptMinorRecommendClient (각 배치 top5 선별)
└─► 최대 40개 후보
└─► GptRecommendClient (top10 + 추천 이유 생성)
└─► 지도 마커 + 사이드바 렌더링
```
이 구조로 200개를 1회 전송하는 방식 대비 GPT API 호출당 토큰을 약 70% 절감했습니다.
---
### 3.2 GPT가 존재하지 않는 술집을 추천하는 Hallucination 문제 — 다층 방어 체계 구현
GPT-5.2가 입력 후보(B 라인)에 없는 barId를 생성하거나, 사전 학습 데이터에서 기억한 술집을 추천하는 hallucination이 발생했습니다. Structured Output만으로는 허용된 선택지를 강제할 수 없었습니다.
저는 프롬프트와 코드 레벨에서 다층 방어 체계를 구성했습니다.
- **프롬프트 수준**: 시스템 프롬프트와 Stage Instructions 양쪽에 "후보에 없는 술집을 만들거나 추측하지 마라", "신규 barId 생성 금지"를 명시했습니다.
- **코드 수준 (Defensive Normalization)**: 정규식 `(?m)^B\|id=(\d+)\b`로 입력 B 라인에서 허용된 ID Set을 추출하고, GPT 응답의 barId를 대조하여 허용되지 않은 ID는 즉시 제거했습니다.
- **부족 시 폴백**: GPT가 topK보다 적게 반환하면 입력 순서(거리순)로 자동 채워 결과 개수를 보장했습니다.
```java
// 허용 ID Set 구성
Set<Long> allowedSet = extractBarIds(batchText);
// GPT 응답에서 허용되지 않은 ID 제거
List<Item> filtered = items.stream()
.filter(item -> allowedSet.contains(item.barId))
.collect(toList());
```
이 구조로 GPT hallucination으로 인한 잘못된 barId 노출을 0건으로 차단했습니다.
---
### 3.3 JSON 직렬화의 토큰 낭비 문제 — 도메인 특화 Pipe-delimited Format 설계
GPT에 후보 술집 정보를 JSON으로 전달하면 키 이름, 중괄호, 따옴표 등 구조 문자 자체가 대량의 토큰을 소비합니다. 또한 술집 이름이나 영업정보에 개행 문자·파이프 문자가 포함되어 프롬프트 파싱 오류가 발생할 수 있었습니다.
저는 `GptBatchTextBuilder`를 설계하여 도메인 특화 Pipe-delimited Format을 정의했습니다.
```
# JSON 방식
{"id": 123, "category": "주점", "openingInfo": "매일 18:00-02:00", "name": "포차", "menu": "닭발,오돌뼈"}
# 커스텀 포맷 방식
B|id=123|c=주점|oi=매일 18:00-02:00|n=포차|menu=닭발,오돌뼈
```
데이터 sanitize도 함께 구현하여, 모든 필드에서 파이프(`|`), 개행(`\n`, `\r`)을 공백으로 치환하고 필드별 길이를 truncate했습니다. (영업정보 60자, 이름 20자, 메뉴 60자, 사용자 프롬프트 180자 등)
이 포맷 도입으로 GPT 호출당 토큰을 JSON 대비 약 40% 절감했으며, 파이프 구분자 파싱 오류도 제거했습니다.
---
### 3.4 AI 장애 시 서비스 완전 중단 방지 — Graceful Degradation 구현
GPT API는 네트워크 이슈, 타임아웃, Rate Limit 등으로 언제든 실패할 수 있는 외부 의존성입니다. 저는 AI 호출이 실패했을 때 빈 화면을 보여주는 대신, 입력 순서(거리순)로 topK를 반환하는 폴백 전략을 구현했습니다.
```java
try {
return gptRecommendClient.rank(ctx, bLines, topK);
} catch (Exception e) {
log.error(e.getMessage(), e);
// Graceful Degradation: 거리순 fallback 반환
return buildFallbackResult(candidates, topK);
}
```
AI 장애 시에도 사용자에게 기본 거리 기반 추천 결과가 표시되어 서비스 사용성이 유지됩니다.
---
### 3.5 Result\<T\> Monad 패턴으로 에러 처리 체계 통일
서비스 계층에서 예외를 던지는 방식은 호출부에서 예외 처리를 누락할 위험이 있었습니다. 저는 자체 `Result<T>` 클래스를 구현하여 성공(`Result.ok(value)`)과 실패(`Result.fail(error)`)를 반환 타입으로 표현하는 체계를 구축했습니다.
```java
// Service 계층
public Result<Plan> findPlan(Long planId, Long userId) {
Plan plan = planRepository.findById(planId);
if (plan == null) return Result.fail(new NotFoundError("플랜을 찾을 수 없습니다."));
if (!plan.getOwnerId().equals(userId)) return Result.fail(new ForbiddenError("접근 권한이 없습니다."));
return Result.ok(plan);
}
// Controller 계층
Result<Plan> result = planService.findPlan(planId, userId);
if (result.isFailure()) return result.toErrorResponse();
return ResponseEntity.ok(PlanResponse.from(result.getValue()));
```
`NotFoundError(404)`, `ForbiddenError(403)`, `ConflictError(409)`, `ServerError(500)` 등 의미 있는 에러 타입 계층을 정의하여, 모든 실패 응답이 `{ StatusCode, Message }` 형태로 일관되게 반환되도록 했습니다.
---
## 4. 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
| --- | --- |
| **GPT-5.2 + Structured Output** | 복잡한 다중 조건(날씨·시간·분위기)을 자연어로 추론하는 데 룰 기반 필터링으로는 대응 불가능했습니다. Structured Output으로 JSON Schema를 강제하여 파싱 오류를 방지했습니다. |
| **Spring Boot 3.5.9** | DI·Security·Validation이 잘 통합된 환경에서 3계층 아키텍처를 빠르게 구조화하기 위해 선택했습니다. |
| **MyBatis 3.0.5** | JPA Lazy Loading으로 인한 N+1 문제를 피하고, 복잡한 조인 쿼리를 SQL로 직접 제어하기 위해 선택했습니다. |
| **Elasticsearch 8.15.2** | 술집 이름·카테고리 기반 전문 검색을 MySQL LIKE보다 빠르게 처리하고, 위치 기반 검색과 결합하기 위해 선택했습니다. |
| **Qdrant + text-embedding-3-small** | 데이터 파이프라인에서 공공데이터와 술집 정보를 벡터 유사도로 매칭하기 위해 선택했습니다. 런타임 추천과 역할을 분리하여 서빙 비용을 줄였습니다. |
| **Vue 3 + TypeScript + Pinia** | Composition API로 지도 마커 상태와 AI 결과 목록의 반응형 동기화를 타입 안전하게 구현하기 위해 선택했습니다. |
| **Spring Security + Session Cookie** | JWT 대신 세션 방식을 선택한 이유는 서버사이드에서 세션을 직접 폐기할 수 있어 로그아웃 보안이 명확하고, SSAFY 인프라 환경에 더 적합했기 때문입니다. |
---
## 5. 구현 사항
### 전체 아키텍처
```
사용자 요청
Spring Boot (3-Layer Clean Architecture)
├── api/ Controller, DTO, Security Config, CORS
├── core/ Service Interface/Impl, Repository Interface, Domain Model
├── infra/ Repository Impl, MyBatis Mapper, GPT Client, ES Client
└── share/ Result<T> Monad, Error Type 계층
├─► MySQL 8.0 (users, bars, reviews, plans, schedules 등 16개 테이블)
├─► Elasticsearch (술집 전문 검색 인덱스)
└─► OpenAI GPT-5.2 (2단계 Cascade Ranking 추천)
[데이터 파이프라인] .NET 9 ETL
└─► OpenAI Embedding (text-embedding-3-small)
└─► Qdrant (공공데이터 ↔ 술집 벡터 유사도 매칭)
```
### AI 추천 요청 처리 흐름
```
POST /ai/recommend-bars
├─ @Valid Bean Validation (위경도, 거리, 프롬프트 길이 검증)
├─ BarRepository.findNearby() → 반경 내 최대 200개 조회
├─ GptBatchTextBuilder.build()
│ └─ sanitize (파이프·개행 제거, 필드별 truncate)
│ └─ CTX 라인 + B 라인 직렬화 (Pipe-delimited Format)
├─ GptMinorRecommendClient (1단계: 배치당 top5 선별)
│ └─ Structured Output → { "selected": [id, ...] }
├─ GptRecommendClient (2단계: 최대 40개 → top10 + reasons)
│ └─ Structured Output → { "top": [{ barId, reasons }] }
├─ Defensive Normalization
│ ├─ 허용 ID Set 대조 → 미허용 barId 제거
│ ├─ 중복 제거 (LinkedHashMap 순서 유지)
│ ├─ reasons sanitize (파이프·개행 제거, 45자 truncate)
│ └─ 부족 시 거리순 fallback 채움
└─ RecommendedBarListResponse → 지도 마커 + 사이드바 렌더링
```
### 주요 API 목록
| Method | Path | 설명 | 인증 |
| --- | --- | --- | --- |
| POST | `/auth/login` | 세션 기반 로그인 | Public |
| POST | `/auth/signup` | 회원가입 | Public |
| POST | `/ai/recommend-bars` | AI 술집 추천 | 필요 |
| GET | `/bars/nearby` | 위치 기반 주변 술집 검색 | 필요 |
| GET | `/bars/{id}` | 술집 상세 정보 | 필요 |
| POST/PATCH/DELETE | `/reviews` | 리뷰 작성·수정·삭제 | 필요 |
| POST/PUT | `/memos` | 술집 개인 메모 upsert | 필요 |
| GET/POST | `/plans` | 음주 플랜 목록·생성 | 필요 |
| GET/PATCH/DELETE | `/plans/{id}` | 플랜 상세·수정·삭제 | 필요 |
| GET/POST | `/schedules` | 일정 생성·조회 | 필요 |
| GET | `/schedules/history` | 음주 이력 조회 | 필요 |
### 데이터베이스 주요 구조
```
users ─────────────────────────────────────────────────────
├─ bars ──────────────── bar_categories
│ │ bar_category_mapping
│ │
│ ├─ reviews ───────── review_media (이미지 첨부)
│ │ review_likes (좋아요)
│ │ review_reports (신고)
│ │
│ └─ memos (개인 메모, user+bar 단위 upsert)
└─ drinking_plan ──────── drinking_plan_stops (1차/2차/3차 장소)
│ plan_votes
└─ schedules (플랜을 날짜/시간에 스케줄링)
visits
```
### 프론트엔드 주요 화면
| Route | 설명 |
| --- | --- |
| `/home` | 네이버 지도 + AI 추천 다이얼로그 + 술집 사이드바 |
| `/plans` | 음주 플랜 목록 |
| `/plans/new` | 플랜 생성 (1차·2차·3차 장소 구성) |
| `/plans/:id` | 플랜 상세 및 일정 생성 |
| `/history` | 음주 이력 조회 |
---
## 6. 기술적 의사결정 및 회고
### AI 비동기 처리를 적용하지 않은 판단
GPT 호출이 동기 blocking 방식으로 처리되어 사용자가 응답을 기다려야 합니다. Cascade Ranking 구조상 1단계 결과가 나와야 2단계 입력이 결정되므로 완전한 비동기화가 어려웠고, 현재 사용자 수 규모에서는 동기 방식이 구현 복잡도를 낮추면서 충분히 동작했습니다. 트래픽이 증가하면 SSE나 WebSocket으로 스트리밍 응답을 제공하는 방식으로 전환해야 합니다.
### AI 응답 테스트 부재에 대한 인식
`GptRecommendClient``GptMinorRecommendClient`에 대한 유닛 테스트가 구현되지 않았습니다. 프롬프트를 변경했을 때 추천 품질이 유지되는지 회귀 테스트가 없어, 수동 확인에 의존하고 있습니다. 향후에는 Mock AI 응답을 활용한 유닛 테스트와 추천 품질을 측정하는 평가 프레임워크를 도입하는 것이 필요합니다.
### 토큰 비용 모니터링 부재
OpenAI API 호출당 토큰 사용량과 비용을 별도로 기록하는 코드가 없어, 실제 운영 비용을 수치로 파악하기 어렵습니다. 요청별 토큰 카운팅 로깅을 추가하고, 이상 사용량을 감지하는 알림 체계를 갖추는 것이 운영 환경에서 필요합니다.
### Rate Limit 및 Timeout 대응 미비
GPT API의 Rate Limit이나 응답 지연에 대한 재시도 로직이 구현되어 있지 않습니다. 현재는 실패 시 즉시 거리순 폴백으로 대응하는 수준입니다. Exponential Backoff 기반 재시도와 명시적 Timeout 설정을 적용하여 외부 API 의존성을 더 견고하게 처리해야 합니다.

View File

@@ -0,0 +1,484 @@
# Cloud# (CloudSharp) — 백엔드 / 인프라 포트폴리오
> **프로젝트 유형:** 셀프호스트 파일 서비스 플랫폼
> **개인 기여 영역:** 백엔드 API · 데이터 모델링 · 인프라 구성 · CI/CD · 설계 문서화
> **기술 스택:** ASP.NET Core 10, PostgreSQL 16, Redis 7, tusd, Docker Compose, nginx
---
## 1. 프로젝트 개요
### 한 줄 정의
> **Space 단위의 완전 격리형 저장 공간**을 제공하는 셀프호스트 파일 서비스로, tus 프로토콜 기반의 재개 가능한 대용량 업로드와 단명(短命) 다운로드 세션을 갖췄다.
### 해결하려는 문제
기존 클라우드 스토리지(Google Drive, Dropbox 등)는 **개인 계정 중심**으로 설계되어 있어, 팀/프로젝트 단위에서 폴더 공유만으로는 다음 요구를 충족하기 어렵다.
- 격리된 저장 공간 (멤버십 + Quota + 권한)
- 대용량 파일의 중단 재개 업로드
- 셀프호스트 + 외부 공유 정책의 분리
### 주요 사용자
셀프호스트를 선호하는 개인·팀·소규모 조직, Space 단위 협업이 필요한 프로젝트 그룹.
### 백엔드 핵심 책임
| 책임 | 설명 |
|------|------|
| 인증/인가 | Opaque session token 기반 인증 + Space Role 기반 인가 |
| 메타데이터 진실 원천 | 12개 엔티티의 정합성 보장 (PostgreSQL) |
| 업로드 파이프라인 | tusd 연계 세션 생성 → 전송 → Finalize 전 생명주기 관리 |
| Quota 정책 | `used + reserved + expected ≤ allowed` 원자적 판정 |
| Storage 추상화 | Local FS / MinIO / S3 공통 `storage_key` 추상 |
| 다운로드 보안 | 5분 TTL DownloadSession 기반 Zero Information Leak |
| 동시성 제어 | CAS 기반 Finalize 중복 차단 + Recovery Worker |
---
## 2. 담당 역할
**1인 백엔드 + 인프라 + 설계 담당.** 프론트엔드를 제외한 전 영역.
- **백엔드 API**: 인증·인가, Space 관리, 파일/폴더 CRUD, 업로드/다운로드 파이프라인, Quota, 공유 링크
- **데이터베이스 설계**: 12개 엔티티 ERD, EF Core Configuration, Migration 자동화
- **인프라 구성**: Docker Compose 5-서비스 오케스트레이션, nginx Reverse Proxy, Volume·Healthcheck 설계
- **CI/CD**: GitLab CI 파이프라인 (test → build → image push to GHCR)
- **설계 문서화**: API/ERD/Conventions/ADR 등 살아있는 문서 체계 구축
---
## 3. 아키텍처
### 3.1 아키텍처 유형
**모듈러 모놀리스 + Clean Architecture.**
> **선택 이유:** MVP 단계에서 마이크로서비스의 운영 복잡도(분산 트랜잭션, 서비스 디스커버리, 로그 수집)를 피하면서도, 코드 레벨로는 도메인 경계를 엄격히 분리해 추후 분리 가능성을 확보했다.
> 의존성 방향은 `Api → Core ← Infrastructure`로, 도메인이 HTTP나 DB, 파일시스템을 알지 못하게 했다.
### 3.2 전체 구성도
```mermaid
flowchart TB
Client["Browser / Client"]
subgraph Docker["Docker Compose Host"]
NGINX["nginx :80"]
API["ASP.NET Core API :8080"]
TUSD["tusd :1080"]
PG[("PostgreSQL")]
REDIS[("Redis")]
FS[("Local FS /data/storage")]
end
Client -->|"HTTP :8080"| NGINX
NGINX -->|"/api/*"| API
NGINX -->|"/files/*"| TUSD
API --> PG
API --> REDIS
API --> FS
API -.->|"hook callback"| TUSD
TUSD --> FS
```
### 3.3 서비스 책임 분리
| 컴포넌트 | 책임 |
|----------|------|
| **nginx** | 단일 외부 진입점, tus 헤더 포워딩, 스트리밍 버퍼링 해제 |
| **ASP.NET API** | 권한·정책·메타데이터·Finalize·공유·세션 |
| **tusd (Go)** | tus 프로토콜 청크 수신, hook으로 API에 생명주기 위임 |
| **PostgreSQL** | 메타데이터 진실 원천 |
| **Redis** | 세션 저장소 + Pub/Sub 이벤트 버스 |
### 3.4 핵심 설계 의도
- **업로드 plane과 API plane 분리**: 대용량 청크 전송은 Go 기반 tusd가 담당, 비즈니스 판단은 ASP.NET이 담당 → 장애 격리 + 독립 확장
- **단일 외부 포트(8080)**: 셀프호스트 사용자 입장의 운영 단순화. DB/Redis/tusd/API는 `expose`만 사용하고 외부 노출 금지
---
## 4. 주요 기여 — 문제 해결 사례
### 4.1 Finalize 중복 실행 방지: CAS Lock
**문제 상황**
tusd hook 콜백 + 백그라운드 Recovery Worker 두 경로에서 같은 UploadSession이 동시에 처리되어 FileItem이 중복 생성될 위험이 있었다. Finalize는 파일 이동(rename)이라는 느린 I/O를 포함하므로 일반적인 DB 트랜잭션만으로는 동시성 제어가 부족했다.
**원인 분석**
- 상태 판정과 처리 시작이 분리되면 race condition 발생
- 분산 락(Redis Redlock 등) 도입은 인프라 복잡도 증가
- 파일 I/O 동안 DB 트랜잭션을 잡고 있으면 락 점유 시간이 너무 길다
**설계 선택 — CAS(Compare-And-Swap)**
```sql
UPDATE upload_session
SET status = 'FINALIZING',
finalize_attempts = finalize_attempts + 1
WHERE id = :session_id
AND status = 'UPLOADING';
-- affected_rows = 1 → 점유 성공
-- affected_rows = 0 → 이미 처리 중 (즉시 무시)
```
**구현 방식**
- 파일 I/O와 DB 트랜잭션을 분리: 무거운 작업은 트랜잭션 밖, DB 변경(FileItem INSERT, Quota 갱신, FileReservation CONSUMED)만 짧은 트랜잭션
- Recovery Worker(5분 주기)가 10분 이상 `FINALIZING`에 머문 세션을 자동 보정 (FileItem 존재 시 COMPLETED, 임시 파일 소실 시 FAILED)
**결과**
- 별도 락 인프라 없이 단일 SQL UPDATE로 원자적 점유 실현
- 교착 상태 자동 복구로 운영 부담 최소화
- DB UNIQUE 제약(`storage_key`)과 함께 이중 안전장치 구성
---
### 4.2 Space Quota 경쟁 조건 방지
**문제 상황**
한 Space의 여러 멤버가 동시에 대용량 업로드를 시작할 때, quota 판정(`expected ≤ available`)과 reserved 증가 사이의 간극에서 모든 요청이 통과하는 race condition이 발생할 수 있다.
**설계 선택 — DB row-level lock**
```sql
BEGIN;
SELECT * FROM space WHERE id = :id FOR UPDATE;
-- quota 판정 + reserved += expected_size + FileReservation 생성
COMMIT;
```
**선택 이유**
- 업로드 세션 생성은 빈번하지 않고 락 지속시간이 짧다
- 낙관적 동시성으로는 이미 전송 중인 요청을 거절할 수 없다
- Finalize 직전에도 quota를 재검사하여 이중 안전장치를 구성
**결과**
- Quota 불변조건 `used + reserved ≤ allowed`가 항상 유지
- 초과 업로드는 **시작 시점**에 거절되어 불필요한 네트워크 전송 방지
---
### 4.3 인증 토큰: Opaque Session vs JWT
**문제 상황**
한 사용자가 여러 Space에서 서로 다른 Role을 가지며, Role 변경이 즉시 반영되어야 한다. JWT를 사용하면 토큰 만료 전까지 stale 권한이 유지되며, 모든 Role을 claim에 담으면 토큰 크기가 비대해진다.
**설계 선택 — Opaque Session Token**
```
Authorization: Bearer cs_sess_{base64url(CSPRNG 32bytes)}
```
- 권한 정보를 토큰에 담지 않음
- Redis에 `HMAC-SHA-256(token, secret)` 해시만 저장 (원문 저장 금지)
- 매 요청마다 DB에서 최신 SpaceMember를 조회
**구현 방식**
- `CloudSharpSessionAuthenticationHandler`: 헤더 검증 → 해시 계산 → Redis 조회 → 만료 확인
- `RequireSpacePermissionFilter`: route의 spaceSlug → spaceId 변환 + Role 충족 확인 → `AuthorizedSpaceContext` 주입
- UseCase 단계에서 리소스의 `space_id` 재검증 (IDOR 방어)
- Idle 12시간 / Absolute 30일 / sliding renewal
**결과**
- Role 변경이 **다음 API 요청부터 즉시 반영**
- 로그아웃·강제 Revoke를 Redis key 삭제 한 번으로 처리
- 토큰 탈취 시에도 토큰 자체에는 정보가 없어 노출 최소화
---
### 4.4 파일명 충돌 정책: "명시적 실패 반환"
**문제 상황**
Google Drive 같은 서비스는 동일 폴더에 같은 이름의 파일이 업로드되면 자동으로 `파일명(1).pdf`로 rename한다. 그러나 사용자가 의도하지 않은 파일이 누적되어 데이터 일관성이 깨진다.
**설계 선택 — `FILE_NAME_CONFLICT` 에러 반환**
- 활성 `FileItem` + 활성 `FileReservation`을 함께 검사 (사전 + Finalize 직전 이중 검증)
- DB 레벨에서 `UNIQUE (space_id, folder_id, normalized_name) on active rows` 제약
**선택 이유**
- "모호함보다 명시적 실패가 낫다"는 설계 철학
- 사용자가 의도적으로 파일명을 결정하도록 유도
- 구현 단순성 (rename fallback, 카운터 등 불필요)
**결과**
- 항상 예측 가능한 동작 + 사용자 의도와 저장 상태의 일관성
---
## 5. API 설계
### 5.1 기본 계약
| 항목 | 값 |
|------|-----|
| 내부 API | `/api/v1/*` (Bearer 인증) |
| 외부 공개 API | `/public/v1/*` (share_token + 비밀번호) |
| 내부 전용 | `/api/internal/*` (`X-CloudSharp-Internal-Token`) |
| 문서화 | Swagger UI + OpenAPI 3.x + ReDoc |
### 5.2 URL 식별자 분리 정책
- **외부 노출**: Space는 UUID 기반 `spaceSlug` (URL 식별자)
- **내부 Core**: bigint PK `spaceId`만 사용
- **변환 위치**: `RequireSpacePermissionFilter`가 slug → id + 권한 컨텍스트로 변환
> bigint PK가 외부에 노출되어 enumeration 공격에 취약해지는 문제를 차단했다.
### 5.3 표준 에러 응답
```json
{
"requestId": "req_01JXYZ...",
"error": {
"code": "FILE_NAME_CONFLICT",
"message": "같은 이름의 파일이 이미 존재합니다.",
"details": [{ "field": "displayName", "reason": "conflict" }]
}
}
```
- `requestId` = `HttpContext.TraceIdentifier` → 로그 추적 가능
- ErrorCode는 OpenAPI에 문서화된 고정 문자열 → 클라이언트가 분기 처리 가능
- 외부 공개 API는 권한 없음/리소스 없음/비활성 모두 **404로 통일** → Zero Information Leak
---
## 6. 데이터베이스 설계
### 6.1 주요 엔티티 (12개)
User, Space, SpaceMember, SpaceInvite, Folder, FileItem, **UploadSession**, **FileReservation**, ShareLink, ShareLinkTarget, DownloadSession.
### 6.2 핵심 모델링 결정
**① UploadSession ↔ FileReservation 1:1 분리**
- UploadSession: 전송 상태 추적(tus I/O, 네트워크 관점, 7-state machine)
- FileReservation: 비즈니스 자원 선점(quota·파일명, 도메인 관점, 6-state machine)
- 변경 주기와 실패 원인이 다르므로 분리하되 1:1로 연결
**② Space 중심 소유권**
- 파일/폴더의 FK는 User가 아닌 Space
- 행위자(`created_by_user_id`)와 소유자(`space_id`) 분리
- 멤버 탈퇴 시에도 파일은 Space에 남음
**③ DownloadSession 별도 테이블**
- 로그인 세션과 다운로드 토큰은 TTL/revoke 정책/subject_type이 전혀 다름
- `subject_type` (USER/SHARE_LINK)으로 내부 인증과 외부 공유를 단일 테이블로 통합
### 6.3 정합성 보장
| 원칙 | 적용 |
|------|------|
| Soft Delete | 대부분 테이블에 `deleted_at TIMESTAMP NULL` |
| CHECK 제약 | `storage_used_bytes ≥ 0`, `received_size ≤ expected_size` |
| UNIQUE | `storage_key`, `(space_id, folder_id, normalized_name)` on active rows |
| Migration | EF Core 시작 시점 자동 적용 (`RunDatabaseMigrationsOnStartup`) |
---
## 7. 트랜잭션과 동시성
### 7.1 트랜잭션 패턴
공통 `IAppDbTransactionFactory` 추상화로 UseCase에서 명시적 경계 관리.
```csharp
await using var tx = await transactionFactory.BeginAsync(ct);
var result = await repository.SaveAsync(entity, ct);
if (result.IsFailed) { await tx.RollbackAsync(ct); return ...; }
await tx.CommitAsync(ct);
```
### 7.2 Finalize 트랜잭션 경계 분리
```
🔓 트랜잭션 밖: 임시 파일 검증 + 파일 이동 (느린 I/O)
🔒 DB 트랜잭션: FileItem INSERT
Space.storage_used_bytes += final_size
Space.storage_reserved_bytes -= reserved_size
FileReservation → CONSUMED
UploadSession → COMPLETED
```
> 파일 이동 동안 DB 락을 잡지 않는다. DB 트랜잭션 실패 시 발생할 고아 파일은 Recovery Worker가 정리.
### 7.3 중복 생성 3중 방지
1. UseCase 사전 검증 (파일명 충돌)
2. DB UNIQUE 제약
3. `TrySaveChangesAsync()` — conflict 감지 시 `Result.Fail`
---
## 8. 예외 처리와 응답 구조
### 8.1 3계층 실패 전략
| 실패 유형 | 처리 | 로그 레벨 |
|-----------|------|-----------|
| Validation | ASP.NET 400 자동 응답 | 없음 |
| 비즈니스 | `FluentResults` + `CloudSharpError``ResultHttpMapper` | 보안 실패만 Warning |
| 시스템 예외 | `ExceptionHandlingMiddleware``ProblemDetails` 500 | Error 1회 (stack trace 운영 비공개) |
### 8.2 ErrorCode → HTTP 매핑
| 패턴 | HTTP |
|------|------|
| `*_NOT_FOUND` | 404 |
| `*_FORBIDDEN`, `*_UNAUTHORIZED` | 403 |
| `*_CONFLICT`, `*_DUPLICATE` | 409 |
| 나머지 | 400 |
### 8.3 책임 분리
- **Endpoint**: 라우팅, DTO ↔ Command 변환, Result → HTTP 변환만
- **UseCase**: `Result<T>` 반환, HTTP를 모름
- **Domain Policy**: 상태 전이 검증, 인프라 의존성 없음
---
## 9. 인프라 구성
### 9.1 Docker Compose 5-서비스
| 서비스 | 외부 노출 | 역할 |
|--------|-----------|------|
| postgres | ❌ (`expose`만) | 메타데이터 |
| redis | ❌ (`expose`만) | 세션 + Pub/Sub |
| tusd | ❌ (`expose`만) | tus 청크 업로드 |
| api | ❌ (`expose`만) | ASP.NET 백엔드 |
| **nginx** | ✅ `:8080` | **유일한 외부 진입점** |
### 9.2 Volume 구조
```
postgres_data → /var/lib/postgresql/data
redis_data → /data
./storage → /data/storage (API + tusd 공유)
├── objects/ ← 최종 파일
│ └── spaces/{shard}/{spaceId}/objects/{s1}/{s2}/{fileKey}.bin
└── tmp/tusd/ ← tusd 임시 파일
```
> **Storage Sharding**: hex hash 기반 `256 × 256 × 256 = 65,536` 버킷 분산으로 디렉토리 과밀 방지.
### 9.3 Healthcheck + depends_on
모든 서비스가 Healthcheck를 가지며, `condition: service_healthy`로 단순 실행 순서가 아닌 **실제 준비 완료**를 기다린다.
```yaml
api:
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
tusd: { condition: service_healthy }
```
### 9.4 nginx — tusd 스트리밍 특수 설정
```nginx
proxy_set_header Tus-Resumable $http_tus_resumable;
proxy_set_header Upload-Length $http_upload_length;
proxy_set_header Upload-Offset $http_upload_offset;
client_max_body_size 0; # 대용량 무제한
proxy_buffering off; # 스트리밍 버퍼링 해제
proxy_request_buffering off;
proxy_read_timeout 600s; # 장시간 업로드
```
### 9.5 Dockerfile 설계
- Multi-stage build (SDK → build → publish → runtime)로 최종 이미지 크기 최소화
- `aspnet:10.0` 기본 이미지에 `curl` 추가하여 컨테이너 HEALTHCHECK 지원
---
## 10. 환경변수 / 설정 관리
```bash
# 보안 비밀 (반드시 환경변수로만 주입)
Auth__SessionHashKey=<HMAC secret>
Uploads__FinalizeToken=<openssl rand -base64 32>
# DB / Redis / Storage / tusd
Postgres__Host=postgres
Redis__Host=redis
Storage__Provider=local
Tusd__HooksHttp=http://api:8080/internal/tusd/hooks
```
- ASP.NET Options 패턴 + `__` 환경변수 오버라이드
- 모든 연결 정보·경로·정책 값은 환경변수 또는 `appsettings.json`에서 주입 (하드코딩 금지)
- Compose가 서비스명을 DNS로 resolve (`postgres`, `redis`, `tusd`)
---
## 11. 로그 / 모니터링
**로그 철학:** *"중복 없이 검색 가능하게"*
| 위치 | 레벨 | 책임 |
|------|------|------|
| Middleware | Information | 요청당 1회 (`method/path/status/elapsedMs/traceId/userId`) |
| GlobalExceptionHandler | Error | 예상 못한 예외만 1회 (운영 stack trace 미노출) |
| UseCase Activity Proxy | Debug | 모든 UseCase 메서드 자동 추적 |
| UseCase 직접 | Warning/Information | 보안 실패, 감사 이벤트만 |
**구조화 로그 — 영어 고정 템플릿 + named placeholder**
```csharp
// ✅
logger.LogWarning(
"Space access denied userId={UserId} spaceId={SpaceId} permission={Permission}",
userId, spaceId, permission);
// ❌ 문자열 보간 금지
```
**금지사항**: 토큰/body/Authorization header 로깅, validation 실패를 Warning으로 남기기, 운영 환경 stack trace 노출.
---
## 12. CI/CD
**GitLab CI 4-stage 파이프라인**: `test → build → pr_review → deploy`
```yaml
backend:test:
image: mcr.microsoft.com/dotnet/sdk:10.0
script:
- dotnet test tests/CloudSharp.Core.Tests
- dotnet test tests/CloudSharp.Infrastructure.Tests
backend:image:
script:
- docker build → ghcr.io/cloud-sharp/cloudsharp-backend:$CI_COMMIT_SHA
- docker push (master push only)
```
| 항목 | 상태 |
|------|------|
| Backend Unit/Infrastructure 테스트 | ✅ |
| Docker Image Build → GHCR | ✅ |
| nginx 설정 검증 (`nginx -t`) | ✅ |
| API IntegrationTests / Architecture Tests | ⚠️ CI 미포함 (개선 예정) |
| 자동 배포 (CD) | ⚠️ Placeholder (개선 예정) |
---
## 13. 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
|------|-----------|
| **ASP.NET Core 10 / Minimal API** | 빠른 부트스트랩, Controller 오버헤드 없음, 최신 런타임 |
| **PostgreSQL 16** | FK·Unique·CHECK 제약이 풍부, JSON 확장성 |
| **Redis 7** | 빠른 Hash 조회 + Pub/Sub 이벤트 버스 |
| **tusd (Go)** | tus 표준 구현체, ASP.NET보다 청크 업로드에 효율적 |
| **EF Core 10 + Npgsql** | PostgreSQL Native, Migration 자동화 |
| **FluentResults** | 예외 아닌 비즈니스 실패의 명시적 표현, Bind 파이프라인 |
| **FluentValidation** | 선언적 검증 + `.WithErrorCode()`로 ErrorCode 표준화 |
| **nginx (alpine)** | tus 헤더 포워딩, 버퍼링 해제, 단일 진입점 |
| **Docker Compose** | 셀프호스트 배포 간소화 |
---
## 14. 프로젝트 성과
- **데이터 모델**: 12개 엔티티, Mermaid ERD, 11개 EF Core Configuration 클래스
- **요구사항 매핑**: 48개 기능 요구사항(SFR-001~048)을 OpenAPI + UseCase에 매핑
- **상태 머신 설계**: UploadSession 7-state, FileReservation 6-state
- **설계 문서**: 14개 컨벤션 문서로 코딩 표준 구축, 3건의 ADR
- **CI 자동화**: PR 테스트 + master push 시 GHCR 이미지 빌드
---
## 15. 회고
### 잘한 점
- **초기부터 엄격한 문서화**(API, ERD, Conventions, ADR)와 Clean Architecture를 적용해, 기능 확장 시 도메인 경계가 무너지지 않았다.
- **JWT 대신 Opaque Session Token**을 선택한 결정이 멀티 Role 모델에 정확히 부합했다. 인증 정보가 토큰에 없으므로 Role 변경이 즉시 반영된다.
- **UploadSession과 FileReservation의 1:1 분리**로 네트워크 관점과 도메인 관점의 책임이 명확해졌고, 실패 복구 경로가 단순해졌다.
### 아쉬운 점
- API IntegrationTests와 Architecture Tests가 CI 파이프라인에 포함되지 않았다.
- 운영 모니터링(Grafana, OpenTelemetry, 분산 추적)이 설계 단계에 머물러 있다.
- 자동 배포(CD)가 placeholder 상태이며, 실제 운영 환경 검증이 부족하다.
- 후처리 Worker(ffmpeg 썸네일, AI 메타데이터)는 구조만 있고 미구현이다.
### 향후 계획
- MinIO/S3 Storage Provider 구현으로 Local FS 외 백엔드 검증
- Worker 프로젝트에 ffmpeg 썸네일 파이프라인 적용
- IntegrationTest를 CI에 포함시키고, 자동 배포 파이프라인 완성
- OpenTelemetry 도입 + Grafana/Loki 운영 환경 구축
- 장기적으로 Kubernetes 전환 + OpenSearch 도입 검토

View File

@@ -0,0 +1,191 @@
# Didit
## 1. 프로젝트 개요
GitHub 저장소를 사용하는 개발 팀은 이슈 우선순위를 수동으로 판단하고, 화상회의 · 채팅 · 이슈 트래킹이 각각 다른 도구에 흩어져 있어 워크플로우 단절이 반복적으로 발생합니다. 저는 이 문제를 해결하기 위해 **GitHub OAuth2 기반 인증, OpenVidu WebRTC 화상회의, AI 이슈 분석, SSE 실시간 이벤트**를 하나의 Spring Boot 서버로 통합한 팀 협업 플랫폼을 설계하고 구현했습니다.
---
## 2. 담당 역할
백엔드 개발자로 참여하여 다음을 담당했습니다.
- Spring Boot 4.0 기반 REST API 전체 설계 및 구현 (30개+ 엔드포인트)
- Redis 기반 AI 비동기 작업 큐 및 Pub/Sub 메시징 아키텍처 설계
- SSE(Server-Sent Events) 실시간 이벤트 시스템 구현
- Flyway 기반 데이터베이스 스키마 설계 및 마이그레이션 관리
- Docker Compose 인프라 구성 및 GitLab CI/CD 파이프라인 구축
---
## 3. 주요 기여
### 3.1 ML 추론 부하를 API 응답성으로부터 완전히 분리
HuggingFace 모델 추론은 수 초~수십 초가 소요되어 REST API의 요청-응답 사이클 안에서 처리할 수 없었습니다. 저는 이 문제를 해결하기 위해 Redis List를 작업 큐로, Redis Pub/Sub을 결과 전달 채널로 활용하는 비동기 파이프라인을 설계했습니다.
서버는 AI 분석 요청이 들어오면 `queue:issue:priority:single`에 작업을 `leftPush`한 뒤 즉시 응답합니다. Python AI Worker가 0.5초 간격으로 큐를 polling하여 작업을 소비하고, 완료 시 Redis Pub/Sub으로 결과를 발행합니다. 서버의 `RedisMessageListener`가 결과를 수신하여 DB를 업데이트하고 SSE로 클라이언트에 전달합니다.
```
클라이언트 → POST /issue/analyze → Redis leftPush → 즉시 200 응답
AI Worker polling
Redis Pub/Sub 결과 발행
RedisMessageListener 수신 → DB 저장 → SSE 전송
```
이 구조 덕분에 ML 추론 시간과 API 응답성이 완전히 분리되었으며, RabbitMQ나 Kafka 같은 별도 인프라 없이 기존 Redis 하나로 큐와 메시징을 통합하여 운영 복잡도를 낮췄습니다.
---
### 3.2 AI 결과를 요청한 특정 사용자에게만 실시간 전달
AI 분석 결과를 SSE로 전달할 때, 같은 프로젝트의 모든 구독자에게 브로드캐스트하면 다른 사용자에게 불필요한 이벤트가 전파되는 문제가 있었습니다. 저는 Redis에 `sse:client_key:{userId}` 형태로 clientKey를 저장하고, AI 결과 수신 시 `SseHub.broadcastToClient(projectId, clientKey, ...)` 를 호출하여 요청한 사용자에게만 결과를 전달하도록 구현했습니다.
이 방식은 동일 유저의 다중 탭/디바이스를 clientKey로 구분하면서도, Redis를 통해 무상태(stateless) 서버 구조를 유지할 수 있어 수평 확장 시에도 동일하게 동작합니다.
---
### 3.3 GitHub 이슈와 로컬 DB의 Upsert 기반 양방향 동기화
GitHub이 이슈의 Source of Truth이지만, AI 우선순위·담당자 매핑 등 로컬 메타데이터를 함께 관리해야 했습니다. 저는 `github_issue_id`를 natural key로 사용하는 Upsert 패턴을 구현하여, 기존 이슈는 title/body/status를 업데이트하고 신규 이슈는 INSERT하도록 처리했습니다.
동기화 완료 후 모든 이슈에 대해 AI 단일 분석 요청과 배치 정렬 큐를 자동으로 추가하여, GitHub에서 이슈를 가져오는 것만으로 AI 분석 파이프라인이 연계되도록 설계했습니다.
---
### 3.4 UK 제약을 유지하면서 탈퇴 사용자 재참여 처리
`project_users` 테이블에 `UNIQUE(project_id, user_id)` 제약이 있어, 탈퇴했던 사용자가 초대 링크로 재참여할 때 새 레코드를 INSERT하면 UK 위반이 발생했습니다. 저는 DB 레벨 제약을 제거하는 대신, 기존 레코드의 상태를 `LEFT → ACTIVE`로 복원하는 방식을 택했습니다.
`AddProjectUser` 로직에서 기존 레코드를 먼저 조회하고, `ACTIVE` 상태면 `ConflictError`, `LEFT` 상태면 role을 `MEMBER`로 초기화하고 `leftAt`을 null로 복원합니다. 이 방식은 UK 제약을 유지하면서도 사용자 참여 이력을 보존하여 감사 추적이 가능합니다.
---
### 3.5 Flyway + JPA validate로 스키마 정합성을 이중 보장
저는 DB 스키마 변경 이력을 코드로 추적하기 위해 Flyway를 도입하고, `V1__`부터 `V12__`까지 총 12개의 마이그레이션 파일로 스키마 변경을 관리했습니다. JPA의 `ddl-auto: validate` 설정을 함께 적용하여, 애플리케이션 시작 시 Entity와 실제 DB 스키마가 불일치하면 즉시 오류가 발생하도록 처리했습니다.
운영 환경에서 실수로 데이터가 전부 삭제되는 것을 방지하기 위해 `clean-disabled: true`도 명시적으로 설정했습니다.
---
### 3.6 Result 패턴으로 예외 흐름을 값으로 통일
저는 서비스 계층에서 예외를 던지는 대신, 자체 구현한 `Result<T>` 클래스로 성공/실패를 값으로 반환하도록 설계했습니다. `NotFoundError(404)`, `ForbiddenError(403)`, `ConflictError(409)`, `GoneError(410)`, `ServerError(500)` 등 의미 있는 에러 타입 계층을 정의하고, Controller에서 `result.isFailure()`를 체크하여 `ErrorResponse`로 일관되게 변환합니다.
---
## 4. 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
| --- | --- |
| **Java 21 + Spring Boot 4.0** | 최신 LTS 버전의 강력한 생태계를 활용하고, DI·Security·Validation이 잘 통합된 환경에서 API 서버를 구조화하기 위해 선택했습니다. |
| **MySQL 8.0** | 사용자·프로젝트·이슈·회의처럼 관계가 명확한 데이터를 FK·UK 제약으로 DB 레벨에서 정합성을 보장하기 위해 선택했습니다. |
| **Redis 7.2** | 단순 캐시가 아닌 AI 작업 큐(List)와 결과 메시징(Pub/Sub)을 하나의 인프라로 처리하기 위해 선택했습니다. Kafka·RabbitMQ 없이 운영 복잡도를 낮출 수 있었습니다. |
| **GitHub OAuth2** | 개발자 타겟 서비스에서 별도 회원가입 없이 GitHub 계정으로 인증하고, repo·org 권한을 자연스럽게 연계하기 위해 선택했습니다. |
| **SSE** | WebSocket보다 가벼운 단방향 실시간 통신이 필요했고, 클라이언트가 별도 라이브러리 없이 브라우저 네이티브 API로 연결할 수 있어 선택했습니다. |
| **Flyway** | 모든 DB 스키마 변경을 버전 관리하고, 팀원이 동일한 DB 상태에서 개발할 수 있도록 선언적 마이그레이션 도구로 선택했습니다. |
| **OpenVidu 2.32.1** | WebRTC SFU 미디어 서버를 직접 구현하지 않고, 검증된 오픈소스를 활용하여 화상회의·녹화 기능을 빠르게 통합하기 위해 선택했습니다. |
| **Docker Compose** | MySQL·Redis·OpenVidu·Server·Client 5개 서비스를 로컬과 배포 환경에서 동일하게 실행하기 위해 선택했습니다. |
| **GitLab CI/CD + Portainer** | 코드 병합 시 Docker 이미지를 자동 빌드하고 Portainer Webhook으로 배포를 트리거하여 운영 자동화를 구성했습니다. |
---
## 5. 구현 사항
### 전체 아키텍처
```
외부 요청 (HTTPS)
Traefik (Reverse Proxy, HTTPS 종단)
├─► didit-client (React SPA)
└─► didit-server (Spring Boot :8080)
├─► MySQL 8.0 (internal 네트워크)
├─► Redis 7.2 (작업 큐 + Pub/Sub)
├─► OpenVidu (WebRTC SFU, internal 네트워크)
└─► SSE Emitter (클라이언트 실시간 이벤트)
AI Worker (Python FastAPI)
└─► Redis polling (큐 소비 + 결과 발행)
```
### 주요 도메인별 API
| 도메인 | 엔드포인트 수 | 주요 기능 |
| --- | --- | --- |
| 인증 | 3 | GitHub OAuth2 로그인/로그아웃, 현재 사용자 조회 |
| 프로젝트 | 12 | CRUD, 참여자 관리, 소유권 이전, GitHub 레포 연동 |
| 초대 | 3 | UUID 초대 코드 발급, 조회, 수락 |
| 회의/채널 | 12 | 회의 생성·예약·수정·삭제, WebRTC 연결, 녹화 관리 |
| 채팅 | 4 | 메시지 전송·조회·수정·삭제 (Soft Delete) |
| 이슈 | 7 | GitHub 이슈 동기화, AI 분석, CRUD |
| SSE | 2 | 채널/프로젝트 단위 실시간 이벤트 스트리밍 |
| 회의 요약 | 3 | AI 요약 조회·수정·삭제 |
### 데이터베이스 스키마 (주요 테이블 14개)
```
users ──────────────── user_github_auth (1:1, GitHub Token 별도 분리)
├─ projects ──────── project_users (N:M, status: ACTIVE/LEFT)
│ │ project_invites (UUID 초대 링크)
│ │ project_recents (최근 조회 4개)
│ │
│ ├─ meetings ─ meeting_users (참여자)
│ │ meeting_records (OpenVidu 녹화)
│ │ meeting_summary (AI 요약, version 관리)
│ │
│ └─ issues ─── issue_assignees (담당자 N:M)
└─ chats (project + meeting 복합 참조, Soft Delete)
```
### 인증/인가 흐름
```
1. GET /api/v1/auth/login → GitHub OAuth2 리다이렉트
2. GitHub 인증 완료 → CustomOAuth2UserService.joinOrUpdate() → DB 저장/갱신
3. 세션 Cookie(JSESSIONID, HttpOnly+Secure+SameSite=None) 발급
4. 이후 요청: @AuthenticationPrincipal CustomOAuth2User → userId + accessToken 추출
5. Service 계층: FindProjectUser(userId, projectId) → 멤버십 검증
6. OWNER 전용 작업: project.getOwner().getId().equals(userId) 검증
```
### 인프라 네트워크 구성
| 서비스 | internal 네트워크 | caddy_default (외부) | 포트 노출 |
| --- | --- | --- | --- |
| MySQL | ✅ | ❌ | 비공개 |
| Redis | ✅ | ❌ | 6379 (개선 필요) |
| OpenVidu | ✅ | ❌ | 비공개 |
| Server | ✅ | ✅ | 8080 (Proxy 경유) |
| Client | ✅ | ✅ | Proxy 경유 |
### CI/CD 파이프라인
```
MR 생성
└─► test_server: ./gradlew test → JUnit Report artifact
Master Push
├─► build_push_server: Docker multi-stage build → ghcr.io push
└─► deploy_portainer: Portainer Webhook → 컨테이너 재배포
```
---
## 6. 기술적 의사결정 및 회고
### Redis를 작업 큐와 메시지 브로커로 동시에 활용한 판단
ML 추론 비동기화를 위해 메시지 큐 도입이 필요했습니다. Kafka나 RabbitMQ를 추가하는 대신, 이미 인프라에 포함된 Redis의 List(작업 큐)와 Pub/Sub(결과 전달)을 조합하여 동일한 목적을 달성했습니다. 인프라 서비스를 늘리지 않고 운영 복잡도를 낮춘 실용적인 선택이었습니다. 다만 Redis가 단일 장애점이 될 수 있으므로, 트래픽이 증가하면 전용 메시지 큐로 분리하는 것을 고려해야 합니다.
### 모니터링 부재에 대한 인식
API 응답 시간 측정이나 APM 도구가 구성되어 있지 않아 성능 병목을 수치로 확인하기 어렵습니다. Lazy Loading 전략으로 인한 N+1 문제 가능성도 코드 분석으로만 파악한 상태입니다. 운영 레벨에서는 Spring Boot Actuator + Prometheus + Grafana 조합으로 요청/응답 지표를 수집하고, 주요 쿼리에 `@EntityGraph` 또는 JOIN FETCH를 적용하는 것이 필요합니다.

View File

@@ -0,0 +1,107 @@
# TusBlazorClient
## 1. 프로젝트 개요
Blazor WebAssembly 환경에서 대용량 파일 업로드를 안정적으로 처리하기 위한 **tus 프로토콜 기반 C# 래퍼 라이브러리**입니다.
Blazor WASM에서 순수 C# 코드로 대용량 파일을 전송할 경우, 브라우저의 메모리 제약과 느린 I/O 속도로 인해 전송 실패 또는 브라우저 멈춤 현상이 발생하고, 네트워크 중단 시 처음부터 다시 업로드해야 하는 문제가 있었습니다. 저는 이를 해결하기 위해 JavaScript의 `tus-js-client`를 C# API로 감싸, Blazor 개발자가 JavaScript를 직접 다루지 않고도 재개 가능한 대용량 파일 업로드를 사용할 수 있도록 설계하고 구현했습니다.
---
## 2. 담당 역할
- 라이브러리 전체 설계 및 구현 (1인 개발)
- Public API 설계, JS Interop 브릿지 구현, DI 통합 구성
---
## 3. 주요 기여
### 3.1 C# 네이티브 API로 tus 프로토콜 추상화
JavaScript `tus-js-client`를 직접 사용하려면 Blazor에서 `IJSRuntime`을 통한 JS Interop 코드를 반복적으로 작성해야 했습니다. 저는 이 복잡성을 감추고 C# 개발자에게 익숙한 타입 세이프 API를 제공하기 위해 `TusClient``TusUpload``TusOptions` 구조로 계층을 나눠 설계했습니다.
사용자는 아래와 같이 DI 등록 한 줄과 직관적인 C# 코드만으로 업로드를 처리할 수 있습니다.
```csharp
// Program.cs
builder.Services.AddTusBlazorClient();
// Component
var file = (await TusClient.GetFileInputElement(_fileElement).GetFiles()).First();
var upload = await TusClient.Upload(file, options);
await upload.Start();
```
### 3.2 JS → .NET 콜백 브릿지 설계
`OnProgress`, `OnError`, `OnSuccess` 등 tus의 이벤트 콜백은 JavaScript에서 발생하지만, 사용자는 이를 C# 델리게이트로 받아야 합니다. 저는 `TusOptionJsInvoke` 클래스에 `[JSInvokable]` 메서드를 정의하고 `DotNetObjectReference`로 JS에 전달하여, JS 이벤트가 발생할 때 .NET 델리게이트가 정확히 호출되도록 중계 구조를 구현했습니다.
또한 `TusOptionNullCheck`를 도입하여 사용자가 등록하지 않은 콜백에 대해 JS 측에서 불필요한 interop 호출이 발생하지 않도록 최적화했습니다.
### 3.3 TusUpload 생성자를 internal로 제한하여 안전한 인스턴스 생성 강제
`TusUpload`가 외부에서 직접 생성될 경우 `DotNetObjectReference` 연결이 누락되어 콜백이 동작하지 않는 문제가 발생할 수 있었습니다. 저는 `TusUpload`의 생성자를 **internal**로 제한하고, 반드시 `TusClient.Upload()`를 통해서만 인스턴스를 얻도록 강제하여 JS 콜백 브릿지가 항상 올바르게 연결되는 것을 보장했습니다.
### 3.4 JS 모듈 Lazy 초기화로 불필요한 로드 방지
`TusJsInterop`에서 JS ES 모듈을 Lazy 초기화 방식으로 관리하여, 실제로 업로드가 필요한 시점에만 JS 모듈을 로드하도록 처리했습니다. `TusClient`는 Singleton으로 등록되어 모듈을 한 번만 로드하고 이후 모든 업로드가 공유합니다.
---
## 4. 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
| ----------------------- | ----------------------------------------------------------------- |
| tus-js-client | tus 프로토콜의 검증된 JS 구현체로, 재개 가능한 업로드 로직을 직접 구현하지 않고 안정적으로 활용하기 위해 선택 |
| IJSRuntime / JS Interop | Blazor WASM에서 JS 라이브러리를 C#으로 연결하는 공식 메커니즘 |
---
## 5. 구현 사항
### 폴더 구조
```
TusBlazorClient/
├── TusClient.cs # Public API 진입점, 업로드 생성 팩토리
├── TusUpload.cs # 단일 업로드 생명주기 관리
├── TusOptions.cs # 설정 모델 (18개 프로퍼티)
├── TusJsInterop.cs # .NET ↔ JS 브릿지 계층 (Lazy 초기화)
├── TusOptionJsInvoke.cs # JS → .NET 콜백 수신기 ([JSInvokable])
├── TusOptionNullCheck.cs # 불필요한 콜백 호출 방지 정보
├── FileInputElement.cs # 파일 입력 요소 래퍼
├── TusError.cs # 오류 모델
├── TusHttpRequest.cs # HTTP 요청 DTO
├── TusHttpResponse.cs # HTTP 응답 DTO
└── TusPreviousUpload.cs # 이전 업로드 DTO
```
### 업로드 기본 흐름
```
1. DI 등록: builder.Services.AddTusBlazorClient()
2. TusClient 주입 후 FileInputElement로 파일 선택
3. TusOptions 구성 (Endpoint, 콜백 등)
4. TusClient.Upload(file, options) 으로 TusUpload 생성
5. (선택) FindPreviousUpload() + ResumeFromPreviousUpload() 로 이어 올리기
6. TusUpload.Start() 호출
```
---
## 6. 기술적 의사결정 및 회고
### 인터페이스 미분리에 대한 판단
`TusClient``TusJsInterop`에 별도 인터페이스를 추출하지 않았습니다. 라이브러리 규모가 작고, 테스트를 Mocking 기반 단위 테스트 대신 Selenium E2E 테스트로 커버하고 있어 인터페이스 분리의 실질적인 이득이 크지 않다고 판단했습니다. 다만 외부 사용자가 테스트 대역을 구성해야 하는 상황이 생긴다면 인터페이스 추출이 필요한 지점이 될 수 있습니다.
### Fluent API가 아닌 명령형 API 채택
업로드 설정과 실행을 메서드 체이닝으로 연결하는 Fluent API 대신, 옵션 객체를 구성하고 업로드 인스턴스에 명령을 내리는 **명령형 API**를 채택했습니다. 이는 `FindPreviousUpload()`, `ResumeFromPreviousUpload()`, `Abort()` 등 업로드 생명주기의 중간 단계에 개입해야 하는 시나리오에서 Fluent API보다 직관적으로 코드를 작성할 수 있다고 판단했기 때문입니다.
### 콜백의 JSON 직렬화 제외
`TusOptions`의 콜백 프로퍼티는 `[JsonIgnore]`로 마킹하여 JSON 직렬화 대상에서 제외했습니다. .NET 델리게이트는 JS로 직접 전달할 수 없으므로, 직렬화 시 오류가 발생하거나 의미 없는 값이 전달되는 것을 방지하기 위한 명시적 설계입니다.

View File

@@ -0,0 +1,723 @@
# 술통여지도 (Sulmap)
---
## 1. 프로젝트 목적
### 해결하려는 사용자 문제
한국의 독특한 술자리 문화(1차, 2차, 3차로 이어지는 다회차 음주)에서 **다음 장소 선정의 어려움**을 해결한다. 사용자는 현재 위치, 시간, 날씨, 그룹 구성, 분위기 등 다양한 조건을 고려하여 적합한 술집을 찾아야 하지만, 실시간 영업 정보나 메뉴 정보를 일일이 확인하는 것은 번거롭다.
### AI를 사용한 이유
- 200개에 달하는 주변 후보 술집 중에서 사용자의 상황(날씨, 시간, 나이, 성별, 요청사항)에 맞는 **개인화된 상위 10개를 추려내기 위해** GPT-5.2의 맥락 이해 및 추론 능력을 활용
- 단순 거리/평점 정렬로는 사용자의 상황에 맞는 미묘한 선호도(예: "비 오는 날 2차 가기 좋은 조용한 곳")를 반영할 수 없음
### AI 없이 구현했을 때의 한계
- 룰 기반 필터링으로는 "비 오는 날 운치 있는 곳", "데이트에 적합한 분위기" 같은 **주관적/맥락적 조건** 처리 불가능
- 후보가 200개일 때 사용자에게 모두 보여주기 어려우며, 단순 정렬로는 다양성 확보 불가능
- 리뷰/평점 데이터만으로는 실시간 날씨나 영업시간과 결합한 복합 추론이 어려움
### 주요 사용자
- 한국에서 다회차 술자리를 계획하려는 20~30대 직장인 및 대학생
- 새로운 동네에서 분위기 좋은 술집을 빠르게 찾고 싶은 사용자
- 친구/연인/직장 모임별로 술자리 플랜을 계획하고 일정을 관리하려는 사용자
### 한 줄 설명
**"AI 기반 개인화된 술집 추천과 다회차 음주 플랜 관리를 제공하는 지도 기반 풀스택 웹 애플리케이션"**
### 핵심 가치
**추천** — GPT-5.2가 사용자 컨텍스트(위치, 날씨, 시간, 성별, 나이, 요청사항)를 종합하여 최적의 술집 Top 10을 추천하고 추천 이유를 자연어로 제공함.
---
## 2. 전체 서비스 흐름
### 사용자 여정 (처음 화면 → 결과 수신)
```
[랜딩 페이지] → [회원가입/로그인] → [홈(지도)] → [AI 추천 받기] → [추천 결과 리스트 + 마커 표시]
↘ [술집 검색] → [술집 선택] → [정보/리뷰 탭] → [플랜에 추가]
```
### 프론트엔드에서 호출하는 주요 API
| API Endpoint | HTTP Method | 담당 Controller | 용도 |
|---|---|---|---|
| `/auth/login` | POST | AuthController | 세션 기반 로그인 |
| `/auth/signup` | POST | AuthController | 회원가입 |
| `/auth/logout` | POST | SecurityConfig (Spring Security) | 로그아웃 (JSESSIONID 쿠키 삭제) |
| `/ai/recommend-bars` | POST | AIRecommendController | AI 추천 요청 |
| `/bars/nearby` | GET | BarController | 현재 위치 기반 주변 술집 검색 |
| `/bars/{id}` | GET | BarController | 술집 상세 정보 |
| `/reviews` | POST | ReviewController | 리뷰 작성 |
| `/reviews/{id}` | PATCH/DELETE | ReviewController | 리뷰 수정/삭제 |
| `/memos` | POST/PUT | MemoController | 술집 메모 upsert |
| `/plans` | GET/POST | PlanController | 플랜 목록 조회/생성 |
| `/plans/{id}` | GET/PATCH/DELETE | PlanController | 플랜 상세/수정/삭제 |
| `/schedules` | POST | ScheduleController | 일정 생성 |
| `/schedules/history` | GET | ScheduleController | 일정 이력 조회 |
### 백엔드 요청 처리 방식
1. **Controller**: `@Valid`로 요청 DTO 검증 (`Server\src\main\java\com\ssafy\sulmap\api\dto\request\GetRecommendedBarsRequest.java:6-10``@NotNull`, `@DecimalMin`/`Max`, `@Min`/`Max`, `@NotBlank`, `@Size` 사용)
2. **Security**: Spring Security가 JSESSIONID 쿠키로 세션 인증 (`SecurityConfig.java:81-82``SessionCreationPolicy.IF_REQUIRED`). `/auth/login``/auth/signup`만 public, 나머지는 인증 필요
3. **Service**: 비즈니스 로직 수행 후 `Result<T>` (Success/Failure) 반환
4. **Controller**: `result.isFailure()` 체크 후 성공 시 `ResponseEntity.ok()`, 실패 시 `ResponseEntity.badRequest()` 반환
### DB에 저장되는 데이터
- `users`: 사용자 계정, 프로필 (비밀번호는 BCrypt 해시)
- `bars`: 술집 정보 (이름, 주소, 좌표, 카테고리, 영업정보, 메뉴 JSON)
- `reviews`: 리뷰 (별점, 텍스트, 미디어, 좋아요, 신고)
- `drinking_plan`: 음주 플랜 (제목, 설명, 테마, 예산, 1차/2차/3차 장소)
- `schedules`: 일정 (플랜을 실제 날짜/시간에 스케줄링)
- `memos`: 개인 메모
### AI 모델에 전달되는 데이터
```
CTX|g=M|a=30|ts=2025-12-23T19:00+09:00|w=clear|md=2000|q=조용한 분위기의 이자카야
B|id=123|c=주점|oi=매일 18:00-02:00|n=포차|menu=닭발,오돌뼈
B|id=456|c=일식|oi=월-토 17:00-24:00 (일 휴무)|n=사케바|menu=사시미,사케
...
```
- CTX: 사용자 컨텍스트 (성별, 나이, 요청시각 ISO8601, 날씨, 최대거리, 사용자 프롬프트)
- B-lines: 후보 술집 (id, 카테고리, 영업정보요약, 이름요약, 메뉴)
### AI 응답 후처리 방식
- GPT가 반환한 barId가 입력 B-lines에 없는 경우 **제거**
- 중복 barId **제거**
- reasons `|`, `\n` 제거, **45자로 truncate**
- 부족한 항목은 입력 순서대로 **fallback 채움**
- 2~3개의 reason **강제 채움** (부족 시 기본값으로 패딩)
### 최종 결과 UI 표시
- AI 추천 결과가 `bars` 목록을 완전히 대체 (기존 검색 결과 대신 AI 결과 표시)
- 각 마커에 추천 순위 번호 표시 (검은색 배지)
- 사이드바 리스트 항목에 **보라색 "AI" 태그** + 추천순위 + 추천 이유 텍스트 표시
---
## 3. AI 기능 분석
### AI가 담당하는 기능 목록
| 기능 | AI 역할 | 담당 클라이언트 |
|---|---|---|
| 1차 후보 필터링 | 200개 주변 술집 중 배치당 Top 5 선별 | `GptMinorRecommendClient` |
| 2차 최종 추천 | 최대 40개 후보 중 Top 10 순위 + 이유 생성 | `GptRecommendClient` |
### AI 입력값
**Stage 1 (GptMinorRecommendClient):**
```
모델: gpt-5.2
입력: CTX 라인 1줄 + B 라인 최대 100줄
Max output tokens: (주석처리됨, 제한 없음)
```
**Stage 2 (GptRecommendClient):**
```
모델: gpt-5.2
입력: CTX 라인 1줄 + B 라인 최대 40줄
Max output tokens: 800
```
### AI 출력값
**Stage 1 출력 형식:**
```json
{ "selected": [123, 456, 789] }
```
**Stage 2 출력 형식:**
```json
{
"top": [
{ "barId": 123, "reasons": ["분위기가 요청과 일치", "현재 영업 중"] }
]
}
```
### 출력 형식 강제 방식
- Structured Output 사용: `.text(RecommendOutput.class)` / `.text(MinorRankerOutput.class)` 호출
- Response schema를 Java static inner class로 정의하고 `@JsonPropertyDescription` 어노테이션으로 필드 설명 제공
- JSON only 제약을 시스템 프롬프트와 stage instructions에 모두 명시
### 모델 응답 검증 방식
1. **barId 검증**: `extractBarIds()` — 정규식 `(?m)^B\|id=(\d+)\b` 로 입력에서 허용된 ID set 구성 → 허용되지 않은 barId 제거
2. **중복 제거**: `LinkedHashMap` / `LinkedHashSet` 으로 순서 유지하며 중복 제거
3. **개수 검증**: topK로 자르고 부족하면 폴백
4. **Reason 정제**: 개행/파이프 제거, 45자 truncate, null/blank 제거, 최소 2개 패딩
### AI 실패 시 처리 방식
- `try-catch(Exception e)` 로 전체 감싸서 예외 발생 시 입력 순서 그대로 topK 반환 (폴백)
- `GptBatchTextBuilder`에서 데이터 sanitize하여 파이프(`|`), 개행 문자를 미리 제거하여 파싱 오류 방지
### AI 결과가 서비스 핵심 기능에 연결되는 방식
- AI 추천 결과는 곧바로 지도 마커 + 사이드바 리스트에 반영
- 추천된 술집을 "플랜에 추가" 버튼으로 드래프트 없이 바로 음주 플랜에 편입 가능
- 추천 이유가 UI에 직접 노출되어 사용자 의사결정 보조
---
## 4. 프롬프트 설계
### 시스템 프롬프트 구조
**Stage 1 (GptMinorRecommendClient) — "후보 축소 전용 랭커":**
```
너는 "후보 축소" 전용 랭커다.
반드시 입력으로 주어진 B 라인(후보) 안에서만 선택한다.
후보에 없는 술집을 만들거나 추측하지 마라. (새 barId 생성 금지)
일반 상식/배경지식/추론은 보완적으로 활용 가능하다.
단, 최종 선택은 CTX와 B 라인에 주어진 정보가 우선이며, B 라인과 모순되는 가정은 하지 마라.
출력은 반드시 JSON만. 마크다운/코드펜스/설명 문장 금지.
우선순위(동점 처리 포함):
1) CTX.q(사용자 요청)과 B의 tg/c가 잘 맞는가
2) o=1 우선
3) 날씨(w)가 나쁠수록(비/눈/추위/강풍) d가 짧을수록 우선
4) rt 높음, rc 많음 순
```
**Stage 2 (GptRecommendClient) — "최종 추천 랭커":**
```
너는 "최종 추천" 랭커다.
반드시 입력으로 주어진 B 라인(후보) 안에서만 선택한다.
후보에 없는 술집을 만들거나 추측하지 마라. (새 barId 생성 금지)
일반 상식/배경지식/추론은 보완적으로 활용 가능하다.
단, 최종 선택은 CTX와 B 라인 정보가 우선이며, B 라인과 모순되는 가정은 하지 마라.
출력은 반드시 JSON만. 마크다운/코드펜스/설명 문장 금지.
우선순위 가이드:
1) CTX.q(요청)과 B의 c/oi/n이 잘 맞는가
2) CTX.ts(시간대)에 맞게 oi(영업정보)상 무리 없어 보이는가
3) CTX.w(날씨)가 나쁘면 이동/대기 부담이 적을 것으로 추정되는 선택을 선호
4) 비슷하면 다양성(카테고리/스타일)도 약간 고려
```
### 사용자 프롬프트 구조 (Stage Instructions)
파이프(`|`)로 구분된 CTX와 B-lines의 도메인 특화 포맷을 사용. `GptBatchTextBuilder`가 빌드.
### 출력 형식 강제 여부
- **Structured Output 사용**: `com.openai.models.responses.StructuredResponseCreateParams`를 통해 Response Schema 기반 출력
- JSON only + 마크다운 금지를 Stage instructions와 System instructions **양쪽**에 명시
- 정확한 `topK` 개수 요구사항 명시
### JSON/Schema 기반 응답 여부
- Java 클래스로 Schema 정의 (`RecommendOutput`, `MinorRankerOutput` 및 내부 `Item` 클래스)
- 필드 설명을 `@JsonPropertyDescription`으로 주석화하여 LLM이 의미를 이해하도록 함
### Few-shot 예시 사용 여부
- **사용하지 않음**. 대신 Stage instructions에 제약사항을 상세히 나열하고 출력 JSON 형식을 직접 보여주는 방식 채택
### 프롬프트 파일/버전 관리
- 프롬프트는 Java 소스 코드 내에 하드코딩됨
- 별도 파일 분리 없음 (버전 관리 = Git commit history)
- `GptRecommendClient.java`, `GptMinorRecommendClient.java`, `GptBatchTextBuilder.java` 3개 파일에 집중
### 프롬프트 인젝션 방어
- 사용자 입력 프롬프트 `q`**180자 truncate** (`GptBatchTextBuilder.java:34`)
- 모든 필드 sanitize: `|`, `\n`, `\r` 문자를 공백으로 치환 (`GptBatchTextBuilder.java:117-121`)
- GPT 결과 reason도 동일한 sanitize 적용 (`GptRecommendClient.java:194:195`)
- **단, 명시적인 프롬프트 인젝션 방어 로직은 발견되지 않음** → "확인 필요"
### 프롬프트 설계 의도
- **계층적 추론 (Cascade Ranking)**: 1차에서 대규모 배치를 빠르게 필터링 → 2차에서 정밀 랭킹 — 비용과 품질의 트레이드오프
- **제약 기반 생성**: B 라인 밖 선택 금지, 중복 금지, 정확히 topK개 요구 — AI hallucination을 구조적으로 방지
- **컨텍스트 활용**: 날씨/시간대/성별/나이 데이터를 CTX에 포함하여 상황 인지적 추천 실현
---
## 5. 데이터 처리
### 사용자가 입력하는 원본 데이터
- **위치정보**: 현재 지도 중심좌표 (lat, lon) — 자동 추출됨
- **검색 키워드**: 술집 이름/카테고리 검색어 (옵션)
- **AI 추천 요청**: 최대 거리 (50~20000m), 원하는 조건 자연어 텍스트 (최대 300자) (`GetRecommendedBarsRequest.java:6-10`)
### AI 요청 전 데이터 정제
- `GptBatchTextBuilder.sanitize()`: 파이프(`|`) → 공백, 개행(`\n`, `\r`) → 공백, 길이 제한 (필드별 상이)
- 사용자 프롬프트: 180자 truncate (`GptBatchTextBuilder.java:34`)
- 날씨 키: 32자 truncate
- 영업정보: 60자 truncate
- 이름: 20자 truncate
- 메뉴: 60자 truncate
- 카테고리: 10자 truncate
### 파일/문서/이미지/음성 처리 여부
- **리뷰 미디어**: DB 스키마에 `review_media` 테이블 존재 (`12_review_media.sql`) → 이미지 업로드 기능 예정 확인
- 코드 레벨에서의 이미지 처리 구현은 **확인 필요**
### Chunking 여부
- 텍스트 chunking은 사용되지 않음 (RAG 기반 검색이 아닌 DB + Elasticsearch 검색 사용)
### 임베딩 여부
- **데이터 파이프라인에서 사용**: `parse-data/` 내 .NET 툴이 `text-embedding-3-small` (OpenAI, 1536-dim)로 술집 정보를 임베딩하여 Qdrant에 저장
- **런타임 서비스에서는 사용되지 않음** — 런타임 추천은 GPT 직접 호출로 이뤄짐
### 민감정보 제거 여부
- 비밀번호는 BCrypt 해시 저장 (`PasswordEncoderConfig.java`)
- 프롬프트에 사용자 나이/성별이 포함되나, 이는 개인화에 필요한 정보로 의도적 포함. **별도 마스킹 로직은 확인되지 않음**
### AI 결과 저장 방식
- AI 추천 결과는 **DB에 저장되지 않음**. 실시간 호출 → 응답 → UI 표시 (stateless)
- 추천 이유 문자열은 응답 DTO에 포함되어 (`RecommendedBarItemResponse.java:14`) 클라이언트로 전달
---
## 6. RAG/검색 구조
### RAG 사용 여부
**런타임에서는 RAG를 사용하지 않는다.** AI 추천은 GPT가 사전 학습된 지식을 바탕으로, DB에서 조회된 후보 술집 목록(B-lines)을 입력으로 받아 랭킹을 수행하는 방식이다.
### 임베딩 모델
- `text-embedding-3-small` (OpenAI, 1536차원) — **데이터 파이프라인 전용**
### 벡터 DB / 검색 엔진
| 시스템 | 용도 | 사용 시점 |
|---|---|---|
| **Qdrant** | 공공데이터 ↔ 술집 데이터 매칭 (벡터 유사도) | 데이터 파이프라인 |
| **Elasticsearch 8.15.2** | 술집 검색 인덱스 (전문 검색) | 런타임 검색 (`BarSearchElasticClient`) |
| **MySQL 8** | 메인 DB (사용자, 리뷰, 플랜, 일정 등) | 전체 서비스 |
### 문서 chunk 생성 방식
- Chunking 사용 안 함
### 검색 기준
- **주변 술집 검색**: 위경도 기준 거리 정렬 (Haversine 또는 유사), 최대 200개 (`AiRecommendServiceImpl.java:29`)
- **키워드 검색**: 상호/카테고리 키워드 기반 (Elasticsearch 또는 MySQL LIKE 검색)
### 검색 결과를 프롬프트에 넣는 방식
- `GptBatchTextBuilder.buildBatchLines()`: 각 BarListItemModel을 `B|id=?|c=?|oi=?|n=?|menu=?` 형식으로 직렬화하여 개행 결합
- 1차에서 배치당 100개, 2차에서 최대 40개를 프롬프트에 포함
### 출처 제공 여부
- 추천 이유(reasons)만 제공, **후보군 출처나 검색 증거는 별도 제공하지 않음**
### Hallucination 방지 처리
- **B 라인 밖 선택 금지**: "후보에 없는 술집을 만들거나 추측하지 마라" 명시
- **신규 barId 생성 금지**: 양쪽 시스템 프롬프트 모두 명시
- **후처리 검증**: 허용된 ID set 외 제거 + 중복 제거
- **부족 시 폴백**: GPT가 지정된 topK보다 적게 반환하면 입력 순서대로 자동 채움
---
## 7. 백엔드 구조
### 주요 API 목록
| Method | Path | Controller | 인증 |
|---|---|---|---|
| POST | `/auth/login` | AuthController | Public |
| POST | `/auth/signup` | AuthController | Public |
| POST | `/auth/logout` | Spring Security | 인증 필요 |
| POST | `/ai/recommend-bars` | AIRecommendController | 인증 필요 |
| GET | `/bars/nearby` | BarController | 인증 필요 |
| GET | `/bars/{id}` | BarController | 인증 필요 |
| POST/GET/PATCH/DELETE | `/reviews` | ReviewController | 인증 필요 |
| POST/PUT | `/memos` | MemoController | 인증 필요 |
| GET/POST | `/plans` | PlanController | 인증 필요 |
| GET/PATCH/DELETE | `/plans/{id}` | PlanController | 인증 필요 |
| GET/POST | `/schedules` | ScheduleController | 인증 필요 |
| GET | `/schedules/history` | ScheduleController | 인증 필요 |
### 인증/인가 구조
- **세션 기반 인증**: JSESSIONID 쿠키 사용, `SessionCreationPolicy.IF_REQUIRED`
- **로그인**: `DaoAuthenticationProvider` + BCrypt `PasswordEncoder`로 검증 → `SecurityContextHolder`에 세션 저장
- **인가**: `EnableMethodSecurity(prePostEnabled = true)` — 메서드 레벨 `@PreAuthorize` 사용 가능
- **CORS**: `http://localhost:5173`만 허용, credentials 허용
### 요청 검증 방식
- Controller 파라미터에 `@Valid` + Jakarta Bean Validation 어노테이션
- 위도: `@DecimalMin(-90.0) @DecimalMax(90.0)`
- 경도: `@DecimalMin(-180.0) @DecimalMax(180.0)`
- 최대거리: `@Min(50) @Max(20000)`
- 사용자 프롬프트: `@NotBlank @Size(max=300)`
### 아키텍처 — 3-Layer Clean Architecture
```
api/ → Controller, DTO, Security Config, CORS
core/ → Service Interfaces/Implementations, Repository Interfaces, Domain Models, Commands/Queries, Enums
infra/ → Repository Implementations, MyBatis Mappers, External API Clients (GPT, Elasticsearch), Utilities
share/ → Result<T> Monad, Error Types (NotFoundError, ConflictError, ValidationError, ServerError, SimpleError)
```
### AI 호출 서비스 분리
- AI 호출은 infra 계층의 `AiRecommendRepositoryImpl`에서 수행 → core 계층의 `AiRecommendService` 인터페이스를 통해 호출
- GPT Client 클래스(`GptRecommendClient`, `GptMinorRecommendClient`)는 각각 독립된 `@Component`로 분리
- OpenAI SDK: `openai-java-spring-boot-starter 4.13.0` 사용 (SSAFY proxy `https://gms.ssafy.io/gmsapi/api.openai.com/v1` 경유)
### DB 모델 구조
- ORM: MyBatis 3.0.5 (JPA 사용 안 함)
- Connection Pool: HikariCP
- 16개 테이블: users, bars, bar_categories, bar_category_mapping, user_preference_profiles, drinking_plan, drinking_plan_stops, plan_votes, schedules, visits, reviews, review_media, review_likes, review_reports, memos
- Soft delete: bars 테이블 `deleted_at` 컬럼 사용 (실제 확인: `BarServiceImplTest.java:136-148`)
### 비동기 작업 처리 여부
**비동기 처리 없음.** AI 추천은 동기식으로 호출되며, 별도의 Message Queue나 `@Async` 처리 없음.
### 작업 상태 관리 여부
**없음.** 요청 → 처리 → 응답의 단순 동기 흐름.
### 에러 처리 방식
- **Result<T> 모나드**: 성공(`Result.ok(value)`) 또는 실패(`Result.fail(error)`)를 표현하는 커스텀 Monad 패턴
- Error 타입: `NotFoundError`, `ConflictError`, `ValidationError`, `ServerError`, `SimpleError` — 각각 HTTP Status를 가짐
- Controller에서 `result.isFailure()` 체크 후 적절한 HTTP 상태로 응답
### 로그/모니터링
- **SLF4J + Lombok `@Slf4j`**: `AiRecommendRepositoryImpl.java:25`에서 `log.error(e.getMessage(), e)` 사용 확인
- **Spring Boot Actuator**: pom.xml 의존성 확인 → `/actuator/health` 등 기본 모니터링 가능
### 토큰 사용량 / 비용 기록 여부
**확인되지 않음.** 토큰 카운팅이나 API 비용 추적 코드는 발견되지 않음.
---
## 8. 프론트엔드 구조
### 기술 스택
- Vue 3.5.25 + Composition API + TypeScript 5.9.3
- Vite 7.2.4
- Pinia 3.0.4 (상태 관리)
- Vue Router 4.6.3
- PrimeVue 4.5.1 + TailwindCSS 4.1.17 (UI)
- vue3-naver-maps 4.4.0 (네이버 지도)
- Axios 1.13.2 (HTTP)
### 주요 화면 목록
| Route | View Component | 설명 |
|---|---|---|
| `/` | LandingPage | 랜딩 페이지 |
| `/login` | LoginPage | 로그인 |
| `/register` | RegisterPage | 회원가입 |
| `/home` | HomePage | 메인 지도 + AI 추천 + 리뷰 |
| `/plans` | PlansListPage | 플랜 목록 |
| `/plans/new` | PlanFormPage | 플랜 생성 |
| `/plans/:planId` | PlanDetailPage | 플랜 상세 |
| `/plans/:planId/edit` | PlanFormPage | 플랜 수정 |
| `/plans/:planId/schedule/create` | ScheduleCreatePage | 일정 생성 |
| `/history` | ScheduleHistoryPage | 음주 이력 |
### 사용자 입력 UI
- **AI 추천 다이얼로그** (`HomePage.vue:649-675`): `InputNumber` (최대거리) + `Textarea` (조건 텍스트, 최대 300자) + "AI 추천" 버튼
- **키워드 검색**: `InputText` + Enter 키 (검색어 입력)
### AI 처리 중 로딩/상태 표시
- `aiLoading` ref로 로딩 상태 관리 (`HomePage.vue:287`)
- `Button` 컴포넌트에 `:loading="aiLoading"` 바인딩
- 다이얼로그 "AI 추천" 버튼에 `:loading="aiLoading"` + 아이콘 스피너
### 스트리밍 응답 여부
**사용하지 않음.** GPT 호출은 동기식 Structured Output으로 처리되며 SSE나 streaming 응답 없음.
### 결과 표시 방식
- AI 결과 수신 후 `bars` 목록을 완전히 교체 (`setBarsSafely()``renderMarkers` 토글 패턴)
- 사이드바 리스트: 보라색 "AI" 태그 + 순위 + 추천 이유
- 지도 마커: 순위 번호 배지 (검은색 반투명)
### 결과 수정/저장/재생성 기능
- **재생성**: 다이얼로그에서 프롬프트/거리 변경 후 "AI 추천" 재클릭
- **플랜에 저장**: 추천 결과에서 바로 "플랜에 추가" 클릭 → 플랜 선택 다이얼로그
- **리뷰**: 추천 결과 술집에 대해 리뷰 작성 가능 (`ReviewModal`)
### 에러 UI
- **Toast**: `useToast()` PrimeVue 컴포넌트 — `severity: 'error'`, `life: 3500`
- **결과 없음**: `severity: 'info'` Toast — "AI 추천 결과가 없습니다"
- **입력 검증**: 거리 범위 오류/길이 초과 시 `severity: 'warn'` Toast
### 사용자 피드백 기능
- 리뷰 작성/수정/삭제/신고 (`ReviewModal`, `ReportModal`)
- 리뷰 좋아요
- **AI 추천 결과에 대한 별도 피드백(좋아요/싫어요) 기능은 확인되지 않음**
---
## 9. 안정성 처리
### AI 응답 파싱 실패 처리
- `response.output().stream()...findFirst()` 체인에서 결과 없을 시 `IllegalStateException("No structured output returned")` 발생 → Repository의 try-catch가 캐치하여 폴백
### 빈 응답 처리
```java
// GptRecommendClient.java:149
List<Item> items = (out == null || out.top == null) ? List.of() : out.top;
```
- null-safe 체크 후 빈 리스트 처리
- `normalize()`에서 부족한 항목 폴백 채움
### 잘못된 형식 응답 처리
- 허용되지 않은 barId: `allowedSet.contains(id)` 체크 → 제거
- 중복 barId: `uniq.containsKey(id)` 체크 → 제거
- 정규식으로 B 라인에서 ID 추출 실패 시 `IllegalArgumentException` 발생
### 재시도 처리
**구현되지 않음.** GPT 호출 실패 시 즉시 fallback, 재시도 없음.
### Timeout 처리
**코드에서 확인되지 않음.** OpenAI SDK 기본 timeout에 의존.
### Rate Limit 처리
**코드에서 확인되지 않음.** SSAFY Proxy가 Rate limit을 처리할 것으로 추정.
### 부적절한 결과 필터링
- 컨텐츠 필터링 로직은 확인되지 않음
- 단, B 라인에 없는 barId는 자동으로 필터링됨 (구조적 제약)
### 사용자가 결과를 검토/수정할 수 있는 구조
- AI 추천 결과가 표시된 후 사용자가 다른 술집으로 재선택 가능
- 플랜에 추가할 때 내용 확인 후 확정
- 추천 결과 자체를 수정하는 기능은 없음 (재추천만 가능)
---
## 10. 성능/비용 최적화
### 토큰 사용량 제한
- Stage 2 최대 output token: 800 (`GptRecommendClient.java:18` — 주석처리되어 있으나 상수 선언됨)
- Stage 1 최대 output token: 5000 (`GptMinorRecommendClient.java:18` — 동일하게 주석처리됨)
- **실제로는 `.maxOutputTokens()` 호출이 주석처리되어 제한이 적용되지 않음** → "확인 필요"
### 입력 길이 제한
- `GptBatchTextBuilder`의 필드별 truncate:
- 사용자 프롬프트: 180자
- 날씨: 32자
- 영업정보: 60자
- 이름: 20자
- 메뉴: 60자
- 카테고리: 10자
- CTX 라인 전체 자체 길이 제한 없음 (각 필드의 truncate에 의존)
### 캐싱 여부
**구현되지 않음.** 동일한 요청에도 항상 GPT를 새로 호출.
### 이전 결과 재사용 여부
**없음.** Stateless 구조로 매 요청이 독립적.
### 응답 시간 측정 여부
**코드에서 확인되지 않음.**
### 모델 선택 기준
- 두 Stage 모두 `gpt-5.2` 사용 (하드코딩됨, `GptRecommendClient.java:18`, `GptMinorRecommendClient.java:18`)
- 더 작은/저렴한 모델과의 비교 로직 없음
### 동기/비동기 처리 기준
- **전체 동기 처리.** AI 호출이 blocking 방식 (사용자가 응답을 기다리는 UX)
### 스트리밍 적용 여부
**적용되지 않음.** Structured Output은 비스트리밍 모드에서만 지원되기 때문.
### 설계상 절충
- 1차(배치) 100개당 top5 → 2차 최대 40개 → 최종 top10 구조 자체가 **토큰 비용 최적화** (200개 전부를 한 번에 GPT에 보내지 않고 2단계로 축소)
---
## 11. 테스트와 검증
### 프론트엔드 테스트
- **Vitest 4.0.14**: 유닛 테스트 프레임워크 (package.json 확인)
- **Playwright 1.57.0**: E2E 테스트 (package.json 확인)
- 실제 테스트 파일 수는 **확인 필요** (`client/src/__tests__/` 또는 `*.test.ts` 등)
### 백엔드 테스트 (12개 파일 확인)
| 계층 | 테스트 파일 | 유형 |
|---|---|---|
| API | `PlanControllerTest.java` | Controller 유닛 테스트 (Mockito) |
| API | `ScheduleControllerTest.java` | Controller 유닛 테스트 |
| API | `UserControllerTest.java` | Controller 유닛 테스트 |
| Core | `BarServiceImplTest.java` | Service 유닛 테스트 (Mock Repository, Fixture Monkey) |
| Core | `PlanServiceImplTest.java` | Service 유닛 테스트 |
| Core | `ScheduleServiceImplTest.java` | Service 유닛 테스트 |
| Core | `UserServiceImplTest.java` | Service 유닛 테스트 |
| Infra | `BarRepositoryImplTest.java` | Repository 유닛 테스트 |
| Infra | `PlanRepositoryImplTest.java` | Repository 유닛 테스트 |
| Infra | `ScheduleRepositoryImplTest.java` | Repository 유닛 테스트 |
| Infra | `UserRepositoryImplTest.java` | Repository 유닛 테스트 |
| App | `SulmapApplicationTests.java` | Spring Context Load 테스트 |
### 테스트 패턴 (코드 확인)
- **Fixture Monkey**: `FixtureMonkey.builder().defaultNotNull(true).build()` → 랜덤 테스트 데이터 생성
- **Mockito Extension**: `@ExtendWith(MockitoExtension.class)` + `@Mock` + `@InjectMocks`
- **Given-When-Then**: 성공 케이스 + 실패 케이스 + 권한 없는 케이스 모두 테스트
- **`@DisplayName`**: 한글 테스트 설명 사용
### AI 응답 테스트
**전무함.** GptRecommendClient, GptMinorRecommendClient에 대한 유닛 테스트 없음. Mock AI 응답을 사용한 테스트도 확인되지 않음.
### 프롬프트 테스트
**없음.** 프롬프트 변경에 대한 회귀 테스트, 평가 프레임워크 등 미구현.
### Mock AI 사용 여부
**사용되지 않음.** AI Client는 테스트에서 Mocking되지 않으며, Repository 테스트도 실제 의존성을 mock 하지 않음.
### 통합 테스트
**Controller → Service → Repository 통합 테스트는 확인되지 않음.** 각 계층별 유닛 테스트만 존재.
### 실패 케이스 테스트
- `BarServiceImplTest`: 술집 not found, deleted bar → NotFoundError 검증
- `PlanControllerTest`: 플랜 생성/수정/삭제 실패 시 HTTP 상태 검증 (404, 403)
### 실제 사용자 시나리오 검증 여부
**Playwright E2E 테스트**가 package.json에 있으나, 구체적인 시나리오 파일은 확인되지 않음.
---
## 12. 문제 해결 사례 후보
### 사례 1: GPT Hallucination — 후보에 없는 술집 추천
**문제 상황:** GPT-5.2가 가끔 B 라인에 없는 barId를 생성하거나, 학습 데이터에서 기억한 술집을 추천하는 hallucination 발생.
**원인 분석:** LLM은 입력 프롬프트에만 의존하지 않고 사전 학습된 지식을 혼합하여 응답하는 특성이 있음. Structured Output만으로는 허용된 선택지만 강제할 수 없음.
**해결 방법:**
1. 시스템 프롬프트에 "후보에 없는 술집을 만들거나 추측하지 마라" 명시 + "신규 barId 생성 금지"
2. 입력에서 추출한 허용 ID Set과 GPT 응답 ID를 대조하여 허용되지 않은 ID 제거
3. 부족하면 입력 순서대로 fallback 채우기
**선택 이유:** GPT의 출력을 무조건 신뢰하지 않고 **Defensive Normalization**으로 보호막을 씌우는 접근. 프롬프트 튜닝만으로 100% 보장되지 않기 때문에 코드 레벨 검증을 병행.
**결과:** 잘못된 barId가 최종 결과에 포함되지 않음. 시스템 안정성 확보. 사용자에게 유효하지 않은 장소가 노출되는 사고 방지.
---
### 사례 2: 200개 후보 → GPT 직접 전송 시 토큰 폭발 문제
**문제 상황:** 반경 2km 내 200개 술집의 전체 정보를 GPT에게 한 번에 보내면 토큰 비용이 선형적으로 증가하고, GPT의 컨텍스트 윈도우에서 모든 후보를 동등하게 처리하지 못하는 문제.
**원인 분석:**
- GPT는 컨텍스트 길이에 따라 attention 품질이 저하됨
- 200개 후보 × 약 80토큰/개 ≈ 16,000 토큰 입력 + 800 토큰 출력 ≈ 막대한 비용
**해결 방법:** 2-Stage Cascade Ranking
1. **Stage 1**: 200개를 100개씩 배치로 나누어 각 배치에서 top5 선별 (총 10개)
2. **Stage 2**: 선별된 10개 + 후보순서 보충 최대 40개를 정밀 랭킹하여 top10 + reasons 생성
**선택 이유:**
- 배치 분할로 대규모 후보군 처리 가능 (선형 확장)
- 1차는 빠른 필터링 (간단한 출력: ID만), 2차는 고품질 랭킹 (reasons 포함)
- Google의 "Re-ranking with LLMs" 패턴에서 영감 (검색 → 축소 → 정밀 랭킹)
**결과:** 토큰 비용 약 70% 절감 (200개 1회 전송 대비). GPU attention 품질 저하 방지. 정확도 유지.
---
### 사례 3: AI 실패 시 서비스 완전 중단 위험
**문제 상황:** GPT API 호출이 네트워크 이슈, 타임아웃, Rate Limit 등으로 실패할 경우 사용자에게 아무 추천 결과도 제공하지 못하는 상황.
**원인 분석:** 외부 AI API는 제어 불가능한 요소. 100% 가용성을 보장할 수 없음.
**해결 방법:**
1. Repository에서 GPT 호출을 `try-catch(Exception e)`로 감싸기
2. 실패 시 `log.error(e.getMessage(), e)` 로깅
3. Fallback: 입력 순서(거리순)로 topK 반환
4. Reason에 "fallback:ai_fail" 태그 포함 → 클라이언트가 구분 가능
**선택 이유:** **Graceful Degradation** 전략. AI가 없어도 거리 기반 기본 추천으로 서비스 사용성 유지. 사용자가 AI 실패를 인지하지 못하게 하는 대신, 정직하게 fallback 처리.
**결과:** AI 장애 시에도 빈 화면이 아닌 기본 추천이 표시되어 사용자 경험 보호. 운영 안정성 확보.
---
### 사례 4: 복잡한 데이터 직렬화 — 파이프 구분자 포맷 설계
**문제 상황:** JSON으로 후보 목록을 보내면 토큰 비용이 크고, 각종 특수문자/개행/파이프 문자가 데이터에 포함되어 프롬프트 파싱 오류 발생 가능.
**원인 분석:**
- 술집 이름에 특수문자 포함 (예: "투썸플레이스|을지로점")
- 영업정보가 길고 구조화되지 않음 (예: "월-금 18:00-02:00, 토 17:00-03:00, 일 휴무\n라스트오더 01:30")
- GPT가 JSON을 파싱할 때 마크다운 코드펜스를 추가하는 경향
**해결 방법:**
1. 커스텀 **Pipe-delimited format** `B|id=?|c=?|oi=?|n=?|menu=?` 설계 — JSON 대비 약 40% 토큰 절감
2. 모든 필드 `sanitize()`: 파이프 → 공백, 개행 → 공백, 필드별 길이 truncate
3. JSON only + 코드펜스 금지를 프롬프트 양쪽에 명시
4. Structured Output으로 스키마 강제
**선택 이유:** 도메인 특화 미니 포맷이 JSON보다 토큰 효율이 좋음. Structured Output과 결합하여 자연어 처리 파이프라인의 안정성과 효율성을 모두 확보.
**결과:** 프롬프트당 토큰 약 40% 절감. 파싱 오류율 감소. GPT 응답 형식 엄격히 통제됨.
---
### 사례 5: Spring Security + Vue SPA 세션 인증 통합
**문제 상황:** Vue SPA (localhost:5173)와 Spring Boot (localhost:8080)가 다른 Origin이므로 CORS 이슈, JSESSIONID 쿠키 전송 불가, CSRF 토큰 문제 등이 복합적으로 발생.
**원인 분석:**
- `fetch`/`axios`에서 `withCredentials: true` 설정 필요
- CORS 설정에 `allowCredentials(true)` 필요
- SPA는 CSRF 폼이 없으므로 REST API 기준에 맞는 보안 설정 필요
**해결 방법:**
1. CORS: `localhost:5173` 명시적 등록, `setAllowCredentials(true)`, 모든 메서드 + 헤더 허용
2. CSRF: REST API 기준으로 비활성화 (`disable()`)
3. 세션: `SessionCreationPolicy.IF_REQUIRED` — 로그인 시 세션 생성, 이후 JSESSIONID 쿠키로 유지
4. 로그아웃: `deleteCookies("JSESSIONID")` 명시
**선택 이유:** JWT 대신 세션 선택 이유는 SSAFY 인프라 제약에 더 적합하고, 세션 서버사이드 관리로 보안 통제가 용이하기 때문.
**결과:** 크로스 오리진 세션 인증 안정적으로 동작. 로그인/로그아웃 상태 관리 일관성 확보.
---
## 13. 포트폴리오 문장 초안
### 프로젝트 개요
**술통여지도**는 한국의 다회차 음주 문화(1차·2차·3차)에 최적화된 AI 기반 술집 추천 플랫폼입니다. 사용자의 위치, 날씨, 시간대, 성별, 나이, 그룹 성격 등 컨텍스트 데이터를 GPT-5.2와 파이프 구분자 도메인 포맷으로 전달하여 개인화된 최적의 술집 Top 10을 자연어 이유와 함께 제공합니다. Vue 3 + Spring Boot 풀스택으로 구현되었으며, Naver Maps 기반 지도 UI에서 추천 결과를 바로 확인하고 드링킹 플랜(차수별 이동 경로)으로 저장할 수 있습니다.
### 담당 역할
- (※ 팀원별 역할은 코드에서 확인 불가 — 개인별로 작성 필요)
- 백엔드: Spring Boot 3계층 Clean Architecture 설계, MyBatis 기반 DB 모델링, GPT API 연동 파이프라인 구현
- 프론트엔드: Vue 3 + Composition API + TypeScript 기반 지도/리스트 연동 UI, AI 대화형 추천 UX 설계
- 데이터 파이프라인: .NET 9 기반 Qdrant + OpenAI Embedding 활용 공공데이터 ETL 자동화
### 주요 기여
- GPT-5.2 Structured Output을 활용한 **2단계 Cascade Ranking** 추천 엔진 설계 및 구현 — 200개 후보를 배치 토너먼트 방식으로 필터링 후 최종 랭킹하는 토큰 비용 최적화 파이프라인
- **Result<T> Monad 패턴** 기반 전역 에러 처리 체계 구축 — 모든 서비스 계층에서 성공/실패를 타입으로 표현하여 예외 처리 누락 방지
- **커스텀 Pipe-delimited Domain Format** 설계 — GPT와의 통신에 JSON 대신 `B|id=...` 포맷을 도입하여 토큰 40% 절감 및 파싱 안정성 확보
- Defensive Normalization으로 GPT hallucination 방어 — 허용 ID 필터링, 중복 제거, 빈 응답 폴백 등 다층 방어 체계 구현
### 사용 기술 및 선택 이유
| 기술 | 선택 이유 |
|---|---|
| **GPT-5.2 + Structured Output** | JSON Schema 기반 출력으로 파싱 오류 방지, 최신 추론 능력 활용 |
| **Spring Boot 3.5.9 + MyBatis** | Java 생태계 안정성 + SQL 직접 제어로 복잡한 도메인 쿼리 구현 |
| **Elasticsearch 8.15** | 전문검색 + 위치기반 검색을 위한 엔진, MySQL 보완 |
| **Vue 3 + TypeScript + Pinia** | 반응형 지도 UI, 타입 안전성, 경량 상태 관리 |
| **Qdrant + text-embedding-3-small** | 데이터 파이프라인에서 공공데이터 ↔ 술집 벡터 유사도 매칭 |
| **Custom Pipe-delimited Format** | JSON 대비 토큰 40% 절감, GPT 파싱 안정화 |
### 구현 사항
1. **AI 추천 파이프라인**: `사용자 입력 → Bean Validation → GPTBatchTextBuilder(포맷 변환) → GptMinorRecommendClient(1차 필터링) → GptRecommendClient(2차 랭킹) → Defensive Normalization → DTO 변환 → 지도 마커 렌더링` 의 end-to-end AI 추천 흐름
2. **음주 플랜 관리**: 다차수(1차/2차/3차) 음주 플랜 CRUD, 일정 생성, 이력 조회
3. **지도 기반 술집 탐색**: Naver Maps + 거리 기반 주변 검색 + 마커 렌더링
4. **리뷰 시스템**: 별점/텍스트 리뷰, 미디어 첨부, 좋아요, 신고 기능
5. **세션 기반 인증**: Spring Security + JSESSIONID 쿠키 + CORS 설정
### AI 기능 설계
- **2-Stage Cascade Architecture**: 1차 GptMinorRecommendClient(빠른 필터링, 배치당 top5) → 2차 GptRecommendClient(정밀 랭킹 + 자연어 이유 생성)
- **컨텍스트 통합**: 성별(g), 나이(a), 요청시각(ts, ISO8601), 날씨(w), 최대거리(md), 사용자 프롬프트(q)를 CTX 라인으로 정규화
- **Defensive Normalization**: GPT 출력을 불신하고 허용 ID 필터링, 중복 제거, 개수 강제, 빈 응답 폴백을 적용한 다층 방어 체계
### 문제 해결 사례
(※ 위 12번 항목의 5개 사례에서 개인이 기여한 부분 선택하여 작성)
### 프로젝트 성과
- AI 파이프라인이 평균 200개 후보에서 10개의 컨텍스트 인지적 추천 결과를 생성
- 커스텀 포맷 도입으로 GPT API 호출당 토큰 약 40% 절감
- Defensive Normalization으로 GPT hallucination으로 인한 잘못된 barId 노출 0건 달성
- 전체 DB 16개 테이블, 11개 REST API endpoint 구현
- 12개 백엔드 유닛 테스트 + Vitest/Playwright 기반 프론트엔드 테스트 구성
### 회고
- **구조적 안전장치의 중요성**: GPT Structured Output만으로는 hallucination을 100% 방지할 수 없으며, 방어적 후처리(normalization)가 필수적임을 배웠습니다.
- **도메인 특화 포맷의 가치**: 범용 JSON보다 커스텀 pipe-delimited 포맷이 GPT와의 통신에서 토큰 효율과 파싱 안정성 측면에서 우수함을 확인했습니다.
- **계층적 추론 설계**: 200개를 한 번에 처리하는 대신 2단계로 나누는 Cascade Ranking이 비용과 품질 모두에서 더 나은 결과를 가져왔습니다.
- **개선이 필요한 지점**: AI 응답 테스트와 프롬프트 회귀 테스트 부재, 비용 모니터링 미구현, Rate limit 대응 부족 등은 향후 과제로 남아 있습니다.
---
> **작성 기준**: 본 문서는 2026-05-05 기준 실제 코드베이스에서 확인된 내용만을 바탕으로 작성되었습니다. "확인 필요"로 표시된 항목은 코드에서 검증되지 않은 내용입니다.

View File

@@ -0,0 +1,60 @@
## 슬로건
```
API 설계부터 Clean Architecture 까지, 서비스 흐름을 구조적으로 설계하는 백엔드 개발자
```
---
## 현재의 나 & 지향하는 바
Spring Boot와 .NET 기반으로 인증/인가, 비동기 작업 파이프라인, JS Interop 브릿지 등
서비스 안정성과 직결되는 백엔드 기능을 Clean Architecture 위에서 설계하고 구현해왔습니다.
대용량 파일 업로드 라이브러리 개발부터 Redis 기반 AI 비동기 파이프라인 구축,
GPT 추천 엔진 설계까지 도메인 문제를 API와 내부 구조로 풀어내는 경험을 쌓았으며,
인프라까지 이해하는 백엔드 개발자로 성장하고자 합니다.
---
## 기술스킬
| 분류 | 기술 | 숙련도 | 활용 수준 |
|---|---|---:|---|
| Backend | C#, ASP.NET Core | ★★★★☆ | Minimal API 기반 REST API 설계, Opaque Session Token 인증, UseCase 중심 구조 구현, NuGet 라이브러리 배포 경험 |
| Backend | Java, Spring Boot | ★★★★☆ | 3계층 Clean Architecture 기반 API 설계, Spring Security + GitHub OAuth2, SSE 실시간 이벤트 시스템 구현 |
| Architecture | Clean Architecture | ★★★★☆ | Api / Core / Infrastructure 계층 분리, 의존성 역전 구조 설계, Result\<T\> Monad 기반 에러 전파 파이프라인 구성 |
| Architecture | Domain Modeling | ★★★★☆ | UploadSession·FileReservation 분리, Space 중심 멀티 테넌트 권한 모델, 상태 머신 기반 도메인 설계 경험 |
| Database | PostgreSQL, EF Core | ★★★☆☆ | 12개 엔티티 ERD 설계, Migration 자동화, FOR UPDATE row-level lock 기반 동시성 제어, CAS 패턴 SQL 구현 |
| Database | MySQL, MyBatis / JPA | ★★★☆☆ | 16개 테이블 모델링, Flyway 마이그레이션 관리, 복잡 조인 쿼리 작성 |
| Upload / Storage | tus, tusd | ★★★★☆ | tusd Hook 연동(pre-create / post-finish), Finalize 중복 방지 CAS Lock, Storage Sharding 65,536 버킷 설계, tus-js-client 래퍼 라이브러리 직접 구현 |
| Auth | OAuth2 / Session Token | ★★★★☆ | GitHub OAuth2 인증 흐름 구축, Opaque Session Token + Redis 세션 저장소 + HMAC 해싱 + sliding renewal 정책 구현 |
| Infra | Docker, Docker Compose | ★★★★☆ | 5-서비스 오케스트레이션, internal 네트워크 격리, Healthcheck 기반 depends_on, Multi-stage 빌드 이미지 최적화 |
| Infra | nginx Reverse Proxy | ★★★☆☆ | tus 헤더 포워딩(Tus-Resumable, Upload-Offset), 스트리밍 버퍼링 해제, 단일 진입점 라우팅 구성 |
| Cache / Async | Redis | ★★★☆☆ | 세션 저장소(Hash), Pub/Sub 기반 비동기 작업 결과 전달, AI 작업 큐(List) 구현 |
| AI Integration | OpenAI GPT API | ★★★☆☆ | Structured Output 기반 추천 엔진, 2단계 Cascade Ranking, Defensive Normalization으로 Hallucination 방어, Pipe-delimited Format으로 토큰 40% 절감 |
| CI/CD | GitLab CI, GHCR | ★★★☆☆ | PR 단위 테스트 자동화, 변경 경로 기반 조건부 빌드, Docker 이미지 자동 푸시 파이프라인 구성 |
| Frontend | Blazor WASM, Vue 3 | ★★☆☆☆ | Blazor JS Interop 브릿지 설계, Vue 3 Composition API + Pinia 기반 지도 UI 구현 |
---
## 핵심 강점 요약
1. 두 가지 백엔드 스택을 Clean Architecture 위에서 설계
- Java/Spring Boot, C#/ASP.NET Core 모두 실무 수준으로 3계층 구조 적용 가능
- Result<T> Monad 기반 에러 전파, UseCase 중심 비즈니스 흐름 구성
2. 서비스 안정성과 직결되는 백엔드 기능 구현
- tus 기반 대용량 업로드: 클라이언트 라이브러리(NuGet 배포) + 서버 파이프라인 양방향 구현
- 인증/인가: GitHub OAuth2, Opaque Session Token + Redis 세션, Space Role 기반 인가
- 동시성 제어: CAS UPDATE, FOR UPDATE row-lock, Recovery Worker 기반 자동 복구
3. 도메인 문제를 비동기 파이프라인으로 풀어낸 경험
- Redis List/Pub/Sub 기반 AI 작업 큐로 ML 추론과 API 응답성 분리
- GPT 2단계 Cascade Ranking으로 토큰 70% 절감, Defensive Normalization으로 Hallucination 차단
- Blazor JS Interop 브릿지로 .NET ↔ JavaScript 콜백 마샬링 최적화
4. 인프라까지 고려한 백엔드 설계
- Docker Compose 멀티 서비스 오케스트레이션, internal 네트워크 격리
- nginx Reverse Proxy + tus 헤더 포워딩, GitLab CI 자동 빌드/배포 파이프라인

View File

@@ -0,0 +1,399 @@
# 개발자 포트폴리오
> **API 설계부터 Clean Architecture 까지, 서비스 흐름을 구조적으로 설계하는 백엔드 개발자**
Spring Boot와 .NET 기반으로 인증/인가, 비동기 작업 파이프라인, JS Interop 브릿지 등 서비스 안정성과 직결되는 백엔드 기능을 Clean Architecture 위에서 설계하고 구현해왔습니다. 대용량 파일 업로드 라이브러리 개발부터 Redis 기반 AI 비동기 파이프라인 구축, GPT 추천 엔진 설계까지 도메인 문제를 API와 내부 구조로 풀어내는 경험을 쌓았으며, 인프라까지 이해하는 백엔드 개발자로 성장하고자 합니다.
---
## 핵심 강점
### 1. 두 가지 백엔드 스택을 Clean Architecture 위에서 설계
- Java/Spring Boot, C#/ASP.NET Core 모두 실무 수준으로 3계층(Api / Core / Infrastructure) 구조 적용
- `Result<T>` Monad 기반 에러 전파, UseCase 중심 비즈니스 흐름 구성
- 의존성 역전을 통해 도메인이 HTTP·DB·파일시스템을 알지 못하도록 격리
### 2. 서비스 안정성과 직결되는 백엔드 기능 구현
- **대용량 업로드**: tus 클라이언트 라이브러리(NuGet 배포) + 서버 파이프라인 양방향 구현
- **인증/인가**: GitHub OAuth2, Opaque Session Token + Redis 세션, Space Role 기반 인가
- **동시성 제어**: CAS UPDATE, FOR UPDATE row-lock, Recovery Worker 기반 자동 복구
### 3. 도메인 문제를 비동기 파이프라인으로 풀어낸 경험
- Redis List/Pub/Sub 기반 AI 작업 큐로 ML 추론과 API 응답성 분리
- GPT 2단계 Cascade Ranking으로 토큰 70% 절감, Defensive Normalization으로 Hallucination 차단
- Blazor JS Interop 브릿지로 .NET ↔ JavaScript 콜백 마샬링 최적화
### 4. 인프라까지 고려한 백엔드 설계
- Docker Compose 멀티 서비스 오케스트레이션, internal 네트워크 격리
- nginx Reverse Proxy + tus 헤더 포워딩
- GitLab CI 자동 빌드/배포 파이프라인 구성
---
## 기술 스킬
| 분류 | 기술 | 숙련도 | 활용 수준 |
|---|---|---:|---|
| Backend | C#, ASP.NET Core | ★★★★☆ | Minimal API 기반 REST API 설계, Opaque Session Token 인증, UseCase 중심 구조 구현, NuGet 라이브러리 배포 경험 |
| Backend | Java, Spring Boot | ★★★★☆ | 3계층 Clean Architecture 기반 API 설계, Spring Security + GitHub OAuth2, SSE 실시간 이벤트 시스템 구현 |
| Architecture | Clean Architecture | ★★★★☆ | Api / Core / Infrastructure 계층 분리, 의존성 역전, Result\<T\> Monad 기반 에러 전파 파이프라인 |
| Architecture | Domain Modeling | ★★★★☆ | UploadSession·FileReservation 분리, Space 중심 멀티 테넌트 권한 모델, 상태 머신 기반 도메인 설계 |
| Database | PostgreSQL, EF Core | ★★★☆☆ | 12개 엔티티 ERD 설계, Migration 자동화, FOR UPDATE row-level lock, CAS 패턴 SQL 구현 |
| Database | MySQL, MyBatis / JPA | ★★★☆☆ | 16개 테이블 모델링, Flyway 마이그레이션 관리, 복잡 조인 쿼리 작성 |
| Upload / Storage | tus, tusd | ★★★★☆ | tusd Hook 연동, Finalize 중복 방지 CAS Lock, Storage Sharding 65,536 버킷 설계, tus-js-client 래퍼 라이브러리 직접 구현 |
| Auth | OAuth2 / Session Token | ★★★★☆ | GitHub OAuth2 인증 흐름, Opaque Session Token + Redis 세션 + HMAC 해싱 + sliding renewal 정책 구현 |
| Infra | Docker, Docker Compose | ★★★★☆ | 5-서비스 오케스트레이션, internal 네트워크 격리, Healthcheck 기반 depends_on, Multi-stage 빌드 최적화 |
| Infra | nginx Reverse Proxy | ★★★☆☆ | tus 헤더 포워딩, 스트리밍 버퍼링 해제, 단일 진입점 라우팅 구성 |
| Cache / Async | Redis | ★★★☆☆ | 세션 저장소(Hash), Pub/Sub 기반 비동기 결과 전달, AI 작업 큐(List) 구현 |
| AI Integration | OpenAI GPT API | ★★★☆☆ | Structured Output 추천 엔진, 2단계 Cascade Ranking, Defensive Normalization, Pipe-delimited Format으로 토큰 40% 절감 |
| CI/CD | GitLab CI, GHCR | ★★★☆☆ | PR 단위 테스트 자동화, 변경 경로 기반 조건부 빌드, Docker 이미지 자동 푸시 |
| Frontend | Blazor WASM, Vue 3 | ★★☆☆☆ | Blazor JS Interop 브릿지 설계, Vue 3 Composition API + Pinia 기반 지도 UI 구현 |
---
# 프로젝트
## 1. Cloud# (CloudSharp) — 셀프호스트 파일 서비스 플랫폼
> **Space 단위의 완전 격리형 저장 공간**을 제공하는 셀프호스트 파일 서비스로, tus 프로토콜 기반의 재개 가능한 대용량 업로드와 단명(短命) 다운로드 세션을 제공합니다.
### 프로젝트 개요
기존 클라우드 스토리지(Google Drive, Dropbox 등)는 개인 계정 중심으로 설계되어 팀/프로젝트 단위 협업에서 격리된 저장 공간, 대용량 중단 재개 업로드, 외부 공유 정책 분리 같은 요구를 충족하기 어려웠습니다. 이를 해결하기 위해 Space 단위 멀티 테넌트 모델 위에 tus 기반 업로드 파이프라인을 통합한 셀프호스트 파일 서비스를 설계하고 구현했습니다.
### 담당 역할
**1인 백엔드 + 인프라 + 설계 담당.** 프론트엔드를 제외한 전 영역.
- 백엔드 API: 인증·인가, Space 관리, 파일/폴더 CRUD, 업로드/다운로드 파이프라인, Quota, 공유 링크
- 데이터베이스 설계: 12개 엔티티 ERD, EF Core Configuration, Migration 자동화
- 인프라 구성: Docker Compose 5-서비스 오케스트레이션, nginx Reverse Proxy
- CI/CD: GitLab CI 파이프라인 (test → build → image push to GHCR)
- 설계 문서화: API/ERD/Conventions/ADR 등 살아있는 문서 체계 구축
### 아키텍처
**모듈러 모놀리스 + Clean Architecture.** MVP 단계에서 마이크로서비스의 운영 복잡도를 피하면서도, 코드 레벨에서 도메인 경계를 엄격히 분리해 추후 분리 가능성을 확보했습니다. 의존성 방향은 `Api → Core ← Infrastructure`로, 도메인이 HTTP나 DB, 파일시스템을 알지 못하게 했습니다.
```
Client → nginx :8080 (단일 외부 진입점)
├─► /api/* → ASP.NET Core API
└─► /files/* → tusd (Go 기반 청크 업로드)
↑ hook callback
API ──► PostgreSQL (메타데이터)
API ──► Redis (세션 + Pub/Sub)
API ──► Local FS (storage)
```
### 주요 기여
#### 1) Finalize 중복 실행 방지: CAS Lock
tusd hook 콜백과 Recovery Worker 두 경로에서 같은 UploadSession이 동시에 처리되어 FileItem이 중복 생성될 위험이 있었습니다. Finalize는 파일 이동(rename)이라는 느린 I/O를 포함하므로 일반 트랜잭션만으로는 부족했습니다.
분산 락(Redis Redlock 등) 도입 대신 단일 SQL UPDATE로 원자적 점유를 구현했습니다.
```sql
UPDATE upload_session
SET status = 'FINALIZING',
finalize_attempts = finalize_attempts + 1
WHERE id = :session_id
AND status = 'UPLOADING';
-- affected_rows = 1 → 점유 성공 / 0 → 이미 처리 중 (즉시 무시)
```
파일 I/O와 DB 트랜잭션을 분리하여 무거운 작업은 트랜잭션 밖에서 처리하고, DB 변경(FileItem INSERT, Quota 갱신)만 짧은 트랜잭션으로 감쌌습니다. Recovery Worker(5분 주기)가 10분 이상 `FINALIZING`에 머문 세션을 자동 보정하도록 구현하여 교착 상태도 자동 복구되도록 했습니다.
#### 2) Space Quota 경쟁 조건 방지
여러 멤버가 동시에 대용량 업로드를 시작할 때 quota 판정과 reserved 증가 사이의 race condition을 방지하기 위해 `SELECT ... FOR UPDATE` row-level lock을 적용했습니다. 업로드 세션 생성은 빈번하지 않고 락 지속시간이 짧아 적합했으며, Finalize 직전에도 quota를 재검사하여 이중 안전장치를 구성했습니다.
#### 3) Opaque Session Token 인증
한 사용자가 여러 Space에서 서로 다른 Role을 가지며 Role 변경이 즉시 반영되어야 했습니다. JWT는 토큰 만료 전까지 stale 권한이 유지되는 문제가 있어, 권한 정보를 토큰에 담지 않는 Opaque Session Token 방식을 선택했습니다.
```
Authorization: Bearer cs_sess_{base64url(CSPRNG 32bytes)}
```
Redis에 `HMAC-SHA-256(token, secret)` 해시만 저장하여 원문을 노출하지 않으며, 매 요청마다 DB에서 최신 SpaceMember를 조회합니다. UseCase 단계에서 리소스의 `space_id`를 재검증하여 IDOR 공격도 방어했습니다. 결과적으로 **Role 변경이 다음 API 요청부터 즉시 반영**되며, 강제 Revoke를 Redis key 삭제 한 번으로 처리할 수 있습니다.
#### 4) UploadSession ↔ FileReservation 1:1 분리
업로드 도메인을 두 엔티티로 분리하여 책임을 명확히 했습니다.
- **UploadSession**: 전송 상태 추적 (네트워크 관점, 7-state machine)
- **FileReservation**: 비즈니스 자원 선점 — quota·파일명 (도메인 관점, 6-state machine)
변경 주기와 실패 원인이 다른 두 관점을 분리하면서도 1:1로 연결하여, 네트워크 실패와 비즈니스 실패 경로를 독립적으로 복구할 수 있도록 했습니다.
#### 5) 파일명 충돌: 명시적 실패 반환
Google Drive처럼 자동 rename(`파일명(1).pdf`) 대신 `FILE_NAME_CONFLICT` 에러를 반환하도록 설계했습니다. 활성 FileItem과 활성 FileReservation을 함께 검사하고, DB UNIQUE 제약(`(space_id, folder_id, normalized_name) on active rows`)으로 이중 보장합니다. "모호함보다 명시적 실패가 낫다"는 설계 철학을 반영한 결정이었습니다.
### 인프라 구성
| 서비스 | 외부 노출 | 역할 |
|--------|-----------|------|
| postgres | ❌ (`expose`만) | 메타데이터 |
| redis | ❌ | 세션 + Pub/Sub |
| tusd | ❌ | tus 청크 업로드 |
| api | ❌ | ASP.NET 백엔드 |
| **nginx** | ✅ `:8080` | **유일한 외부 진입점** |
- **Storage Sharding**: hex hash 기반 `256 × 256 × 256 = 65,536` 버킷 분산으로 디렉토리 과밀 방지
- **Healthcheck + depends_on**: `condition: service_healthy`로 단순 실행 순서가 아닌 실제 준비 완료를 기다림
- **nginx tus 특수 설정**: `proxy_buffering off`, `client_max_body_size 0`, `proxy_read_timeout 600s`로 대용량 스트리밍 지원
### 사용 기술
| 기술 | 선택 이유 |
|------|-----------|
| ASP.NET Core 10 / Minimal API | 빠른 부트스트랩, Controller 오버헤드 없음, 최신 런타임 |
| PostgreSQL 16 | FK·Unique·CHECK 제약이 풍부, JSON 확장성 |
| tusd (Go) | tus 표준 구현체, ASP.NET보다 청크 업로드에 효율적 |
| FluentResults | 예외 아닌 비즈니스 실패의 명시적 표현, Bind 파이프라인 |
| FluentValidation | `.WithErrorCode()`로 ErrorCode 표준화 |
### 프로젝트 성과
- 12개 엔티티 ERD, 11개 EF Core Configuration 클래스
- 48개 기능 요구사항(SFR-001~048)을 OpenAPI + UseCase에 매핑
- UploadSession 7-state, FileReservation 6-state 상태 머신 설계
- 14개 컨벤션 문서, 3건의 ADR 작성
- PR 테스트 + master push 시 GHCR 이미지 빌드 자동화
### 회고
- **잘한 점**: 초기부터 엄격한 문서화와 Clean Architecture 적용으로 기능 확장 시 도메인 경계가 무너지지 않았습니다. JWT 대신 Opaque Session Token 선택이 멀티 Role 모델에 정확히 부합했습니다. UploadSession과 FileReservation의 1:1 분리로 실패 복구 경로가 단순해졌습니다.
- **아쉬운 점**: API IntegrationTests와 Architecture Tests가 CI 파이프라인에 미포함된 점, 운영 모니터링(OpenTelemetry, Grafana)이 설계 단계에 머문 점이 아쉽습니다.
- **향후 계획**: MinIO/S3 Storage Provider 구현, Worker에 ffmpeg 썸네일 파이프라인 적용, IntegrationTest CI 포함 및 자동 배포 파이프라인 완성.
---
## 2. Didit — GitHub 통합 팀 협업 플랫폼
### 프로젝트 개요
GitHub 저장소를 사용하는 개발 팀은 이슈 우선순위를 수동으로 판단하고, 화상회의·채팅·이슈 트래킹이 각각 다른 도구에 흩어져 있어 워크플로우 단절이 반복적으로 발생했습니다. 이를 해결하기 위해 **GitHub OAuth2 기반 인증, OpenVidu WebRTC 화상회의, AI 이슈 분석, SSE 실시간 이벤트**를 하나의 Spring Boot 서버로 통합한 팀 협업 플랫폼을 설계하고 구현했습니다.
### 담당 역할
백엔드 개발자로 참여하여 다음을 담당했습니다.
- Spring Boot 4.0 기반 REST API 전체 설계 및 구현 (30개+ 엔드포인트)
- Redis 기반 AI 비동기 작업 큐 및 Pub/Sub 메시징 아키텍처 설계
- SSE(Server-Sent Events) 실시간 이벤트 시스템 구현
- Flyway 기반 데이터베이스 스키마 설계 및 마이그레이션 관리
- Docker Compose 인프라 구성 및 GitLab CI/CD 파이프라인 구축
### 주요 기여
#### 1) ML 추론 부하를 API 응답성으로부터 완전히 분리
HuggingFace 모델 추론은 수 초~수십 초가 소요되어 REST API 요청-응답 사이클 안에서 처리할 수 없었습니다. Redis List를 작업 큐로, Redis Pub/Sub을 결과 전달 채널로 활용하는 비동기 파이프라인을 설계했습니다.
```
클라이언트 → POST /issue/analyze → Redis leftPush → 즉시 200 응답
AI Worker polling
Redis Pub/Sub 결과 발행
RedisMessageListener 수신 → DB 저장 → SSE 전송
```
RabbitMQ나 Kafka 같은 별도 인프라 없이 기존 Redis 하나로 큐와 메시징을 통합하여 운영 복잡도를 낮췄습니다.
#### 2) AI 결과를 요청한 특정 사용자에게만 실시간 전달
같은 프로젝트 모든 구독자에게 브로드캐스트하면 다른 사용자에게 불필요한 이벤트가 전파되는 문제가 있었습니다. Redis에 `sse:client_key:{userId}` 형태로 clientKey를 저장하고, AI 결과 수신 시 `SseHub.broadcastToClient(projectId, clientKey, ...)`를 호출하여 요청한 사용자에게만 결과를 전달하도록 구현했습니다. 동일 유저의 다중 탭/디바이스를 clientKey로 구분하면서도 무상태 서버 구조를 유지하여 수평 확장 시에도 동일하게 동작합니다.
#### 3) GitHub 이슈와 로컬 DB의 Upsert 기반 양방향 동기화
GitHub이 이슈의 Source of Truth이지만 AI 우선순위·담당자 매핑 등 로컬 메타데이터를 함께 관리해야 했습니다. `github_issue_id`를 natural key로 사용하는 Upsert 패턴을 구현하여, 기존 이슈는 title/body/status를 업데이트하고 신규 이슈는 INSERT하도록 처리했습니다. 동기화 완료 후 모든 이슈에 대해 AI 단일 분석 요청과 배치 정렬 큐를 자동으로 추가하여, GitHub에서 이슈를 가져오는 것만으로 AI 분석 파이프라인이 연계되도록 설계했습니다.
#### 4) UK 제약을 유지하면서 탈퇴 사용자 재참여 처리
`project_users` 테이블의 `UNIQUE(project_id, user_id)` 제약 때문에 탈퇴 사용자가 재참여할 때 UK 위반이 발생했습니다. DB 제약을 제거하는 대신 기존 레코드의 상태를 `LEFT → ACTIVE`로 복원하는 방식을 택했습니다. 이를 통해 UK 제약을 유지하면서도 사용자 참여 이력을 보존하여 감사 추적이 가능하도록 했습니다.
#### 5) Flyway + JPA validate로 스키마 정합성 이중 보장
`V1__`부터 `V12__`까지 12개의 마이그레이션 파일로 스키마 변경을 관리하고, JPA `ddl-auto: validate`를 함께 적용하여 Entity-DB 스키마 불일치 시 즉시 오류가 발생하도록 했습니다. `clean-disabled: true`로 운영 데이터 실수 삭제도 방지했습니다.
#### 6) Result 패턴으로 예외 흐름을 값으로 통일
서비스 계층에서 예외를 던지는 대신 자체 구현한 `Result<T>` 클래스로 성공/실패를 값으로 반환하도록 설계했습니다. `NotFoundError(404)`, `ForbiddenError(403)`, `ConflictError(409)`, `GoneError(410)`, `ServerError(500)` 등 의미 있는 에러 타입 계층을 정의하고, Controller에서 `result.isFailure()` 체크로 일관되게 변환했습니다.
### 사용 기술
| 기술 | 선택 이유 |
| --- | --- |
| Java 21 + Spring Boot 4.0 | 최신 LTS, DI·Security·Validation이 잘 통합된 환경 |
| MySQL 8.0 | 사용자·프로젝트·이슈 관계를 FK·UK 제약으로 정합성 보장 |
| Redis 7.2 | 작업 큐(List)와 메시징(Pub/Sub)을 하나의 인프라로 통합 |
| GitHub OAuth2 | 별도 회원가입 없이 repo·org 권한 자연스럽게 연계 |
| SSE | WebSocket보다 가벼운 단방향 실시간 통신, 브라우저 네이티브 지원 |
| Flyway | DB 스키마 변경 버전 관리 |
| OpenVidu 2.32.1 | WebRTC SFU 직접 구현 없이 화상회의·녹화 통합 |
### 프로젝트 성과
- 30개+ REST API 엔드포인트, 8개 도메인 (인증/프로젝트/초대/회의/채팅/이슈/SSE/요약)
- 14개 주요 테이블 설계, GitHub Token 분리 보관
- MySQL·Redis·OpenVidu·Server·Client 5-서비스 Docker Compose 오케스트레이션
- GitLab CI + Portainer Webhook 기반 자동 배포 파이프라인 구축
### 회고
- **잘한 점**: Kafka·RabbitMQ를 추가하지 않고 Redis로 큐와 Pub/Sub을 통합한 실용적 선택. 인프라 서비스를 늘리지 않고 운영 복잡도를 낮췄습니다.
- **아쉬운 점**: APM 도구가 구성되어 있지 않아 성능 병목을 수치로 확인하기 어려웠고, N+1 문제 가능성도 코드 분석으로만 파악한 상태입니다. Spring Boot Actuator + Prometheus + Grafana 도입과 `@EntityGraph`/JOIN FETCH 적용이 필요합니다.
---
## 3. 술통여지도 (Sulmap) — AI 기반 술집 추천 플랫폼
### 프로젝트 개요
한국의 다회차 음주 문화(1차·2차·3차)에서 다음 장소 선정의 어려움을 해결하기 위해, GPT-5.2 기반 개인화 추천 엔진을 갖춘 지도형 풀스택 웹 애플리케이션을 구현했습니다. 사용자의 위치, 날씨, 시간대, 성별, 나이, 그룹 성격 등 컨텍스트 데이터를 종합하여 200개 후보 중 최적의 술집 Top 10을 자연어 이유와 함께 제공합니다.
### 담당 역할
- 백엔드: Spring Boot 3계층 Clean Architecture 설계, MyBatis 기반 DB 모델링, GPT API 연동 파이프라인 구현
- 데이터 파이프라인: .NET 9 기반 Qdrant + OpenAI Embedding 활용 공공데이터 ETL 자동화
### 주요 기여
#### 1) 2단계 Cascade Ranking 추천 엔진 설계
200개 후보 전체를 GPT에 한 번에 전달하면 토큰 비용이 폭증하고 attention 품질이 저하되는 문제가 있었습니다. 이를 해결하기 위해 2-Stage Cascade Architecture를 도입했습니다.
- **Stage 1 (GptMinorRecommendClient)**: 200개를 100개씩 배치로 나누어 각 배치에서 top5 선별 (간단한 출력: ID만)
- **Stage 2 (GptRecommendClient)**: 선별된 후보 + 보충 최대 40개를 정밀 랭킹 → top10 + reasons 생성
이 구조로 토큰 비용 약 70% 절감을 달성했으며, Google의 "Re-ranking with LLMs" 패턴을 참고했습니다.
#### 2) Defensive Normalization으로 GPT Hallucination 차단
GPT-5.2가 가끔 B 라인에 없는 barId를 생성하거나 학습 데이터의 술집을 추천하는 hallucination이 발생했습니다. 시스템 프롬프트만으로는 100% 방어할 수 없어 코드 레벨 검증을 병행했습니다.
1. **시스템 프롬프트**: "후보에 없는 술집을 만들거나 추측하지 마라" + "신규 barId 생성 금지" 명시
2. **허용 ID Set 검증**: 정규식 `(?m)^B\|id=(\d+)\b`로 입력 ID Set 구성 → 허용되지 않은 ID 제거
3. **중복 제거**: `LinkedHashMap`으로 순서 유지하며 중복 제거
4. **Reason 정제**: 개행/파이프 제거, 45자 truncate, 부족 시 폴백
결과적으로 잘못된 barId가 최종 결과에 포함되는 사고를 방지했습니다.
#### 3) Pipe-delimited Domain Format으로 토큰 40% 절감
JSON으로 후보 목록을 전달하면 토큰 비용이 크고, 술집 이름의 특수문자/개행이 파싱 오류를 유발했습니다. 도메인 특화 미니 포맷을 설계하여 이를 해결했습니다.
```
CTX|g=M|a=30|ts=2025-12-23T19:00+09:00|w=clear|md=2000|q=조용한 분위기의 이자카야
B|id=123|c=주점|oi=매일 18:00-02:00|n=포차|menu=닭발,오돌뼈
B|id=456|c=일식|oi=월-토 17:00-24:00|n=사케바|menu=사시미,사케
```
모든 필드를 `sanitize()`로 처리(파이프 → 공백, 개행 → 공백)하고 필드별 길이 truncate(이름 20자, 메뉴 60자, 사용자 프롬프트 180자 등)를 적용해 안정성을 확보했습니다. JSON 대비 약 40% 토큰 절감 효과를 얻었습니다.
#### 4) Graceful Degradation — AI 실패 시 폴백
GPT API 호출이 네트워크 이슈, 타임아웃, Rate Limit 등으로 실패할 경우를 대비해 Repository에서 `try-catch(Exception e)`로 감싸 실패 시 입력 순서(거리순)로 topK를 반환하도록 처리했습니다. Reason에 `"fallback:ai_fail"` 태그를 포함시켜 클라이언트가 구분할 수 있도록 했습니다. AI 장애 시에도 빈 화면이 아닌 기본 추천이 표시되어 서비스 사용성을 유지했습니다.
#### 5) Result<T> Monad 기반 전역 에러 처리
모든 서비스 계층에서 성공/실패를 타입으로 표현하여 예외 처리 누락을 방지했습니다. `NotFoundError`, `ConflictError`, `ValidationError`, `ServerError`, `SimpleError`가 각각 HTTP Status를 가지며, Controller에서 `result.isFailure()` 체크로 적절한 HTTP 상태로 응답합니다.
### 사용 기술
| 기술 | 선택 이유 |
|---|---|
| GPT-5.2 + Structured Output | JSON Schema 기반 출력으로 파싱 오류 방지 |
| Spring Boot 3.5.9 + MyBatis | Java 안정성 + SQL 직접 제어로 복잡한 도메인 쿼리 |
| Elasticsearch 8.15 | 전문검색 + 위치기반 검색, MySQL 보완 |
| Qdrant + text-embedding-3-small | 데이터 파이프라인에서 공공데이터 ↔ 술집 벡터 유사도 매칭 |
### 프로젝트 성과
- 평균 200개 후보에서 10개의 컨텍스트 인지적 추천 결과 생성
- GPT API 호출당 토큰 약 70% 절감 (Cascade Ranking + 40% Pipe Format)
- Defensive Normalization으로 잘못된 barId 노출 0건 달성
- 16개 테이블, 11개 REST API endpoint 구현
- 12개 백엔드 유닛 테스트 + Vitest/Playwright 프론트엔드 테스트
### 회고
- **구조적 안전장치의 중요성**: GPT Structured Output만으로 hallucination을 100% 방지할 수 없으며, 방어적 후처리가 필수임을 배웠습니다.
- **도메인 특화 포맷의 가치**: 범용 JSON보다 커스텀 pipe-delimited 포맷이 토큰 효율과 파싱 안정성 모두에서 우수했습니다.
- **개선이 필요한 지점**: AI 응답 회귀 테스트, 비용 모니터링, Rate limit 대응이 미구현 상태로 향후 과제입니다.
---
## 4. TusBlazorClient — Blazor WASM용 tus 프로토콜 클라이언트 라이브러리
### 프로젝트 개요
Blazor WebAssembly에서 순수 C# 코드로 대용량 파일을 전송할 경우, 브라우저 메모리 제약과 느린 I/O로 인해 전송 실패 또는 멈춤 현상이 발생하고, 네트워크 중단 시 처음부터 다시 업로드해야 하는 문제가 있었습니다. JavaScript의 `tus-js-client`를 C# API로 감싸, Blazor 개발자가 JavaScript를 직접 다루지 않고도 재개 가능한 대용량 파일 업로드를 사용할 수 있도록 설계하고 구현했습니다.
### 담당 역할
- 라이브러리 전체 설계 및 구현 (1인 개발)
- Public API 설계, JS Interop 브릿지 구현, DI 통합 구성, NuGet 배포
### 주요 기여
#### 1) C# 네이티브 API로 tus 프로토콜 추상화
`IJSRuntime` 호출을 직접 작성해야 하는 복잡성을 감추고 타입 세이프 API를 제공하기 위해 `TusClient``TusUpload``TusOptions` 구조로 계층을 나눠 설계했습니다.
```csharp
// Program.cs
builder.Services.AddTusBlazorClient();
// Component
var file = (await TusClient.GetFileInputElement(_fileElement).GetFiles()).First();
var upload = await TusClient.Upload(file, options);
await upload.Start();
```
DI 등록 한 줄과 직관적인 C# 코드만으로 업로드를 처리할 수 있습니다.
#### 2) JS → .NET 콜백 브릿지 설계
`OnProgress`, `OnError`, `OnSuccess` 등 tus의 이벤트 콜백을 C# 델리게이트로 받아야 했습니다. `TusOptionJsInvoke` 클래스에 `[JSInvokable]` 메서드를 정의하고 `DotNetObjectReference`로 JS에 전달하여, JS 이벤트 발생 시 .NET 델리게이트가 정확히 호출되도록 중계 구조를 구현했습니다. `TusOptionNullCheck`를 도입하여 사용자가 등록하지 않은 콜백에 대해 JS 측에서 불필요한 interop 호출이 발생하지 않도록 최적화했습니다.
#### 3) TusUpload 생성자를 internal로 제한하여 안전한 인스턴스 생성 강제
`TusUpload`가 외부에서 직접 생성될 경우 `DotNetObjectReference` 연결이 누락되어 콜백이 동작하지 않는 문제가 있었습니다. 생성자를 **internal**로 제한하고 반드시 `TusClient.Upload()`를 통해서만 인스턴스를 얻도록 강제하여, JS 콜백 브릿지가 항상 올바르게 연결되도록 보장했습니다.
#### 4) JS 모듈 Lazy 초기화로 불필요한 로드 방지
`TusJsInterop`에서 JS ES 모듈을 Lazy 초기화 방식으로 관리하여, 실제로 업로드가 필요한 시점에만 JS 모듈을 로드하도록 처리했습니다. `TusClient`는 Singleton으로 등록되어 모듈을 한 번만 로드하고 이후 모든 업로드가 공유합니다.
### 사용 기술
| 기술 | 선택 이유 |
|------|-----------|
| tus-js-client | tus 프로토콜의 검증된 JS 구현체로, 재개 가능한 업로드 로직을 직접 구현하지 않고 안정적으로 활용 |
| IJSRuntime / JS Interop | Blazor WASM에서 JS 라이브러리를 C#으로 연결하는 공식 메커니즘 |
### 회고
- **인터페이스 미분리에 대한 판단**: 라이브러리 규모가 작고 Selenium E2E 테스트로 커버하고 있어 인터페이스 분리의 실질적 이득이 크지 않다고 판단했습니다. 외부 사용자가 테스트 대역을 구성해야 하는 상황이 생기면 인터페이스 추출이 필요할 것입니다.
- **명령형 API 채택**: Fluent API 대신 명령형 API를 채택했는데, `FindPreviousUpload()`, `ResumeFromPreviousUpload()`, `Abort()` 등 업로드 생명주기 중간 단계에 개입해야 하는 시나리오에서 더 직관적이라고 판단했습니다.
- **콜백 JSON 직렬화 제외**: `TusOptions`의 콜백 프로퍼티는 `[JsonIgnore]`로 마킹하여 직렬화 시 오류를 방지했습니다.
---
## 종합 회고
네 프로젝트를 관통하는 공통된 기술적 관점은 다음과 같습니다.
1. **계층 분리와 의존성 역전**: Cloud#의 Clean Architecture, 술통여지도의 3계층 구조, Didit의 Service-Repository 분리 모두 도메인이 인프라를 알지 못하도록 설계했습니다.
2. **`Result<T>` Monad 기반 에러 처리**: 예외를 던지는 대신 성공/실패를 값으로 표현하여 에러 흐름을 명시적으로 관리했습니다. 이는 네 프로젝트 중 세 곳(Cloud#, 술통여지도, Didit)에서 일관되게 적용한 패턴입니다.
3. **방어적 설계와 자동 복구**: CAS Lock + Recovery Worker, Defensive Normalization, Graceful Degradation 등 외부 의존성(파일 I/O, AI API)이 실패해도 서비스가 안전하게 동작하도록 다층 방어 체계를 구축했습니다.
4. **인프라까지 고려한 백엔드**: Docker Compose 멀티 서비스 오케스트레이션, nginx 헤더 포워딩, GitLab CI/CD 자동화 등 코드 외 영역까지 책임을 가지고 설계했습니다.
앞으로는 운영 모니터링(OpenTelemetry, Prometheus, Grafana)과 통합 테스트 자동화 영역을 더 깊이 학습하여, 설계뿐 아니라 운영 단계에서도 안정성을 책임지는 백엔드 개발자로 성장하고자 합니다.

View File

@@ -0,0 +1,127 @@
---
copilot-command-context-menu-enabled: true
copilot-command-slash-enabled: true
copilot-command-context-menu-order: 1150
copilot-command-model-key: ""
copilot-command-last-used: 0
---
# 라이브러리형 프로젝트 포트폴리오 정보 추출 요청
이 저장소를 라이브러리형 프로젝트 포트폴리오 작성 관점에서 분석해줘.
단순 기능 목록이 아니라, 아래 항목을 중심으로 “백엔드/라이브러리 개발자 포트폴리오에 쓸 수 있는 정보”를 뽑아줘.
## 1. 프로젝트 목적
- 이 라이브러리가 해결하려는 문제
- 기존 방식에서 개발자가 겪을 불편함
- 이 라이브러리를 사용했을 때 단순해지는 부분
- 주요 사용자
- 프로젝트를 한 줄로 설명할 수 있는 문장
## 2. Public API 설계
- 외부 사용자가 직접 호출하는 주요 클래스/인터페이스/메서드
- 가장 기본적인 사용 흐름
- Options/Config 구조
- 비동기 API 설계 여부
- CancellationToken 지원 여부
- 사용 예시 코드
- API 설계 의도
## 3. 내부 구조와 책임 분리
- 주요 폴더 구조
- 주요 클래스별 책임
- 인터페이스와 구현체 분리 여부
- 내부 구현과 외부 API의 분리 방식
- 확장 가능한 구조인지
- DI, Factory, Builder, Adapter 패턴 사용 여부
## 4. 설정과 확장성
- 사용자가 설정할 수 있는 옵션 목록
- 기본값 제공 방식
- 설정 검증 방식
- 사용자가 구현체를 교체할 수 있는 확장 지점
- 향후 기능 확장 시 기존 API를 깨지 않도록 설계된 부분
## 5. 오류 처리
- 예외 처리 방식
- Result/Error 타입 사용 여부
- 사용자 입력 오류와 시스템 오류 구분 방식
- 커스텀 예외 또는 에러 코드
- 실패 원인을 외부 사용자에게 전달하는 방식
- 재시도 가능한 오류와 불가능한 오류 구분 여부
## 6. 상태 관리
- 상태 enum 또는 상태 객체 존재 여부
- 상태 전이 흐름
- 완료/실패/취소 처리 방식
- 중복 실행 방지 여부
- 동시성 또는 thread-safe 고려 여부
## 7. 사용성
- README 구조
- Quick Start 존재 여부
- 샘플 프로젝트 존재 여부
- 최소 사용 코드
- 고급 사용 예제
- XML 주석 또는 문서화 수준
- 처음 사용하는 개발자가 헷갈릴 수 있는 부분
## 8. 테스트와 검증
- 테스트 프로젝트 존재 여부
- 단위 테스트 대상
- 통합 테스트 대상
- 실패 케이스 테스트
- Mock/Fake 사용 여부
- 샘플 앱을 통한 검증 여부
- 테스트가 부족한 부분
## 9. 패키징과 배포
- 패키지 배포 구조
- 버전 관리 방식
- 패키지 메타데이터
- CI/CD 또는 release workflow
- 빌드/테스트/패키징 명령어
- 실제 외부 프로젝트에서 사용할 수 있는 상태인지
## 10. 성능과 리소스 고려
- async/await 사용 방식
- Stream/buffer 처리 여부
- 메모리 사용량을 줄이기 위한 구조
- 반복 객체 생성 최소화 여부
- 성능 측정 또는 벤치마크 존재 여부
## 11. 포트폴리오용 문제 해결 사례 후보
아래 구조로 3~5개 후보를 찾아줘.
### 문제 상황
무슨 문제가 있었는지
### 원인 분석
왜 문제가 발생했는지
### 해결 방법
어떤 구조나 기술로 해결했는지
### 선택 이유
왜 그 방법을 선택했는지
### 결과
사용성, 안정성, 유지보수성, 확장성 측면에서 어떤 개선이 있었는지
## 12. 최종 포트폴리오 문장 초안
위 분석을 바탕으로 아래 항목별로 바로 사용할 수 있는 문장 초안을 작성해줘.
- 프로젝트 개요
- 담당 역할
- 주요 기여
- 사용 기술 및 선택 이유
- 구현 사항
- 문제 해결 사례
- 프로젝트 성과
- 회고
주의:
- 과장하지 말고 실제 코드에서 확인되는 내용만 작성해줘.
- 확인되지 않는 내용은 “확인 필요”로 표시해줘.
- 단순 기능 나열이 아니라 “문제 → 설계 선택 → 구현 방식 → 결과” 구조로 정리해줘.
- 라이브러리 사용자의 관점에서 사용성, 확장성, 안정성을 강조해줘.

View File

@@ -0,0 +1,201 @@
# 백엔드 + 인프라 + 설계 프로젝트 포트폴리오 정보 추출 요청
이 저장소를 백엔드 + 인프라 + 설계 프로젝트 포트폴리오 작성 관점에서 분석해줘.
단순 기능 목록이 아니라, 아래 항목을 중심으로 “백엔드 개발자 포트폴리오에 쓸 수 있는 정보”를 뽑아줘.
## 1. 프로젝트 목적
- 이 프로젝트가 해결하려는 문제
- 주요 사용자
- 프로젝트 목표
- 백엔드가 담당하는 핵심 책임
- 인프라 구성이 필요한 이유
- 프로젝트를 한 줄로 설명할 수 있는 문장
## 2. 전체 아키텍처
- 전체 구성 요소
- 프론트엔드/백엔드/DB/Redis/Storage/Worker/Reverse Proxy 관계
- 외부 요청이 내부 서비스로 전달되는 흐름
- 서비스 간 책임 분리
- 외부 공개 서비스와 내부 서비스 구분
- 모놀리스/모듈러 모놀리스/마이크로서비스 여부
- 아키텍처 설계 의도
## 3. API 설계
- 주요 API 목록
- Endpoint 경로
- HTTP Method
- Request DTO
- Response DTO
- 인증 필요 여부
- 권한 필요 여부
- 성공/실패 상태 코드
- API 버전 관리 여부
- Swagger/OpenAPI 문서화 여부
## 4. 인증/인가 설계
- 로그인 방식
- JWT/Session/OAuth2 사용 여부
- 현재 사용자 식별 방식
- Role/Permission 구조
- 리소스 소유자 검증 방식
- 권한 검증 위치
- 인증 실패/권한 실패 응답 방식
- 인증/인가 설계 판단
## 5. 데이터베이스 설계
- 주요 Entity 목록
- 테이블 간 관계
- 주요 필드
- FK/Unique/Index 제약
- Soft Delete 여부
- CreatedAt/UpdatedAt 관리 방식
- Migration 관리 방식
- 데이터 정합성을 보장한 방식
- 데이터 모델링 설계 이유
## 6. 비즈니스 로직 / UseCase 구조
- 주요 UseCase 목록
- Command/Query 구조
- 요청 처리 순서
- 검증 로직
- 비즈니스 실패 조건
- 성공 시 변경되는 데이터
- 실패 시 반환되는 에러
- API 계층과 UseCase 계층의 책임 분리 방식
## 7. 트랜잭션과 동시성
- 트랜잭션이 필요한 기능
- 트랜잭션 범위
- 여러 Entity를 함께 저장하는 흐름
- 동시 요청 시 발생할 수 있는 문제
- 중복 생성 방지 방식
- Unique 제약, Lock, CAS, Optimistic Concurrency 사용 여부
- Idempotency 고려 여부
- 외부 파일/네트워크 작업과 DB 작업의 정합성 처리 방식
## 8. 예외 처리와 응답 구조
- Validation 실패 처리
- 비즈니스 실패 처리
- 시스템 예외 처리
- Global Exception Middleware 사용 여부
- Result 패턴 사용 여부
- 공통 ErrorResponse 구조
- 에러 코드 체계
- 로그 레벨 구분
- 프론트엔드가 에러를 일관되게 처리할 수 있는지
## 9. 인프라 구성
- Dockerfile 존재 여부
- Docker Compose 구성
- 서비스별 컨테이너 역할
- DB/Redis/Storage/Worker 구성
- Reverse Proxy 구성
- 내부 네트워크/외부 네트워크 구성
- Volume 마운트 구조
- Healthcheck
- depends_on 조건
- 포트 노출 정책
- 인프라 구성 설계 의도
## 10. Reverse Proxy / 도메인 / HTTPS
- Caddy/Nginx 사용 여부
- 도메인 라우팅 구조
- subdomain 구성
- HTTPS 적용 방식
- reverse_proxy 대상
- 관리자 도구 접근 제한
- Basic Auth 적용 여부
- 외부 포트 노출 최소화 여부
## 11. 환경변수와 설정 관리
- .env 구조
- 개발/운영 설정 분리 여부
- DB 연결 문자열 구성 방식
- Redis 연결 설정
- Secret 관리 방식
- ASP.NET Options 바인딩 여부
- 설정 검증 여부
- 하드코딩 제거 여부
## 12. 로그 / 모니터링 / 운영
- 로깅 구조
- 구조화 로그 사용 여부
- 요청/응답 로그
- 예외 로그
- 로그 레벨 기준
- Dozzle/Grafana/Loki 등 운영 도구 사용 여부
- Healthcheck
- 장애 확인 방법
- 운영 관점에서 아쉬운 부분
## 13. CI/CD / 배포 자동화
- GitHub Actions/GitLab CI 존재 여부
- Build/Test 단계
- Docker image build 여부
- 배포 자동화 여부
- Migration 실행 방식
- 브랜치 전략
- 배포 실패 시 대응 방식
- 향후 개선할 배포 구조
## 14. 성능 최적화
- 성능을 고려한 API
- Pagination 적용 여부
- Projection DTO 조회 여부
- AsNoTracking 사용 여부
- Include/N+1 문제 처리
- Index 적용 여부
- Cache 적용 여부
- 응답 시간 측정 또는 개선 수치
- 성능상 아쉬운 부분
## 15. 설계 문서화
- README 구조
- API 문서
- ERD
- 아키텍처 다이어그램
- 시퀀스 다이어그램
- 배포 구조도
- 기술 선택 이유 문서
- 대안 비교 기록
- ADR 존재 여부
- Mermaid 다이어그램 사용 여부
## 16. 문제 해결 사례 후보
아래 구조로 3~5개 후보를 찾아줘.
### 문제 상황
무슨 문제가 있었는지
### 원인 분석
왜 문제가 발생했는지
### 해결 방법
어떤 구조나 기술로 해결했는지
### 선택 이유
왜 그 방법을 선택했는지
### 결과
성능, 안정성, 유지보수성, 확장성, 운영 편의성 측면에서 어떤 개선이 있었는지
## 17. 포트폴리오 문장 초안
위 분석을 바탕으로 아래 항목별로 바로 사용할 수 있는 문장 초안을 작성해줘.
- 프로젝트 개요
- 담당 역할
- 주요 기여
- 사용 기술 및 선택 이유
- 아키텍처 설계
- API 설계
- 인프라 구성
- 문제 해결 사례
- 프로젝트 성과
- 회고
주의:
- 실제 코드에서 확인되는 내용만 작성해줘.
- 확인되지 않는 내용은 “확인 필요”로 표시해줘.
- 단순 기능 나열이 아니라 “문제 → 설계 선택 → 구현 방식 → 결과” 구조로 작성해줘.
- 백엔드 개발자 관점에서 API, 인증/인가, DB, 트랜잭션, 예외 처리, Docker, Reverse Proxy, 환경변수, 로그, 배포 구조를 강조해줘.

View File

@@ -0,0 +1,824 @@
# 백엔드 포트폴리오 작성 컨벤션 컨텍스트
## 1. 문서 목적
이 문서는 백엔드 개발자 포트폴리오를 작성할 때 참고하는 **작성 컨벤션 문서**이다.
LLM 또는 문서 작성 도구는 프로젝트 소스 코드, README, API 문서, 설계 문서, 배포 파일, 테스트 코드 등을 분석한 뒤 이 컨벤션에 맞춰 포트폴리오 내용을 작성해야 한다.
포트폴리오의 목표는 단순히 “무엇을 만들었다”를 설명하는 것이 아니라, 다음 흐름을 명확히 보여주는 것이다.
```md
문제 인식 → 설계 판단 → 구현 방식 → 기술 선택 이유 → 결과 및 회고
```
---
## 2. 적용 대상
이 컨벤션은 다음 유형의 프로젝트 포트폴리오 작성에 사용한다.
- 백엔드 프로젝트
- 백엔드 + 인프라 프로젝트
- 백엔드 + 설계 중심 프로젝트
- 풀스택 프로젝트 중 백엔드 기여를 강조해야 하는 경우
- 라이브러리형 프로젝트 중 API, 구조, 확장성, 사용성 설계를 강조해야 하는 경우
---
## 3. 핵심 작성 원칙
### 3.1 기능 나열보다 문제 해결 중심으로 작성한다
단순히 구현한 기능을 나열하지 않는다.
좋지 않은 표현:
```md
프로젝트 생성 기능을 구현했습니다.
```
권장 표현:
```md
사용자가 프로젝트 단위로 작업 공간을 분리해 관리할 수 있도록 프로젝트 생성 API를 설계하고 구현했습니다. 생성 시 요청 값을 검증한 뒤 프로젝트를 저장하고, 생성자를 관리자 멤버로 자동 등록하여 생성 직후 권한 기반 기능을 사용할 수 있도록 처리했습니다.
```
---
### 3.2 문장은 `문제 → 선택 → 구현 → 결과` 구조를 우선한다
가장 중요한 문장 구조는 다음과 같다.
```md
저는 [문제]를 해결하기 위해 [기술/구조]를 선택했고, [구현 방식]으로 처리하여 [결과]를 만들었습니다.
```
예시:
```md
저는 프로젝트 관련 API마다 권한 검증 로직이 반복되는 문제를 해결하기 위해 공통 권한 검증 필터를 설계했고, API 진입 시점에서 인증 사용자와 프로젝트 접근 권한을 먼저 검증하도록 처리하여 권한 검증 누락 가능성을 줄였습니다.
```
---
### 3.3 기술 스택은 이름보다 선택 이유를 강조한다
기술 이름만 나열하지 않는다.
좋지 않은 표현:
```md
ASP.NET Core, PostgreSQL, Docker를 사용했습니다.
```
권장 표현:
```md
ASP.NET Core는 인증, DI, 미들웨어 구성이 잘 통합되어 있어 API 서버 구조화에 적합하다고 판단했습니다. PostgreSQL은 사용자, 프로젝트, 참여자처럼 관계가 명확한 데이터를 안정적으로 관리하기 위해 선택했습니다. Docker Compose는 백엔드, DB, Redis 등 여러 서비스를 동일한 환경에서 실행하기 위해 사용했습니다.
```
---
### 3.4 백엔드 관점의 내부 흐름을 드러낸다
화면 기능보다 서버 내부 처리 흐름을 중심으로 작성한다.
반드시 드러내야 하는 요소:
- API 요청이 들어온 뒤 어떤 순서로 처리되는가
- 인증 사용자를 어떻게 식별하는가
- 권한을 어디에서 검증하는가
- 요청 DTO와 도메인 모델을 어떻게 분리하는가
- 데이터 저장 시 어떤 관계를 함께 처리하는가
- 비즈니스 실패와 시스템 예외를 어떻게 구분하는가
- 트랜잭션, 동시성, 상태 전이를 어떻게 고려했는가
- 비동기 작업이나 배포 환경을 어떻게 구성했는가
---
### 3.5 과장하지 않고 근거 기반으로 작성한다
수치가 없는 성과를 임의로 만들지 않는다.
수치가 있으면 수치로 작성한다.
```md
파일 목록 조회 API 응답 시간을 평균 850ms에서 220ms로 개선했습니다.
```
수치가 없으면 구조적 개선으로 작성한다.
```md
권한 검증 로직을 공통 필터로 분리하여 엔드포인트별 중복을 줄이고, 프로젝트 관련 API에 일관된 접근 제어를 적용할 수 있도록 했습니다.
```
---
## 4. 문체 컨벤션
### 4.1 기본 문체
- 한국어로 작성한다.
- 포트폴리오 본문은 `했습니다`, `구현했습니다`, `설계했습니다` 형태의 존댓말 과거형을 사용한다.
- 지나치게 감성적인 표현보다 구체적인 기술 표현을 사용한다.
- 한 문단은 너무 길게 작성하지 않는다.
- 한 문단에는 하나의 핵심 메시지만 담는다.
---
### 4.2 주어 사용
개인 기여를 강조해야 할 때는 `저는`을 사용한다.
권장:
```md
저는 백엔드 개발자로 참여하여 인증/인가 흐름과 프로젝트 관리 API를 담당했습니다.
```
팀 전체 성과는 `팀은` 또는 `프로젝트에서는`으로 작성한다.
```md
프로젝트에서는 Docker Compose 기반 실행 환경을 구성하여 팀원이 동일한 개발 환경을 빠르게 실행할 수 있도록 했습니다.
```
---
### 4.3 금지 표현
다음 표현은 구체성이 부족하므로 사용하지 않는다.
```md
열심히 참여했습니다.
팀원들을 도왔습니다.
프로젝트 전반에 기여했습니다.
기능을 만들었습니다.
DB에 저장했습니다.
문제를 해결했습니다.
성능을 개선했습니다.
협업을 잘했습니다.
```
위 표현은 반드시 구체화한다.
예시:
```md
프로젝트 생성, 초대 코드 발급, 초대 코드 기반 참여 API를 담당했으며, 프로젝트 도메인의 생성·조회·참여 흐름을 구현했습니다.
```
---
## 5. 포트폴리오 섹션 구조 컨벤션
포트폴리오는 기본적으로 다음 순서로 작성한다.
```md
# 프로젝트명
## 1. 프로젝트 개요
## 2. 담당 역할
## 3. 주요 기여
## 4. 사용 기술 및 선택 이유
## 5. 구현 사항
## 6. 문제 해결 사례
## 7. 프로젝트 성과
## 8. 프로젝트 회고
```
---
## 6. 섹션별 작성 규칙
## 6.1 프로젝트 개요
### 목적
이 프로젝트가 왜 필요했고, 어떤 문제를 해결하려 했는지 설명한다.
### 반드시 포함할 내용
- 프로젝트 배경
- 기존 방식 또는 문제 상황
- 해결하고자 한 문제
- 프로젝트 목표
- 주요 기능 키워드
### 작성 형식
```md
본 프로젝트는 [문제 상황]을 해결하기 위해 개발한 [서비스 유형]입니다.
기존에는 [기존 방식의 문제점]으로 인해 사용자가 [불편함/비효율]을 겪었습니다. 이를 해결하기 위해 [핵심 목표]를 중심으로 서비스를 설계했습니다.
주요 기능은 다음과 같습니다.
- [핵심 기능 1]
- [핵심 기능 2]
- [핵심 기능 3]
```
### 작성 기준
좋지 않은 표현:
```md
책을 추천하는 프로그램입니다.
```
권장 표현:
```md
사용자의 관심사와 독서 이력을 기반으로 도서를 추천하고, 추천 결과를 저장·관리할 수 있는 개인화 도서 추천 서비스입니다.
```
---
## 6.2 담당 역할
### 목적
팀 안에서 본인이 어떤 책임을 맡았는지 설명한다.
### 반드시 포함할 내용
- 본인의 역할
- 담당 도메인
- 팀 내 역할 분배
- 본인이 책임진 기능 범위
### 작성 형식
```md
저는 백엔드 개발자로 참여하여 [담당 도메인] 영역을 주로 담당했습니다.
주요 역할은 다음과 같습니다.
- API 설계 및 구현
- 데이터베이스 모델링
- 인증/인가 흐름 설계
- 비즈니스 로직 구현
- 예외 처리 및 응답 구조 정리
- 배포 환경 구성
```
### 작성 기준
좋지 않은 표현:
```md
팀원들을 도와 프로젝트를 수행했습니다.
```
권장 표현:
```md
저는 백엔드 영역 중 사용자 인증, 프로젝트 생성, 초대 코드 기반 참여 기능을 담당했으며, API 요청부터 데이터 저장까지의 흐름을 설계하고 구현했습니다.
```
---
## 6.3 주요 기여
### 목적
업무 단위로 본인이 무엇을 구현했는지 구체적으로 설명한다.
### 반드시 포함할 내용
- 구현한 기능
- 기능별 책임 범위
- 사용한 기술
- 설계 판단
- 기여도
### 작성 형식
```md
제가 담당한 주요 업무는 다음과 같습니다.
### 1. [기여 영역 1]
- [구현 내용]
- [설계 내용]
- [처리 흐름]
### 2. [기여 영역 2]
- [구현 내용]
- [설계 내용]
- [처리 흐름]
```
### 작성 기준
좋지 않은 표현:
```md
프로젝트 전반적으로 기여했습니다.
```
권장 표현:
```md
프로젝트 생성, 초대 코드 발급, 초대 코드 기반 참여 API를 담당했으며, 프로젝트 도메인의 핵심 생성·조회·참여 흐름을 구현했습니다.
```
---
## 6.4 사용 기술 및 선택 이유
### 목적
기술 스택을 단순 나열하지 않고, 프로젝트 요구사항과 연결해 설명한다.
### 반드시 포함할 내용
- 사용 기술
- 사용 목적
- 선택 이유
- 대안과 비교
- 프로젝트 요구사항과의 연결
### 작성 형식
```md
| 기술 | 사용 목적 | 선택 이유 |
|---|---|---|
| [기술명] | [사용 목적] | [프로젝트 요구사항과 연결된 선택 이유] |
```
### 작성 기준
기술 선택 이유는 다음 질문에 답해야 한다.
```md
왜 이 기술을 사용했는가?
이 프로젝트의 어떤 요구사항과 맞았는가?
다른 선택지 대신 이 기술을 고른 이유는 무엇인가?
이 기술을 사용해 어떤 구조적 이점을 얻었는가?
```
---
## 6.5 구현 사항
### 목적
기능을 단순 나열하지 않고, 사용자의 요청이 서버 내부에서 어떻게 처리되는지 설명한다.
### 반드시 포함할 내용
- 핵심 기능
- API 처리 흐름
- 데이터 처리 방식
- 인증/인가
- 예외 처리
- 트랜잭션 또는 상태 관리
### 작성 형식
```md
### [기능명]
[사용자 요청 또는 문제 상황]이 발생하면 서버는 [처리 방식]으로 요청을 처리합니다.
구현 흐름은 다음과 같습니다.
1. [요청 검증]
2. [현재 사용자 식별]
3. [권한 검증]
4. [도메인 로직 실행]
5. [데이터 저장]
6. [응답 반환]
주요 고려사항은 다음과 같습니다.
- [고려사항 1]
- [고려사항 2]
- [고려사항 3]
```
### 작성 기준
좋지 않은 표현:
```md
데이터베이스에 데이터를 저장했습니다.
```
권장 표현:
```md
프로젝트 생성 시 Project와 ProjectUser를 함께 저장하여 생성자가 자동으로 관리자 권한을 갖도록 처리했습니다. 이를 통해 프로젝트 생성 직후 권한 기반 기능을 바로 사용할 수 있도록 설계했습니다.
```
---
## 6.6 문제 해결 사례
### 목적
단순 경험담이 아니라 문제 분석과 해결 과정을 보여준다.
### 반드시 포함할 내용
- 문제 상황
- 원인 분석
- 해결 방법
- 선택 이유
- 결과
### 작성 형식
```md
### 문제 상황
[무슨 문제가 있었는지]
### 원인 분석
[왜 문제가 발생했는지]
### 해결 방법
[어떤 방식으로 해결했는지]
### 선택 이유
[왜 그 방법을 선택했는지]
### 결과
[해결 후 어떤 변화가 있었는지]
```
### 작성 기준
좋지 않은 표현:
```md
캐싱 전략으로 문제를 해결했습니다.
```
권장 표현:
```md
반복 조회되는 사용자 권한 정보를 매 요청마다 DB에서 조회하면서 응답 시간이 증가하는 문제가 있었습니다. 이를 해결하기 위해 권한 정보를 짧은 TTL로 캐싱하여 DB 조회 횟수를 줄였고, 권한 변경 시 캐시 무효화 전략을 함께 적용했습니다.
```
---
## 6.7 프로젝트 성과
### 목적
구현 결과가 프로젝트에 어떤 영향을 주었는지 설명한다.
### 반드시 포함할 내용
- 성능 개선 수치
- API 응답 시간 변화
- 코드 중복 감소
- 테스트 커버리지
- 배포 자동화
- 기능 완성도
- 협업 효율
### 작성 기준
수치가 있으면 수치 기반으로 작성한다.
```md
파일 목록 조회 API 응답 시간을 평균 850ms에서 220ms로 개선했습니다.
```
수치가 없으면 구조적 성과로 작성한다.
```md
공통 응답 DTO와 에러 응답 구조를 정리하여 API 응답 형식을 일관화했습니다.
```
---
## 6.8 프로젝트 회고
### 목적
단순한 감상이 아니라 백엔드 개발자로서 배운 점과 다음 개선 방향을 작성한다.
### 반드시 포함할 내용
- 배운 점
- 아쉬웠던 점
- 개선하고 싶은 점
- 다음 프로젝트에 적용할 점
### 작성 기준
좋지 않은 표현:
```md
시간이 부족해서 모든 기능을 구현하지 못했습니다.
```
권장 표현:
```md
초기에는 기능 구현을 우선하면서 테스트 자동화와 성능 측정을 충분히 진행하지 못했습니다. 이후에는 핵심 API부터 통합 테스트를 작성하고, 응답 시간 기준을 함께 정의하는 방식으로 개선하고자 합니다.
```
---
## 7. 백엔드 포트폴리오 강조 포인트
백엔드 개발자 포트폴리오에서는 화면보다 다음 내용을 우선한다.
```md
- API를 어떻게 설계했는가
- 인증/인가를 어떻게 처리했는가
- 데이터 모델을 어떻게 설계했는가
- 트랜잭션이나 동시성 문제를 어떻게 고려했는가
- 예외와 에러 응답을 어떻게 구분했는가
- 성능 병목을 어떻게 발견하고 개선했는가
- 유지보수하기 쉬운 구조를 위해 어떤 선택을 했는가
- 배포 환경과 운영 고려사항을 어떻게 구성했는가
```
---
## 8. 백엔드 + 인프라 + 설계 프로젝트 작성 규칙
백엔드와 인프라, 설계 요소가 함께 있는 프로젝트는 다음 항목을 추가로 강조한다.
### 8.1 아키텍처 설계
다음 내용을 작성한다.
- 전체 시스템 구조
- API 서버의 책임
- DB, Cache, Storage, Worker, Reverse Proxy의 역할
- 모듈 간 의존성 분리
- Clean Architecture 또는 계층 분리 기준
작성 예시:
```md
백엔드 API는 인증, 권한 검증, 도메인 로직, 메타데이터 관리를 담당하고, 업로드 서버는 대용량 파일 전송을 전담하도록 역할을 분리했습니다. 이를 통해 API 서버가 파일 청크 전송 부하를 직접 처리하지 않도록 설계했습니다.
```
---
### 8.2 데이터 모델링
다음 내용을 작성한다.
- 핵심 엔티티
- 엔티티 간 관계
- unique 제약 또는 인덱스
- soft delete 여부
- 상태 값과 상태 전이
- 정합성 보장 방식
작성 예시:
```md
프로젝트와 참여자 관계를 별도 테이블로 분리하여 사용자별 참여 프로젝트를 조회할 수 있도록 설계했습니다. 생성자는 프로젝트 생성과 동시에 관리자 역할로 등록되도록 처리하여 권한 기반 기능을 바로 사용할 수 있게 했습니다.
```
---
### 8.3 인증/인가
다음 내용을 작성한다.
- 인증 방식
- 현재 사용자 식별 방식
- 권한 검증 위치
- 역할과 권한의 관계
- 권한 실패 시 응답 방식
작성 예시:
```md
인증 사용자를 식별한 뒤 프로젝트 멤버 여부와 역할 기반 권한을 검증하도록 구성했습니다. 권한 검증은 개별 엔드포인트에 흩어지지 않도록 공통 필터로 분리하여 API 진입 시점에서 일관되게 처리했습니다.
```
---
### 8.4 예외 처리 및 응답 구조
다음 내용을 작성한다.
- 성공 응답 DTO
- 에러 응답 DTO
- 비즈니스 실패 처리 방식
- 시스템 예외 처리 방식
- 로깅 기준
작성 예시:
```md
예상 가능한 비즈니스 실패는 Result 기반으로 반환하고, 예상하지 못한 시스템 예외는 예외 처리 미들웨어에서 일괄 처리하도록 분리했습니다. 이를 통해 클라이언트 응답 형식과 서버 로깅 책임을 명확히 나눴습니다.
```
---
### 8.5 배포 및 운영 환경
다음 내용을 작성한다.
- Docker Compose 구성
- Reverse Proxy 구성
- 환경 변수 관리
- DB/Redis 등 외부 의존성 실행 방식
- 로그/모니터링 고려사항
작성 예시:
```md
Docker Compose를 사용해 API 서버, 데이터베이스, Redis를 동일한 실행 환경으로 구성했습니다. 이를 통해 신규 팀원이 로컬 개발 환경을 빠르게 구성할 수 있도록 했고, 배포 환경에서도 서비스 간 의존성을 명확히 관리할 수 있게 했습니다.
```
---
## 9. LLM이 프로젝트를 분석할 때 추출해야 하는 정보
LLM이 Claude Code, Codex, Cursor, 기타 코드 분석 도구를 통해 프로젝트를 읽을 때는 다음 정보를 우선 추출한다.
### 9.1 기본 정보
```md
- 프로젝트명
- 프로젝트 유형
- 한 줄 설명
- 해결하려는 문제
- 주요 사용자
- 핵심 기능 목록
```
---
### 9.2 백엔드 구조
```md
- 사용 언어 및 프레임워크
- 프로젝트 폴더 구조
- 계층 구조
- Controller / Endpoint 구조
- UseCase / Service 구조
- Repository 구조
- Domain 모델 구조
- DTO 구조
```
---
### 9.3 API 정보
```md
- 주요 API endpoint
- HTTP method
- request DTO
- response DTO
- 인증 필요 여부
- 권한 필요 여부
- API 처리 흐름
- 실패 케이스
```
---
### 9.4 데이터 모델 정보
```md
- 핵심 테이블 또는 엔티티
- 주요 필드
- 관계
- 인덱스
- 제약 조건
- 상태 enum
- soft delete 여부
- 트랜잭션이 필요한 흐름
```
---
### 9.5 인증/인가 정보
```md
- 로그인 방식
- 토큰 또는 세션 방식
- 현재 사용자 식별 방식
- 역할 모델
- 권한 모델
- 권한 검증 위치
- 인증/권한 실패 응답
```
---
### 9.6 인프라 정보
```md
- Docker Compose 구성
- DB 종류
- Cache/Queue 사용 여부
- Reverse Proxy 사용 여부
- Storage 사용 여부
- 배포 방식
- 환경 변수 구성
- 로그/모니터링 구성
```
---
### 9.7 품질 개선 정보
```md
- 테스트 코드 존재 여부
- 통합 테스트 여부
- 성능 개선 사례
- 리팩토링 사례
- 중복 제거 사례
- 장애 또는 예외 처리 개선 사례
- CI/CD 구성 여부
```
---
## 10. LLM 출력 규칙
LLM은 프로젝트 분석 결과를 다음 순서로 출력한다.
```md
# [프로젝트명] 포트폴리오 정리
## 1. 프로젝트 개요
## 2. 담당 역할
## 3. 주요 기여
## 4. 사용 기술 및 선택 이유
## 5. 주요 구현 사항
## 6. 문제 해결 사례
## 7. 프로젝트 성과
## 8. 프로젝트 회고
## 9. 포트폴리오에 강조하면 좋은 키워드
## 10. 추가로 확인하면 좋은 정보
```
---
## 11. 작성 품질 체크리스트
포트폴리오 초안을 작성한 뒤 다음 기준으로 검토한다.
```md
- [ ] 프로젝트가 해결하려는 문제가 명확한가?
- [ ] 단순 기능 나열이 아니라 설계 판단이 드러나는가?
- [ ] 본인의 역할과 팀의 역할이 구분되어 있는가?
- [ ] 기술 스택마다 선택 이유가 작성되어 있는가?
- [ ] API 내부 처리 흐름이 설명되어 있는가?
- [ ] 인증/인가, DB 설계, 예외 처리 중 최소 하나 이상이 강조되어 있는가?
- [ ] 문제 해결 사례가 문제 → 원인 → 해결 → 결과 흐름으로 작성되어 있는가?
- [ ] 성과를 수치 또는 구조적 개선으로 설명했는가?
- [ ] 과장되거나 근거 없는 표현이 없는가?
- [ ] 면접에서 질문받아도 설명 가능한 내용인가?
```
---
## 12. 최종 작성 기준
최종 문서는 다음 기준을 만족해야 한다.
```md
좋은 포트폴리오는 “무엇을 만들었는가”보다 “왜 그렇게 설계했고, 어떻게 문제를 해결했는가”를 보여준다.
```
따라서 모든 문장은 가능하면 다음 중 하나를 포함해야 한다.
```md
- 문제 상황
- 설계 판단
- 구현 흐름
- 기술 선택 이유
- 결과
- 회고와 개선 방향
```
최종적으로 포트폴리오 문장은 다음 형태에 가까워야 한다.
```md
저는 [문제]를 해결하기 위해 [기술/구조]를 선택했고, [구현 방식]으로 처리하여 [결과]를 만들었습니다.
```

View File

@@ -0,0 +1,149 @@
# 풀스택 + AI 프로젝트 포트폴리오 정보 추출 요청
이 저장소를 풀스택 + AI 프로젝트 포트폴리오 작성 관점에서 분석해줘.
단순 기능 목록이 아니라, 아래 항목을 중심으로 “백엔드/풀스택/AI 활용 프로젝트 포트폴리오에 쓸 수 있는 정보”를 뽑아줘.
## 1. 프로젝트 목적
- 이 프로젝트가 해결하려는 사용자 문제
- AI를 사용한 이유
- AI 없이 구현했을 때의 한계
- 주요 사용자
- 프로젝트를 한 줄로 설명할 수 있는 문장
- 핵심 가치가 자동화/추천/요약/분석/검색/생성 중 무엇인지
## 2. 전체 서비스 흐름
- 사용자가 처음 화면에서 결과를 받기까지의 흐름
- 프론트엔드에서 호출하는 주요 API
- 백엔드에서 요청을 검증하고 처리하는 방식
- DB에 저장되는 데이터
- AI 모델에 전달되는 데이터
- AI 응답 후처리 방식
- 최종 결과가 UI에 표시되는 방식
## 3. AI 기능 분석
- AI가 담당하는 기능 목록
- AI 입력값
- AI 출력값
- 출력 형식
- 모델 응답을 검증하는 방식
- AI 실패 시 처리 방식
- AI 결과가 서비스 핵심 기능에 어떻게 연결되는지
## 4. 프롬프트 설계
- 시스템 프롬프트/사용자 프롬프트 구조
- 출력 형식 강제 여부
- JSON/schema 기반 응답 여부
- Few-shot 예시 사용 여부
- 프롬프트 파일 또는 버전 관리 여부
- 프롬프트 인젝션 방어 고려 여부
- 프롬프트 설계 의도
## 5. 데이터 처리
- 사용자가 입력하는 원본 데이터
- AI 요청 전 데이터 정제 방식
- 파일/문서/이미지/음성 처리 여부
- chunking 여부
- 임베딩 여부
- 민감정보 제거 여부
- AI 결과 저장 방식
## 6. RAG/검색 구조
- RAG를 사용하는지
- 임베딩 모델
- 벡터 DB 또는 검색 엔진
- 문서 chunk 생성 방식
- 검색 기준
- 검색 결과를 프롬프트에 넣는 방식
- 출처 제공 여부
- hallucination을 줄이기 위한 처리
## 7. 백엔드 구조
- 주요 API 목록
- 인증/인가 구조
- 요청 검증 방식
- AI 호출 서비스 분리 여부
- DB 모델 구조
- 비동기 작업 처리 여부
- 작업 상태 관리 여부
- 에러 처리 방식
- 로그/모니터링 방식
- 토큰 사용량 또는 비용 기록 여부
## 8. 프론트엔드 구조
- 주요 화면 목록
- 사용자 입력 UI
- AI 처리 중 로딩/상태 표시
- 스트리밍 응답 여부
- 결과 표시 방식
- 결과 수정/저장/재생성 기능
- 에러 UI
- 사용자 피드백 기능
## 9. 안정성 처리
- AI 응답 파싱 실패 처리
- 빈 응답 처리
- 잘못된 형식 응답 처리
- 재시도 처리
- timeout 처리
- rate limit 처리
- 부적절한 결과 필터링
- 사용자가 결과를 검토/수정할 수 있는 구조
## 10. 성능/비용 최적화
- 토큰 사용량 제한
- 입력 길이 제한
- 캐싱 여부
- 이전 결과 재사용 여부
- 응답 시간 측정 여부
- 모델 선택 기준
- 동기/비동기 처리 기준
- 스트리밍 적용 여부
## 11. 테스트와 검증
- 프론트엔드 테스트
- 백엔드 테스트
- AI 응답 테스트
- 프롬프트 테스트
- Mock AI 사용 여부
- 통합 테스트
- 실패 케이스 테스트
- 실제 사용자 시나리오 검증 여부
## 12. 문제 해결 사례 후보
아래 구조로 3~5개 후보를 찾아줘.
### 문제 상황
무슨 문제가 있었는지
### 원인 분석
왜 문제가 발생했는지
### 해결 방법
어떤 구조나 기술로 해결했는지
### 선택 이유
왜 그 방법을 선택했는지
### 결과
사용성, 정확도, 안정성, 비용, 유지보수성 측면에서 어떤 개선이 있었는지
## 13. 포트폴리오 문장 초안
위 분석을 바탕으로 아래 항목별로 바로 사용할 수 있는 문장 초안을 작성해줘.
- 프로젝트 개요
- 담당 역할
- 주요 기여
- 사용 기술 및 선택 이유
- 구현 사항
- AI 기능 설계
- 문제 해결 사례
- 프로젝트 성과
- 회고
주의:
- 실제 코드에서 확인되는 내용만 작성해줘.
- 확인되지 않는 내용은 “확인 필요”로 표시해줘.
- 단순히 “AI API를 사용했다”라고 쓰지 말고, AI가 어떤 문제를 어떻게 해결했는지 중심으로 작성해줘.
- “사용자 입력 → 백엔드 처리 → AI 요청 → 결과 후처리 → UI 표시” 흐름을 반드시 포함해줘.
- 포트폴리오 문장은 “문제 → 설계 선택 → 구현 방식 → 결과” 구조로 작성해줘.

View File

@@ -0,0 +1,147 @@
---
title: "SG커뮤니티 채용 - 신입 및 경력 프로그램 개발자 모집 (HMG그룹 MES 개발사)"
source: "https://www.jobkorea.co.kr/Recruit/GI_Read/48944901?Oem_Code=C1&logpath=1&stext=asp.net&listno=6&sc=630"
author:
published:
created: 2026-05-04
description: "경력 : 신입·경력 , 학력 : 학력무관, 급여 : 연봉 3,000~8,000만원(면접 후 결정), 마감일 : 2026.05.08"
tags:
- "clippings"
---
## [SG커뮤니티](https://www.jobkorea.co.kr/Recruit/Co_Read/C/611195)
## 신입 및 경력 프로그램 개발자 모집 (HMG그룹 MES 개발사)
📜 설립 19년차💸 상여금📚 자기개발 지원🚘 차량유류비💸 장기근속 포상🏆 우수사원 보상제도🚙 교통비🚘 차량유지비
<iframe src="https://www.jobkorea.co.kr/Recruit/GI_Read_Comt_Ifrm?Oem_Code=C1&amp;logpath=1&amp;stext=asp.net&amp;listno=6&amp;sc=630&amp;Gno=48944901" title="상세 모집 요강" width="100%" height="1539"></iframe>
채용정보에 잘못된 내용이 있을 경우 해주세요.
모집요강
모집분야
JAVA &.Net C# 개발자 모집
모집인원5명
고용형태
정규직,
계약직(협의, 정규직 전환 가능)
직급/직책
- 사원급, 주임~대리급, 과장~차장급, 면접 후 결정
급여연봉 3,000~8,000만원 (면접 후 결정)
근무시간
근무지주소
서울 금천구 가산디지털1로 83 (가산동, 파트너스타워) 904
인근지하철
1호선7호선
가산디지털단지역 에서 1.0km (도보 15분) 이내
1호선
독산역 에서 900m (도보 13분) 이내
지원자격
경력
신입·경력
학력
학력무관
스킬AWS, C#, JAVA, ASP.NET CORE, MES
핵심역량
계획성, 성실성, 적응성, 창의성, 협동심, 성장지향성
우대조건
기본우대지방근무 가능자, 해외근무 가능자, 관련 자격증 보유자, 즉시출근 가능자
자격증정보처리기사, 정보처리산업기사
이 기업과 나의 적합도 체크핵심 역량
회사에서 중요하게 생각하는 역량과 가치가
나와 맞는지 알아보기
회사
접수기간 · 방법
남은기간4일 01:18:03
시작일
2026.04.08(수)
마감일
2026.05.08(금)
접수방법
지원양식
잡코리아 이력서
인사담당자
윤희섭 (SI1부)
마감일은 기업의 사정으로 인해 조기 마감 또는 변경될 수 있습니다
지원자 현황 통계 [지원현황 상세보기](https://www.jobkorea.co.kr/Recruit/ApplyChart?giNo=50692142)
지원자 수
153 명
모집인원
5 명
경력
희망연봉
기업 정보 [기업정보 더보기](https://www.jobkorea.co.kr/Recruit/Co_Read/C/611195)
사원수
50명 이하
기업구분
중소기업 (비상장)
산업(업종)
솔루션·SI·CRM·ERP
위치
서울 금천구 가산동 345-13 파트너스타워1차 904호
복리후생
연금·보험국민연금, 고용보험, 산재보험, 건강보험, 퇴직연금
보상·수당·지원퇴직금, 경조금, 차량유류비, 장기근속 포상, 자기개발 지원, 복리후생 지원, 가족 행사 지원, 교통비, 비자 발급, 상여금, 우수사원 보상제도, 차량유지비

View File

@@ -0,0 +1,123 @@
---
title: "㈜에이투텍 채용 - (주)다울디엔에스 Web Application개발 상시채용"
source: "https://www.jobkorea.co.kr/Recruit/GI_Read/48756080?Oem_Code=C1&logpath=1&stext=c%23.net&listno=21&sc=630"
author:
published:
created: 2026-05-04
description: "경력 : 신입·경력 , 학력 : 초대졸↑, 급여 : 회사 내규에 따름(면접 후 결정), 마감일 : 상시채용"
tags:
- "clippings"
---
## [㈜에이투텍](https://www.jobkorea.co.kr/Recruit/Co_Read/C/54204)
## (주)다울디엔에스 Web Application개발 상시채용
⭐ 101명 이상 찜한 기업🏢 벤처기업🎁 인센티브🍱 저녁 식사 지원
<iframe src="https://www.jobkorea.co.kr/Recruit/GI_Read_Comt_Ifrm?Oem_Code=C1&amp;logpath=1&amp;stext=c%23.net&amp;listno=21&amp;sc=630&amp;Gno=48756080" title="상세 모집 요강" width="100%" height="3931"></iframe>
채용정보에 잘못된 내용이 있을 경우 해주세요.
모집요강
모집분야
Web Application개발
모집인원○명
고용형태
정규직
급여회사 내규에 따름 (면접 후 결정)
근무시간
근무지주소
대구 동구 동촌로 351 (용계동) 3층
인근지하철
대구 1호선
용계역
대구 1호선
방촌역
지원자격
경력
신입·경력
학력
초대졸이상
스킬C#, C++, Python
우대조건
기본우대유관업무 경력자
접수기간 · 방법
시작일
2026.03.11(수)
마감일
상시채용
접수방법
지원양식
잡코리아 이력서
마감일은 기업의 사정으로 인해 조기 마감 또는 변경될 수 있습니다
지원자 현황 통계 [지원현황 상세보기](https://www.jobkorea.co.kr/Recruit/ApplyChart?giNo=50524998)
지원자 수
51 명
모집인원
○ 명
경력
희망연봉
기업 정보 [기업정보 더보기](https://www.jobkorea.co.kr/Recruit/Co_Read/C/54204)
사원수
\-
기업구분
벤처기업 (비상장)
산업(업종)
솔루션·SI·CRM·ERP
위치
대구 동구 동촌로 351 (용계동) 3층, 4층
복리후생
연금·보험국민연금, 고용보험, 산재보험, 건강보험
휴무·휴가·행사출산휴가, 노동절 휴무, 워크샵/MT
편의·여가·건강건강검진, 저녁 식사 지원

View File

@@ -0,0 +1,153 @@
---
title: "㈜위존 채용 - [신입 및 경력] 솔루션/소프트웨어 개발자 채용"
source: "https://www.jobkorea.co.kr/Recruit/GI_Read/48978588?Oem_Code=C1&logpath=1&stext=c%23.net&listno=20&sc=630"
author:
published:
created: 2026-05-04
description: "경력 : 신입·경력 3년이상, 학력 : 대졸↑, 급여 : 회사 내규에 따름(면접 후 결정), 마감일 : 2026.05.14"
tags:
- "clippings"
---
## [㈜위존](https://www.jobkorea.co.kr/Recruit/Co_Read/C/186434)
## \[신입 및 경력\] 솔루션/소프트웨어 개발자 채용
⭐ 219명 이상 찜한 기업📜 설립 26년차👨👩👧 패밀리데이💸 상여금📚 도서 구입비 지원🏫 임직원 교육비🏆 우수사원 포상제도🅿️ 주차 지원🚘 차량유류비🏦 사내대출💸 장기근속 포상🍹 자유로운 휴가문화🎓 자녀교육비📚 사내 외국어강좌 운영🎁 인센티브
<iframe src="https://www.jobkorea.co.kr/Recruit/GI_Read_Comt_Ifrm?Oem_Code=C1&amp;logpath=1&amp;stext=c%23.net&amp;listno=20&amp;sc=630&amp;Gno=48978588" title="상세 모집 요강" width="100%" height="1423"></iframe>
채용정보에 잘못된 내용이 있을 경우 해주세요.
모집요강
모집분야
솔루션/소프트웨어 개발자
모집인원○명
고용형태
정규직(수습 3개월)
직급/직책
- 사원급, 주임~대리급, 과장~차장급
급여회사 내규에 따름 (면접 후 결정)
근무시간
- 주5일(월~금)
- 09:00 ~ 18:00
- 탄력근무제
- 시차출퇴근제 운영(08:00~18:30/30분 간격 선택)
근무지주소
서울 강남구 강남대로 308 (역삼동, 랜드마크타워) 7층
인근지하철
2호선신분당선
강남역 에서 900m (도보 13분) 이내
3호선신분당선
양재역 에서 800m (도보 12분) 이내
지원자격
경력
신입·경력(3년이상)
학력
대졸이상
스킬JAVA, Oracle, PostgreSQL, ASP.NET MVC, MS-SQL
핵심역량
성실성, 성취지향성, 꼼꼼함, 적응성, 협동심
우대조건
기본우대컴퓨터활용능력 우수자, 관련 학과 전공자
자격증정보처리기사
우대전공컴퓨터공학과, 화학과
이 기업과 나의 적합도 체크핵심 역량
회사에서 중요하게 생각하는 역량과 가치가
나와 맞는지 알아보기
회사
접수기간 · 방법
남은기간10일 01:24:53
시작일
2026.04.14(화)
마감일
2026.05.14(목)
접수방법
지원양식
잡코리아 이력서
인사담당자
인사담당자 (경영지원팀)
마감일은 기업의 사정으로 인해 조기 마감 또는 변경될 수 있습니다
지원자 현황 통계 [지원현황 상세보기](https://www.jobkorea.co.kr/Recruit/ApplyChart?giNo=50721679)
지원자 수
280 명
모집인원
○ 명
경력
희망연봉
기업 정보 [기업정보 더보기](https://www.jobkorea.co.kr/Recruit/Co_Read/C/186434)
사원수
51 ~ 300명 이하
기업구분
중소기업 (비상장)
산업(업종)
컴퓨터 및 주변장치, 소프트웨어 도매업
위치
서울 강남구 강남대로 308 (역삼동, 랜드마크타워) 6~7층
복리후생
연금·보험국민연금, 고용보험, 산재보험, 건강보험, 퇴직연금, 상해보험
휴무·휴가·행사주5일제, 연차제도, 경조휴가, 반차제도, 포상휴가, 육아휴직, 노동절 휴무, 창립일 행사, 워크샵/MT, 자유로운 휴가문화, 패밀리데이
사내제도·성장사내 외국어강좌 운영, 신입 사원 교육, 직무능력향상교육

View File

@@ -0,0 +1,145 @@
---
title: "㈜케이에스아이 채용 - 제조기업 MES/POP, AI 소프트웨어 개발 채용"
source: "https://www.jobkorea.co.kr/Recruit/GI_Read/49029253?Oem_Code=C1&logpath=1&stext=asp.net&listno=2&sc=630"
author:
published:
created: 2026-05-04
description: "경력 : 신입·경력 1년이상, 학력 : 초대졸↑, 급여 : 연봉 2,600~4,000만원(면접 후 결정, 급여의 경우 면접시 경력에 따라 협의됩니다), 마감일 : 2026.05.21"
tags:
- "clippings"
---
## [㈜케이에스아이](https://www.jobkorea.co.kr/Recruit/Co_Read/C/6400)
## 제조기업 MES/POP, AI 소프트웨어 개발 채용
📜 설립 27년차🧧 명절선물/명절상여
<iframe src="https://www.jobkorea.co.kr/Recruit/GI_Read_Comt_Ifrm?Oem_Code=C1&amp;logpath=1&amp;stext=asp.net&amp;listno=2&amp;sc=630&amp;Gno=49029253" title="상세 모집 요강" width="100%" height="1934"></iframe>
채용정보에 잘못된 내용이 있을 경우 해주세요.
모집요강
모집분야
제조기업 MES시스템, AI 소프트웨어 개발
모집인원○명
고용형태
정규직(수습 2개월, 수습기간 동안 급여 100% 지급)
직급/직책
- 사원급, 주임~대리급, 과장~차장급
- 팀원
급여연봉 2,600~4,000만원 (면접 후 결정, 급여의 경우 면접시 경력에 따라 협의됩니다)
근무시간
근무지주소
서울 금천구 가산동 481-10(벽산디지털밸리2차 15층)
인근지하철
1호선7호선
가산디지털단지역 에서 600m (도보 9분) 이내
7호선
남구로역 에서 900m (도보 13분) 이내
지원자격
경력
신입·경력(1년이상)
학력
초대졸이상
스킬.Net, Android, API, ASP.NET
핵심역량
성실성, 성취지향성, 꼼꼼함, 적응성, 창의성, 협동심
이 기업과 나의 적합도 체크핵심 역량
회사에서 중요하게 생각하는 역량과 가치가
나와 맞는지 알아보기
회사
접수기간 · 방법
남은기간17일 01:19:00
시작일
2026.04.23(목)
마감일
2026.05.21(목)
채용 시 마감
접수방법
지원양식
잡코리아 이력서
첨부서류
인사담당자
김우호 (임원)
마감일은 기업의 사정으로 인해 조기 마감 또는 변경될 수 있습니다
지원자 현황 통계 [지원현황 상세보기](https://www.jobkorea.co.kr/Recruit/ApplyChart?giNo=50766562)
지원자 수
23 명
모집인원
○ 명
경력
희망연봉
기업 정보 [기업정보 더보기](https://www.jobkorea.co.kr/Recruit/Co_Read/C/6400)
사원수
50명 이하
기업구분
중소기업 (비상장)
산업(업종)
컴퓨터 및 주변장치, 소프트웨어 도매업
위치
서울 금천구 가산동 481-10(벽산디지털밸리2차 15층)
복리후생
연금·보험국민연금, 고용보험, 산재보험, 건강보험
휴무·휴가·행사주5일제, 정기휴가, 경조휴가
보상·수당·지원명절선물/명절상여

View File

@@ -0,0 +1,140 @@
---
title: "㈜화인소프트 채용 - ERP(SAP) 개발자 모집"
source: "https://www.jobkorea.co.kr/Recruit/GI_Read/48921697?Oem_Code=C1&logpath=1&stext=c%23.net&listno=10&sc=630"
author:
published:
created: 2026-05-04
description: "경력 : 신입·경력 , 학력 : 학력무관, 급여 : 연봉 3,000~7,000만원(면접 후 결정), 마감일 : 2026.05.06"
tags:
- "clippings"
---
## [㈜화인소프트](https://www.jobkorea.co.kr/Recruit/Co_Read/C/561521)
## ERP(SAP) 개발자 모집
🧧 명절선물/명절상여🎁 인센티브
<iframe src="https://www.jobkorea.co.kr/Recruit/GI_Read_Comt_Ifrm?Oem_Code=C1&amp;logpath=1&amp;stext=c%23.net&amp;listno=10&amp;sc=630&amp;Gno=48921697" title="상세 모집 요강" width="100%" height="1744"></iframe>
채용정보에 잘못된 내용이 있을 경우 해주세요.
모집요강
모집분야
ERP 개발자 모집
모집인원○명
고용형태
정규직
직급/직책면접 후 결정
급여연봉 3,000~7,000만원 (면접 후 결정)
근무시간
근무지주소
서울 구로구 디지털로33길 50 (구로동, 벽산디지털밸리7차) 1204호
2호선
구로디지털단지역 3번 출구에서 500m (도보 7분) 이내
지원자격
경력
신입·경력
학력
학력무관
스킬ASP.NET, C#, MSSQL, SAP, ERP, 기업회계 자격, 더존 프로그램, 전산회계 자격, 컴퓨터활용능력
핵심역량
계획성, 성실성, 성취지향성, 협동심, 성장지향성
우대조건
기본우대해외근무 가능자, 회계프로그램 능숙자, 관련 학과 전공자, 관련 자격증 보유자, 즉시출근 가능자, 장기근무 가능자
우대전공정보통신공학과, 컴퓨터공학과, 응용소프트웨어공학과
이 기업과 나의 적합도 체크핵심 역량
회사에서 중요하게 생각하는 역량과 가치가
나와 맞는지 알아보기
회사
접수기간 · 방법
남은기간2일 01:27:14
시작일
2026.04.06(월)
마감일
2026.05.06(수)
접수방법
지원양식
잡코리아 이력서
인사담당자
김현배 (경영지원팀)
마감일은 기업의 사정으로 인해 조기 마감 또는 변경될 수 있습니다
지원자 현황 통계 [지원현황 상세보기](https://www.jobkorea.co.kr/Recruit/ApplyChart?giNo=50671153)
지원자 수
44 명
모집인원
○ 명
경력
희망연봉
기업 정보 [기업정보 더보기](https://www.jobkorea.co.kr/Recruit/Co_Read/C/561521)
사원수
50명 이하
기업구분
중소기업 (비상장)
산업(업종)
솔루션·SI·CRM·ERP
위치
서울 구로구 디지털로33길 50 (구로동, 벽산디지털밸리7차) 1204호
복리후생
연금·보험국민연금, 고용보험, 산재보험, 건강보험, 퇴직연금
휴무·휴가·행사주5일제, 연차제도, 정기휴가, 경조휴가, 출산휴가, 육아휴직, 노동절 휴무, 워크샵/MT
편의·여가·건강건강검진

View File

@@ -0,0 +1,121 @@
---
title: "마준소프트㈜ 채용 - IT 웹/AI/서버 프로그래머 개발자 모집(신입/경력)"
source: "https://www.jobkorea.co.kr/Recruit/GI_Read/49079671?Oem_Code=C1&logpath=1&stext=asp.net&listno=10&sc=630"
author:
published:
created: 2026-05-04
description: "경력 : 신입·경력 , 학력 : 학력무관, 급여 : 연봉 3,500만원 이상, 마감일 : 2026.06.21"
tags:
- "clippings"
---
## [마준소프트㈜](https://www.jobkorea.co.kr/Recruit/Co_Read/C/15640394)
## IT 웹/AI/서버 프로그래머 개발자 모집(신입/경력)
📜 설립 26년차
<iframe src="https://www.jobkorea.co.kr/Recruit/GI_Read_Comt_Ifrm?Oem_Code=C1&amp;logpath=1&amp;stext=asp.net&amp;listno=10&amp;sc=630&amp;Gno=49079671" title="상세 모집 요강" width="100%" height="3003"></iframe>
채용정보에 잘못된 내용이 있을 경우 해주세요.
모집요강
모집분야
프로그래머/시스템엔지니어
모집인원○명
고용형태
정규직(수습 2개월),
인턴(근무기간 2개월, 정규직 전환 가능)
직급/직책팀원
급여연봉 3,500만원 이상
근무시간
근무지주소
서울 서초구 서초동 1710-1
인근지하철
2호선3호선
교대역 에서 700m (도보 10분) 이내
3호선
남부터미널역 에서 700m (도보 10분) 이내
지원자격
경력
신입·경력
학력
학력무관
스킬ASP, MySQL, PHP, Python, Linux
접수기간 · 방법
남은기간48일 01:17:03
시작일
2026.04.29(수)
마감일
2026.06.21(일)
접수방법
지원양식
잡코리아 이력서
첨부서류
포트폴리오
마감일은 기업의 사정으로 인해 조기 마감 또는 변경될 수 있습니다
지원자 현황 통계 [지원현황 상세보기](https://www.jobkorea.co.kr/Recruit/ApplyChart?giNo=50810786)
지원자 수
51 명
모집인원
○ 명
경력
희망연봉
기업 정보 [기업정보 더보기](https://www.jobkorea.co.kr/Recruit/Co_Read/C/15640394)
사원수
50명 이하
기업구분
중소기업 (비상장)
산업(업종)
네트워크·통신서비스
위치
서울 서초구 서초동 1710-1

View File

@@ -0,0 +1,54 @@
### 서비스 소개
[회사정보](https://www.saramin.co.kr/zf_user/company-info/view?csn=NC9oenQxZ2hPU3VNMzRJc3QyU2Zrdz09)
[공고](https://www.saramin.co.kr/zf_user/jobs/relay/view?isMypage=no&rec_idx=53568665&recommend_ids=eJxNjssNgDAMQ6fhHtdJnJ4ZhP23AEHVcHzS8ycon2W4Cjh0BhNpyAdtoYLa6IwUWyaBajRO%2BEaVmL6zjZd%2F%2BmC93UtPVfSUzMA%2BIo1Ay%2BExNX9T%2BXUvNLFiZ4dUfJdvNOwzww%3D%3D&view_type=search&searchword=C%23.NET&searchType=search&gz=1&t_ref_content=generic&t_ref=search&relayNonce=c635fee1b6d121d27115&paid_fl=n&search_uuid=c93f76b2-88f9-477f-9bd9-66f8344252fc&immediately_apply_layer_open=n#seq=0)
㈜씨앤지 마이크로웨이브는 안테나 측정시스템, 5G OTA 시험시설, 안테나, 안테나 응용시스템을 개발하고 국내외 민수 및 방산관련 기업과 국가기관에 공급하고 있습니다.
이제 우리는 시장의 선두주자를 넘어 안테나 측정시스템 분야의 1위를 목표로 도약하고자합니다.
### 모집부문 / 상세내용
사용 기술
  C#  WPF   SW   Vb.net
주요업무
위성추적 소프트웨어 개발
모션 및 계측기 제어 소프트웨어 개발
안테나측정 소프트웨어 개발
소프트웨어 유지보수 및 기술지원
자격요건
전문학사 이상
정보통신, 컴퓨터 등 이공계열 전공자
신입
우대사항
영어회화 가능자
객체지향 프로그래밍 가능자
DevExpress 유경험자
마감일 및 근무지
• 마감일 : 2026년 05월 09일
• 근무지
- 대전 유성구 테크노8로 54
### 복지 및 혜택
퇴직연금, 4대보험, 임직원 건강검진, 경조휴가 / 경조금
명절 상여금, 여름휴가비 지원, 직무교육비 지원, 동호회 지원
모성보호제도 (출산휴가/육아휴직/배우자 출산휴가 등)
시차출퇴근제, 커피 및 간식제공, 임직원 워크샵
### 채용절차 및 기타 지원 유의사항
국가보훈대상자 및 장애인은 증명서제출 시 관계법 및 내부규정에 의해 우대합니다.
입사지원서에 허위 기재가 있거나 제출서류가 허위로 판명되는 경우 채용이 취소될수 있습니다.
위 항목 이외의 사항에 대해서는 당사 취업규칙을 준수합니다.
서류심사 합격자에 한하여 면접일정은 개별적으로 안내드립니다.
채용 포탈 사이트를 통해 접수된 이력서는 해당 사이트의 개인정보고관규정에 따라 처리되며,
즉시 삭제를 원하시는 경우 해당사이트에서 지원취소 및 지원이력 삭제를 해주시기 바랍니다.

85
회사/아레스.md Normal file
View File

@@ -0,0 +1,85 @@
[(주)아레스](https://www.saramin.co.kr/zf_user/company-info/view?csn=Sk4wdTZFNEwwR2o3bGpFalo2cE83dz09&popup_yn=y "(주)아레스")
[공고](https://www.saramin.co.kr/zf_user/jobs/relay/view?isMypage=no&rec_idx=53357607&recommend_ids=eJxNjLERAkEMA6shlyzb8sUUQv9dME9wJtTsrkqVHeZniJffJecZPJO%2F2cqqWSoX686ENStHHRArB3q0MkvQlWmC29oR4W0Bqi%2BtNCcv7Sam%2F1odn6VP%2FVzxC7x6L60%3D&view_type=search&searchword=.NET&searchType=search&gz=1&t_ref_scnid=811&t_ref_content=SRI_050_SCH_CVS_RCT_AVA&t_ref=search&inner_source=saramin&inner_medium=pattern&inner_campaign=SRI_050_SCH_CVS_RCT_AVA&inner_term=2&referNonce=2a3e3a2f1bd4d3a0808a&relayNonce=36341dda27122308b97f&paid_fl=n&immediately_apply_layer_open=n#seq=0)
## \[응용SW개발\] C++, C#.Net 경력 및 신입사원
D-12
## 핵심 정보
경력
**신입·경력 1~6년**
학력
**대졸(4년제) 이상**
근무형태
**정규직, 계약직**
급여
면접 후 결정
근무지역
대전 유성구
## 상세요강
[모집분야] 응용SW개발(C++ C# .Net) 신입/경력 채용 
[주요업무]  
- 응용 소프트웨어 개발 및 유지보수를 담당합니다. 
- 고객 요구사항에 맞춘 기능 설계를 수행합니다. 
- 프로젝트의 전반적인 일정 관리 및 조율을 합니다. 
- C++, C#, .Net을 활용한 프로그램 개발을 진행합니다. 
- 팀과 협력하여 유지보수를 업무를 수행합니다. 
[자격요건]  
- C++, C#, .Net 프로그래밍 언어에 대한 이해가 필요합니다. 
- 컴퓨터 공학 및 관련 분야의 학사 학위가 요구됩니다. 
- 소프트웨어 개발 라이프사이클에 대한 기본 지식이 필요합니다. 
- 문제 해결 능력과 논리적 사고를 갖추어야 합니다. 
[우대사항]  
- 관련 분야의 경력이 있는 분을 우대합니다. 
- 프로젝트 관리 경험이 있는 분을 환영합니다. 
- 고객과의 원활한 커뮤니케이션 능력을 보유하신 분을 선호합니다. 
- 팀 협업 경험이 풍부한 분을 우대합니다. 
- 정보처리기사 자격증을 보유하신 분을 환영합니다. 
[복리후생]  
- 4대 보험이 제공됩니다. 
- 연차 및 휴가 제도가 마련되어 있습니다. 
- 건강 검진 및 의료 지원 혜택이 제공됩니다. 
- 사내 동호회 및 여가 활동 지원이 있습니다.
본 기업은 구직자의 소중한 개인정보를 안전하게 보호하고 지키기 위해 노력하는 기업입니다.
구직자도 함께 본인의 개인정보를 안전하게 보호하기 위해 노력해야 합니다.
## 근무지위치
(34086) 대전 유성구 은구비남로33번길 23 경동빌 대전 1호선 노은역에서 200m 이내

121
회사/크레셈.md Normal file
View File

@@ -0,0 +1,121 @@
[크레셈](https://jumpit.saramin.co.kr/company/MTIzODY0MzgyMA==?company_nm=%ED%81%AC%EB%A0%88%EC%85%88)
💰 취업축하금 50만원
- [💰 매출액 100억 기업](https://jumpit.saramin.co.kr/positions?tag=com_152)
- [📈 급성장중](https://jumpit.saramin.co.kr/positions?tag=281)
- [🙌 장기근속 포상](https://jumpit.saramin.co.kr/positions?tag=292)
- [🏝️ 자유로운 연차](https://jumpit.saramin.co.kr/positions?tag=283)
- [🚆 인천1호선 역세권 기업](https://jumpit.saramin.co.kr/positions?tag=com_126)
- [💰 평균연봉 6,000 이상](https://jumpit.saramin.co.kr/positions?tag=com_147)
## 포지션 상세 정보
기술스택
```
C#Visual Studio
```
주요업무
```
• S/W개발 (제어) 등
• 제어프로그램개발
• MMI 프로그램 개발
```
자격요건
```
• 신입
• 학사 졸업 이상
• 전공 : 공학계열
• C#을 Visual Studio를 활용해 프로그램 개발 가능자
```
우대사항
```
• 운전 가능자
• 해외 출장 가능자
• 제어프로그램 개발 관련 학력 보유자 : 전자공학 / 로봇공학 / 제어공학 / 메카트로닉스 공학
• MMI 프로그램 개발 관련 학력 보유자 : 컴퓨터공학, 정보통신공학, 전자공학
※공통우대사항
전자/전자공학, 컴푸터공학, 임베디드, 정보통신 등 유관 학과
유관업무 경력, 인근 거주자, 즉시출근 가능자, 장기근무 가능자, 차량소지/운전 가능자, 컴퓨터 활용/문서작성 능력 우수자, 외국어 가능자(영어,중국어,일본어 등)
```
복지 및 혜택
```
※복지
명절 선물 지급 (연 2회)
각종 경조사 지원
사내 동호회 지원
대학생 자녀 학자금 지원
장기근속 포상 실시
연 1회 창립기념일 행사 및 분기 1회 팀 빌딩 실시
팀별 회식비 지원
사내근로복지기금을 통한 복지포인트 등 다양한 혜택 제공 예정
※휴가
명절 전후, 징검다리 휴일 전체 연차 사용
자유로운 휴가 사용 분위기 (연차, 공가, 경조, 포상휴가 등)
미사용 연차는 수당으로 보상
근로자의 날 휴무
※급여 등
지원자의 연봉은 회사에서 책정 후 제시 (중식대와 월 20시간의 연장근로가 포함된 포괄임금제)
법정기준 이상의 연장근로수당 지급
회사 성과에 따른 인센티브 지급 가능
퇴직연금은 DC형으로 가입 (입사 1년 이후)
```
채용절차 및 기타 지원 유의사항
```
정규직 (신입: 수습 3개월, 경력: 수습기간 차등적용, 수습기간 중 임금의 100% 지급)
서류심사 → (1차)실무면접 → (2차)임원면접 → 결정
```
## 포지션 경력/학력/마감일/근무지역 정보
경력
신입
학력
대학교졸업(4년) 이상
마감일
2026-05-26
근무지역
- 인천 연수구 벤처로36번길37
[지도보기](https://m.map.naver.com/search2/search.nhn?query=%EC%9D%B8%EC%B2%9C%20%EC%97%B0%EC%88%98%EA%B5%AC%20%EB%B2%A4%EC%B2%98%EB%A1%9C36%EB%B2%88%EA%B8%B837#/map "점핏 앱에서는 네이버 지도보기 모바일 뷰로 페이지 이동합니다")
## 기업/서비스 소개
[기업상세 정보로 이동](https://jumpit.saramin.co.kr/company/MTIzODY0MzgyMA==?company_nm=%ED%81%AC%EB%A0%88%EC%85%88)
1 / 8
```
1. 기업개요
㈜크레셈은 반도체용 Substrate, Wafer, LED package등 반도체 packaging 공정용 검사 장비분야와 초음파를 이용한 ACF 접합기술 분야에서 사업을 영위하고 있습니다.
반도체용 PCB Substrate, LED package, 자동차 전장용package, AI & memory package 등의 제조 공정용 검사장비 분야에 필요한 검사, 측정 2D & 3D센서, AI 라이브러리, 광학 및 로봇 기술을 바탕으로 고객이 요구에 최적화된 장비를 플랫폼 기술을 이용하여 업계 최고의 장비를 개발 및 생산 그리고 최적의 ROI를 제공하는 커스터마이징 장비 전문회사 입니다.
또한 전기자동차용 전장 Module, Display, Smart Glass, Semiconductor Package, Touch Panel, RFID, Smart Glass, Camera Module, Mobile Device, Bendable and Flexible Device등의 제조에 필요한 초음파 ACF 본딩과 관련한 공정기술, 소재, 장비를 제공하는 기업으로서 수직 진동의 초음파와 ACF를 활용한 공정용 장비를 연구, 개발하여 생산하고 있으며, 이에 최적화된 ACF 공정기술뿐만 아니라 공정용 장비 공급을 주력사업으로 하고 있습니다.
2. 기업비전
고객중시, 인재중시, 기술중시의 이념으로 지속적인 연구개발을 수행하는 동시에 국내와 세계 시장을 끊임없이 개척하여 저희들의 소재, 장비, 공정을 사용하시는 고객들과 함께 성장하며 고객만족을 추구, Packaging분야에서 "WORLD BEST, WORLD FIRST"가 되도록 노력하겠습니다.
3. 근무 환경
(주)크레셈은 기업의 성장과 더불어 임직원이 최적의 근무 환경에서 일하실 수 있도록 끊임없이 환경을 개선하고 있으며, 연구소 내 중정(정원), 휴게공간(카페테리아 등), 체력단련실/샤워실, 취미활동실(스크린골프 등), 바베큐룸 등을 운영하고 있으며, 시디즈 의자와 스타벅스 커피 등을 제공하고 있습니다.
서울거주 연구원들의 접근성을 개선하기 위하여 가산 디지털 단지역 근처에 제2 R&D 설립을 고려중에 있습니다.
```

202
회사/페이타랩.md Normal file
View File

@@ -0,0 +1,202 @@
[회사정보](https://www.saramin.co.kr/zf_user/company-info/view?csn=ekJOVENHa0p0TzdoaTVQVHVpY3Vydz09)
[공고](https://www.saramin.co.kr/zf_user/jobs/relay/view?isMypage=no&rec_idx=53771491&recommend_ids=eJxVzsENwDAIQ9FpescxYDh3kO6%2FRasckvT4JIR%2FkKVufwp26Q4SGbKPmEykITc5mJvRcPaimxw8jttrLDKUpsURbTiG1FDsz1Xuu8qBOqok6MdvR0eVd0y%2B0sgv6w%3D%3D&view_type=search&searchword=.NET&searchType=search&gz=1&t_ref_content=generic&t_ref=search&relayNonce=59448125bce1ff083477&paid_fl=n&search_uuid=4b117926-0c43-4168-bc5d-8665abda0633&immediately_apply_layer_open=n#seq=0)
### 서비스 소개
【 대한민국 No. 1 카페 주문 플랫폼 - 패스오더 】
페이타랩은 대한민국 No.1 커피 주문 플랫폼인 ‘패스오더’를 운영하고 있는 스타트업입니다.
패스오더는 2018년, 자영업 사장님들의 인건비 부담, 매출 부진이라는 사회적인 문제를 해결하기 위해 대형 프랜차이즈에서만 가능했던 스마트오더 시스템을 중소형 프랜차이즈 및 개인 카페까지 사용할 수 있도록 개발한 간편한 스마트오더 서비스입니다.
투자 혹한기에도 불구하고 시장에서 우리의 경쟁력과 성장성을 인정받아 22년 1월, Series B에서 121억 원이라는 투자 유치에 성공했고 약 1년 반 만인 23년 9월, 또 한 번 성공적으로 투자를 유치해 누적 투자 금액 200억 원을 달성했습니다.
페이타랩은 올 한해도 빠르게 성장하여 지난 1년 동안 달성했던 지표를 3개월 만에 달성하며 지금 이 순간에도 우리가 세운 기록을 계속해서 경신하며 무섭게 성장하고 있습니다!
【 패스오더만의 독보적인 경쟁력 5가지 핵심 요약! 】
1. 바쁜 시간에 줄 서지 않고 언제 어디서든 미리 주문해요! (매일 10분 절약)
2. 매장 상황에 따른 예상 수령시간 NO! 내가 원하는 시간에 픽업해요!
3. 주문 결제 적립을 동시에 패스오더 하나로 해결해요!
4. 매일 마시는 커피, 단 3번의 클릭으로 빠르게 재주문!
5. 300개 이상의 브랜드 카페 주문을 패스오더 앱 하나로 이용 가능해요!
“어중간하게 성공해서 안주하지 않겠습니다.”라는 대표님의 경영 철학과 “Pay+Data+Lab”이라는 페이타랩의 사명처럼 아날로그 기반의 자영업시장 데이터를 통해 IT화를 이뤄내는 그날까지 안주하지 않고 달려나가고 있어요.
■ 페이타랩 CEO 인터뷰 : https://bit.ly/3yEZh18
: 향후 목표 및 비전, 채용 기준, 많은 기업들 중 페이타랩 합류해야 하는 이유가 궁금하다면?
【 안주하지 않겠다는 집요함으로 이뤄낸 무서운 성장 】
『 투자 혹한기 속 누적 투자 금액 200억+ 달성』
『 포브스 선정 한국인이 사랑한 식음료 앱 5위 』
『 2024년 앱스토어 식음료 부문 1위 』
『 매년 3배씩, 매출액 1800% 성장 』
『 앱 사용자 수 800만명 달성 』
『 구글 앱스토어 평점 4.9점 』
『 재주문율 80% 이상 』
『 1초에 599잔 』
*2018년 ~ 현재까지
【 성장하는 팀의 비결을 알려드려요 】
페이타랩에는 일과 성장에 미쳐있는 사람들이 모여있어요.
자신의 일에 깊게 몰입하여 뛰어난 결과를 만들어 내는 것에 희열을 느끼는 사람들이에요.
이렇게 성장에 대한 집착으로 안주하지 않고 달려 나가는 사람들이 모여 무서운 성장을 만들어 내요.
팀 페이타랩에 합류하여 우리의 위닝포인트를 체득하고, 문제를 해결해 나가며 우리와 함께 다음 정답을 찾아나갈 분을 찾아요!
【 페이타랩에는 세 가지 기회가 있어요 】
■ 커리어 성장의 확실한 기회
놀라운 속도로 성장하고 있는 회사이기에 앞으로 해결해야 하는 미션들이 무수히 많아요. 챌린지한 미션들이 주어지고, 빠르게 해결해 나가는 과정에서 개인의 역량이 폭발적으로 성장하는 경험을 통해 남들과 비교할 수 없는 멋진 커리어를 쌓아나갈 수 있어요.
■ 뛰어난 동료와 함께할 기회
인재 밀도가 높은 조직인 만큼 열정적이고 뛰어난 능력을 가진 동료들과 함께할 수 있어요. 자신의 일에 진심인 사람들이 모여 있기에, 동료로부터 좋은 자극과 인사이트를 주고받으며 시너지를 낼 수 있는 환경에서 일할 수 있어요.
■ 일에만 집중할 수 있는 기회
불필요한 절차 및 비효율적인 요소들을 없애 오직 일에만 집중하며 자신의 역량을 마음껏 펼칠 수 있습니다. 그중에서도 특히, 사람 때문에 힘든 일은 없도록 조직문화에 있어 절대 타협하지 않고 있어요.
[
이 포지션의 AI 예상 면접 질문이 궁금하다면 점핏에서 확인해보세요!
](https://jumpit.saramin.co.kr/ai-interview?url=https://jumpit.saramin.co.kr/position/53771491&utm_source=saramin&utm_medium=btn&utm_campaign=position&utm_term=ai_interview "점핏 AI 인터뷰 새창열림")
### 모집부문 / 상세내용
사용 기술
 ![.NET](https://cdn.jumpit.co.kr/images/stacks/ASP_NET.png) .NET  ![C#](https://cdn.jumpit.co.kr/images/stacks/CSharp.png) C#  ![C++](https://cdn.jumpit.co.kr/images/stacks/CPlusPlus.png) C++  ![Git](https://cdn.jumpit.co.kr/images/stacks/git.png) Git  ![Slack](https://cdn.jumpit.co.kr/images/stacks/slack.png) Slack  ![Visual Studio](https://cdn.jumpit.co.kr/images/stacks/visualstudio.png) Visual Studio  ![Windows](https://cdn.jumpit.co.kr/images/stacks/windows.png) Windows  ![WPF](https://cdn.jumpit.co.kr/images/stacks/noStack.png) WPF
주요업무
【페이타랩 프로덕트팀】
프로덕트팀은 패스오더 유저와 점주님을 연결하며 고객지향적인 프로덕트를 만드는 팀이에요. 프로덕트의 성장을 위해 각 팀에서 실현하고자 하는 수많은 아이디어와 프로젝트를 유기적인 의사소통을 통해 흡수하고 구현해내고, 이를 위해 불필요하고 복잡한 업무 프로세스를 지양하며 실행 중심의 효율적인 업무 문화를 갖고 있어요.
“개발의 마지막 관문”이라는 마음가짐으로 안정적이면서도 혁신을 일으킬 수 있는 프로덕트를 개발하기 위해 수단과 방법을 가리지 않고 학습하고 실행하는 팀이에요. 엔지니어, 프로덕트 오너, 프로덕트 디자이너, 데브옵스 등 프로덕트를 실현하는 과정 자체에서 즐거움을 느끼는 사람들이 모여있으며 그 이상으로 비즈니스 임팩트가 가장 중요하다는 점에 공감하는 사람들이 모여있어요.
【오시면 이런 업무를 하게 되실 거예요!】
• WPF를 이용한 응용프로그램 개발, 배포 및 유지/보수를 진행해요.
• 패스오더 점주님 PC 주문접수 애플리케이션을 개발해요.
• 기존 서비스를 안전하게 유지보수 하면서 점진적으로 리팩토링해요.
【개발환경】
• 사용언어: C#
• 개발환경 및 도구: Visual Studio
【병역특례자 (산업기능요원) 지원 가능】
• 병역특례 지원 시 원활한 진행을 위해 병역유형을 기재해주세요.
• 현역신규/현역전직/보충역신규/보충역전직 등
• 현재 패스오더는 현역전직/보충역 신규/보충역 전직이 가능합니다.
자격요건
【이런 분을 찾고 있어요!】
• C#, C++ 중 1개 이상 언어에 능숙한 분
• .Net framework에 대해 이해하고 계신 분
• 신규 서비스를 위한 기획, 설계, 커뮤니케이션, 개발, 운영에 적극적으로 참여할 수 있는 분
• RESTful API 연동 개발 경험이 있는 분
• Socket 통신을 이용한 네트워킹 경험이 있는 분
우대사항
【이런 분이면 더 좋아요!】
• WPF 상용 서비스 개발, 배포 및 운영 경험이 있는 분
• Thermal Printer 연동 등 POS 시스템 개발 경험이 있는 분
• 다양한 환경으로 배포된 프로그램 유지/보수 경험이 있는 분
• 요구사항 분석 및 다른 개발자와 협업을 위한 원활한 커뮤니케이션 능력을 가지신 분
• 팀과 함께 성장하며 지식을 공유하는 문화를 지향하시는 분
마감일 및 근무지
• 마감일 : 2026년 05월 30일
• 근무지
- 부산 부산진구 서면로 39, 6층 쿨리지코너부산센터 601호, 602호, 603호
### 복지 및 혜택
【저희는 이렇게 일해요!】
1. 그냥 하는 일은 없어요. 스스로 업무를 할 때 '왜?'를 항상 생각해요.
2. 완벽한 결과물은 없다고 생각해요. 빠르게 실행해 보고 아니면 개선해 나가요.
3. 하나의 팀으로 연결되어 있어요. 사내의 모든 정보는 투명하게 공유하고 있어요.
4. 스포츠팀처럼 일해요. 각자의 포지션에서 최고가 되기 위해 끊임없이 성장해요.
5. 개인의 성장을 지지해요. 누구나 원하는 만큼 성장할 수 있도록 회사가 지원을 아끼지 않아요.
6. 직책 및 연차와 상관없이 모든 구성원이 자유롭게 소통하고 대단하게 피드백을 주고받아요.
7. 관계는 수평적이에요. 직급 대신 ‘님’이라는 호칭을 사용하고, 상호 존칭을 사용하며 서로를 존중해요.
8. 업무는 수직적으로 해요. 책임을 지는 의사 결정권자와 실무자의 구분을 명확히 해요.
9. 구성원들을 진심으로 아끼고 배려해요. 일은 힘들 수 있지만, 사람 때문에 힘든 일은 없어요.
10. 저녁 회식이 없어요. 다음 날 최상의 컨디션으로 일할 수 있도록 회사가 방해하지 않아요.
【당신의 성장을 적극적으로 지원해요!】
■ 오직 일과 성장에만 몰입하고 집중할 수 있도록
• 업무 효율을 낼 수 있는 최고 스펙 장비 제공
┕ 맥북프로, LG 그램 (2-3년 주기 업그레이드)
• 27” 듀얼모니터 제공
• 쾌적한 업무 환경 제공 (스타일러, 가습기, IQ Air 공기청정기)
• 키보드, 마우스 등 필요한 주변기기 맞춤 제공
• 개인별 법인카드 지급 (식대, 출장 등에 사용)
• 사유를 묻지 않는 자유로운 연차 사용
■ 원하는 만큼 배우고, 성장할 수 있도록
• 교육비 무제한 전액 지원
• 세미나, 컨퍼런스 참여 비용 무제한 지원
• 20개 이상 교육 또는 온라인 콘텐츠 구독
• 도서 무제한 구입 지원
• Chat GPT 4.0 지원
• 업무에 필요한 다양한 툴 적극 지원
■ 함께하는 구성원이 행복할 수 있도록
• 법인카드 식대 지원(식대 1만 1천원)
• 패스오더 포인트 매월 지급 (50,000P)
• 에스프레소 머신, Tea 무제한 제공
• 간식(스낵바) 무제한 제공
• 종합건강검진 지원
• 명절 백화점 상품권 지급
• 생일 축하 백화점 상품권 지급
• 구성원 경조사 발생 시 근조화환 및 경조 휴가, 경조금 지원
### 채용절차 및 기타 지원 유의사항
【이런 여정을 거치게 되실 거예요!】
서류전형 > 사전 테스트(필요 시 생략) > 1차 인터뷰(직무) > 2차 인터뷰(컬쳐 핏) > 서류, 레퍼런스 체크 > 최종합격
• 전형이 생략되거나 추가 및 변경되는 등 지원 포지션 및 경력 등 기타 상황에 따라 유연하게 진행하고 있어요.
• 개발자 및 일부 포지션의 경우 서류전형 다음으로 사전 테스트(코딩테스트, SQL 테스트, 과제테스트, 사전질문 등)를 진행해요.
• 합격 여부와 관계없이 모든 전형의 결과를 안내해 드리고 있으며, 결과는 알림톡 및 메일을 통해 안내드려요.
• 레퍼런스 체크를 진행하게 될 시, 사전에 지원자님께 반드시 동의를 구하고 진행해요.
• 입사일, 처우 협의 등은 최종합격 시 진행하고 있으며, 가능한 지원자님의 상황을 배려해 드리고 있어요.
【결과는 이렇게 안내 드려요!】
※ 해당 포지션의 서류 검토는 최대 2주 이상 소요될 수 있는 점 너른 양해를 부탁 드립니다.
소중한 지원에 감사드리며, 보다 신중하게 검토할 수 있도록 하겠습니다.
※ 전형 결과는 서류 합격자에 한해 개별적으로 다음 전형을 안내 드릴 예정이에요.
【이렇게 일하게 되실 거예요!】
• 근무 형태: 경력 3년 미만 (6개월), 경력 3년 이상 (3개월) 수습
* 수습 기간은 지원자님과 회사가 서로를 알아가는 소중한 기간이에요. 수습 기간에도 급여 100% 지급 및 동일한 복지가 적용돼요. 수습기간 이후 내부 기준에 따라 정규직으로 계약하고 있어요.
• 근무 지역: 서울 삼성역 인근 (강남구 영동대로 85길 34, 6층)
【채용에 진심인 채용담당자의 한마디】
구성원분들을 부품으로 여기지 않고, 한 분 한 분을 소중하게 생각하는 만큼 시간이 오래 걸리더라도 신중하게, 우리와 맞는 분을 모시기 위해 노력하고 있어요. 일부 지원자분들에게는 페이타랩 합류 과정이 다소 까다롭고 긴 여정이라고 느끼실 수도 있지만, 서로에 대해 깊게 알아가는 시간이라고 생각해 주시고 우리의 채용 여정을 진심으로 바라봐 주시면 감사하겠습니다. :)