서버 개발 기초 노트

서버 개발 기초 노트

세미나 내용을 개념마다 이론 · 비유 · 예시 · 기술 도메인으로 나눠 정리했습니다. 보라색 점선 상자는 원본 자료보다 정확하게 보완하거나 바로잡은 부분입니다.

표시

1. 서버 SERVER

서버 개발은 "여러 명이 동시에 쓴다"와 "서버가 죽으면 전부 죽는다"는 두 전제 위에서 이루어집니다.

클라이언트-서버 구조

이론

단독 실행형(Standalone)은 UI·로직·데이터가 한 컴퓨터에 있습니다. 단순하고 오프라인에서도 동작하지만, 복사본마다 데이터가 따로 놀고 기능을 바꾸려면 모든 사용자가 다시 설치해야 합니다.

데이터 공유, 중앙 관리, 일괄 업데이트가 필요해지면서 요청하는 쪽(Client)과 제공하는 쪽(Server)을 분리했습니다.

  • 성립 조건: 항상 켜져 있음 · 찾을 수 있는 주소(IP, 도메인) · 약속된 프로토콜(HTTP 등)
  • 대화는 항상 클라이언트가 시작합니다. 서버가 먼저 보내려면 WebSocket, SSE, 푸시 알림이 필요합니다.
  • HTTP는 무상태(stateless)입니다. 서버는 이전 요청을 기억하지 않으므로 로그인 상태는 세션·쿠키·토큰(JWT)으로 유지합니다.
보완HTTP/1.1은 keep-alive(지속 연결)가 기본입니다. 응답 후 바로 끊는 쪽이 오히려 예외이고, Connection: close를 명시해야 끊습니다.
비유

식당의 손님과 주방

손님(클라이언트)이 주문서를 내면 주방(서버)이 음식을 만들어 줘요. 주방은 손님이 부르기 전에는 먼저 음식을 가져다주지 않아요.

혼자 집에서 요리하던 때(단독 실행형)는 친구와 같은 요리를 나눠 먹을 수 없었어요. 그래서 모두가 찾아올 수 있는 식당을 만든 거예요.

주방은 손님 얼굴을 기억하지 못해요(무상태). 그래서 올 때마다 번호표(쿠키·토큰)를 보여줘야 해요.

  • 손님→브라우저, 앱
  • 주문서→HTTP 요청
  • 식당 주소→도메인, IP
  • 식당 안 약속(메뉴판)→프로토콜
  • 번호표→세션, 토큰
예시

브라우저에서 https://blog.example.com/posts/1을 열 때:

  1. DNS 조회 → blog.example.com의 IP 획득
  2. TCP 3-way handshake (SYN → SYN-ACK → ACK) + TLS handshake
  3. GET /posts/1 HTTP/1.1 전송
  4. 서버: 라우팅 → 인증/인가 → 비즈니스 로직 → DB 조회
  5. 200 OK + {"id":1,"title":"..."}
  6. 연결 유지(keep-alive) 또는 종료
기술 도메인
  • 웹·모바일 앱 백엔드의 REST API (Spring Boot, Express, FastAPI)
  • 서버 → 클라이언트 실시간 전송: 채팅은 WebSocket, 알림·LLM 응답 스트리밍은 SSE, 앱 푸시는 FCM/APNs

Web Server vs WAS

이론
구분Web ServerWAS
응답정적 리소스 (HTML, CSS, JS, 이미지)로직 실행 결과 (동적)
부가 역할리버스 프록시, 로드 밸런싱, SSL/TLS 종료, 압축·캐싱비즈니스 로직, DB 연동, 세션·트랜잭션
무게가볍고 동시 연결에 강함상대적으로 무거움
대표 구현체Nginx, Apache HTTP ServerTomcat, Jetty, Undertow

분리하는 이유: 정적 요청과 로직이 자원을 두고 경쟁하지 않게, WAS만 따로 늘릴 수 있게, WAS가 죽어도 점검 페이지를 보여줄 수 있게, WAS를 여러 대 두고 하나씩 교체하는 무중단 배포를 위해서입니다.

정정Netty는 서블릿 컨테이너가 아닙니다. Tomcat·Jetty·Undertow는 서블릿 컨테이너이고, Netty는 비동기 네트워크 프레임워크입니다. Spring Boot는 spring-boot-starter-web(MVC)에서 Tomcat을, spring-boot-starter-webflux에서 Reactor Netty를 기본 내장 서버로 씁니다.
비유

빵집의 진열대와 제빵사

진열대 직원(Web Server)은 이미 구워 둔 빵을 바로 건네줘요. "제 이름 쓴 생일 케이크요!" 같은 주문은 안쪽 제빵사(WAS)에게 전달해요.

제빵사가 바빠도 진열대 빵은 계속 팔 수 있고, 제빵사가 쉬는 날엔 "오늘은 주문 제작 쉬어요" 안내판을 걸 수 있어요. 손님이 많아지면 제빵사만 더 뽑으면 돼요.

  • 구워 둔 빵→정적 파일(HTML, 이미지)
  • 주문 케이크→동적 응답
  • 주문 전달하는 직원→리버스 프록시
  • 제빵사 여러 명에게 나눠 주기→로드 밸런싱
예시

정적 파일은 Nginx가 직접 응답하고, /api는 WAS 두 대로 나눠 보내는 설정:

