OneStack์ IT ์ ๋ฌธ๊ฐ์ ์๋ขฐ์ธ์ ์ฐ๊ฒฐํ๋ ๋งค์นญ ํ๋ซํผ์
๋๋ค.
๊ฒ์ฆ๋ ์ ๋ฌธ๊ฐ ํ์ ํตํด ์ ๋ขฐ์ฑ ์๋ ์ธ์ฃผ ์๋น์ค๋ฅผ ์ ๊ณตํ๋ฉฐ, ์ค์๊ฐ ์ฑํ
๊ณผ ํ๋ก์ ํธ ๊ด๋ฆฌ ๊ธฐ๋ฅ์ผ๋ก ํจ์จ์ ์ธ ํ์
์ ์ง์ํฉ๋๋ค.
- ๊ฐ๋ฐ ๊ธฐ๊ฐ: 2024.01 ~ 2024.03 (3๊ฐ์)
- ์ธ์: 1๋ช (๊ฐ์ธ ํ๋ก์ ํธ)
- ๋ฐฐํฌ URL: ONE STACK
- ์ ๋ขฐ์ฑ: ๊ฒ์ฆ๋ ์ ๋ฌธ๊ฐ ๋งค์นญ ์์คํ
- ํจ์จ์ฑ: ์ค์๊ฐ ์ํต ๋ฐ ํ๋ก์ ํธ ๊ด๋ฆฌ
- ํฌ๋ช ์ฑ: ๋ช ํํ ๊ฐ๊ฒฉ ์ ์ฑ ๊ณผ ๋ฆฌ๋ทฐ ์์คํ
- ์นดํ ๊ณ ๋ฆฌ๋ณ ์ ๋ฌธ๊ฐ ํํฐ๋ง: ๊ธฐ์ ๋ถ์ผ๋ณ ์ ๋ฌธ๊ฐ ๊ฒ์ ๋ฐ ํํฐ๋ง
- ๋ฌดํ ์คํฌ๋กค: ํจ์จ์ ์ธ ์ ๋ฌธ๊ฐ ๋ฆฌ์คํธ ๋ก๋ฉ
- ์ ๋ฌธ๊ฐ ํ๋กํ: ์์ธํ ํ๋กํ ๋ฐ ํฌํธํด๋ฆฌ์ค ๊ด๋ฆฌ
@PostMapping("/proFilter")
public Map<String, Object> filterPros2(@RequestBody Map<String, Object> requestData) {
List<String> appType = (List<String>) requestData.get("filters");
String sort = (String) requestData.get("sort");
int itemNo = (int) requestData.get("itemNo");
int page = (int) requestData.get("page");
int size = (int) requestData.get("size");
// ํํฐ ์กฐ๊ฑด์ ๋ง๋ ์ ๋ฌธ๊ฐ ๋ฆฌ์คํธ ๊ฐ์ ธ์ค๊ธฐ
List<MemProAdInfoCate> pros = proService.getPaginatedFilteredAndSortedPros(
appType, sort, itemNo, page, size);
// ํ๊ท ๊ฐ๊ฒฉ ๊ณ์ฐ
double overallAveragePrice = calculateAveragePrice(pros);
// ์๋ต ๊ตฌ์ฑ
Map<String, Object> response = new HashMap<>();
response.put("pros", pros);
response.put("hasMore", pros.size() == size);
response.put("overallAveragePrice",
String.format("%,d", (long) overallAveragePrice));
return response;
}- WebSocket ๊ธฐ๋ฐ ์ค์๊ฐ ํต์ : STOMP ํ๋กํ ์ฝ์ ํ์ฉํ ์๋ฐฉํฅ ํต์
- ์ฑํ ๋ฐฉ ๊ด๋ฆฌ: ๊ฒฌ์ ๋ณ ์ฑํ ๋ฐฉ ์์ฑ, ๊ด๋ฆฌ
- ๋ฉ์์ง ์ ์ฅ: ๋ชจ๋ ๋ํ ๋ด์ญ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ ์ฅ
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// ๋ฉ์์ง๋ฅผ ๊ตฌ๋
ํ๋ prefix
config.enableSimpleBroker("/topic", "/queue");
// ๋ฉ์์ง๋ฅผ ๋ฐํํ๋ prefix
config.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws-stomp")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}
@MessageMapping("/chat/message")
public void sendMessage(@Payload ChatMessage message) {
// ๋ฉ์์ง ์ ์ฅ
message.setSentAt(LocalDateTime.now());
chatService.saveMessage(message);
// ํน์ ์ฑํ
๋ฐฉ์ผ๋ก ๋ฉ์์ง ์ ์ก
messagingTemplate.convertAndSend(
"/topic/chat/room/" + message.getRoomId(), message);
}- ๊ฒ์ํ ๊ธฐ๋ฅ: ์ฑํ ๋ฐฉ ๋ด ๊ฒ์๊ธ ์์ฑ ๋ฐ ๊ด๋ฆฌ
- ์ผ์ ๊ด๋ฆฌ: ํ๋ก์ ํธ ์ผ์ ๊ณต์ ๋ฐ ๊ด๋ฆฌ (FullCalendar api)
- ์ค์๊ฐ ์๋ฆผ: ๋ชจ๋ ์ก์ ์ ๋ํ ์ค์๊ฐ ์๋ฆผ
@PostMapping
public ResponseEntity<Map<String, Object>> createBoard(
@RequestBody ChatBoardEvent board, HttpSession session) {
Map<String, Object> response = new HashMap<>();
chatBoardService.createBoard(board);
Member member = (Member) session.getAttribute("member");
// ์์คํ
๋ฉ์์ง ์์ฑ
ChatMessage systemMessage = new ChatMessage();
systemMessage.setRoomId(board.getRoomId());
systemMessage.setSender(member.getMemberId());
systemMessage.setNickname(member.getNickname());
systemMessage.setType("SYSTEM");
systemMessage.setMessage(member.getNickname() +
"๋์ด ๊ฒ์๊ธ์ ์์ฑํ์์ต๋๋ค.");
systemMessage.setSentAt(LocalDateTime.now());
// DB์ ์์คํ
๋ฉ์์ง ์ ์ฅ
chatService.saveMessage(systemMessage);
// ์์คํ
๋ฉ์์ง๋ฅผ ์น์์ผ์ผ๋ก ์ ์ก
messagingTemplate.convertAndSend(
"/topic/chat/room/" + board.getRoomId(), systemMessage);
return ResponseEntity.ok(response);
}- OAuth2.0 ์ธ์ฆ: ์นด์นด์ค, ๊ตฌ๊ธ ์์ ๋ก๊ทธ์ธ ์ง์
- ๊ฐํธํ ํ์๊ฐ์ : ์์ ๊ณ์ ์ผ๋ก ๊ฐํธ ํ์๊ฐ์
- ๋ณด์ ๊ฐํ: ์ํธํ๋ ๊ณ์ ๊ด๋ฆฌ
@GetMapping("/callback")
public String kakaoCallback(
@RequestParam(name = "code", required = true) String code,
HttpSession session,
Model model) {
try {
// 1. ์ก์ธ์ค ํ ํฐ ๋ฐ๊ธฐ
String accessToken = requestAccessToken(code);
session.setAttribute("accessToken", accessToken);
// 2. ์ฌ์ฉ์ ์ ๋ณด ๋ฐ๊ธฐ
Map<String, Object> userData = requestUserInfo(accessToken);
// 3. ์นด์นด์ค ID ์ถ์ถ
String kakaoId = userData.get("id").toString();
Map<String, Object> properties =
(Map<String, Object>) userData.get("properties");
String nickname = (String) properties.get("nickname");
// 4. ๊ธฐ์กด ํ์์ธ์ง ํ์ธ
Member existingMember = memberService.getMember(kakaoId);
if (existingMember != null) {
// 5a. ๊ธฐ์กด ํ์์ด๋ฉด ๋ก๊ทธ์ธ ์ฒ๋ฆฌ
session.setAttribute("member", existingMember);
session.setAttribute("isLogin", true);
return "redirect:/mainPage";
} else {
// 5b. ์ ๊ท ํ์์ด๋ฉด ์ถ๊ฐ ์ ๋ณด ์
๋ ฅ ํ์ด์ง๋ก
model.addAttribute("kakaoId", kakaoId);
model.addAttribute("nickname", nickname);
return "member/kakaoAddJoinForm";
}
} catch (Exception e) {
return "redirect:/loginForm?error=kakao";
}
}- ํฌํธ์ ๊ฒฐ์ ์ฐ๋: ์์ ํ ๊ฒฐ์ ํ๋ก์ธ์ค
- ๊ฒฐ์ ๊ฒ์ฆ: ์๋ฒ ์ฌ์ด๋ ๊ฒฐ์ ๊ฒ์ฆ
- ๊ฒฐ์ ๋ด์ญ ๊ด๋ฆฌ: ์ฌ์ฉ์๋ณ ๊ฒฐ์ ๋ด์ญ ๊ด๋ฆฌ
public boolean verifyPayment(String impUid, int estimationNo, int paidAmount)
throws Exception {
// 1. ์ก์ธ์ค ํ ํฐ ๋ฐ๊ธ
String accessToken = getAccessToken();
// 2. ๊ฒฐ์ ์ ๋ณด ์กฐํ API ํธ์ถ
RestTemplate restTemplate = new RestTemplate();
String url = "https://api.iamport.kr/payments/" + impUid;
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", accessToken);
HttpEntity<Void> entity = new HttpEntity<>(headers);
ResponseEntity<String> response =
restTemplate.exchange(url, HttpMethod.GET, entity, String.class);
// 3. ๊ฒฐ์ ๊ฒ์ฆ
if (response.getStatusCode() == HttpStatus.OK) {
JSONObject responseBody = new JSONObject(response.getBody());
JSONObject paymentData = responseBody.getJSONObject("response");
double amount = paymentData.getDouble("amount");
String status = paymentData.getString("status");
// 4. DB์์ ์ฃผ๋ฌธ ๊ธ์ก ์กฐํ
double orderAmount = payMapper.getPrice(estimationNo);
// 5. ๊ฒฐ์ ๊ฒ์ฆ ๋ก์ง
if (amount == orderAmount && "paid".equals(status) &&
paidAmount == amount) {
return true;
} else {
throw new Exception("๊ฒฐ์ ๊ฒ์ฆ ์คํจ: ๊ธ์ก ๋๋ ์ํ ๋ถ์ผ์น");
}
} else {
throw new Exception("๊ฒฐ์ ์ ๋ณด ์กฐํ ์คํจ");
}
}graph TB
subgraph Client
A[Web Browser]
end
subgraph "AWS Cloud"
subgraph "AWS EC2"
B[NGINX Proxy]
C[Spring Boot Application]
D[Jenkins]
end
subgraph "Storage"
E[Image Server]
end
subgraph "Database"
F[MySQL]
end
end
subgraph "External Services"
G[Kakao OAuth]
H[Google OAuth]
I[PortOne Payment]
J[Gmail SMTP]
end
A -->|HTTPS| B
B -->|Reverse Proxy| C
C -->|WebSocket| A
C -->|JDBC| F
C -->|File Upload| E
C <-->|OAuth2| G & H
C <-->|Payment API| I
C -->|Email| J
D -->|CI/CD| C
-
์๋ฒ: AWS EC2 (Ubuntu 20.04 LTS)
-
์น ์๋ฒ: NGINX 1.18
-
์ปจํ ์ด๋ํ: Docker & Docker Compose
-
CI/CD: Jenkins Pipeline
-
๋ฐ์ดํฐ๋ฒ ์ด์ค: MySQL 8.0
-
๋ชจ๋ํฐ๋ง: Spring Actuator
-
์ปจํ ์ด๋ํ: Docker ๋ฐ Docker Compose๋ฅผ ํ์ฉํ ์ ํ๋ฆฌ์ผ์ด์ ์ปจํ ์ด๋ํ
# Dockerfile
# ๋น๋ ๋จ๊ณ
FROM gradle:8.12-jdk17-alpine AS build
WORKDIR /app
# ์์กด์ฑ ๋จผ์ ๋ณต์ฌ ๋ฐ ๋ค์ด๋ก๋ (์บ์ ํ์ฉ)
COPY build.gradle settings.gradle ./
RUN gradle dependencies --no-daemon
# ์์ค ๋ณต์ฌ ๋ฐ ๋น๋
COPY src ./src
RUN gradle build --no-daemon -x test
# ์คํ ๋จ๊ณ
FROM amazoncorretto:17-alpine
WORKDIR /app
# MySQL ์ค์ ํ์ผ ์์ฑ
RUN mkdir -p /etc/mysql/conf.d
RUN echo "[mysqld]" > /etc/mysql/conf.d/my.cnf
RUN echo "bind-address = 0.0.0.0" >> /etc/mysql/conf.d/my.cnf
RUN echo "skip-name-resolve" >> /etc/mysql/conf.d/my.cnf
# ํ์ํ ํ์ผ๋ง ๋ณต์ฌ
COPY --from=build /app/build/libs/*.jar app.jar
ENV JAVA_OPTS="-Xms512m -Xmx512m"
ENV SERVER_PORT=8080
# MySQL ํฌํธ๋ ๋
ธ์ถ
EXPOSE 8080 3306
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar app.jar"]- CI/CD: Jenkins๋ฅผ ํ์ฉํ ์ง์์ ํตํฉ ๋ฐ ๋ฐฐํฌ
version: '3.8'
services:
db:
image: mysql:8.0.41-bookworm
container_name: myone
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: Kyle9907!
MYSQL_DATABASE: onestack
MYSQL_USER: kyle
MYSQL_PASSWORD: Kyle9907!
networks:
- onestack-network
volumes:
- mysql_data:/var/lib/mysql
- ./src/main/resources/SQL:/docker-entrypoint-initdb.d
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pKyle9907!"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
restart: always
app:
build:
context: .
dockerfile: Dockerfile
container_name: onestack
ports:
- "1234:8080"
depends_on:
db:
condition: service_healthy
volumes:
- /usr/share/nginx/html/images:/usr/share/nginx/html/images
networks:
- onestack-network
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/onestack?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8
SPRING_DATASOURCE_USERNAME: kyle
SPRING_DATASOURCE_PASSWORD: Kyle9907!
KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID}
KAKAO_REDIRECT_URI: ${KAKAO_REDIRECT_URI}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI}
GMAIL_USERNAME: ${GMAIL_USERNAME}
GMAIL_PASSWORD: ${GMAIL_PASSWORD}
PORTONE_API_KEY: ${PORTONE_API_KEY}
PORTONE_API_SECRET: ${PORTONE_API_SECRET}
restart: always
networks:
onestack-network:
driver: bridge
volumes:
mysql_data:- ๊ฒฐ์ : Spring WebSocket + STOMP ํ๋กํ ์ฝ ์ฑํ
- ์ด์ :
- ์ค์๊ฐ ์๋ฐฉํฅ ํต์ ์ง์
- STOMP๋ฅผ ํตํ ๋ฉ์์ง ๋ผ์ฐํ ๋จ์ํ
- ํ์ฅ์ฑ ๋ฐ ์ฑ๋ฅ ์ต์ ํ
- ๊ฒฐ์ : JPA ๋์ MyBatis ์ฌ์ฉ
- ์ด์ :
- ๋ณต์กํ SQL ์ฟผ๋ฆฌ ์ง์ ์์ฑ ๊ฐ๋ฅ
- ๋์ ์ฑ๋ฅ ๋ฐ ์ต์ ํ ์ฉ์ด
- ์กฐ์ธ ์ฟผ๋ฆฌ ์ฒ๋ฆฌ ํจ์จ์
- ๊ฒฐ์ : Docker์ Docker Compose ํ์ฉ
- ์ด์ :
- ๊ฐ๋ฐ/์ด์ ํ๊ฒฝ ์ผ๊ด์ฑ ํ๋ณด
- ๋ฐฐํฌ ์๋ํ ์ฉ์ด
- ์๋น์ค ๋ ๋ฆฝ์ฑ ๋ฐ ํ์ฅ์ฑ
- ๋ฐฑ์๋ ๊ฐ๋ฐ (๊ธฐ์ฌ๋ 80%)
- ํ๋ก ํธ์๋ ๊ฐ๋ฐ (๊ธฐ์ฌ๋ 50%)
- ์๋น์ค ๊ธฐํ (๊ธฐ์ฌ๋ 85%)
- ์ธํ๋ผ ๊ตฌ์ถ ๋ฐ ๋ฐฐํฌ (๊ธฐ์ฌ๋ 100%)
์ด ํ๋ก์ ํธ๋ MIT ๋ผ์ด์ผ์ค๋ฅผ ๋ฐ๋ฆ ๋๋ค. ์์ธํ ๋ด์ฉ์ LICENSE ํ์ผ์ ์ฐธ์กฐํ์ธ์.