upstream app {
    server 10.0.0.11:8080;   # Spring Boot #1
    server 10.0.0.12:8080;   # Spring Boot #2
}
server {
    listen 443 ssl;                     # TLS 종료
    location /static/ { root /var/www; }  # 정적: Nginx가 끝냄
    location /api/    { proxy_pass http://app; }  # 동적: WAS로 전달
}
기술 도메인
  • Nginx + Spring Boot(내장 Tomcat) 조합이 국내 서비스의 가장 흔한 구성
  • 클라우드에서는 정적 파일을 S3 + CloudFront(CDN)로, 로드 밸런싱을 AWS ALB나 Kubernetes Ingress로 대체하기도 함

설계 지표와 트레이드오프

이론

지표: 가용성(Availability) · 지연 시간(Latency) · 처리량(Throughput) · 정합성(Consistency) · 확장성 · 보안 · 유지보수성.

이 지표들은 서로 충돌하므로 무엇을 포기할지 정하는 것이 설계입니다.

  • 정합성↑ → 락·트랜잭션 증가 → 느려짐
  • 캐시 → 빨라지지만 오래된(stale) 데이터 가능
  • 복제 → 가용성↑, 대신 복제본 간 동기화 문제
보완분산 시스템에서는 이를 CAP 정리로 설명합니다. 네트워크 분할(P)이 생기면 정합성(C)과 가용성(A) 중 하나를 택해야 합니다. 지연 시간은 평균보다 p99(상위 1% 느린 요청) 같은 백분위로 봅니다.
비유

소풍 준비

"제일 빨리 출발하기", "준비물 하나도 안 빠뜨리기", "친구 제일 많이 태우기"를 한꺼번에 다 1등 할 수는 없어요.

꼼꼼히 챙기면 늦게 출발하고, 어제 싸 둔 도시락(캐시)은 빠르지만 오늘 아침 만든 게 아니에요. 그래서 어디로 가는 소풍인지 보고 가장 중요한 하나를 먼저 골라요.

  • 빨리 출발→지연 시간
  • 준비물 정확히→정합성
  • 많이 태우기→처리량
  • 어제 싸 둔 도시락→캐시
예시
가용성 목표연간 허용 다운타임
99.9% (three nines)약 8.76시간
99.99% (four nines)약 52.6분
99.999% (five nines)약 5.26분
기술 도메인
서비스최우선대표 기술 선택
은행 이체정합성RDB 트랜잭션(ACID), 비관적 락
SNS 좋아요 수처리량Redis INCR로 집계 후 DB에 비동기 반영
실시간 게임지연 시간UDP, 지역별 서버, 메모리 상태
로그 수집처리량Kafka, ELK 스택
티켓 예매동시성 제어대기열, Redis 분산 락, DB 유니크 제약

2. 서버의 동작 PROCESS · THREAD · I/O

서버는 요청마다 스레드를 배정해 동시에 처리하고, I/O 대기가 많으면 비동기·논블로킹으로 스레드 점유를 줄입니다.

프로세스와 스레드

이론
용어정의
프로그램디스크에 저장된 실행 파일
프로세스메모리에 올라가 실행 중인 프로그램. 자원 할당 단위
스레드프로세스 안의 실행 흐름. CPU 스케줄링(실행) 단위

프로세스 메모리: Code(기계어) · Data(전역·정적 변수) · Heap(동적 할당) · Stack(지역변수, 매개변수, 복귀 주소).

스레드끼리는 Code·Data·Heap을 공유하고 Stack·PC·레지스터는 각자 가집니다. 프로세스끼리는 메모리를 공유하지 않아 IPC(파이프, 소켓, 공유 메모리)가 필요합니다.

보완스레드 전환이 싼 이유는 같은 주소 공간을 쓰기 때문입니다. 프로세스 전환은 페이지 테이블 교체와 TLB 무효화가 따라와 비용이 큽니다. 반대로 프로세스는 하나가 죽어도 다른 프로세스에 영향이 없다는 격리 장점이 있습니다.
비유

집과 가족

프로세스는 집, 스레드는 그 집에 사는 가족이에요.

가족은 냉장고와 거실(Heap)을 같이 쓰지만, 각자 자기 일기장(Stack)이 있어요. 옆집 냉장고는 열 수 없어서 전화(IPC)로 물어봐야 해요.

가족 한 명이 이사 오는 건 새 집을 짓는 것보다 훨씬 쉽고 빨라요. 서버가 요청마다 집 대신 가족을 늘리는 이유예요.

  • 집→프로세스
  • 가족→스레드
  • 같이 쓰는 냉장고→Heap
  • 각자 일기장→Stack
  • 옆집에 전화→IPC
예시
// 한 JVM 프로세스 안에서 두 스레드가 같은 Heap 객체(list)를 공유
List<String> list = Collections.synchronizedList(new ArrayList<>());
Thread a = new Thread(() -> list.add("A"));
Thread b = new Thread(() -> list.add("B"));
a.start(); b.start();
a.join();  b.join();
System.out.println(list);   // [A, B] 또는 [B, A]
기술 도메인
  • Tomcat: 한 프로세스 + 요청당 스레드(thread-per-request)
  • Nginx: master 프로세스 + CPU 코어 수만큼의 worker 프로세스, 각 worker는 이벤트 루프
  • PostgreSQL: 연결마다 프로세스를 띄움 → 커넥션 풀(PgBouncer)이 중요
  • Chrome: 탭마다 프로세스를 분리해 한 탭이 죽어도 나머지는 살아 있음

동시성 문제

이론
  • 경쟁 상태(Race Condition): 실행 순서에 따라 결과가 달라지는 상황
  • 임계 구역(Critical Section): 공유 자원에 접근하는 코드 영역
  • 동기화: 임계 구역에 한 번에 한 스레드만 들어가게 통제 (synchronized, ReentrantLock)
보완동시성 문제는 원자성만이 아닙니다. 한 스레드가 바꾼 값이 다른 스레드에 보이지 않는 가시성 문제도 있고, 이는 volatile로 해결합니다. 단, volatile은 count++ 같은 복합 연산의 원자성은 보장하지 않습니다.
비유

칠판에 쿠키 개수 적기

쿠키 통 옆 칠판에 "100개"라고 적혀 있어요. 두 친구가 동시에 칠판을 보고 쿠키를 하나씩 넣은 뒤 둘 다 "101"이라고 고쳐 적었어요. 실제로는 102개인데 칠판에는 101개예요.

그래서 규칙을 정해요. "칠판 앞에는 열쇠를 가진 한 명만 설 수 있어요." 이 열쇠가 락이에요.

  • 칠판→공유 변수
  • 칠판 앞 자리→임계 구역
  • 동시에 고쳐 쓰기→경쟁 상태
  • 한 명만 쓰는 열쇠→synchronized, Lock
예시

count++는 읽기 → 더하기 → 쓰기 3단계입니다. 두 스레드가 동시에 100을 읽으면 둘 다 101을 써서 결과는 102가 아니라 101이 됩니다.

// ❌ 경쟁 상태
private int count = 0;
public void increase() { count++; }

// ✅ 방법 1: 락
public synchronized void increase() { count++; }

// ✅ 방법 2: CAS 기반 원자 연산 (락 없이)
private final AtomicInteger count = new AtomicInteger();
public void increase() { count.incrementAndGet(); }
기술 도메인
  • 재고 차감·좌석 예매: DB 비관적 락(SELECT … FOR UPDATE), JPA 낙관적 락(@Version), Redis 분산 락(Redisson)
  • Spring 빈은 기본이 싱글톤이라 모든 요청 스레드가 같은 객체를 씁니다. 빈에 변경 가능한 필드를 두면 바로 경쟁 상태가 됩니다.

동기·비동기 / 블로킹·논블로킹

이론

서로 다른 두 축입니다.

  • 동기/비동기: 작업 완료를 누가 신경 쓰는가. 호출한 쪽이 결과를 받아 이어서 처리하면 동기, 완료 처리를 콜백 등에 넘기면 비동기.
  • 블로킹/논블로킹: 호출된 함수가 제어권을 언제 돌려주는가. 끝날 때까지 붙잡으면 블로킹, 바로 돌려주면 논블로킹.
조합동작예
동기 + 블로킹끝날 때까지 기다림JDBC 쿼리
동기 + 논블로킹멈추지 않지만 완료 여부를 직접 반복 확인논블로킹 소켓 + polling
비동기 + 블로킹맡겨 놓고 결국 기다림 (대개 안티패턴)Future.get()
비동기 + 논블로킹맡기고 다른 일, 완료 시 콜백Netty, WebFlux, Node.js

동기 블로킹 서버에서 DB 조회가 200ms 걸리면, 그동안 스레드는 CPU를 거의 안 쓰면서도 점유됩니다. 동시 요청이 스레드 수를 넘으면 큐에서 대기하고, 큐도 차면 연결 거부나 타임아웃이 납니다.

보완Spring Boot 내장 Tomcat 기본값은 최대 스레드 200(server.tomcat.threads.max), 대기 큐 100(server.tomcat.accept-count)입니다. 이벤트 루프 방식은 OS의 I/O 멀티플렉싱(Linux epoll, macOS kqueue) 위에서 동작합니다.
비유

라면 끓이기

  • 동기 + 블로킹: 물이 끓을 때까지 냄비만 쳐다봐요.
  • 동기 + 논블로킹: 그림을 그리다가 계속 와서 "끓었나?" 확인해요.
  • 비동기 + 논블로킹: 주전자 알람을 켜 두고 그림을 그려요. "삐~" 하면 그때 가요.
  • 비동기 + 블로킹: 알람을 켜 놓고도 냄비 앞에 서서 기다려요.

선생님 200명이 모두 냄비만 쳐다보고 있으면, 새로 온 친구를 봐줄 선생님이 없어요. 이게 스레드 풀 고갈이에요.

  • 선생님→스레드
  • 물 끓이기→DB 조회, 외부 API
  • 알람→콜백
예시
// 비동기 + 논블로킹: 두 외부 호출을 동시에 보내고, 둘 다 끝나면 합침
CompletableFuture<User>  user  = CompletableFuture.supplyAsync(() -> userApi.get(id));
CompletableFuture<Point> point = CompletableFuture.supplyAsync(() -> pointApi.get(id));

user.thenCombine(point, MyPage::new)
    .thenAccept(page -> log.info("done: {}", page));   // 완료 시 콜백

// 비동기 + 블로킹 (안티패턴): 맡겨 놓고 바로 기다림
User u = user.get();
기술 도메인
  • 동기가 충분: 일반 CRUD API (Spring MVC + JPA/JDBC). 코드가 읽기 쉽고 디버깅이 쉬움
  • 비동기가 유리: 채팅·알림, API 게이트웨이(Spring Cloud Gateway는 Netty 기반), 외부 API를 많이 호출하는 서버 (WebFlux + R2DBC, Node.js)
  • 비동기는 상위 호환이 아니라, I/O 대기가 길고 동시 연결이 많을 때 고르는 도구입니다.

3. Java JVM · GC · THREAD

Java는 안정적인 멀티스레드, Spring 생태계, JVM 덕분에 서버 개발의 주력 언어입니다. 여기서는 문법보다 서버가 도는 환경으로서 봅니다.

JVM 실행 구조

이론
  1. javac가 소스를 JVM 바이트코드(.class)로 컴파일
  2. 클래스 로더가 .class를 메모리에 적재
  3. 실행 엔진이 인터프리터로 실행하다가, 자주 쓰이는(hot) 코드는 JIT 컴파일러가 네이티브 코드로 바꿔 캐싱

그래서 Java 프로그램은 플랫폼 독립적이고, JVM은 플랫폼 종속적입니다. ("Write Once, Run Anywhere")

  • JVM: 바이트코드 실행
  • JRE: JVM + 표준 라이브러리 (실행 환경)
  • JDK: JRE + 개발 도구 (javac, jar, javap, jlink 등)
보완Oracle은 Java 11부터 JRE를 따로 배포하지 않습니다. 지금은 JDK를 설치하거나, jlink로 필요한 모듈만 담은 런타임을 만들어 씁니다.
비유

통역사와 그림책

Java 코드는 어느 나라에서나 읽을 수 있는 공통 그림책(바이트코드)으로 만들어요. 나라마다 있는 통역사(JVM)가 그 나라 말로 읽어 줘요.

통역사는 자주 읽는 페이지를 아예 외워 버려서 다음엔 더 빨리 읽어요(JIT).

  • 공통 그림책→바이트코드(.class)
  • 나라별 통역사→OS별 JVM
  • 외워 버리기→JIT 컴파일
  • 통역사 + 사전→JRE
  • 그림책 만드는 도구 상자까지→JDK
예시
$ javac Main.java        # Main.class (바이트코드) 생성
$ java Main              # JVM이 Main.class 실행
$ javap -c Main          # 바이트코드 확인: aload_0, invokevirtual ...

같은 Main.class를 macOS, Linux, Windows의 JVM에서 그대로 실행할 수 있습니다.

기술 도메인
  • Kotlin, Scala, Groovy도 JVM 바이트코드로 컴파일되어 Java 라이브러리를 그대로 씀
  • Spring Boot 앱은 실행 가능한 .jar 하나로 배포하고, Docker 이미지에 JDK/JRE 런타임을 함께 담음
  • GraalVM Native Image는 미리 네이티브로 컴파일해 시작 시간과 메모리를 줄임 (서버리스에 유리)

메모리와 GC

이론

JVM 런타임 데이터 영역:

영역공유내용
Method Area (Metaspace)모든 스레드클래스 정보, static 변수, 상수 풀
Heap모든 스레드new로 만든 객체, 배열
JVM Stack스레드별메서드 호출 프레임(지역변수, 매개변수)
PC Register스레드별현재 실행 중인 명령 위치
Native Method Stack스레드별C/C++ 네이티브 메서드 호출

Heap 세대 구조: Young(Eden + Survivor 0/1)과 Old. "대부분의 객체는 금방 죽는다"는 약한 세대 가설에 따라 Young만 자주 청소합니다.

  • GC 대상 = GC Root(스택의 지역변수, static 변수, JNI 참조 등)에서 참조를 따라가 닿지 않는 객체
  • Minor GC: Eden이 차면 Young 영역 정리. 살아남은 객체는 Survivor로, 여러 번 살아남으면 Old로 승격(Promotion)
  • Major GC: Old 영역 정리 / Full GC: Heap 전체(+Metaspace) 정리
  • GC 중에는 애플리케이션 스레드가 멈추는 Stop-The-World가 발생 → 응답 지연의 원인
보완Java 8부터 클래스 메타데이터 영역이 PermGen에서 네이티브 메모리의 Metaspace로 바뀌었습니다. 승격 기준 나이는 -XX:MaxTenuringThreshold(기본·최대 15)로 정합니다. "Major GC"는 공식 용어가 아니어서 GC 구현마다 의미가 조금씩 다릅니다.
비유

장난감 방 정리

새 장난감은 입구 바구니(Eden)에 넣어요. 바구니가 가득 차면 선생님이 정리해요. 아무도 끈을 잡고 있지 않은 장난감은 치우고, 계속 갖고 노는 장난감은 큰 선반(Old)으로 옮겨요.

정리하는 동안에는 모두 잠깐 "얼음!"이에요(Stop-The-World). 그리고 "절대 버리지 마" 상자(static)에 계속 넣기만 하면 방이 꽉 차 버려요.

  • 입구 바구니→Eden
  • 큰 선반→Old Generation
  • 장난감을 잡은 끈→참조
  • 얼음!→Stop-The-World
  • 방이 꽉 참→OutOfMemoryError
예시
Member m = new Member();   // Eden에 생성, 스택 변수 m이 참조 → 도달 가능
m = null;                  // 참조 끊김 → 도달 불가 → 다음 Minor GC에서 회수

static List<Member> cache = new ArrayList<>();
cache.add(new Member());   // static이 계속 참조 → 회수 안 됨 → 쌓이면 메모리 누수
기술 도메인
GC특징
Serial / Parallel단순, 처리량 위주. 소규모·배치
G1Java 9부터 기본. Heap을 리전으로 나눠 멈춤 시간 목표 관리
ZGC밀리초 이하 멈춤. Java 21에서 세대별(Generational) 모드 도입

운영 옵션 예: java -Xms2g -Xmx2g -XX:+UseG1GC -jar app.jar. OutOfMemoryError가 나면 힙 덤프를 떠서 누수 객체를 추적합니다.

스레드와 스레드 풀

이론
  • JVM은 프로세스 하나에 스레드 여럿으로 동작합니다.
  • Java의 기본 스레드(플랫폼 스레드)는 OS 스레드와 1:1로 매핑되고, 스케줄링은 OS가 합니다. 스레드마다 스택 메모리(64비트 Linux 기본 약 1MB)를 잡으므로 생성 비용이 큽니다.
  • 너무 많으면 메모리와 컨텍스트 스위칭 비용이 커져 오히려 느려집니다.
  • 스레드 풀: 스레드를 미리 만들어 두고 작업마다 재사용합니다.
보완Java 21의 가상 스레드는 JVM이 관리하는 경량 스레드로, 소수의 플랫폼 스레드(캐리어) 위에 M:N으로 올라갑니다. 블로킹 I/O를 만나면 캐리어에서 내려오기 때문에, 동기 코드 그대로 높은 동시성을 얻습니다. Java 21에서는 synchronized 안에서 블로킹하면 캐리어에 고정(pinning)되는 문제가 있었고 Java 24(JEP 491)에서 해결되었습니다.
비유

놀이터 그네

친구가 올 때마다 그네를 새로 만들면 너무 오래 걸려요. 그래서 그네 10개를 미리 만들어 두고 차례대로 타요. 이게 스레드 풀이에요.

가상 스레드는 이래요. 그네는 몇 개 없지만, 타던 친구가 신발 끈을 묶느라 멈추면 그 자리에 다른 친구가 바로 타요. 그래서 친구 수만 명도 함께 놀 수 있어요.

  • 그네→플랫폼 스레드
  • 미리 만든 그네들→스레드 풀
  • 신발 끈 묶기→블로킹 I/O
  • 잠깐 비켜 주기→가상 스레드 언마운트
예시
// 고정 크기 스레드 풀: 스레드 10개가 작업 100개를 나눠 처리
ExecutorService pool = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
    int n = i;
    pool.submit(() -> handle(n));
}
pool.shutdown();

// Java 21 가상 스레드: 작업마다 스레드 하나, 수만 개도 가능
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        int n = i;
        ex.submit(() -> handle(n));
    }
}
기술 도메인
  • Tomcat의 요청 처리 스레드 풀 (기본 최대 200)
  • 같은 원리의 커넥션 풀: HikariCP(Spring Boot 기본, 기본 최대 10개)가 DB 연결을 재사용
  • Spring Boot 3.2+는 spring.threads.virtual.enabled=true 한 줄로 Tomcat 요청 처리를 가상 스레드로 전환

4. 객체지향 OOP · SOLID

서비스는 반드시 바뀝니다. 캡슐화·상속·다형성·추상화는 OOP의 필수 요소이고, SOLID는 설계 품질을 높이는 선택적 원칙입니다.

절차지향 vs 객체지향

이론
구분절차지향객체지향
핵심 질문어떤 작업을 어떤 순서로?어떤 객체가 어떤 책임을 지고 협력?
구성함수 중심, 데이터와 기능 분리객체 = 상태 + 행동, 메시지로 협력
장점흐름이 단순, 작은 프로그램에 빠름변경 범위가 좁고 확장에 강함
한계공유 데이터 변경 추적이 어렵고, 규모가 커지면 복잡도 급증설계 비용, 과설계 위험
비유

혼자 요리 vs 역할 나눠 요리

절차지향은 한 사람이 레시피 1번부터 끝까지 순서대로 해요. 냉장고 재료를 누구나 꺼내 쓰니까 뭐가 없어졌는지 알기 어려워요.

객체지향은 반죽 친구, 오븐 친구, 장식 친구가 각자 자기 일을 맡고 "반죽 다 됐어?" 하고 서로 부탁해요.

  • 레시피 순서→절차, 함수
  • 역할 맡은 친구→객체
  • 서로 부탁하기→메시지(메서드 호출)
예시
// 절차지향: 데이터(balance)를 여러 함수가 직접 수정
int balance = 10000;
balance = withdraw(balance, 3000);

// 객체지향: 데이터와 규칙을 객체가 함께 가짐
Account account = new Account(10000);
account.withdraw(3000);
기술 도메인
  • 절차지향: C로 짠 임베디드·OS 커널, 짧은 스크립트·배치
  • 객체지향: Java/Spring 엔터프라이즈 백엔드처럼 요구사항이 자주 바뀌고 여러 명이 오래 유지보수하는 코드

캡슐화 (Encapsulation)

이론

데이터와 그 데이터를 다루는 기능을 묶고, 내부 구현을 숨기는 것. 외부는 객체가 공개한 메서드로만 상호작용하므로 내부가 바뀌어도 영향이 적습니다.

접근 제어자접근 범위
private같은 클래스
(default)같은 패키지
protected같은 패키지 + 다른 패키지의 자식 클래스
public어디서나

모든 private 필드에 getter/setter를 다는 것은 캡슐화가 아닙니다. 객체가 스스로 책임을 수행하게 만드는 것이 핵심입니다. ("Tell, Don't Ask")

비유

자판기

자판기 문을 열고 음료를 직접 꺼내면 안 돼요. 버튼만 눌러요. 돈이 모자라면 자판기가 알아서 음료를 안 줘요.

안에서 음료를 어떻게 꺼내는지는 몰라도 되고, 자판기 속이 바뀌어도 버튼은 그대로예요.

  • 잠긴 자판기 속→private 필드
  • 버튼→public 메서드
  • 돈이 모자라면 안 줌→규칙(검증)은 객체 안에
예시
public class Account {
    private long balance;

    public void withdraw(long amount) {
        if (amount <= 0) throw new IllegalArgumentException("금액은 0보다 커야 합니다");
        if (balance < amount) throw new IllegalStateException("잔액이 부족합니다");
        balance -= amount;
    }
}
// ❌ account.setBalance(account.getBalance() - 1000);  규칙이 호출부로 새어 나감
// ✅ account.withdraw(1000);                           규칙은 Account 안에
기술 도메인
  • JPA 엔티티에 setter를 열지 않고 order.cancel() 같은 도메인 메서드로 상태를 바꾸는 관례
  • API 응답은 엔티티 대신 DTO로 내보내 내부 구조를 숨김

상속 (Inheritance)

이론

기존 클래스의 상태와 행동을 물려받아 새 클래스를 정의하는 것. "is-a" 관계를 표현하고 다형성의 수단이 됩니다.

  • 부모 구현에 자식이 강하게 결합되어, 부모 변경이 자식을 깨뜨릴 수 있음
  • Java는 클래스 다중 상속을 지원하지 않음 (인터페이스는 여러 개 구현 가능)
  • 코드 재사용이 목적이면 합성(Composition)이 대개 더 적절 ("has-a")
비유

닮은 가족, 그리고 로봇 팔

펭귄은 새예요. 그래서 "새"가 가진 것(알을 낳아요)을 물려받아도 이상하지 않아요.

하지만 "줄 서기"가 "아무 데나 끼어 앉는 의자 줄"을 물려받으면 새치기가 가능해져요(Stack과 Vector). 그럴 땐 물려받지 말고 가지고 있어요. 로봇이 팔을 "가지고" 있는 것처럼요(합성).

  • 펭귄은 새다→is-a, 상속
  • 로봇이 팔을 가졌다→has-a, 합성
예시
class Member {
    protected String name;
    public void login() { ... }
}
class AdminMember extends Member {       // 관리자는 회원이다 (is-a) → 상속 OK
    public void banUser(Member target) { ... }
}

// 합성: 재사용만 필요하면 필드로 가짐
class OrderService {
    private final DiscountPolicy discountPolicy;   // has-a
}
기술 도메인
  • 실패 사례: JDK의 java.util.Stack은 Vector를 상속해서, 스택인데도 중간 삽입(add(index, e))이 가능해짐. 그래서 지금은 ArrayDeque 사용을 권장
  • 성공 사례: 예외 계층 RuntimeException → IllegalArgumentException처럼 개념적으로 같은 종류일 때

다형성 (Polymorphism)

이론

같은 타입으로 다뤄도 실제 객체에 따라 다른 동작을 하는 것. 핵심은 "구현체를 갈아끼울 수 있다"이며, OCP·DIP로 이어집니다.

구분오버라이딩오버로딩
의미부모 메서드를 자식이 재정의같은 이름, 다른 매개변수
결정 시점런타임 (동적 디스패치)컴파일 타임
다형성 종류서브타입 다형성애드혹(ad-hoc) 다형성
비유

"노래해!"

"노래해!"라고 똑같이 말해도 참새는 짹짹, 고양이는 야옹, 강아지는 멍멍 해요. 누가 듣느냐에 따라 다르게 해요. 이게 다형성이에요.

"그려 줘!"에 크레파스를 주면 크레파스 그림, 물감을 주면 물감 그림이 나와요. 무엇을 주는지 보고 미리 정해져요(오버로딩).

  • "노래해!"→같은 메서드 호출
  • 참새·고양이→서로 다른 구현체
  • 누가 듣는지 보고 결정→런타임 동적 디스패치
예시
interface Payment { void pay(int amount); }
class CardPayment  implements Payment { public void pay(int a) { /* 카드 */ } }
class KakaoPayment implements Payment { public void pay(int a) { /* 카카오 */ } }

class OrderService {
    private final Payment payment;               // 구체 타입을 모름
    OrderService(Payment payment) { this.payment = payment; }
    void order(int amount) { payment.pay(amount); }   // 실제 객체의 pay가 실행
}
기술 도메인
  • Spring은 같은 인터페이스의 빈들을 Map<String, Payment>로 주입해 줌 → 전략 패턴으로 결제 수단 선택
  • JDBC: 코드는 Connection 인터페이스만 쓰고, 실제 동작은 MySQL/PostgreSQL 드라이버 구현체가 결정

추상화 (Abstraction)

이론

복잡한 세부를 숨기고 필요한 핵심만 드러내는 것. "무엇을 하는가"와 "어떻게 하는가"를 분리합니다.

구분abstract classinterface
목적공통 상태·구현 공유역할·규약 정의
개수하나만 상속여러 개 구현
필드인스턴스 필드 가능상수(public static final)만
생성자있음없음
구현 제공일반 메서드default·static(Java 8+), private(Java 9+)
비유

리모컨

TV 리모컨은 "켜기", "채널 올리기" 버튼만 알면 돼요. 안에 전선이 어떻게 연결됐는지는 몰라도 돼요.

인터페이스는 "이 버튼들이 꼭 있어야 해" 하는 약속 카드예요. 추상 클래스는 몸통은 다 만들어져 있고 얼굴만 직접 그리는 반쯤 만든 인형 키트예요.

  • 버튼→무엇을 하는가(메서드 이름)
  • 안의 전선→어떻게 하는가(구현)
  • 약속 카드→interface
  • 반쯤 만든 키트→abstract class
예시
abstract class Animal {
    protected final String name;              // 공통 상태
    protected Animal(String name) { this.name = name; }
    abstract void sound();                    // 자식마다 다름
    void sleep() { System.out.println(name + " 잠을 잔다"); }   // 공통 구현
}
class Dog extends Animal {
    Dog(String name) { super(name); }
    @Override void sound() { System.out.println("멍멍"); }
}
기술 도메인
  • JDK 컬렉션: List(인터페이스, 규약) → AbstractList(추상 클래스, 공통 구현) → ArrayList(구현체)
  • Spring의 JpaRepository: 인터페이스만 선언하면 구현체는 프레임워크가 만들어 줌

SOLID 원칙

이론
원칙정의
SRP 단일 책임클래스가 바뀌는 이유는 하나여야 한다
OCP 개방-폐쇄확장에는 열려 있고, 수정에는 닫혀 있어야 한다
LSP 리스코프 치환자식 타입은 부모 타입의 계약을 깨지 않고 대체 가능해야 한다
ISP 인터페이스 분리클라이언트는 쓰지 않는 메서드에 의존하지 않아야 한다
DIP 의존성 역전상위 정책과 하위 구현 모두 추상화에 의존해야 한다

DIP의 "역전": 원래 OrderService → MySqlOrderRepository로 향하던 의존이, 구현체가 상위 쪽 인터페이스 OrderRepository를 구현하면서 MySqlOrderRepository → OrderRepository로 방향이 뒤집힙니다.

비유

우리 반 약속 다섯 가지

  • S: 급식 당번은 급식만, 칠판 당번은 칠판만 해요.
  • O: 레고 성에 탑을 더 올리고 싶으면 블록을 끼우면 돼요. 성을 부수지 않아요.
  • L: 술래 대신 들어온 친구가 "난 술래 안 할래" 하면 놀이가 망가져요. 대신 들어오면 같은 약속을 지켜요.
  • I: 축구하러 갈 땐 축구공만 든 작은 가방을 들어요. 필요 없는 짐까지 다 든 큰 가방은 안 돼요.
  • D: 선풍기는 벽 속 발전소가 아니라 콘센트 모양에 맞춰요. 그래서 어느 집에 가도 꽂을 수 있어요.
  • 콘센트 모양→인터페이스(추상화)
  • 어느 집 전기든→어떤 구현체든
예시
// SRP: 가입·메일·로그를 각각 분리
class UserService { void register(User u) { ... } }
class EmailSender { void sendWelcome(User u) { ... } }

// OCP: if-else 대신 구현체 추가
// if ("CARD".equals(type)) ... else if ("KAKAO".equals(type)) ...   ❌
class TossPayment implements Payment { ... }                          // ✅

// LSP 위반: 계약(send)을 지키지 못하는 구현체
class KakaoNotification implements Notification {
    public void send(String msg) { throw new UnsupportedOperationException(); }
}

// ISP: 거대한 Worker 대신 역할별 분리
interface Workable   { void work(); }
interface Approvable { void approve(); }

// DIP: 인터페이스에 의존 + 생성자 주입
class OrderService {
    private final OrderRepository repository;
    OrderService(OrderRepository repository) { this.repository = repository; }
}
기술 도메인
  • SRP: Controller / Service / Repository 계층 분리
  • OCP: Spring Security 필터 체인에 필터를 추가해 인증 방식을 확장
  • LSP: List.of(), Collections.unmodifiableList()의 add()는 예외를 던짐. JDK는 이를 "선택적 연산"으로 문서화했지만, 호출부가 수정 가능하다고 가정하면 깨지는 대표 사례
  • ISP: Spring Data의 Repository → CrudRepository → ListCrudRepository처럼 필요한 만큼만 상속
  • DIP: Spring IoC 컨테이너가 구현체를 생성자로 주입 (테스트에서는 가짜 구현으로 교체)

5. 아키텍처 패턴 ARCHITECTURE

아키텍처는 관심사를 어떻게 나눌지, 의존성을 어느 방향으로 흐르게 할지를 정하는 일입니다.

MVC

이론
  • Model: 데이터와 비즈니스 로직. View와 Controller를 몰라야 함
  • View: 사용자에게 보여주는 표현. 비즈니스 로직이 없어야 함
  • Controller: 요청을 받아 Model을 호출하고 응답(View)을 결정. 로직이 들어가면 비대해짐

한계: 큰 틀만 제공하므로, 규모가 커지면 Model 안에 비즈니스 로직과 데이터 접근이 뒤섞입니다. → Layered Architecture

비유

인형극

이야기와 인형들(Model), 무대와 조명(View), 그리고 "이제 공주가 나올 차례예요!" 하고 둘을 이어 주는 진행자(Controller)가 있어요.

인형은 무대가 어떻게 생겼는지 몰라도 되고, 무대는 이야기를 직접 바꾸지 않아요.

  • 이야기와 인형→Model
  • 무대→View
  • 진행자→Controller
예시

요청 → DispatcherServlet → HandlerMapping → Controller → Model 생성 → ViewResolver → View(Thymeleaf) 렌더링 → 응답

@Controller
public class PostController {
    @GetMapping("/posts/{id}")
    public String detail(@PathVariable Long id, Model model) {
        model.addAttribute("post", postService.find(id));
        return "post/detail";      // templates/post/detail.html
    }
}
기술 도메인
  • Spring MVC의 @Controller + Thymeleaf (서버 사이드 렌더링)
  • REST API에서는 @RestController가 View 대신 JSON을 반환. 이때 View 역할은 프론트엔드(React 등)가 맡음

Layered Architecture

이론

역할별 계층으로 나누고, 상위는 하위를 쓸 수 있지만 하위는 상위를 몰라야 합니다.

계층책임하면 안 되는 것
Controller요청 검증, DTO 변환, Service 호출, 상태 코드비즈니스 로직, DB 직접 접근
Service비즈니스 규칙, 트랜잭션 경계, Repository 조합HTTP·SQL 직접 처리
RepositoryCRUD, 쿼리, 데이터 매핑비즈니스 판단

한계: 의존성이 결국 DB 기술을 향합니다. MySQL → MongoDB로 바꾸면 Repository가 제공하던 메서드가 달라져 Service까지 영향을 받을 수 있습니다.

비유

3층짜리 식당

1층 홀 직원(Controller)이 주문을 받고, 2층 셰프(Service)가 무엇을 어떻게 요리할지 정하고, 3층 창고지기(Repository)는 재료만 꺼내 줘요.

위층이 아래층에 부탁하지, 창고지기가 손님에게 직접 가지는 않아요. 그런데 창고를 냉장고에서 냉동고로 바꾸면 셰프 요리법까지 바뀔 수 있어요. 이게 Layered의 한계예요.

  • 홀 직원→Controller
  • 셰프→Service
  • 창고지기→Repository
  • 창고 종류→DB 기술
예시
com.example.board
├── controller/  PostController.java    @RestController
├── service/     PostService.java       @Service, @Transactional
├── repository/  PostRepository.java    extends JpaRepository<Post, Long>
├── domain/      Post.java              @Entity
└── dto/         PostCreateRequest.java, PostResponse.java
기술 도메인
  • 대부분의 Spring Boot 프로젝트와 튜토리얼의 기본 구조. 작은~중간 규모 서비스에 충분

Hexagonal · Clean Architecture

이론

둘 다 비즈니스 로직을 중심에 두고, DIP로 외부 기술이 안쪽을 의존하게 만듭니다.

구분HexagonalClean
제안Alistair Cockburn (2005)Robert C. Martin (2012 글, 2017 책)
핵심 개념Port(인터페이스) & Adapter(기술 구현)동심원 계층 & Dependency Rule
강조점외부와 연결되는 경계의존성은 항상 바깥 → 안쪽
공통 목표비즈니스 로직을 외부 기술로부터 보호

Port는 두 종류입니다. Inbound Port(유스케이스: 외부가 코어를 호출)와 Outbound Port(코어가 외부를 호출: 저장, 메일 발송 등).

비유

게임기와 카트리지, 그리고 양파

Hexagonal: 게임기 본체(도메인)는 그대로 두고, 모양만 맞으면 조이스틱(인바운드 어댑터)이나 메모리 카드(아웃바운드 어댑터)를 바꿔 끼워요. 꽂는 구멍 모양이 포트예요.

Clean: 양파처럼 가장 안쪽 알맹이가 제일 중요해요. 바깥 껍질이 안쪽에 맞추지, 알맹이가 껍질에 맞추지 않아요.

  • 게임기 본체, 양파 알맹이→도메인(비즈니스 로직)
  • 꽂는 구멍 모양→Port
  • 바꿔 끼우는 조이스틱·카드→Adapter
  • 바깥 껍질→프레임워크, DB
예시
com.example.board
├── domain/                        Post.java (순수 Java, JPA 어노테이션 없음)
├── application/
│   ├── port/in/   CreatePostUseCase.java      ← 인바운드 포트
│   ├── port/out/  SavePostPort.java           ← 아웃바운드 포트
│   └── service/   CreatePostService.java      implements CreatePostUseCase
└── adapter/
    ├── in/web/            PostController.java       → CreatePostUseCase 호출
    └── out/persistence/   PostJpaAdapter.java       implements SavePostPort

MySQL을 MongoDB로 바꿔도 PostMongoAdapter만 새로 만들면 되고 domain, application은 그대로입니다.

기술 도메인
  • 결제·정산·주문처럼 도메인 규칙이 복잡하고 외부 연동(PG사, 메시지 큐)이 많은 서비스
  • 작은 프로젝트에서는 파일·인터페이스 수만 늘어나는 과설계가 되기 쉬우므로 Layered로 시작하는 편이 낫습니다.

6. 실습 BOARD REFACTORING

main() 하나에 몰린 절차지향 게시판을 ① 객체에 책임 부여 → ② MVC 분리 순서로 리팩토링합니다. 아래 코드는 여러 정답 중 하나의 예시입니다.

Step 1 · Post에 책임 부여

이론

지금의 Post는 필드를 누구나 고칠 수 있는 데이터 묶음입니다. 필드를 private으로 막고, 조회와 수정을 Post가 직접 책임지게 합니다. (캡슐화)

비유

내 일기장

내 일기장은 내가 고쳐요. 친구가 마음대로 지우지 못하게 자물쇠(private)를 채우고, 고치고 싶으면 나에게 부탁(update)해요.

빈 페이지를 내밀면 "제목부터 써야 해!" 하고 돌려줘요(검증).

  • 자물쇠→private
  • 나에게 부탁→update() 메서드
  • "제목부터 써야 해"→validate()
예시
public class Post {
    private String title;
    private String content;

    public Post(String title, String content) {
        validate(title, content);
        this.title = title;
        this.content = content;
    }

    public void update(String title, String content) {
        validate(title, content);
        this.title = title;
        this.content = content;
    }

    public String getTitle()   { return title; }
    public String getContent() { return content; }

    private void validate(String title, String content) {
        if (title == null || title.isBlank())   throw new IllegalArgumentException("제목을 입력해 주세요.");
        if (content == null || content.isBlank()) throw new IllegalArgumentException("내용을 입력해 주세요.");
    }
}

Step 2 · MVC로 역할 분리

이론
  • Model (Post): 게시글 데이터 관리·검증
  • View (PostView): 메뉴, 입력 프롬프트, 결과 출력
  • Controller (PostController): 메뉴 선택에 따라 기능 흐름 제어
  • Main: 객체를 조립하고 실행만
비유

우리 반 게시판

게시물 종이(Model), 게시판에 붙이고 읽어 주는 친구(View), "1번은 쓰기, 2번은 보기"를 정해 주는 반장(Controller)이 있어요. 선생님(Main)은 "시작!"만 외쳐요.

  • 게시물 종이→Post
  • 읽어 주는 친구→PostView
  • 반장→PostController
  • "시작!"→Main
예시
// PostView.java
public class PostView {
    private final Scanner scanner = new Scanner(System.in);

    public int readMenu() {
        System.out.println("\n=== 게시판 ===");
        System.out.println("1. 작성  2. 목록  3. 조회  4. 수정  5. 삭제  6. 종료");
        System.out.print("선택: ");
        return Integer.parseInt(scanner.nextLine());
    }
    public String read(String label) { System.out.print(label + ": "); return scanner.nextLine(); }
    public int readNumber(String label) { return Integer.parseInt(read(label)) - 1; }

    public void printList(List<Post> posts) {
        if (posts.isEmpty()) { printMessage("게시글이 없습니다."); return; }
        for (int i = 0; i < posts.size(); i++) System.out.println((i + 1) + ". " + posts.get(i).getTitle());
    }
    public void printPost(Post post) {
        System.out.println("제목: " + post.getTitle());
        System.out.println("내용: " + post.getContent());
    }
    public void printMessage(String message) { System.out.println(message); }
}
// PostController.java
public class PostController {
    private final PostView view;
    private final List<Post> posts = new ArrayList<>();

    public PostController(PostView view) { this.view = view; }

    public void run() {
        while (true) {
            try {
                switch (view.readMenu()) {
                    case 1 -> create();
                    case 2 -> view.printList(posts);
                    case 3 -> view.printPost(find(view.readNumber("조회할 번호")));
                    case 4 -> update();
                    case 5 -> { posts.remove(index(view.readNumber("삭제할 번호"))); view.printMessage("삭제되었습니다."); }
                    case 6 -> { view.printMessage("프로그램을 종료합니다."); return; }
                    default -> view.printMessage("잘못된 입력입니다.");
                }
            } catch (IllegalArgumentException e) {   // NumberFormatException 포함
                view.printMessage(e.getMessage());
            }
        }
    }

    private void create() {
        posts.add(new Post(view.read("제목"), view.read("내용")));
        view.printMessage("게시글이 작성되었습니다.");
    }
    private void update() {
        Post post = find(view.readNumber("수정할 번호"));
        post.update(view.read("새로운 제목"), view.read("새로운 내용"));
        view.printMessage("게시글이 수정되었습니다.");
    }
    private Post find(int i) { return posts.get(index(i)); }
    private int index(int i) {
        if (i < 0 || i >= posts.size()) throw new IllegalArgumentException("존재하지 않는 게시글입니다.");
        return i;
    }
}
// Main.java
public class Main {
    public static void main(String[] args) {
        new PostController(new PostView()).run();
    }
}

과제로 남은 문제

이론
원칙현재 문제개선 방향
SRPView가 입력과 출력을 함께, Controller가 저장(List)까지 담당InputView/OutputView 분리, PostRepository·PostService 도입
OCPView가 Post에 직접 의존해 댓글 등 추가 시 재사용 어려움View에는 출력용 DTO(PostResponse)를 전달
DIPController가 구현체를 직접 생성·의존PostRepository 인터페이스 + 생성자 주입, 조립은 Main에서
참고세미나 피드백 사진은 받지 못해 이 노트에 반영되어 있지 않습니다.
비유

반장이 너무 바빠요

지금 반장은 순서 정하기에 서랍 정리까지 해요. 서랍은 서랍 담당 친구(Repository)에게 맡겨요.

읽어 주는 친구에게는 원본 대신 복사본(DTO)을 줘요. 그리고 반장은 "서랍 담당이 누구든" 약속만 알면 돼요(DIP).

  • 서랍 담당→PostRepository
  • 복사본→DTO
  • 누구든 약속만→인터페이스 + 주입